LeetCode 148:Sort List(链表排序)完整解析:从冒泡到归并

这篇文章记录的是自己刷 LeetCode 148「Sort List」时的思考过程:

从一开始想用冒泡排序交换链表节点位置,到最后理解并实现 O(n log n)、O(1) 额外空间的归并排序链表版本。

题目链接:LeetCode 148. Sort List。leetcode

题目概述

给你一个单链表的头结点 head,请将其按升序排序,并返回排序后的链表头结点。链表长度范围为 [0, 5 * 10^4],节点值在 [-10^5, 10^5] 之间。进阶要求是时间复杂度 O(n log n),额外空间复杂度 O(1)。leetcode

初始想法:冒泡排序 + 交换链表节点

最直观的想法是冒泡排序:不停比较相邻两个节点,如果前一个比后一个大,就交换它们在链表中的位置。对于链表,可以通过调整指针而不是交换值来实现"交换节点"。csdn+1​

这种做法可以工作,但有两个明显问题:

  • 时间复杂度是 O(n²)。每一趟需要从头遍历链表,整体重复多次,不满足题目进阶要求的 O(n log n)。geeksforgeeks+1
  • 在数据量接近 5 * 10⁴ 时,O(n²) 很容易超时。
    所以这道题推荐的做法不是冒泡,而是归并排序链表。

为什么链表适合用归并排序

数组排序时,常见的高效算法有快速排序、堆排序、归并排序等;但在链表上,归并排序有天然优势:baeldung+1​

  • 快速排序依赖随机访问(按下标访问)做分区,在链表上实现既不方便又不够高效。stackoverflow
  • 堆排序需要数组形式的堆结构,也同样依赖下标。
  • 归并排序只需顺序遍历与指针操作:
    • 拆分:通过快慢指针把链表一分为二。
    • 合并 :按值大小,像"合并两个有序链表"一样把两个有序段合起来。
      归并排序对长度为 n 的链表只会遍历 O(log n) 层,每层合并代价 O(n),总时间复杂度 O(n log n)。如果使用迭代自底向上的写法,额外空间还可以做到 O(1)。题解中通常用递归版,虽然严格说递归栈会占 O(log n) 空间,但在面试和本题环境下一般是可以接受的。geeksforgeeks+2

Top-Down 归并排序链表:整体思路

所谓 Top-Down,就是"先递归拆分到底,再一层层往上合并"。整体逻辑可以分成三步:分裂、递归排序、合并。geeksforgeeks+2​

递归终止条件

当前链表 head 为:

  • 空链表 head == NULL,或
  • 单节点链表 head->next == NULL

这两种情况,链表长度 ≤ 1,天然有序,直接返回 head。这就是递归的返回条件。scaler+1​

用快慢指针找到中点,把链表一分为二

使用 slow、fast 两个指针:

  • slow 每次走 1 步。
  • fast 每次走 2 步。
  • 循环条件一般写成 while (fast != NULL && fast->next != NULL)。geeksforgeeks+1

同时用一个 prev 指针记录 slow 的前一个节点,以便之后把链表从中间断开:prev->next = NULL

循环结束时:

  • slow 位于中间节点(奇数长度时是真·中点,偶数长度时是"中偏右"或"中偏左",根据写法略有不同,整体不影响排序正确性)。
  • 左半段:[head ... prev]。
  • 右半段:[slow ... tail]。baeldung+1

对左右两半分别递归排序 + merge 合并

  • 对左半段递归 left = sortList(left)
  • 对右半段递归 right = sortList(right)
  • 这两个递归返回时,各自已经"局部有序"。
  • 调用 merge(left, right) 把两个有序链表按升序合并成一个更长的有序链表,然后返回给上一层。真正的排序行为就发生在这里的 merge 中。geeksforgeeks+1

"递归只拆不排"?L 和 R 为什么有序?

一个常见疑问是:递归往下的时候只是不断拆分链表,那怎么保证每一层拿到的 left / right 是有序的呢?

这里用"归纳"的角度理解归并排序的正确性:youtubegeeksforgeeks

  • 基础情况:当链表长度 ≤ 1 时,sortList 直接返回,这个链表天然有序。
  • 归纳假设:假设对所有长度 < n 的链表,sortList 都能返回有序结果。
  • 归纳步骤
    • 对于长度为 n 的链表,先用快慢指针拆成左右两半 L、R。
    • 对 L 调用 sortList(L),根据假设,返回值 leftSorted 一定有序;对 R 同理得到有序的 rightSorted。
    • 在当前层,调用 merge(leftSorted, rightSorted),按归并规则合并两个有序链表,得到新的有序链表返回。
      因此,在当前层调用 merge 时,L 和 R 已经是由"更深一层的递归"排好序的结果,当前层只负责把"两段有序链表"进一步合并。这就是"往下拆、往上合"的精髓所在。read.learnyard+1

merge 两个有序链表的思路

merge 的目标是:给定两个已按升序排好的链表 left 和 right,生成一个新的升序链表,复用原来的节点,只动指针,不新建数据节点。geeksforgeeks+1​

核心步骤如下:

准备哑结点和指针

  • 新建一个 dummy 节点(只当占位,不关心它的值),用 tail 指向 dummy,表示当前结果链表的尾节点。
  • 用指针 p = leftq = right。scaler+1

循环比较,把小的接到 tail 后面

p != NULL && q != NULL 时,反复执行:

  • 如果 p->val <= q->val,则 tail->next = pp = p->next
  • 否则 tail->next = qq = q->next
  • 然后 tail = tail->next,保持 tail 永远指向结果链表的末尾。

这一步就是"比头部、接较小"的过程,和经典题「Merge Two Sorted Lists」完全一致。leetcode+1​

一边用完后,直接挂接另一边剩余部分

跳出循环后,p 或 q 至少有一个为 NULL。

  • 如果 p != NULL,说明 left 还有剩余,tail->next = p
  • 否则 tail->next = q

所有剩余节点本身已经有序,整体接上去仍然有序。geeksforgeeks+1​

返回结果头结点

最终结果从 dummy->next 开始,dummy 自身可以释放或忽略。geeksforgeeks+1​

这一步就是整个算法中真正进行比较和排序的地方:每个元素会在不同层的 merge 中被比较若干次,总体比较次数是 O(n log n)。wikipedia+1​

快慢指针找中点的细节:奇数 vs 偶数长度

在链表归并排序中,如何用快慢指针准确切半,是一个细节问题。scaler+1​

典型写法如下(伪代码风格):

复制代码
初始化:
slow = head
fast = head
prev = NULL
循环:
条件:while (fast != NULL && fast->next != NULL)
循环体:
prev = slow
slow = slow->next
fast = fast->next->next
循环结束后:
slow 在中点位置。
prev 在中点前一个。
通过 prev->next = NULL 把链表断成两半:
左半:[head ... prev]
右半:[slow ... tail]
  • 奇数长度(如 5) :fast 每轮走两步,最后 fast 落在最后一个节点,再往前走会使 fast->next 为 NULL,循环结束;此时 slow 在第 3 个节点,左边 2 个,右边 2 个。
  • 偶数长度(如 4) :fast 最终走到 NULL,循环停止;slow 一般位于第 3 个节点,prev 是第 2 个。断开后:
    • 左半长度 2(1,2),右半长度 2(3,4)。

有些变体会故意让 slow 落在"偏左中点",区别只是在于左右子链表长度差是否允许为 1,本题中两种都可以正常工作,不影响归并排序的正确性与复杂度。geeksforgeeks+1​

递归整体伪代码(Top-Down)

结合上面的讨论,可以把 Top-Down 归并排序链表的伪代码抽象成这样(接近 C 风格):stackoverflow+1​

text 复制代码
ListNode* sortList(ListNode* head) {
    // 1. 递归终止条件:长度 <= 1
    if (head == NULL || head->next == NULL) {
        return head;
    }

    // 2. 用快慢指针找到中点,并断开成左右两半
    ListNode* slow = head;
    ListNode* fast = head;
    ListNode* prev = NULL;

    while (fast != NULL && fast->next != NULL) {
        prev = slow;
        slow = slow->next;
        fast = fast->next->next;
    }

    // 此时 slow 在中点,prev 在中点前一个
    prev->next = NULL;       // 断开,左半:head;右半:slow
    ListNode* left = head;
    ListNode* right = slow;

    // 3. 递归排序左右两半
    left = sortList(left);
    right = sortList(right);

    // 4. 合并两个有序链表
    return merge(left, right);
}

merge(left, right) 的伪代码就是前面描述的"哑结点 + 两指针比较 + 掛剩余"的那一套逻辑,这里不再重复。可以直接复用你在「Merge Two Sorted Lists」那题里写过的版本。geeksforgeeks+1​

小结

整套算法的核心可以用一句话概括:

往下递归时 :只负责把链表拆到足够小(长度 0 或 1);往上返回时:每一层负责 merge 它的左右两个有序子链表。

对应到代码上,就是这三个关键点:

  1. 递归终止条件head == NULL || head->next == NULL
  2. 快慢指针切半 :fast 走两步、slow 走一步,prev->next = NULL 断开链表。
  3. merge 两个有序链表:哑结点 + 比较当前节点值 + 掛剩余部分。

把这三块细节想清楚、写顺之后,这类"链表 + 归并排序"的题就比较稳了。

相关推荐
程序员-King.35 分钟前
day120—二分查找—统计公平数对的数目(LeetCode-2563)
算法·leetcode·二分查找·双指针
栈低来信44 分钟前
Linux侵入式链表详解
linux·链表
别学LeetCode1 小时前
#leetcode# 、
leetcode
利刃大大1 小时前
【JavaSE】十、ArrayList && LinkedList
java·链表·数组
2401_841495641 小时前
【LeetCode刷题】轮转数组
数据结构·python·算法·leetcode·数组·双指针·轮转数组
Aspect of twilight2 小时前
LeetCode华为2025年秋招AI大模型岗刷题(四)
算法·leetcode·职场和发展
有泽改之_9 小时前
leetcode146、OrderedDict与lru_cache
python·leetcode·链表
im_AMBER9 小时前
Leetcode 74 K 和数对的最大数目
数据结构·笔记·学习·算法·leetcode
长安er10 小时前
LeetCode 206/92/25 链表翻转问题-“盒子-标签-纸条模型”
java·数据结构·算法·leetcode·链表·链表翻转