1. 链表基础与LeetCode高频题型解析
链表作为数据结构中的经典类型,在LeetCode算法题中占据重要地位。不同于数组的连续存储特性,链表通过指针将零散的内存块串联起来,这种非连续存储方式带来了独特的操作特性和解题思路。
1.1 链表的核心特性
链表由节点(Node)组成,每个节点包含数据域和指针域。单向链表的指针域只存储下一个节点的地址,而双向链表则同时存储前驱和后继节点的地址。这种结构决定了链表操作的关键特点:
- 插入/删除高效:时间复杂度O(1),只需修改相邻节点的指针
- 随机访问低效:必须从头节点开始遍历,时间复杂度O(n)
- 空间利用率灵活:不需要预先分配连续内存空间
理解这些特性是解决链表问题的第一步。例如,当题目要求频繁插入删除时,链表通常比数组更合适;而需要快速随机访问时,数组更有优势。
1.2 LeetCode链表题常见类型
根据题目考察重点,链表问题可分为以下几类:
- 基础操作类:反转链表、合并链表、环形链表检测等
- 指针技巧类:快慢指针、多指针协同、虚拟头节点等
- 综合应用类:链表排序、重排链表、复杂链表复制等
- 数学特性类:相交链表、回文链表、环形链表入口等
2. 高频题型深度解析与实现
2.1 反转链表系列
2.1.1 基础反转(LeetCode 206)
反转单链表是必须掌握的基本功,迭代法和递归法都需要熟练掌握:
cpp复制// 迭代法
ListNode* reverseList(ListNode* head) {
ListNode *prev = nullptr, *curr = head;
while (curr) {
ListNode *next = curr->next; // 临时保存下一个节点
curr->next = prev; // 反转指针
prev = curr; // 前驱指针后移
curr = next; // 当前指针后移
}
return prev;
}
// 递归法
ListNode* reverseList(ListNode* head) {
if (!head || !head->next) return head;
ListNode *newHead = reverseList(head->next);
head->next->next = head; // 反转指针
head->next = nullptr; // 断开原指针
return newHead;
}
关键点:
- 迭代法需要维护三个指针:prev、curr、next
- 递归法的基准情况是到达链表末尾
- 两种方法时间复杂度都是O(n),空间复杂度迭代法O(1),递归法O(n)
2.1.2 区间反转(LeetCode 92)
反转链表指定区间需要更精细的指针控制:
cpp复制ListNode* reverseBetween(ListNode* head, int left, int right) {
ListNode dummy(0);
dummy.next = head;
ListNode *pre = &dummy;
// 移动到left前一个节点
for (int i = 0; i < left - 1; ++i)
pre = pre->next;
ListNode *curr = pre->next;
for (int i = 0; i < right - left; ++i) {
ListNode *next = curr->next;
curr->next = next->next;
next->next = pre->next;
pre->next = next;
}
return dummy.next;
}
注意事项:
- 使用虚拟头节点(dummy)简化头节点特殊处理
- 先定位到left前一个节点(pre)
- 每次将curr的下一个节点插入到pre之后
- 共需执行(right-left)次反转操作
2.2 快慢指针技巧
2.2.1 环形链表检测(LeetCode 141)
快慢指针是解决链表问题的利器,环形检测是典型应用:
cpp复制bool hasCycle(ListNode *head) {
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true;
}
return false;
}
数学原理:
- 快指针每次走两步,慢指针每次走一步
- 若有环,快慢指针必在环内相遇(类似于跑道上快跑者套圈)
- 时间复杂度O(n),空间复杂度O(1)
2.2.2 环形链表入口定位(LeetCode 142)
找到环的入口需要更深入的数学分析:
cpp复制ListNode *detectCycle(ListNode *head) {
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) { // 相遇点
ListNode *ptr = head;
while (ptr != slow) {
ptr = ptr->next;
slow = slow->next;
}
return ptr;
}
}
return nullptr;
}
关键推导:
- 设头节点到入口距离为a,入口到相遇点距离为b,相遇点到入口距离为c
- 快指针路程:a + n(b+c) + b
- 慢指针路程:a + b
- 由2(a+b) = a + n(b+c) + b 推导出 a = c + (n-1)(b+c)
- 这意味着从相遇点和头节点同时出发的两个指针必在入口处相遇
2.3 链表排序(LeetCode 148)
链表排序通常采用归并排序,因其符合链表特性:
cpp复制ListNode* sortList(ListNode* head) {
if (!head || !head->next) return head;
// 快慢指针找中点
ListNode *slow = head, *fast = head->next;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
ListNode *mid = slow->next;
slow->next = nullptr; // 切断链表
return merge(sortList(head), sortList(mid));
}
ListNode* merge(ListNode* l1, ListNode* l2) {
ListNode dummy(0);
ListNode *tail = &dummy;
while (l1 && l2) {
if (l1->val < l2->val) {
tail->next = l1;
l1 = l1->next;
} else {
tail->next = l2;
l2 = l2->next;
}
tail = tail->next;
}
tail->next = l1 ? l1 : l2;
return dummy.next;
}
算法分析:
- 时间复杂度:O(nlogn),标准归并排序复杂度
- 空间复杂度:O(logn),递归栈空间
- 关键点在于准确找到中点(fast从head->next开始可保证偶数节点时slow在前半段末尾)
3. 复杂链表操作与边界处理
3.1 复杂链表的复制(LeetCode 138)
带随机指针的链表复制需要巧妙的方法:
cpp复制Node* copyRandomList(Node* head) {
if (!head) return nullptr;
// 第一步:在每个原节点后面插入复制节点
Node *curr = head;
while (curr) {
Node *copy = new Node(curr->val);
copy->next = curr->next;
curr->next = copy;
curr = copy->next;
}
// 第二步:处理random指针
curr = head;
while (curr) {
if (curr->random) {
curr->next->random = curr->random->next;
}
curr = curr->next->next;
}
// 第三步:分离两个链表
Node *newHead = head->next;
curr = head;
while (curr) {
Node *temp = curr->next;
curr->next = temp->next;
if (temp->next) {
temp->next = temp->next->next;
}
curr = curr->next;
}
return newHead;
}
算法优势:
- O(1)空间复杂度(不考虑结果链表)
- 三次遍历完成复制,时间复杂度O(n)
- 巧妙利用原链表结构存储新节点,避免哈希表额外空间
3.2 链表重排(LeetCode 143)
将链表L0→L1→...→Ln-1→Ln重排为L0→Ln→L1→Ln-1→...:
cpp复制void reorderList(ListNode* head) {
if (!head || !head->next) return;
// 找中点
ListNode *slow = head, *fast = head;
while (fast->next && fast->next->next) {
slow = slow->next;
fast = fast->next->next;
}
// 反转后半部分
ListNode *prev = nullptr, *curr = slow->next;
slow->next = nullptr; // 断开前后两部分
while (curr) {
ListNode *next = curr->next;
curr->next = prev;
prev = curr;
curr = next;
}
// 合并两个链表
ListNode *p1 = head, *p2 = prev;
while (p2) {
ListNode *temp1 = p1->next;
ListNode *temp2 = p2->next;
p1->next = p2;
p2->next = temp1;
p1 = temp1;
p2 = temp2;
}
}
实现要点:
- 快慢指针找中点时,fast从head开始可确保后半部分不长于前半部分
- 反转后半部分后,两个链表长度相差不超过1
- 合并时交替连接节点,注意指针保存和更新顺序
4. 链表解题技巧与常见错误
4.1 虚拟头节点技巧
虚拟头节点(dummy node)能极大简化链表操作:
cpp复制ListNode* removeElements(ListNode* head, int val) {
ListNode dummy(0);
dummy.next = head;
ListNode *curr = &dummy;
while (curr->next) {
if (curr->next->val == val) {
ListNode *temp = curr->next;
curr->next = temp->next;
delete temp;
} else {
curr = curr->next;
}
}
return dummy.next;
}
使用场景:
- 可能修改头节点的情况(如删除、插入)
- 需要统一处理逻辑,避免特殊判断
- 简化边界条件处理(如空链表)
4.2 常见错误与调试技巧
典型错误:
- 指针丢失:修改next指针前未保存后续节点
- 空指针访问:未检查节点是否为nullptr就访问成员
- 循环链表:反转或拼接时意外形成环
- 内存泄漏:C++中删除节点前未保存必要指针
调试建议:
- 画图辅助理解指针变化
- 对短链表手动模拟运行
- 使用断言检查关键不变量
- 打印中间状态辅助诊断
4.3 链表与数组的性能对比
表格对比两种数据结构的主要特性:
| 特性 | 数组 | 链表 |
|---|---|---|
| 内存布局 | 连续内存 | 非连续内存 |
| 随机访问 | O(1) | O(n) |
| 头部插入/删除 | O(n) | O(1) |
| 尾部插入/删除 | O(1)(已知末尾) | O(1)(双向链表) |
| 中间插入/删除 | O(n) | O(1)(已知位置) |
| 缓存友好性 | 好 | 差 |
| 空间开销 | 仅数据 | 数据+指针 |
| 大小调整 | 固定或需重新分配 | 动态灵活 |
理解这些差异有助于在实际问题中选择合适的数据结构。例如,需要频繁随机访问时选择数组,而需要频繁插入删除时链表更有优势。