力扣hot100之148:排序链表
力扣hot100之148:排序链表

LeetCode 148:排序链表(Sort List)题解

Sort List 是 LeetCode Hot 100 里面一道非常经典的链表题。

这道题的难点在于:

它不是让我们把链表转成数组以后再排序,而是要求我们直接在链表上完成排序,并且时间复杂度要尽量优秀。

如果是数组排序,我们可能很自然会想到快速排序、归并排序,或者直接调用排序函数。但放到链表里,很多数组中常见的操作就不那么方便了。

比如:

  • 数组可以通过下标快速访问中间位置

  • 链表不能随机访问,只能一步一步往后走

  • 数组适合原地交换

  • 链表更适合做“拆分”和“合并”

所以这道题最适合链表的数据结构特点的方法,其实就是:

归并排序

你给出的这份 Java 代码,正是这道题最标准、也最常见的一种写法:

快慢指针找中点 + 递归归并排序 + 合并两个有序链表

这种做法的整体思路非常清晰:

  1. 先用快慢指针把链表从中间断开

  2. 递归地把左半部分排好序

  3. 递归地把右半部分排好序

  4. 最后把两个有序链表合并起来

本文就结合这段代码,系统地讲一下这道题的解法。


一、题目描述

给你链表的头结点 head,请将其按升序排列并返回排序后的链表。

示例 1:

输入:head = [4,2,1,3]
输出:[1,2,3,4]

示例 2:

输入:head = [-1,5,3,4,0]
输出:[-1,0,3,4,5]

示例 3:

输入:head = []
输出:[]

二、这道题为什么适合用归并排序?

很多同学一开始会想:

能不能像数组一样,直接对链表做快速排序?

理论上当然可以,但实现起来会比较麻烦,因为链表不支持随机访问,交换节点也不如数组方便。

而归并排序非常适合链表,原因有两个:

1. 链表很容易从中间拆成两段

只要用快慢指针,我们就能在线性时间内找到中点,把链表断开。

2. 两个有序链表非常容易合并

这其实就是链表的基本操作之一,只需要不断比较两个链表当前节点的值,把较小的那个接到结果链表后面即可。

所以整道题就变成了一个非常典型的“分治”问题:

  • 大问题:排序整个链表

  • 小问题:排序左半部分、排序右半部分

  • 合并:把两个有序链表合并成一个有序链表

这就是归并排序的核心思想。


三、代码整体结构

先看你给出的代码:

/**
 * Definition for singly-linked list.
 * 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; }
 * }
 */
class Solution {
    public ListNode sortList(ListNode head) {
        if (head == null || head.next == null)
            return head;
        ListNode fast = head.next, slow = head;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }
        ListNode tmp = slow.next;
        slow.next = null;
        ListNode left = sortList(head);
        ListNode right = sortList(tmp);
        ListNode h = new ListNode(0);
        ListNode res = h;
        while (left != null && right != null) {
            if (left.val < right.val) {
                h.next = left;
                left = left.next;
            } else {
                h.next = right;
                right = right.next;
            }
            h = h.next;
        }
        h.next = left != null ? left : right;
        return res.next;
    }
}

这段代码主要可以分成三部分:

  • 递归终止条件

  • 找中点并断开链表

  • 合并两个有序链表

下面分别来看。


四、递归终止条件

if (head == null || head.next == null)
    return head;

这一步很好理解。

如果链表为空,或者链表只有一个节点,那么它本身就已经是有序的了,不需要再继续拆分。

所以这里直接返回当前链表即可。

这也是归并排序递归结束的条件。


五、如何找到链表中点?

ListNode fast = head.next, slow = head;
while (fast != null && fast.next != null) {
    slow = slow.next;
    fast = fast.next.next;
}

这里使用的是经典的快慢指针

  • slow 每次走一步

  • fast 每次走两步

fast 走到链表末尾时,slow 就会停在链表的中间附近。

为什么这里是 fast = head.next

这一点非常关键。

如果写成 fast = head,在某些偶数长度链表里,左右两部分的划分可能会不太理想。

这里让 fasthead.next 开始,可以让 slow 最终停在“前半段的最后一个节点”上,这样更方便我们直接断链。

例如链表:

4 -> 2 -> 1 -> 3

初始化:

  • slow = 4

  • fast = 2

循环过程:

  • 第一轮后:slow = 2fast = 3

  • 此时 fast.next == null,循环结束

最后 slow 指向值为 2 的节点,也就是前半段的最后一个节点。


六、如何把链表断成两部分?

ListNode tmp = slow.next;
slow.next = null;

这两行代码非常重要。

假设当前链表是:

4 -> 2 -> 1 -> 3

如果 slow 停在 2 这个节点,那么:

  • slow.next 就是后半部分的头节点 1

  • tmp 记录这个位置

  • 然后把 slow.next = null

这样链表就被真正拆成了两段:

左边:4 -> 2
右边:1 -> 3

注意,这一步一定不能少。

如果你只找中点而不把链表断开,那么递归时左右两边实际上还是连在一起的,程序就会出问题,甚至可能死递归。


七、递归排序左右两部分

ListNode left = sortList(head);
ListNode right = sortList(tmp);

这里就是归并排序里的“分治”。

  • 左半部分继续递归排序

  • 右半部分继续递归排序

直到每一部分都只剩一个节点或者为空时,递归结束。

这样返回上来时,左右两边就已经分别是有序链表了。


八、如何合并两个有序链表?

ListNode h = new ListNode(0);
ListNode res = h;
while (left != null && right != null) {
    if (left.val < right.val) {
        h.next = left;
        left = left.next;
    } else {
        h.next = right;
        right = right.next;
    }
    h = h.next;
}
h.next = left != null ? left : right;
return res.next;

这一部分其实就是“合并两个有序链表”的标准模板。

1. 先创建一个虚拟头节点

ListNode h = new ListNode(0);
ListNode res = h;

这里的 h 用来不断往后接节点。

res 则用来保存最终结果链表的头部位置,因为后面 h 会不断往后移动,如果不额外保存头节点,最后就找不到结果链表的起点了。

2. 比较左右链表当前节点

while (left != null && right != null) {
    if (left.val < right.val) {
        h.next = left;
        left = left.next;
    } else {
        h.next = right;
        right = right.next;
    }
    h = h.next;
}

每次从 leftright 当前节点中选一个较小的接到结果链表后面。

然后把对应链表的指针后移。

3. 把剩余部分直接接上

h.next = left != null ? left : right;

因为左右两边本身已经是有序链表,所以当其中一边走完以后,另一边剩下的节点一定已经有序,直接整体接到后面即可。

4. 返回结果

return res.next;

因为 res 指向的是虚拟头节点,所以真正的头节点要从 res.next 开始返回。


九、用一个例子完整模拟一下

我们以这组数据为例:

head = [4,2,1,3]

第一步:第一次拆分

原链表:

4 -> 2 -> 1 -> 3

找到中点并断开后:

左边:4 -> 2
右边:1 -> 3

第二步:递归排序左边 4 -> 2

继续拆分:

左边:4
右边:2

两个长度为 1 的链表天然有序,开始合并:

2 -> 4

第三步:递归排序右边 1 -> 3

继续拆分:

左边:1
右边:3

合并后得到:

1 -> 3

第四步:合并两个有序链表

现在左右两边分别是:

2 -> 4
1 -> 3

合并过程:

  • 比较 21,先接 1

  • 比较 23,接 2

  • 比较 43,接 3

  • 最后把剩余的 4 接上

最终结果:

1 -> 2 -> 3 -> 4

十、时间复杂度分析

1. 时间复杂度:O(n log n)

归并排序的时间复杂度分析和数组版本是一样的。

  • 每一层递归都会遍历整条链表,把节点拆分或者合并,总成本是 O(n)

  • 递归层数大约是 log n

所以总时间复杂度是:

O(n log n)

这也是这道题比较理想的时间复杂度。

2. 空间复杂度:O(log n)

这份代码不是迭代版,而是递归版。

所以额外空间主要来自递归调用栈。

递归深度大约是 log n,因此空间复杂度为:

O(log n)

如果是严格要求常数级额外空间,那么通常需要写成自底向上的迭代归并排序,实现会复杂不少。


十一、这段代码的优点

你这份代码写得很简洁,而且是面试和刷题里非常经典的一版。

它的优点主要有:

1. 思路标准

这是链表排序最常见、最稳定的做法。

2. 非常符合链表特性

链表不适合随机访问,但非常适合拆分和合并,所以归并排序比快速排序更自然。

3. 代码结构清晰

整个流程就是:

  • 找中点

  • 断链

  • 递归排序

  • 合并结果

每一步都很明确,阅读成本比较低。


十二、这道题容易出错的地方

这题虽然思路标准,但细节上还是有几个常见坑。

1. 忘记断链

slow.next = null;

这一句必须有。

否则左右两部分没有真正分开,递归时会反复处理同一段链表,导致死循环或者结果错误。

2. 合并后忘记返回 res.next

因为 res 是虚拟头节点,所以最后真正返回的应该是:

return res.next;

而不是直接返回 res

3. 快慢指针初始化不当

这里写成:

ListNode fast = head.next, slow = head;

是比较合适的。

如果写法不同,也许依然能做出来,但要特别小心偶数长度链表的划分是否正确。


十三、完整代码

/**
 * Definition for singly-linked list.
 * 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; }
 * }
 */
class Solution {
    public ListNode sortList(ListNode head) {
        if (head == null || head.next == null)
            return head;

        ListNode fast = head.next, slow = head;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }

        ListNode tmp = slow.next;
        slow.next = null;

        ListNode left = sortList(head);
        ListNode right = sortList(tmp);

        ListNode h = new ListNode(0);
        ListNode res = h;
        while (left != null && right != null) {
            if (left.val < right.val) {
                h.next = left;
                left = left.next;
            } else {
                h.next = right;
                right = right.next;
            }
            h = h.next;
        }
        h.next = left != null ? left : right;
        return res.next;
    }
}

十四、总结

这道 排序链表 本质上就是一道链表上的归并排序

它最核心的思想有三点:

  1. 用快慢指针找到链表中点

  2. 递归地排序左右两部分

  3. 合并两个有序链表

相比数组,链表不适合随机访问,但特别适合做“拆分”和“合并”,所以归并排序几乎就是这道题最自然的解法。

如果你把这题真正理解透了,那么下面这些链表题也会更容易上手:

  • 合并两个有序链表

  • 合并 K 个升序链表

  • 链表中点相关题目

  • 链表归并排序相关扩展

这道题不只是让你学会一个排序方法,更重要的是帮助你建立一个意识:

遇到链表排序时,要优先考虑归并排序,而不是照搬数组的思路。

如果对您有帮助的话,能否支持一下博主?
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇