文章目录
-
- [一、 核心原理:链表指针操作的本质](#一、 核心原理:链表指针操作的本质)
- [二、 两两交换链表中的节点 (Medium)](#二、 两两交换链表中的节点 (Medium))
-
- [2.1 题目描述](#2.1 题目描述)
- [2.2 深度拆解:画图画图画图](#2.2 深度拆解:画图画图画图)
- [2.3 C++ 代码实战](#2.3 C++ 代码实战)
- [三、 重排链表 (Medium)](#三、 重排链表 (Medium))
-
- [3.1 题目描述](#3.1 题目描述)
- [3.2 深度拆解:三步分解,各个击破](#3.2 深度拆解:三步分解,各个击破)
- [3.3 C++ 代码实战](#3.3 C++ 代码实战)
- [四、 合并 K 个升序链表 (Hard)](#四、 合并 K 个升序链表 (Hard))
-
- [4.1 题目描述](#4.1 题目描述)
- [4.2 深度拆解:两种思路的对比](#4.2 深度拆解:两种思路的对比)
- [4.3 C++ 代码实战(解法一:小根堆)](#4.3 C++ 代码实战(解法一:小根堆))
- [4.4 C++ 代码实战(解法二:分治递归)](#4.4 C++ 代码实战(解法二:分治递归))
- [五、 K 个一组翻转链表 (Hard)](#五、 K 个一组翻转链表 (Hard))
-
- [5.1 题目描述](#5.1 题目描述)
- [5.2 深度拆解:分组 + 头插法](#5.2 深度拆解:分组 + 头插法)
- [5.3 C++ 代码实战](#5.3 C++ 代码实战)
- [六、 总结与避坑](#六、 总结与避坑)
一、 核心原理:链表指针操作的本质
💬 底层逻辑 :
链表题的核心永远是指针的重定向 。不同于数组可以随机访问,链表的每一次"操作"本质上都是在修改
next指针的指向。
- 哨兵节点(虚拟头节点) :
new ListNode(0)是链表题的万能润滑剂。它统一了"头节点也需要被修改"的边界情况,让代码逻辑更整洁。- 画图是第一生产力 :链表指针绕来绕去,脑子里很难追踪。强烈建议动手画出每一步的指针变化,再翻译成代码,否则极易出现断链或空指针。
- 操作顺序至关重要:在重新连接指针时,顺序一旦错误,就会丢失对后续节点的引用,导致链表断裂。每次操作前务必确认"我还拿着我需要的节点吗?"
🚀 链表题通用模板:
- 虚拟头节点 :
ListNode* dummy = new ListNode(0); dummy->next = head;- 多指针协作 :
prev、cur、next、nnext各司其职,提前保存好"待会儿还要用的节点"。- 善用头插法:逆序一段链表时,头插法是最简洁的实现方式。
二、 两两交换链表中的节点 (Medium)
2.1 题目描述
链接 :24. 两两交换链表中的节点
给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。必须实际交换节点,不能只修改节点内部的值。
2.2 深度拆解:画图画图画图
画图画图画图,重要的事情说三遍~
-
四指针协作 :每次循环需要同时掌控
prev、cur、next、nnext四个指针,分别对应交换区间的前驱、第一个节点、第二个节点、后继。 -
交换三步走:
prev->next = next(前驱接到第二个节点)next->next = cur(第二个节点反指第一个)cur->next = nnext(第一个节点接到后继)
-
指针后移的顺序 :
prev = cur必须在cur = nnext之前,因为 cur 此时已经是交换后的"尾节点",是下一轮的 prev。
2.3 C++ 代码实战
cpp
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
if (head == nullptr || head->next == nullptr) return head;
// 1. 虚拟头节点,统一边界处理
ListNode* newHead = new ListNode(0);
newHead->next = head;
ListNode* prev = newHead, *cur = prev->next, *next = cur->next, *nnext = next->next;
while (cur && next) {
// 2. 交换节点:修改三条指针
prev->next = next;
next->next = cur;
cur->next = nnext;
// 3. 后移指针,注意顺序
prev = cur; // prev 先移到 cur(此时 cur 是交换后的尾节点)
cur = nnext;
if (cur) next = cur->next;
if (next) nnext = next->next;
}
cur = newHead->next;
delete newHead;
return cur;
}
};
三、 重排链表 (Medium)
3.1 题目描述
链接 :143. 重排链表
给定单链表
L(0) → L(1) → ... → L(n),将其重排为L(0) → L(n) → L(1) → L(n-1) → L(2) → L(n-2) → ...,不能只改变节点值,必须实际交换节点。
3.2 深度拆解:三步分解,各个击破
画图画图画图,重要的事情说三遍~
这道题直接模拟很难,但拆成三个子问题后,每一步都是经典操作:
- 第一步:找中间节点 ------ 快慢双指针。
fast每次走两步,slow每次走一步,fast到头时slow就在中间。务必画图确认slow的落点,奇偶长度下落点不同。 - 第二步:逆序后半段 ------ 头插法。将
slow->next之后的所有节点头插到一个新的虚拟头节点后,自然得到逆序链表。注意要将前后两段断开 (slow->next = nullptr)。 - 第三步:合并两条链表 ------ 双指针交替拼接。
cur1遍历前半段,cur2遍历后半段,交替接到结果链表上。
3.3 C++ 代码实战
cpp
class Solution {
public:
void reorderList(ListNode* head) {
if (head == nullptr || head->next == nullptr || head->next->next == nullptr) return;
// 1. 找到链表的中间节点 - 快慢双指针(一定要画图考虑 slow 的落点在哪里)
ListNode* slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
// 2. 把 slow 后面的部分给逆序 - 头插法
ListNode* head2 = new ListNode(0);
ListNode* cur = slow->next;
slow->next = nullptr; // 注意把两个链表给断开
while (cur) {
ListNode* next = cur->next;
cur->next = head2->next;
head2->next = cur;
cur = next;
}
// 3. 合并两个链表 - 双指针交替拼接
ListNode* ret = new ListNode(0);
ListNode* prev = ret;
ListNode* cur1 = head, *cur2 = head2->next;
while (cur1) {
// 先放第一个链表
prev->next = cur1;
cur1 = cur1->next;
prev = prev->next;
// 再放第二个链表
if (cur2) {
prev->next = cur2;
prev = prev->next;
cur2 = cur2->next;
}
}
delete head2;
delete ret;
}
};
四、 合并 K 个升序链表 (Hard)
4.1 题目描述
链接 :23. 合并 K 个升序链表
给你一个链表数组,每个链表都已按升序排列,请将所有链表合并到一个升序链表中并返回。
4.2 深度拆解:两种思路的对比
-
解法一(小根堆) :合并两个有序链表时,双指针每次选较小的节点。推广到 K K K 个链表,我们需要每次快速找到 K K K 个头节点中最小的那个,这正是小根堆 的用武之地。将所有头节点入堆,每次弹出最小节点,接到答案链表,再将该节点的
next压入堆,时间复杂度 O ( N log K ) O(N \log K) O(NlogK)。 -
解法二(分治/递归) :逐一合并时,答案链表越来越长,后面每个小链表的元素都要参与大量无效比较。分治思路让长度相近的链表两两合并 ,类比归并排序,总比较次数从 O ( N K ) O(NK) O(NK) 优化到 O ( N log K ) O(N \log K) O(NlogK),常数更小,实践中更快。
4.3 C++ 代码实战(解法一:小根堆)
cpp
class Solution {
struct cmp {
bool operator()(const ListNode* l1, const ListNode* l2) {
return l1->val > l2->val; // 小根堆
}
};
public:
ListNode* mergeKLists(vector<ListNode*>& lists) {
// 1. 创建小根堆,让所有头节点入堆
priority_queue<ListNode*, vector<ListNode*>, cmp> heap;
for (auto l : lists)
if (l) heap.push(l);
// 2. 合并 K 个有序链表
ListNode* ret = new ListNode(0);
ListNode* prev = ret;
while (!heap.empty()) {
ListNode* t = heap.top(); heap.pop();
prev->next = t;
prev = t;
if (t->next) heap.push(t->next); // 弹出后将其 next 补入堆
}
prev = ret->next;
delete ret;
return prev;
}
};
4.4 C++ 代码实战(解法二:分治递归)
cpp
class Solution {
public:
ListNode* mergeKLists(vector<ListNode*>& lists) {
return merge(lists, 0, lists.size() - 1);
}
ListNode* merge(vector<ListNode*>& lists, int left, int right) {
if (left > right) return nullptr;
if (left == right) return lists[left];
// 1. 平分数组,让长度相近的链表合并
int mid = left + right >> 1;
ListNode* l1 = merge(lists, left, mid);
ListNode* l2 = merge(lists, mid + 1, right);
// 2. 合并两个有序链表
return mergeTwoList(l1, l2);
}
ListNode* mergeTwoList(ListNode* l1, ListNode* l2) {
if (l1 == nullptr) return l2;
if (l2 == nullptr) return l1;
ListNode head;
ListNode* cur1 = l1, *cur2 = l2, *prev = &head;
head.next = nullptr;
while (cur1 && cur2) {
if (cur1->val <= cur2->val) { prev = prev->next = cur1; cur1 = cur1->next; }
else { prev = prev->next = cur2; cur2 = cur2->next; }
}
if (cur1) prev->next = cur1;
if (cur2) prev->next = cur2;
return head.next;
}
};
五、 K 个一组翻转链表 (Hard)
5.1 题目描述
链接 :25. K 个一组翻转链表
给你链表的头节点
head,每 k k k 个节点一组进行翻转,返回修改后的链表。若剩余节点不足 k k k 个,则保持原有顺序。
5.2 深度拆解:分组 + 头插法
本题逻辑清晰,难度在于细节处理:
- 分组计数 :先遍历链表求总长度 n n n,然后 n ÷ k n \div k n÷k 得到需要逆序的组数,剩余节点直接接上即可,不需要特判。
- 组内逆序(头插法) :对每组的 k k k 个节点,依次将每个节点头插到
prev之后。头插结束后,原来的组头节点tmp自然成为了该组的组尾,是下一轮的prev。 - 关键变量
tmp:在头插开始前,先用tmp = cur记住当前组的第一个节点(头插结束后它会成为组尾),作为下一轮循环prev的新值。
5.3 C++ 代码实战
cpp
class Solution {
public:
ListNode* reverseKGroup(ListNode* head, int k) {
// 1. 先求出需要逆序多少组
int n = 0;
ListNode* cur = head;
while (cur) { cur = cur->next; n++; }
n /= k;
// 2. 重复 n 次:对长度为 k 的链表段进行头插逆序
ListNode* newHead = new ListNode(0);
ListNode* prev = newHead;
cur = head;
for (int i = 0; i < n; i++) {
ListNode* tmp = cur; // 记住组头,头插后它将成为组尾
for (int j = 0; j < k; j++) {
ListNode* next = cur->next;
cur->next = prev->next;
prev->next = cur;
cur = next;
}
prev = tmp; // 组头已变组尾,作为下一轮的 prev
}
// 3. 把不需要翻转的剩余部分接上
prev->next = cur;
cur = newHead->next;
delete newHead;
return cur;
}
};
六、 总结与避坑
💬 复盘:链表的指针操作是模板化程度很高的技巧,掌握以下规律后大部分题目都能迎刃而解。
- 虚拟头节点是标配 :只要头节点可能被修改(交换、合并、逆序),第一步就加
dummy,省去大量边界判断。 - 画图再动手:指针操作在脑子里极易绕晕,每道新题务必先画出节点变化图,再翻译成代码。
- 提前保存待用节点 :修改
cur->next之前,必须先用临时变量保存cur->next,否则后继节点将永久丢失。 - 头插法逆序的本质 :每次将
cur插到prev->next的位置,循环 k k k 次后,原来从左到右的顺序自然变为从右到左。 - 分治优于逐一合并 :合并 K K K 个链表时,分治比逐一合并的实际运行时间快很多,因为每条链表的节点参与比较的总次数从 O ( K ) O(K) O(K) 降到 O ( log K ) O(\log K) O(logK)。