一、题目
给定链表头节点 head,要求将链表按升序排列,并返回排序后的链表。
例如:
4 → 2 → 1 → 3
排序后:
1 → 2 → 3 → 4
这道题最经典的做法是:
归并排序
因为链表非常适合:
找中点
→ 断开
→ 分别排序
→ 合并两个有序链表
二、为什么链表适合归并排序?
数组归并排序也是:
先分
再合
链表同样如此。
例如:
4 → 2 → 1 → 3
先拆:
4 → 2
1 → 3
继续:
4 2
1 3
单个节点本身就是有序的。
然后开始合并:
4 + 2
→
2 → 4
以及:
1 + 3
→
1 → 3
最后:
2 → 4
+
1 → 3
合并成:
1 → 2 → 3 → 4
所以整道题可以压缩成:
排序链表
=
归并排序
1. 找中点
2. 断链
3. 左右递归排序
4. 合并两个有序链表
三、最终标准代码
class Solution {
public ListNode sortList(ListNode head) {
// 0 个或 1 个节点,本身有序
if (head == null || head.next == null) {
return head;
}
// 找左中点
ListNode slow = head;
ListNode fast = head.next;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 从中间断开
ListNode rightHead = slow.next;
slow.next = null;
// 左右分别排序
ListNode left = sortList(head);
ListNode right = sortList(rightHead);
// 合并两个有序链表
return merge(left, right);
}
private ListNode merge(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
cur.next = l1;
l1 = l1.next;
} else {
cur.next = l2;
l2 = l2.next;
}
cur = cur.next;
}
cur.next = l1 != null ? l1 : l2;
return dummy.next;
}
}
四、第一步:递归终止条件
代码:
if (head == null || head.next == null) {
return head;
}
为什么?
因为:
0 个节点
或者
1 个节点
天然已经有序。
例如:
null
不用排序。
或者:
4
也不用排序。
所以:
return head;
即可。
五、这里为什么必须用 ||
这里非常容易写错。
正确:
head == null || head.next == null
其中:
||
=
或者
意思:
head 为空,或者只有一个节点。
满足任意一种,就结束递归。
而:
&&
=
并且
要求两个条件同时成立。
如果错误写成:
if (head == null && head.next == null)
不仅逻辑不对,还可能出现空指针。
因为如果:
head == null
后面继续访问:
head.next
就有问题。
所以这里固定:
0 个 OR 1 个
→ ||
六、Java 中 && 和 ||
可以简单记:
&& = 与 / 并且
|| = 或 / 或者
例如:
while (l1 != null && l2 != null)
意思:
l1 和 l2 都不为空,才能继续比较。
而:
if (head == null || head.next == null)
意思:
head 为空,或者只有一个节点,都直接返回。
七、第二步:快慢指针找中点
代码:
ListNode slow = head;
ListNode fast = head.next;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
其中:
slow 每次走 1 步
fast 每次走 2 步
这是典型快慢指针。
但是这里最容易产生一个疑问:
为什么
slow = head,但是fast = head.next?
因为这里不是单纯为了:
找到"某个中点"
而是为了:
让 slow 停在左半部分最后一个节点。
八、fast=head 和 fast=head.next 有什么区别?
对于偶数长度链表:
1 → 2 → 3 → 4
有两个中间节点:
2 和 3
可以称为:
2 = 左中点
3 = 右中点
不同初始化会得到不同结果。
九、slow=head,fast=head
如果:
ListNode slow = head;
ListNode fast = head;
对于:
1 → 2 → 3 → 4
开始:
slow = 1
fast = 1
第一轮:
slow = 2
fast = 3
第二轮:
slow = 3
fast = null
所以最终:
slow = 3
也就是:
右中点
十、slow=head,fast=head.next
如果:
ListNode slow = head;
ListNode fast = head.next;
开始:
slow = 1
fast = 2
第一轮:
slow = 2
fast = 4
下一轮发现:
fast.next == null
结束。
最终:
slow = 2
也就是:
左中点
所以可以先形成一个经验:
slow=head
fast=head
→ 偶数长度时通常得到右中点
slow=head
fast=head.next
→ 偶数长度时通常得到左中点
十一、为什么排序链表需要左中点?
因为我们需要把:
1 → 2 → 3 → 4
拆成:
1 → 2
3 → 4
所以希望:
slow = 2
然后:
ListNode rightHead = slow.next;
得到:
rightHead = 3
再:
slow.next = null;
真正拆成:
1 → 2 → null
3 → 4 → null
非常自然。
十二、真正关键的是两个节点情况
理解:
fast = head.next
最好的例子不是四个节点,而是:
4 → 2
归并排序最终必须把它拆成:
4
2
否则递归无法继续缩小。
十三、如果 fast=head 会发生什么?
初始化:
slow = head;
fast = head;
也就是:
slow = 4
fast = 4
循环:
while (fast != null && fast.next != null)
可以进入。
执行:
slow = slow.next;
fast = fast.next.next;
得到:
slow = 2
fast = null
然后:
ListNode rightHead = slow.next;
结果:
rightHead = null
再:
slow.next = null;
但原来的:
4 → 2
根本没有被真正拆成:
4
2
左边还是:
4 → 2
之后再次:
sortList(head)
还是对:
4 → 2
排序。
又进入同样流程。
于是可能造成:
无限递归
最终:
StackOverflowError
十四、如果 fast=head.next 呢?
对于:
4 → 2
初始化:
slow = 4
fast = 2
判断:
fast != null
成立。
但是:
fast.next != null
不成立。
所以循环不进入。
最终:
slow = 4
然后:
ListNode rightHead = slow.next;
得到:
rightHead = 2
再:
slow.next = null;
成功拆成:
4 → null
2 → null
这样两边长度都比原来小。
递归才能正常结束。
所以排序链表这里:
ListNode slow = head;
ListNode fast = head.next;
不是随便写的。
它真正保证的是:
偶数长度时 slow 停在左中点,特别是两个节点时能够正确断链。
十五、是不是所有快慢指针题都这样初始化?
不是。
快慢指针没有固定初始化方式。
真正应该问的是:
这道题最后希望 slow 停在哪里?
不同题目目的不同。
十六、环形链表为什么都从 head 开始?
例如 Floyd 判环:
ListNode slow = head;
ListNode fast = head;
因为目的只是:
一个走 1 步
一个走 2 步
判断是否相遇
这里不需要:
左中点
或者:
右中点
所以都从:
head
开始即可。
十七、普通找中点时为什么也常常都从 head 开始?
如果题目要求:
返回链表中间节点。
对于:
1 → 2 → 3 → 4
如果要求偶数长度时返回第二个中间节点:
3
那么就可以:
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
最终:
slow = 3
正好是右中点。
十八、三种典型情况一起记
1. 环形链表
slow = head;
fast = head;
目的:
追赶并相遇
2. 普通找中点
slow = head;
fast = head;
偶数长度通常:
得到右中点
3. 链表归并排序
slow = head;
fast = head.next;
偶数长度通常:
得到左中点
目的:
方便把链表断成左右两半
所以不要死背:
快慢指针一定怎么初始化
应该记:
初始化方式取决于我希望 slow 最终停在哪里。
十九、第三步:断链
代码:
ListNode rightHead = slow.next;
slow.next = null;
这两句是整个递归非常关键的部分。
例如:
4 → 2 → 1 → 3
找到:
slow = 2
先:
ListNode rightHead = slow.next;
得到:
rightHead = 1
此时仍然:
4 → 2 → 1 → 3
然后:
slow.next = null;
真正断开:
4 → 2 → null
1 → 3 → null
二十、为什么必须 slow.next=null?
因为递归必须保证:
每次问题规模都变小。
如果不写:
slow.next = null;
那么:
sortList(head)
可能还是原来的完整链表。
递归不会缩小。
就可能:
不断递归
所以:
slow.next = null;
本质是在:
真正把左半部分切出来。
二十一、第四步:左右递归排序
代码:
ListNode left = sortList(head);
ListNode right = sortList(rightHead);
注意:
必须接住递归返回值。
不能只写:
sortList(head);
sortList(rightHead);
为什么?
例如:
4 → 2
排序以后:
2 → 4
原来的:
head = 4
已经不是排序后的头节点。
所以:
sortList(head)
会返回:
2
必须:
ListNode left = sortList(head);
接住这个新的头节点。
右边同理。
二十二、递归返回值为什么很重要?
例如:
左链表:
4 → 2
排序前:
head
↓
4 → 2
排序后:
2 → 4
↑
新的头
所以返回值:
ListNode
就是:
排好序之后的新头节点。
因此:
ListNode left = sortList(head);
ListNode right = sortList(rightHead);
是必须的。
二十三、第五步:合并两个有序链表
左右排序以后,例如:
left:
2 → 4
right:
1 → 3
现在只需要做我们熟悉的:
合并两个有序链表。
创建:
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
然后:
while (l1 != null && l2 != null)
只要两边都有节点,就比较。
二十四、为什么 merge 里必须是 &&?
正确:
while (l1 != null && l2 != null)
意思:
两个链表都还有节点,才能比较
val。
如果写:
while (l1 != null || l2 != null)
假设:
l1 = null
l2 != null
因为:
false || true
=
true
还会进入循环。
然后:
l1.val
直接访问空对象。
所以这里必须:
两边都有节点
→ &&
二十五、merge 怎么工作?
例如:
l1:
2 → 4
l2:
1 → 3
第一次:
2 vs 1
1 小:
dummy → 1
第二次:
2 vs 3
2 小:
dummy → 1 → 2
第三次:
4 vs 3
3 小:
dummy → 1 → 2 → 3
此时:
l2 == null
循环结束。
剩下:
4
直接接上:
cur.next = l1 != null ? l1 : l2;
最终:
1 → 2 → 3 → 4
二十六、三目运算符
这句:
cur.next = l1 != null ? l1 : l2;
等价于:
if (l1 != null) {
cur.next = l1;
} else {
cur.next = l2;
}
因为主循环退出以后:
至少一个链表为空
剩下的那个链表已经有序,所以全部接上即可。
二十七、dummy.next=null 为什么不用写?
如果创建:
ListNode dummy = new ListNode(0);
Java 对象字段:
next
默认就是:
null
所以:
dummy.next = null;
没有必要。
直接:
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
即可。
二十八、为什么不建议把 cur 叫 head?
有时会写:
ListNode head = dummy;
虽然逻辑上可以,但容易和原链表的:
head
混淆。
更推荐:
ListNode cur = dummy;
因为它表示:
当前正在构建结果链表的位置。
变量含义更清晰。
二十九、我第一次写代码时容易出现的几个错误
错误 1:终止条件用 &&
错误:
if (head == null && head.next == null)
正确:
if (head == null || head.next == null)
因为:
空链表 OR 单节点
→ 都结束
错误 2:fast 从 head 开始
slow = head;
fast = head;
在两个节点时会让:
slow 跑到第二个节点
不利于当前断链模板。
正确:
slow = head;
fast = head.next;
让 slow 停在左中点。
错误 3:递归结果不接住
错误:
sortList(head);
sortList(rightHead);
正确:
ListNode left = sortList(head);
ListNode right = sortList(rightHead);
错误 4:最后没有 return
错误:
merge(left, right);
正确:
return merge(left, right);
因为:
sortList()
本身需要返回排好序链表的新头节点。
错误 5:merge 用 ||
错误:
while (l1 != null || l2 != null)
正确:
while (l1 != null && l2 != null)
因为只有两边都非空时才能:
l1.val
l2.val
进行比较。
三十、归并排序完整递归过程
例如:
4 → 2 → 1 → 3
第一次:
4 → 2
1 → 3
递归左边:
4
2
合并:
2 → 4
递归右边:
1
3
合并:
1 → 3
最终:
2 → 4
+
1 → 3
合并:
1 → 2 → 3 → 4
整个过程:
4 2 1 3
/ \
4 2 1 3
/ \ / \
4 2 1 3
\ / \ /
2 4 1 3
\ /
1 2 3 4
归并排序就是:
一路拆到单节点,再一路合回来。
三十一、复杂度分析
链表长度为:
n
每一层合并总共需要访问:
n
个节点。
时间:
O(n)
链表每次大约折半:
n
n/2
n/4
...
递归层数约:
log n
所以总时间复杂度:
O(n log n)
递归调用栈:
O(log n)
因此:
时间复杂度:O(n log n)
额外递归空间:O(log n)
三十二、面试怎么讲?
可以这样回答:
我使用归并排序。首先通过快慢指针找到链表的中点,为了方便把偶数长度链表切成左右两半,我让 slow 从 head 开始、fast 从 head.next 开始,这样 slow 会停在左中点。
然后保存
slow.next作为右半部分头节点,并令slow.next = null真正断开链表。接着递归排序左右两部分,注意递归返回的是排序后的新头节点,所以需要分别接住返回值。
最后使用合并两个有序链表的方法合并左右结果。
时间复杂度是 O(n log n),递归栈空间复杂度是 O(log n)。
三十三、快慢指针初始化的面试理解
这次最重要的新理解是:
快慢指针不是固定都从 head 开始。
先问:
我希望 slow 最终停在哪里?
再决定初始化。
可以记:
环形链表:
slow=head
fast=head
目的:
追赶相遇
普通中点:
slow=head
fast=head
偶数时:
slow 通常到右中点
归并排序:
slow=head
fast=head.next
偶数时:
slow 到左中点
方便断链
三十四、为什么这次不能只死背 fast=head.next?
因为以后你还会遇到很多快慢指针题。
如果只是记:
排序链表 fast=head.next
当然也能做题。
但更重要的是知道:
不同初始化
→ 改变 slow 最后的停留位置
这才是可以迁移到其他题的能力。
以后看到快慢指针,可以先想:
我要判断环?
我要找右中点?
我要找左中点?
我要找倒数第 k 个?
目的不同,初始化和移动方式可能都不同。
三十五、面试前 30 秒速记
看到:
排序链表
直接想到:
归并排序
=
找中点 + 断链 + 递归 + merge
主函数:
if (head == null || head.next == null) {
return head;
}
ListNode slow = head;
ListNode fast = head.next;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
ListNode rightHead = slow.next;
slow.next = null;
ListNode left = sortList(head);
ListNode right = sortList(rightHead);
return merge(left, right);
merge:
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
cur.next = l1;
l1 = l1.next;
} else {
cur.next = l2;
l2 = l2.next;
}
cur = cur.next;
}
cur.next = l1 != null ? l1 : l2;
return dummy.next;
核心记忆:
0/1 个节点
→ return
fast=head.next
→ 找左中点
slow.next=null
→ 真正断开
递归返回值要接住
merge 两边都非空
→ &&
三十六、最终总结
这道题最核心不是单独某一行代码,而是一条完整思维链:
链表要排序
↓
适合归并
↓
快慢指针找左中点
↓
slow.next 断链
↓
左右递归排序
↓
接住递归返回的新头节点
↓
合并两个有序链表
其中这次最值得记住的知识点是:
slow=head,fast=head.next并不是所有快慢指针题的模板,而是因为排序链表需要让 slow 停在左中点,从而方便把链表真正拆成两个更小的子问题。
尤其是两个节点:
4 → 2
必须能够成功拆成:
4
2
这也是为什么:
fast = head.next;
在这套归并排序写法里非常关键。
最后把整题压缩成一句话:
链表归并排序 = 左中点断链 + 递归排序左右链表 + 合并两个有序链表。