目录
[1. 链表是什么](#1. 链表是什么)
[2. 为什么链表不能像数组一样按下标访问](#2. 为什么链表不能像数组一样按下标访问)
[3. LeetCode 中的 ListNode](#3. LeetCode 中的 ListNode)
[1. dummy:虚拟头结点](#1. dummy:虚拟头结点)
[2. 快慢指针](#2. 快慢指针)
[3. 断开与连接](#3. 断开与连接)
[4. 递归与迭代](#4. 递归与迭代)
[5. 尾插、头插与反转](#5. 尾插、头插与反转)
[模板 1:创建虚拟头结点](#模板 1:创建虚拟头结点)
[模板 2:遍历链表](#模板 2:遍历链表)
[模板 3:迭代反转整条链表](#模板 3:迭代反转整条链表)
[模板 4:合并两个升序链表](#模板 4:合并两个升序链表)
[模板 5:每 k 个节点分组反转](#模板 5:每 k 个节点分组反转)
[四、2. 两数相加(medium)](#四、2. 两数相加(medium))
[五、24. 两两交换链表中的节点(medium)](#五、24. 两两交换链表中的节点(medium))
[六、143. 重排链表(medium)](#六、143. 重排链表(medium))
[七、23. 合并 K 个升序链表(hard)](#七、23. 合并 K 个升序链表(hard))
[八、25. K 个一组翻转链表(hard)](#八、25. K 个一组翻转链表(hard))
[1. 五题都在做"节点重连",不是数组下标操作](#1. 五题都在做“节点重连”,不是数组下标操作)
[2. dummy 让开头统一](#2. dummy 让开头统一)
[3. 反转前先保存 next](#3. 反转前先保存 next)
[4. "断开"与"连接"是成对出现的](#4. “断开”与“连接”是成对出现的)
[5. 有序性决定选择策略](#5. 有序性决定选择策略)
[6. 递归和迭代的取舍](#6. 递归和迭代的取舍)
[十、Java 常用 API 与语句](#十、Java 常用 API 与语句)
这是一份围绕五道代表题整理的学习阶段总结:2. 两数相加、24. 两两交换链表中的节点、143. 重排链表、23. 合并 K 个升序链表、25. K 个一组翻转链表。全文只写 Java,代码采用 LeetCode 的
Solution提交形式。代码依据现有 Java 代码页中的结构、标准方法名和主要变量整理。需要特别说明:原材料中的代码页存在分页、字体编码和换行问题,文本层提取并非可靠的逐字稿;因此下面保留了可辨认的标准方法名、变量名和整体写法,但对排版断裂处做了必要的 Java 格式化与少量注释补全,不声称代码逐字提取。
一、先把链表想清楚
1. 链表是什么
链表是一串由节点连接起来的数据结构。以最常见的单链表为例,每个节点包含两部分:
java
class ListNode {
int val; // 当前节点保存的数值
ListNode next; // 指向下一个节点的引用
}
如果节点依次保存 1、2、3,结构可以画成:
head
↓
[1 | next] -> [2 | next] -> [3 | null]
这里的箭头不是数值,而是"引用关系":第一个节点的 next 指向第二个节点,最后一个节点的 next 是 null,表示后面没有节点。
2. 为什么链表不能像数组一样按下标访问
数组的元素通常连续存放,知道起始地址后,可以通过"起始地址 + 下标 × 元素大小"直接跳到 nums[i]。所以数组按下标读取通常是 O(1)。
链表的节点不要求连续存放。head 只知道第一个节点;想找第 4 个节点,必须依次读取:
head -> 第 1 个 -> 第 2 个 -> 第 3 个 -> 第 4 个
因此:
- 找链表中的第
i个节点通常需要O(i),整体可记为O(n)。 - 链表不能写
head[3],也不能写head.get(3)来访问节点。 - 访问下一个节点只能写
node.next。 - 链表在已知插入位置时,改变几个
next就能完成插入或删除,不需要像数组那样整体搬移元素。
"链表题"真正要处理的通常不是节点值,而是节点之间的 next 关系。题目若说不能修改节点内部的值,就意味着必须通过改指针完成顺序变化。
3. LeetCode 中的 ListNode
平台一般已经提供 ListNode,提交时不需要再次定义。为了让代码能够独立阅读,下面先列出常见结构:
java
public class ListNode {
int val;
ListNode next;
ListNode() {
}
ListNode(int val) {
this.val = val;
}
ListNode(int val, ListNode next) {
this.val = val;
this.next = next;
}
}
在下面的题解代码中,假定平台已经提供这个结构;如果在自己的 Java 文件中运行,可以把它放在 Solution 外面。this.val = val 的意思是:把构造方法收到的参数 val 保存到当前对象的成员变量 val 中。
二、链表题的五个常见动作
1. dummy:虚拟头结点
dummy、newHead、ret 都经常被用作一个新建的辅助节点。它不属于真正的答案,只是放在真正头结点之前:
dummy -> head -> ...
它的中文含义是虚拟头结点 ,不是"假的答案节点"。虚拟头结点的 next 才是最终链表头。
为什么有用?如果没有它,删除头节点、交换第一对节点、合并时第一次插入都可能需要单独写特殊分支;有了它,所有节点都可以按"把节点接到某个节点后面"的统一逻辑处理。
常用固定写法:
java
ListNode dummy = new ListNode(0);
ListNode prev = dummy;
// 处理过程中不断修改 prev.next
return dummy.next;
dummy 最后的值 0 通常没有意义,不会被返回;返回的是 dummy.next。
2. 快慢指针
快慢指针不只用于数组,也常用于链表中点:
java
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
slow每轮走一步。fast每轮走两步。fast到末尾时,slow大约在中间。
中点题一定要用小规模链表手算,确认 slow 落在哪个节点。不同的循环初始位置和条件,可能让中点落在中间两个节点中的不同一个。
3. 断开与连接
链表分段时,不能只把某个指针移到后面就算完成;还要修改原链表中的 next:
java
ListNode cur = slow.next;
slow.next = null;
slow.next = null 把前半段和后半段真正断开。连接时则是:
java
prev.next = cur;
链表操作的核心可以概括成:先保存将要失去的路线,再改 next,最后把各段重新接好。
4. 递归与迭代
- 迭代 :使用
while、for和若干指针逐步处理。链表题更容易画图跟踪,额外栈空间通常为O(1)。 - 递归 :把大问题拆成小问题,函数返回小问题的链表结果,再连接起来。代码可能短,但每次递归调用都占用调用栈,深度为
O(log k)或O(n),要留意边界。
本板块中,23 题展示了堆和递归/分治两种方向;其余题以迭代为主。
5. 尾插、头插与反转
尾插:把新节点接到答案尾部。
java
prev.next = node;
prev = prev.next;
头插:把当前节点插到一段链表的最前面。
java
ListNode next = cur.next; // 先保存后继
cur.next = head2.next; // 当前节点指向原来的第一个节点
head2.next = cur; // 辅助头指向当前节点
cur = next; // 继续处理原来的后继
不断头插会把顺序反过来。反转时最容易丢失后继,所以通常第一句就是保存 next。
三、链表固定模板
下面这些模板不是要机械背诵,而是帮助把题目中的动作翻译成指针操作。
模板 1:创建虚拟头结点
java
ListNode newHead = new ListNode(0);
ListNode prev = newHead;
newHead 是辅助起点,prev 是当前答案尾部。最后返回 newHead.next。
模板 2:遍历链表
java
ListNode cur = head;
while (cur != null) {
// 使用 cur.val
cur = cur.next;
}
cur 从头节点开始,每次沿 next 向后走一个节点。若写成 while (cur.next != null),循环体内就不会处理最后一个节点,必须确认这是否符合题意。
模板 3:迭代反转整条链表
java
ListNode prev = null;
ListNode cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
每一轮完成四件事:保存后继、反转当前箭头、推进 prev、推进 cur。循环结束时 prev 是新头。
模板 4:合并两个升序链表
java
ListNode head = new ListNode(0);
ListNode prev = head;
while (cur1 != null && cur2 != null) {
if (cur1.val <= cur2.val) {
prev.next = cur1;
prev = cur1;
cur1 = cur1.next;
} else {
prev.next = cur2;
prev = cur2;
cur2 = cur2.next;
}
}
if (cur1 != null) prev.next = cur1;
if (cur2 != null) prev.next = cur2;
return head.next;
两个链表都升序时,当前尚未合并的最小节点一定在 cur1 或 cur2 中,比较这两个节点即可。一个链表耗尽后,另一个链表剩余部分已经有序,可以整体接上。
模板 5:每 k 个节点分组反转
java
int n = 0;
ListNode cur = head;
while (cur != null) {
cur = cur.next;
n++;
}
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.next = cur; // 剩余不足 k 个的部分保持原样
return newHead.next;
这是本板块 Java 代码中保留的"先数完整组,再重复头插"的写法。理解它的关键是:prev 指向已处理部分的尾;每组开始时的 tmp 在本组反转完成后变成尾巴,下一组要接在它后面。
四、2. 两数相加(medium)
1. 一句话题目描述
两个链表把两个非负整数按个位在前的逆序形式保存,需要逐位相加并返回同样逆序存储的和。
- 官方题目链接:2. 两数相加
- 链表代码中的标准方法名:
addTwoNumbers - 例子:
[2,4,3]表示342,[5,6,4]表示465,结果是[7,0,8],表示807。
2. 核心难点
- 链表是逆序存储,正好允许从头节点开始模拟小学竖式加法。
- 两条链表可能长度不同,较短链表走完后仍要继续处理较长链表。
- 每一位相加可能产生进位,最后还可能多出一个最高位节点。
- 结果链表从第一位开始不断生成,使用虚拟头结点可以避免第一次插入的特殊处理。
3. 算法思路:从链表结构出发
设:
l1: 2 -> 4 -> 3 表示 342
l2: 5 -> 6 -> 4 表示 465
从头开始处理:
2 + 5 + 0 = 7,结果节点为7,新的进位为0。4 + 6 + 0 = 10,结果节点保存个位0,进位为1。3 + 4 + 1 = 8,结果节点为8,进位回到0。- 两个链表和进位都处理完,返回
7 -> 0 -> 8。
每次让 t 表示"当前这一位相加后、还没有拆开的总和"。
当前位 = t % 10
下一轮进位 = t / 10
循环条件必须包括 t != 0,这样 9 + 1 之类在链表末尾产生的最高位不会被漏掉。
4. 节点与指针含义
l1、l2:传入的两条链表头节点。cur1:当前正在读取的第一条链表节点。cur2:当前正在读取的第二条链表节点。newHead:结果链表的虚拟头结点。prev:结果链表当前尾指针,下一次新节点接在prev.next。t:当前位的加法总和,也承担进位记录。t % 10:当前结果位。t /= 10:把当前总和转换成下一轮要带的进位。
5. 简洁 Java 代码
平台已提供 ListNode 结构时,提交下面的 Solution 即可:
java
class Solution {
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode cur1 = l1, cur2 = l2;
ListNode newHead = new ListNode(0); // 创建一个虚拟头结点,方便记录结果
ListNode prev = newHead; // 尾插操作的尾指针
int t = 0; // 记录进位
while (cur1 != null || cur2 != null || t != 0) {
// 先加上第一个链表
if (cur1 != null) {
t += cur1.val;
cur1 = cur1.next;
}
// 加上第二个链表
if (cur2 != null) {
t += cur2.val;
cur2 = cur2.next;
}
prev.next = new ListNode(t % 10);
prev = prev.next;
t /= 10;
}
return newHead.next;
}
}
6. 关键语句逐行解释
ListNode cur1 = l1, cur2 = l2;
不直接移动参数 l1、l2,而是用 cur1、cur2 作为游标。这样变量含义更清楚:参数代表链表入口,cur 代表当前处理位置。
ListNode newHead = new ListNode(0);
ListNode prev = newHead;
先造一个虚拟头。第一次生成结果节点时,不必判断结果链表是否为空;以后每次都执行同一套尾插逻辑。
while (cur1 != null || cur2 != null || t != 0)
只要第一条未结束、第二条未结束、或者还有进位,就必须继续循环。三个条件是"或",不是"且"。
t += cur1.val;
cur1 = cur1.next;
先读取当前节点值,再沿 next 移向下一位。不能先移动再读取,否则会跳过当前位。
prev.next = new ListNode(t % 10);
new ListNode(...) 创建结果节点。% 10 只保留个位,例如 10 % 10 = 0。
prev = prev.next;
t /= 10;
尾指针前进到刚生成的节点;整数除法 /= 10 丢掉当前个位,留下进位,例如 10 / 10 = 1。
return newHead.next;
虚拟头不属于答案,真正的第一位在 newHead.next。
7. Java 函数、变量与语法中文含义
| 名称 | 中文含义 |
|---|---|
addTwoNumbers |
add two numbers,两个数相加 |
ListNode |
链表节点类型 |
l1、l2 |
list 1、list 2,两条输入链表 |
cur1、cur2 |
current 1/current 2,当前节点 |
newHead |
新的头结点,这里是虚拟头 |
prev |
previous,前一个节点;这里也充当结果尾指针 |
t |
临时加法总和,同时保存进位 |
val |
节点保存的值 |
next |
下一个节点的引用 |
new |
创建对象 |
% |
取余,t % 10 取得个位 |
/= |
除后赋值,t /= 10 等于 t = t / 10 |
8. 时间和空间复杂度
设两条链表长度分别为 m、n:
- 时间复杂度:
O(max(m, n))。 - 结果节点空间:
O(max(m, n)),因为需要生成答案链表。 - 除返回结果所需节点外的额外指针空间:
O(1)。
9. 手算示例
示例 A:产生进位并多出最高位
l1 = [9,9,9]
l2 = [1]
| 轮次 | cur1 |
cur2 |
旧 t |
相加后 t |
新节点 t % 10 |
下一进位 t / 10 |
|---|---|---|---|---|---|---|
| 1 | 9 | 1 | 0 | 10 | 0 | 1 |
| 2 | 9 | 无 | 1 | 10 | 0 | 1 |
| 3 | 9 | 无 | 1 | 10 | 0 | 1 |
| 4 | 无 | 无 | 1 | 1 | 1 | 0 |
返回 [0,0,0,1],表示 1000。
示例 B:长度不相同
l1 = [2,4,3],l2 = [5,6]
依次是 2+5=7、4+6=10、3+1=4,结果为 [7,0,4]。
10. 易错点
- 把逆序链表
[2,4,3]当成243,实际它表示342。 - 循环条件写成
cur1 != null && cur2 != null,会漏掉较长链表和最后进位。 - 加完当前节点后忘记
cur1 = cur1.next或cur2 = cur2.next,造成死循环。 - 只把
t % 10放入结果,却忘记t /= 10,进位不会正确传到下一位。 - 最后返回
newHead,把虚拟节点的0也返回了;正确是newHead.next。 - 题目要求实际操作节点时,本题是新建结果节点,不要误以为可以只修改输入节点的值来代替逐位结果。
11. 下次遇到类似题的判断依据
看到"链表按位存储数字""个位在前""逐位相加"时,可以直接联想到:
- 两个游标分别走两条链表。
- 用一个整数保存进位。
- 循环条件包含"任一链表未结束或仍有进位"。
- 结果从低位到高位生成,天然适合尾插。
- 结果需要不断追加时,优先考虑虚拟头结点。
五、24. 两两交换链表中的节点(medium)
1. 一句话题目描述
把链表中相邻的两个节点交换位置,按 1、2 变 2、1,再处理下一对;不能修改节点值,只能修改节点连接关系。
- 官方题目链接:24. 两两交换链表中的节点
- 标准方法名:
swapPairs - 例子:
[1,2,3,4]变成[2,1,4,3];空链表和单节点链表不变。
2. 核心难点
一对节点不是孤立的,交换时还要保持前一段和后一段连接。交换:
prev -> cur -> next -> nnext
之后应变成:
prev -> next -> cur -> nnext
如果没有先保存 nnext,改动 next.next 或 cur.next 后,后面的路线可能丢失。还要注意交换完成后,下一轮的 prev 应该落在刚交换完的第二个节点 cur 上。
3. 算法思路:从链表结构出发
- 空链表或只有一个节点时,没有完整的一对可交换,直接返回
head。 - 建立
newHead -> head,这样第一对节点也有统一的前驱。 - 令
cur指向一对节点的第一个,next指向第二个,nnext指向第二对的第一个。 - 按三句连接代码完成交换:
prev.next = nextnext.next = curcur.next = nnext
- 更新指针,继续处理下一对。
- 若最后剩一个节点,循环条件不满足,它自然保持原位置。
4. 节点与指针含义
newHead:虚拟头结点,保证第一对节点也有prev。prev:当前待交换一对节点之前的节点。cur:当前一对中的第一个节点。next:当前一对中的第二个节点;交换后成为这一对的头。nnext:当前第二个节点后面的节点,用于接回未处理部分。
这里变量名 next 容易和成员变量 cur.next 混淆:next 是一个局部变量,表示当前第二个节点;cur.next 是节点 cur 的后继引用。
5. 简洁 Java 代码
class Solution {
public ListNode swapPairs(ListNode head) {
if (head == null || head.next == null) return head;
ListNode newHead = new ListNode(0);
newHead.next = head;
ListNode prev = newHead;
ListNode cur = prev.next;
ListNode next = cur.next;
ListNode nnext = next.next;
while (cur != null && next != null) {
// 交换节点
prev.next = next;
next.next = cur;
cur.next = nnext;
// 修改指针
prev = cur; // 注意顺序
cur = nnext;
if (cur != null) next = cur.next;
if (next != null) nnext = next.next;
}
return newHead.next;
}
}
6. 关键语句逐行解释
if (head == null || head.next == null) return head;
没有节点或只有一个节点,不能组成相邻的一对。提前返回也避免后面读取 next.next 时空指针。
newHead.next = head;
把虚拟头接到原头前面。此时初始关系为 newHead -> 1 -> 2。
ListNode prev = newHead, cur = prev.next, next = cur.next, nnext = next.next;
一次把局部窗口中的四个位置保存下来:前驱、第一节点、第二节点、后继。这样下面改箭头时不会找不到后续节点。
prev.next = next;
next.next = cur;
cur.next = nnext;
这是交换的核心。三句执行后,四个节点的连接从 prev -> cur -> next -> nnext 变成 prev -> next -> cur -> nnext。
prev = cur;
交换后,cur 已经是这对节点的后一个;它正好是下一对节点的前驱。注释中的"注意顺序"很重要,不能先把 prev 改成 next。
cur = nnext;
把游标移到尚未处理部分的第一个节点。
if (cur != null) next = cur.next;
if (next != null) nnext = next.next;
只有确认引用非空后才访问 .next,防止最后一对之后发生空指针异常。
7. Java 函数、变量与语法中文含义
| 名称 | 中文含义 |
|---|---|
swapPairs |
swap pairs,交换成对节点 |
head |
链表头节点 |
newHead |
新头、虚拟头 |
prev |
previous,当前对之前的节点 |
cur |
current,当前第一个节点 |
next |
当前第二个节点;英文是下一个 |
nnext |
next next,第二个节点后面的节点 |
head.next |
头节点指向的下一个节点 |
== null |
判断引用为空 |
&& |
并且;两边都为真才为真 |
| ` |
8. 时间和空间复杂度
- 每个节点最多被访问和改指针常数次。
- 时间复杂度:
O(n)。 - 额外空间复杂度:
O(1),只用了若干引用;虚拟头是一个常数个辅助节点。
9. 手算示例
初始:
newHead -> 1 -> 2 -> 3 -> 4 -> null
cur next nnext
第一轮:
prev.next = next : newHead -> 2
next.next = cur : 2 -> 1
cur.next = nnext : 1 -> 3
得到:
newHead -> 2 -> 1 -> 3 -> 4
更新:prev = 1,cur = 3,next = 4,nnext = null。
第二轮得到:
newHead -> 2 -> 1 -> 4 -> 3 -> null
最后返回 newHead.next,即节点 2。
10. 易错点
- 只交换
cur.val和next.val。虽然输出可能看似正确,但题目要求交换节点,且明确不能改值。 - 先执行
cur.next = next等错误连接,形成环或丢失后继。 - 忘记虚拟头,第一对交换后没有办法更新真正的头节点。
prev = next,导致下一轮前驱位置错误;交换后前驱应是cur。- 最后只有一个节点时仍访问
next.next,导致空指针。 - 更新
cur后没有重新计算next、nnext。
11. 下次遇到类似题的判断依据
看到"相邻节点成对交换""只能改指针"时,先画出:
前驱 -> 第一个 -> 第二个 -> 后继
然后固定思考三根新箭头:
前驱 -> 第二个 -> 第一个 -> 后继
只要每次先保存后继,并用虚拟头统一处理开头,题目就从"交换整条链表"变成"重复处理一个四节点窗口"。
六、143. 重排链表(medium)
1. 一句话题目描述
把 L0 -> L1 -> ... -> Ln-1 -> Ln 重排为 L0 -> Ln -> L1 -> Ln-1 -> L2 -> Ln-2 -> ...,实际改变节点连接而不是只修改节点值。
- 官方题目链接:143. 重排链表
- 标准方法名:
reorderList - 方法返回类型是
void:它直接在原链表上重排,调用结束后head所在链表就是答案。
2. 核心难点
这道题把三个经典链表动作串在一起:
- 找中间节点:快慢指针。
- 反转后半段:头插法。
- 交替合并前半段和反转后的后半段:双指针尾插。
最容易出错的地方是中点之后必须断开。如果不执行 slow.next = null,后半段和前半段仍连在一起,合并时可能出现环或重复走节点。
3. 算法思路:三段操作
假设:
1 -> 2 -> 3 -> 4 -> 5
第一步:找中间节点
slow 最终在 3 附近
代码中从 slow = head、fast = head 开始,循环结束后 slow 落在中间位置。取 slow.next 作为后半段开头。
第二步:断开并反转后半段
先保存:
ListNode cur = slow.next;
slow.next = null;
前半段为:
1 -> 2 -> 3 -> null
后半段为:
4 -> 5 -> null
头插反转后得到:
5 -> 4 -> null
第三步:交替合并
分别用 cur1、cur2 指向两段:
cur1: 1 -> 2 -> 3
cur2: 5 -> 4
依次接入 1、5、2、4、3,得到目标结果。
4. 节点与指针含义
head:原链表头,也是重排后第一个节点。slow:慢指针,寻找中点。fast:快指针,每轮前进两步。head2:反转后半段使用的虚拟头结点。cur:反转阶段当前待处理节点。next:提前保存的后继,防止头插改指针后失去路线。cur1:第一段当前节点。cur2:反转后第二段当前节点。ret:合并阶段虚拟头结点。prev:合并结果的尾指针。
5. 简洁 Java 代码
下面保留了代码中的 head2、cur1、cur2、ret、prev 等写法。ret 只是合并辅助起点;由于合并节点本来就是 head 中的节点,最终不需要从 ret 返回新头。
class Solution {
public void reorderList(ListNode head) {
// 处理边界情况
if (head == null || head.next == null || head.next.next == null) return;
// 1. 找链表的中间节点 - 快慢双指针
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 2. 把 slow 后面的部分逆序 - 头插法
ListNode head2 = new ListNode(0);
ListNode cur = slow.next;
slow.next = null; // 把两个链表分离
while (cur != null) {
ListNode next = cur.next;
cur.next = head2.next;
head2.next = cur;
cur = next;
}
// 3. 合并两个链表 - 双指针
ListNode cur1 = head, cur2 = head2.next;
ListNode ret = new ListNode(0);
ListNode prev = ret;
while (cur1 != null) {
// 先放第一个链表
prev.next = cur1;
prev = cur1;
cur1 = cur1.next;
// 再合并第二个链表
if (cur2 != null) {
prev.next = cur2;
prev = cur2;
cur2 = cur2.next;
}
}
}
}
6. 关键语句逐行解释
if (head == null || head.next == null || head.next.next == null) return;
长度为 0、1、2 时,重排后顺序不需要复杂处理或结果与原结构等价。提前返回让后面安全地读取中点和后半段。
while (fast != null && fast.next != null)
只有快指针和它的后继都存在时,才能执行 fast.next.next。每轮 slow 走一步,fast 走两步。
ListNode cur = slow.next;
slow.next = null;
先把后半段入口保存到 cur,再断开前半段尾节点的 next。这两句顺序不能颠倒,否则后半段入口会丢失。
ListNode next = cur.next;
头插前先保存后继。接下来 cur.next 会被改写,若不保存,原来的后半段路线就无法继续遍历。
cur.next = head2.next;
head2.next = cur;
这两句把 cur 插入到 head2 后面。连续处理 4、5 时,先插入 4,再把 5 插到 4 前面,所以结果是 5 -> 4。
prev.next = cur1;
prev = cur1;
cur1 = cur1.next;
把第一段当前节点接到答案尾部,然后保存它为新的尾,再把第一段游标推进。
if (cur2 != null) { ... }
第一段可能比第二段多一个节点,所以第二段的接入要单独判断;第一段耗尽时,目标顺序已经完成。
7. Java 函数、变量与语法中文含义
| 名称 | 中文含义 |
|---|---|
reorderList |
reorder list,重新排列链表 |
slow |
慢指针 |
fast |
快指针 |
head2 |
第二段链表的虚拟头 |
cur |
current,反转阶段当前节点 |
next |
当前节点后继 |
cur1、cur2 |
两段链表的当前节点 |
ret |
return/result 的常见缩写,合并辅助头 |
prev |
合并结果尾指针 |
void |
方法不返回对象,直接修改原链表 |
8. 时间和空间复杂度
- 找中点、反转、合并都只线性扫描,时间复杂度:
O(n)。 - 额外指针空间:
O(1)。 - 代码中创建了常数个虚拟节点,仍属于
O(1)额外空间。 - 没有使用数组或
ArrayList保存所有节点,因此不会因为存储节点副本而增加O(n)空间。
9. 手算示例
偶数个节点:[1,2,3,4]
找中点后可以分成:
第一段:1 -> 2
第二段:3 -> 4
反转第二段:
4 -> 3
交替合并:
1 -> 4 -> 2 -> 3
奇数个节点:[1,2,3,4,5]
代码的 slow 会落在中间节点附近,取 slow.next 后,前半段包含中间节点:
第一段:1 -> 2 -> 3
第二段:4 -> 5
反转并交替合并:
1 -> 2 -> 3 与 5 -> 4
=> 1 -> 5 -> 2 -> 4 -> 3
10. 易错点
- 找中点后不写
slow.next = null,两段没有真正分开。 - 反转时没有先保存
next,导致后半段节点丢失。 - 误修改节点值,而不是改变
next关系。 - 合并时只接第一段,忘记交替接第二段。
- 忘记判断
cur2 != null,在奇数长度链表中访问空指针。 - 把
head2本身当成答案节点;真正的后半段头是head2.next。 - 快慢指针循环条件写错,导致
fast.next.next空指针或中点位置不符合后续分段。
11. 下次遇到类似题的判断依据
看到"头尾交替""前一个、最后一个、前两个、倒数第二个"时,链表不能按下标随机取末尾,优先拆成:
- 快慢指针找中间。
- 反转后半段,让原来的尾部变成可顺序遍历的头部。
- 两段交替合并。
这类"重排顺序"题常常不是一个新算法,而是找中点 + 反转 + 合并三个固定模块的组合。
七、23. 合并 K 个升序链表(hard)
1. 一句话题目描述
给定一个链表数组 lists,每条链表都已按升序排列,把所有链表合并成一条仍然升序的链表。
- 官方题目链接:23. 合并 K 个升序链表
- 标准方法名:
mergeKLists - 代码中还使用了辅助方法:
merge、mergeTwoList - 例子:
[1,4,5]、[1,3,4]、[2,6]合并为[1,1,2,3,4,4,5,6]。
2. 核心难点
合并两条升序链表时,只需比较两个当前头;合并 K 条时,每次要从 K 个当前候选头中找到最小值。
如果每次线性扫描所有 K 条链表,选择最小头的成本是 O(K)。使用小根堆可以把"找当前最小值"降为 O(log K)。另一种思路是分治,把链表两两合并,避免一个答案越来越长后反复和很多链表比较。
本题 Java 代码中明确给出了两种思路:
- 小根堆:
PriorityQueue<ListNode>。 - 递归/分治:
merge平分数组,mergeTwoList合并两条链表。
3. 方法一:小根堆算法思路
第一步:把所有非空链表头放入堆
对于:
1 -> 4 -> 5
1 -> 3 -> 4
2 -> 6
初始堆中只有 1、1、2 三个节点,而不是把所有节点都放进去。
第二步:取出堆顶最小节点
堆顶是当前所有候选节点中值最小的节点。把它接到结果尾部。
第三步:补入被取出节点的后继
假设取出第一条链表的 1,它的下一个节点是 4。这时第一条链表新的候选头变成 4,把 4 放入堆。
每条链表在任意时刻最多贡献一个候选头,因此堆大小最多为 K。
第四步:重复到堆为空
堆为空说明所有链表节点都已经接入答案。
4. 小根堆 Java 代码
import java.util.PriorityQueue;
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
PriorityQueue<ListNode> heap =
new PriorityQueue<>((v1, v2) -> v1.val - v2.val);
// 将所有头结点加入到小根堆中
for (ListNode l : lists) {
if (l != null) heap.offer(l);
}
// 合并
ListNode ret = new ListNode(0);
ListNode prev = ret;
while (!heap.isEmpty()) {
ListNode t = heap.poll();
prev.next = t;
prev = t;
if (t.next != null) heap.offer(t.next);
}
return ret.next;
}
}
关键语句解释
PriorityQueue<ListNode> heap =
new PriorityQueue<>((v1, v2) -> v1.val - v2.val);
PriorityQueue 是 Java 的优先队列。默认最小元素优先;这里通过比较器告诉它:节点值小的优先,因此它充当小根堆。
比较器中的 (v1, v2) -> v1.val - v2.val 是 Lambda 写法:
- 小于
0:v1排在v2前。 - 等于
0:优先级相同。 - 大于
0:v2排在前。
在数值可能接近 Integer.MAX_VALUE 和 Integer.MIN_VALUE 的自定义场景中,v1.val - v2.val 可能整数溢出,更稳妥的写法是 Integer.compare(v1.val, v2.val);本题给定范围下原写法可正常工作。为了保留代码中的变量和写法,上面仍展示原有比较器形式。
for (ListNode l : lists) {
if (l != null) heap.offer(l);
}
增强 for 依次取出链表数组中的每条链表;空链表没有候选节点,不能把 null 放入 PriorityQueue,所以要判断 l != null。
ListNode t = heap.poll();
poll() 取出并删除当前最小节点。peek() 只能查看而不删除,本题要向后推进,所以必须使用 poll()。
prev.next = t;
prev = t;
将堆顶节点接到结果尾部,并让尾指针前进。
if (t.next != null) heap.offer(t.next);
被取出的节点来自某条有序链表,它的后继是该链表新的候选头。把它放回堆,下一次就能继续参与全局最小值竞争。
小根堆中的节点与变量含义
lists:链表数组。l:数组遍历到的某条链表头。heap:小根堆/优先队列。ret:结果虚拟头结点。prev:结果尾指针。t:本轮从堆中取出的最小节点。offer:向优先队列加入元素。poll:取出并删除队首元素。isEmpty:判断队列是否为空。- Lambda 比较器:定义节点的优先级顺序。
小根堆复杂度
设所有链表节点总数为 N,链表数量为 K:
- 初始放入头节点:
O(K log K),更准确地说只对非空链表执行。 - 每个节点出堆、最多一个后继入堆:
O(N log K)。 - 总时间复杂度:
O(N log K)。 - 堆空间:
O(K)。 - 结果使用原节点重新连接,除堆外没有为每个节点新建副本。
5. 方法二:递归/分治
为什么分治更均衡
若把链表依次合并:
先合并第 1 条,再与第 2 条合并,再与第 3 条合并......
中间结果会越来越长,后面的链表需要和越来越长的答案比较。
分治则先让相近规模的链表两两合并,再合并合并结果:
(0,1)、(2,3)、(4,5)、(6,7)
-> 两两结果继续两两合并
-> 最后合并成一个
递归树高度约为 log K,每一层总共处理全部 N 个节点,因此时间为 O(N log K)。
分治步骤
- 如果
left > right,当前没有链表,返回null。 - 如果
left == right,当前区间只有一条链表,不需要合并,直接返回lists[left]。 - 取
mid = (left + right) / 2,平分数组。 - 递归合并左半区和右半区。
- 调用
mergeTwoList合并得到的两条升序链表。
分治 Java 代码
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
return merge(lists, 0, lists.length - 1);
}
public ListNode merge(ListNode[] lists, int left, int right) {
if (left > right) return null;
if (left == right) return lists[left];
// 1. 平分数组
int mid = (left + right) / 2;
// [left, mid] [mid + 1, right]
// 2. 递归处理左右两部分
ListNode l1 = merge(lists, left, mid);
ListNode l2 = merge(lists, mid + 1, right);
// 3. 合并两个有序链表
return mergeTwoList(l1, l2);
}
public ListNode mergeTwoList(ListNode l1, ListNode l2) {
if (l1 == null) return l2;
if (l2 == null) return l1;
// 合并两个有序链表
ListNode head = new ListNode(0);
ListNode cur1 = l1, cur2 = l2, prev = head;
while (cur1 != null && cur2 != null) {
if (cur1.val <= cur2.val) {
prev.next = cur1;
prev = cur1;
cur1 = cur1.next;
} else {
prev.next = cur2;
prev = cur2;
cur2 = cur2.next;
}
}
if (cur1 != null) prev.next = cur1;
if (cur2 != null) prev.next = cur2;
return head.next;
}
}
分治代码关键语句解释
return merge(lists, 0, lists.length - 1);
从链表数组的完整下标区间开始递归。若 lists.length == 0,右端点是 -1,会触发 left > right,安全返回 null。
if (left == right) return lists[left];
递归出口:一条已经有序的链表不需要自己和自己合并。
ListNode l1 = merge(lists, left, mid);
ListNode l2 = merge(lists, mid + 1, right);
先得到两半各自合并好的结果,再用"两条升序链表合并"这个已知子问题继续处理。
if (l1 == null) return l2;
if (l2 == null) return l1;
有一边为空时,另一边本来已经升序,直接返回即可。
if (cur1.val <= cur2.val)
相等时选择 cur1 不影响正确性,且与代码中的写法保持一致。
if (cur1 != null) prev.next = cur1;
if (cur2 != null) prev.next = cur2;
循环结束时最多有一条链表未耗尽,把它整体接上;它内部已升序,不需要逐节点再比较。
分治变量与 API 中文含义
| 名称 | 中文含义 |
|---|---|
mergeKLists |
合并 K 条链表 |
merge |
递归合并数组下标区间 |
mergeTwoList |
合并两条链表;保留代码中的单数 List 命名 |
left、right |
当前递归区间左右下标 |
mid |
中间下标 |
l1、l2 |
两个递归结果链表 |
head |
两链表合并时的虚拟头 |
cur1、cur2 |
两条链表当前节点 |
PriorityQueue |
优先队列,常用作堆 |
offer |
入队/入堆 |
poll |
出队/取出队首 |
Integer.compare |
更安全地比较两个整数 |
分治复杂度
- 每一层递归总共处理
N个节点。 - 递归层数约为
log K。 - 时间复杂度:
O(N log K)。 - 递归调用栈空间:
O(log K)。 - 合并本身只使用常数个指针;返回的链表节点沿用原节点。
6. 手算示例:小根堆
输入:
lists = [
1 -> 4 -> 5,
1 -> 3 -> 4,
2 -> 6
]
堆和结果变化:
| 操作 | 堆中候选值 | 结果 |
|---|---|---|
| 初始 | 1, 1, 2 |
空 |
取 1,补入 4 |
1, 2, 4 |
1 |
取 1,补入 3 |
2, 3, 4 |
1,1 |
取 2,补入 6 |
3, 4, 6 |
1,1,2 |
取 3,补入 4 |
4, 4, 6 |
1,1,2,3 |
依次取 4、4、5、6 |
逐渐为空 | 1,1,2,3,4,4,5,6 |
7. 易错点
- 把所有节点都放进堆,虽然可能得到结果,但没有利用"每条链表只需一个当前候选头"的结构。
lists中有空链表,却直接heap.offer(l),PriorityQueue通常不允许null。poll()后忘记把t.next放回堆,导致某条链表后面的节点丢失。- 合并两条链表时忘记接剩余部分。
- 分治递归没有处理
left > right,空数组时可能越界。 - 把
left + right / 2当成(left + right) / 2;括号能明确表达意图。 - 递归函数名与题目方法名混乱;代码中保留的是
mergeTwoList,不要在调用处写成另一个未定义名称。 - 误以为"hard"一定要新建所有节点;本题可以直接复用原节点的
next。
8. 下次遇到类似题的判断依据
看到"多个已经有序的链表合并"时:
- 先确认每条链表是否已经有序;有序才可以只比较当前头。
K较小或需要逐次取全局最小值时,想到小根堆。- 想把多个链表变成两两合并时,想到分治。
- 题目要求总节点数为
N时,堆和分治都常见为O(N log K)。 - 合并两个链表的模板是基础,23 题只是把"两条合并"扩展成"K 条管理"。
八、25. K 个一组翻转链表(hard)
1. 一句话题目描述
从链表头开始,每 k 个节点作为一组进行翻转;最后不足 k 个的节点保持原有顺序,并且只能修改节点连接而不能改节点值。
- 官方题目链接:25. K 个一组翻转链表
- 标准方法名:
reverseKGroup - 例子:
[1,2,3,4,5],k=2变成[2,1,4,3,5];k=3变成[3,2,1,4,5]。
2. 核心难点
- 最后一组不足
k个时不能翻转。 - 每组翻转后,要把它接到前一组后面,再接回后续部分。
- 头插过程中
prev的含义会变化,必须理解"已处理部分尾指针"。 - 题目要求实际交换节点,不能把节点值放入数组再倒序写回。
3. 算法思路:先数完整组,再逐组头插
设链表长度为 n:
- 先用
cur遍历整条链表,数出n。 n /= k得到完整的、需要翻转的组数。- 创建
newHead,让prev指向已处理部分的尾。 - 每组开始时,
cur是本组第一个节点,把它保存为tmp。 - 重复
k次头插:把当前节点插到prev后面,形成反转顺序。 - 本组处理完成后,原来的第一节点
tmp已经变成本组尾,把prev = tmp,为下一组做准备。 - 循环结束时,
cur指向不足k个的剩余部分,执行prev.next = cur原样接回。
例如:
1 -> 2 -> 3 -> 4 -> 5,k = 2
完整组数 5 / 2 = 2。前两组翻转为 2 -> 1、4 -> 3,剩余 5 原样接上。
4. 节点与指针含义
head:输入链表头。n:链表总节点数,随后变成完整组数。cur:当前还没有被本组头插处理的节点。newHead:整个结果的虚拟头结点。prev:已处理部分的尾节点;本组翻转时也是头插位置的前一个节点。tmp:本组反转前的第一个节点;翻转后它是本组尾。i:第几组。j:本组内第几个节点。next:头插前保存的后继节点。
5. 简洁 Java 代码
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
// 1. 先求出需要逆序多少组
int n = 0;
ListNode cur = head;
while (cur != null) {
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.next = cur;
return newHead.next;
}
}
6. 关键语句逐行解释
while (cur != null) {
cur = cur.next;
n++;
}
先沿 next 走完整条链表,得到总长度 n。这一步不改变链表结构。
n /= k;
整数除法只保留完整组数。例如 5 / 2 = 2,说明只能翻转两组,最后一个节点不能动。
ListNode newHead = new ListNode(0);
ListNode prev = newHead;
cur = head;
prev 一开始在虚拟头。cur 指向第一组的第一个节点。
ListNode tmp = cur;
记录本组原来的头。因为整组反转后,原头会成为本组尾;下一组要接在它后面,所以不能丢掉它。
ListNode next = cur.next;
本组头插前保存还没处理的节点。没有这句,下面改写 cur.next 后就无法继续走原链表。
cur.next = prev.next;
prev.next = cur;
假设已经处理过的部分是:
prev -> A
执行后,当前 cur 插到 prev 后面:
prev -> cur -> A
对本组节点依次头插,顺序自然反转。
cur = next;
继续处理原链表中下一个尚未插入本组的节点。
prev = tmp;
本组反转后,tmp 是本组尾。将 prev 放到这里,下一组的第一个节点就会插到本组后面。
prev.next = cur;
所有完整组处理完后,cur 可能是 null,也可能指向不足 k 个的剩余节点。直接连接即可保持剩余部分原顺序。
7. Java 函数、变量与语法中文含义
| 名称 | 中文含义 |
|---|---|
reverseKGroup |
reverse in K-group,按 K 个一组反转 |
head |
输入头节点 |
n |
节点数,之后表示完整组数 |
newHead |
虚拟头结点 |
prev |
已处理部分的前驱/尾指针 |
cur |
current,当前待处理节点 |
tmp |
temporary,临时保存本组原头 |
next |
当前节点原来的后继 |
i |
外层组编号 |
j |
内层组内编号 |
for |
按次数重复执行 |
n /= k |
n = n / k,保留完整组数 |
8. 时间和空间复杂度
- 第一次数长度:
O(n)。 - 第二次逐节点头插:每个节点最多处理一次,也是
O(n)。 - 总时间复杂度:
O(n)。 - 额外空间复杂度:
O(1),没有使用数组、集合或递归调用栈。 - 节点本身没有复制,都是通过改
next复用原节点。
9. 手算示例
k = 2:1 -> 2 -> 3 -> 4 -> 5
总长度 n = 5,完整组数 n / k = 2。
第一组 1、2
初始:
newHead -> 1 -> 2 -> 3 -> 4 -> 5
prev = newHead, cur = 1
头插 1:
newHead -> 1
头插 2 到 prev 后:
newHead -> 2 -> 1 -> 3 -> 4 -> 5
此时 tmp = 1,所以组尾是 1,令 prev = 1。
第二组 3、4
把 3、4 依次头插到 1 后面,得到:
newHead -> 2 -> 1 -> 4 -> 3 -> 5
此时 cur = 5,不足 k 个,执行 prev.next = cur,结果不改变剩余节点。
k = 3:1 -> 2 -> 3 -> 4 -> 5
完整组数为 1。第一组反转得到:
3 -> 2 -> 1 -> 4 -> 5
剩余 4 -> 5 保持原顺序。
10. 易错点
- 每次只向后看
k个节点,发现不够时才翻转;若边界连接写错,容易破坏原链表。当前代码先统计完整组数,逻辑更直观。 - 忘记
n /= k,把最后不足k个的部分也反转。 - 头插前没有保存
next,后面的节点会丢失。 - 每组结束后令
prev = cur;此时cur已经指向下一组,正确的本组尾应是tmp。 - 最后忘记
prev.next = cur,剩余节点会从结果中断开。 - 只交换节点值而不改
next,不符合题意。 k是正整数,题目保证k >= 1;自定义调用若允许k == 0,n / k会除零异常。
11. 下次遇到类似题的判断依据
看到"每 k 个一组翻转""不足一组保持原序"时,先把链表分成完整组和尾部残组:
- 需要
O(1)空间时,优先用指针原地反转。 - 先确认一组是否完整,再做组内反转;本代码用总长度除以
k统一解决。 - 记录组的原头,因为它反转后会变成组尾。
- 组内反转完成后,连接"前一组尾 -> 新组头 -> 下一组起点"。
- 最后一段不足
k个时直接接回,不做任何指针翻转。
九、五题共同规律
1. 五题都在做"节点重连",不是数组下标操作
链表节点没有直接下标访问。题目中的"交换、重排、合并、翻转",都要落实为:
某个节点.next = 另一个节点;
写代码前画箭头,先明确每个节点改动前后的后继。
2. dummy 让开头统一
- 2 题:结果从第一位开始尾插。
- 24 题:第一对节点也需要一个前驱。
- 23 题:合并结果需要一个稳定的起点。
- 25 题:第一组反转后需要接回虚拟头。
- 143 题:代码中
head2、ret也承担辅助头作用。
常见返回规律:
return dummy.next;
3. 反转前先保存 next
只要代码要改写 cur.next,几乎都应该先问:原来的后继还需要吗?若需要,先写:
ListNode next = cur.next;
这是 143 和 25 的共同核心,也是普通整链表反转的固定习惯。
4. "断开"与"连接"是成对出现的
- 143:
slow.next = null断开两段,合并时重新设置多个prev.next。 - 24:先把一对节点从旧顺序中重新接出,再接回
nnext。 - 25:组内头插后,
prev.next = cur接回未翻转尾段。 - 23:选出节点后把它接到结果尾,并把它的后继放回候选结构。
5. 有序性决定选择策略
- 两条链表已升序:只比较
cur1.val与cur2.val。 - 多条链表已升序:堆维护所有当前头,或分治成多次两链表合并。
- 没有升序保证时,不能直接使用"两头比较就够"的合并逻辑。
6. 递归和迭代的取舍
- 需要稳定的
O(1)额外空间,且指针过程容易画出来:优先迭代。 - 多个同类子问题可以平分,代码用递归表达更清楚:23 题的分治适合递归。
- 递归并不等于更快;复杂度还要看每层处理多少节点。
十、Java 常用 API 与语句
1. new ListNode(value)
ListNode node = new ListNode(5);
创建一个节点,节点的 val 是 5,next 默认是 null。
prev.next = new ListNode(t % 10);
创建结果节点,并把它接到 prev 后面。
2. node.next
cur = cur.next;
沿链表向后走一步。
cur.next = prev;
把当前节点的后继改成 prev,是反转箭头的核心。
访问之前必须确认 cur != null,否则 cur.next 会抛出 NullPointerException。
3. while
while (cur != null) {
cur = cur.next;
}
适合"不知道要走多少步、走到链表结束"的遍历。
条件中的 && 和 || 必须根据题意选择:
cur1 != null && cur2 != null:两者都还有节点时处理,常见于两链表比较合并。cur1 != null || cur2 != null:任意一者还有节点就处理,常见于两数相加。
4. for
for (int i = 0; i < n; i++) {
for (int j = 0; j < k; j++) {
// 处理一组中的 k 个节点
}
}
外层表示组数,内层表示组内节点数。25 题中 i、j 的含义就是这个层次关系。
5. PriorityQueue
PriorityQueue<ListNode> heap =
new PriorityQueue<>((v1, v2) -> v1.val - v2.val);
PriorityQueue 是优先队列,常用于实现堆。配合比较器后,poll() 每次取出值最小的节点。
常见方法:
offer(x):把x加入队列。poll():取出并删除优先级最高的元素。peek():只查看队首,不删除。isEmpty():判断是否为空。
6. ArrayList 和数组:本板块是否需要
这五道题的核心 Java 实现都不需要 ArrayList:
- 2 题直接生成结果节点。
- 24 题直接交换指针。
- 143 题直接分段、反转、合并。
- 23 题用
ListNode[]保存输入链表,堆或递归管理链表头。 - 25 题直接原地分组反转。
如果用 ArrayList<ListNode> 暂存所有节点,也能完成某些重排题,但会额外使用 O(n) 空间,失去链表原地操作的练习价值。ListNode[] lists 是"链表头组成的数组",而不是把所有节点拍平成数组。
7. 递归调用
return merge(lists, 0, lists.length - 1);
递归函数要明确:
- 参数表示什么区间。
- 什么时候停止。
- 返回值代表什么结果。
- 两个子结果如何合并。
23 题中 left == right 是单链表区间的递归出口,left > right 是空区间的递归出口。
十一、复杂度对照
设单条链表节点数为 n,多链表总节点数为 N,链表数量为 K。
| 题目 | 时间复杂度 | 额外空间 | 主要原因 |
|---|---|---|---|
| 2. 两数相加 | O(max(m,n)) |
O(1)(不计结果节点) |
两个游标逐位相加 |
| 24. 两两交换 | O(n) |
O(1) |
每个节点只处理常数次 |
| 143. 重排链表 | O(n) |
O(1) |
找中点、反转、合并各一遍 |
| 23. K 个升序链表:小根堆 | O(N log K) |
O(K) |
每个节点进出堆 |
| 23. K 个升序链表:分治 | O(N log K) |
O(log K) |
分治层数约 log K |
| 25. K 组翻转 | O(n) |
O(1) |
数长度一次、处理节点一次 |
关于空间的表述要区分:
- 返回一个新结果链表所需的节点不通常算作"额外工作空间"。
- 23 题堆中的
K个候选节点是额外管理结构,需要O(K)。 - 递归分治的调用栈是额外空间
O(log K)。 - 143 和 25 虽然创建了常数个
new ListNode(0),仍是O(1)。
十二、看到题目后的判断顺序
面对一条链表题,可以按下面顺序快速判断:
- 题目要求改值还是改节点? 若说不能改值,就必须画
next的变化。 - 是否需要处理头节点? 若第一步可能改变头,创建
dummy/newHead。 - 是遍历、反转、交换、合并还是重排? 先归类,再套相应固定模板。
- 是否需要后继节点? 只要会改
cur.next,先保存ListNode next = cur.next。 - 是否需要断开链表? 分段时明确写出哪一个
next要置为null。 - 循环条件处理谁? 判断是
cur != null、两个都非空,还是任意一个非空;最后一个节点是否会被处理。 - 是否有完整分组?
k组题先确认最后是否足够k个,不足时保持原样。 - 是否有序? 两条有序链表用双指针;多条有序链表考虑
PriorityQueue或分治。 - 返回哪个节点? 有虚拟头时通常返回
dummy.next,不是dummy。 - 用最小例子画一遍。 至少测试空链表、单节点、两节点、奇数长度、偶数长度、不同长度、最后不足一组和产生进位。
十三、最后的阶段性速记
链表不能按下标直接访问,只能沿 next 走。
需要统一处理开头:dummy -> head。
要改 cur.next:先保存 next。
要反转:保存后继,反箭头,推进 prev/cur。
要找中点:slow 一步,fast 两步。
要重排:中点 + 断开 + 反转 + 交替合并。
要合并有序链表:比较当前头,较小者接到尾部。
要合并 K 条:小根堆维护 K 个当前头,或分治两两合并。
要 K 个一组翻转:先确认完整组,组内头插,剩余原样接回。
最后常返回 dummy.next。
五道题表面不同:一题模拟加法、一题交换、一题重排、一题多路合并、一题分组翻转;但它们共同训练的是同一件事:看清每个节点当前指向谁,保存将要失去的后继,再用最少的 next 修改把链表接成目标结构。