LeetCode 148:排序链表|归并排序、快慢指针找左中点与链表断开

一、题目

给定链表头节点 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;

在这套归并排序写法里非常关键。

最后把整题压缩成一句话:

链表归并排序 = 左中点断链 + 递归排序左右链表 + 合并两个有序链表。

相关推荐
LabVIEW开发1 小时前
LabVIEW字符串特殊字符检测兼容
算法·labview·labview知识·labview功能·labview程序
晴天的雨.9922 小时前
[C++算法]快乐数
数据结构·c++·算法
孤狼warrior2 小时前
SCTR 五次失败的安全 BN 路由器
人工智能·python·深度学习·算法·安全·yolo
影视飓风TIM2 小时前
C++11 核心新特性完整梳理
数据结构·c++·算法
晴天的雨.9922 小时前
[C++]算法双指针 复写0
数据结构·c++·算法
橘子汽水1682 小时前
Leetcode 128,49最长连续序列,字母异位词分组
java·算法·leetcode
mmmmath_32 小时前
LeetCode.438.找到字符串中所有字母异位词
数据结构·算法·leetcode
Nil2082 小时前
leetcode 22括号生成
算法·leetcode·深度优先