这篇文章记录的是自己刷 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 = left,q = right。scaler+1
循环比较,把小的接到 tail 后面
当 p != NULL && q != NULL 时,反复执行:
- 如果
p->val <= q->val,则tail->next = p,p = p->next。 - 否则
tail->next = q,q = 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 它的左右两个有序子链表。
对应到代码上,就是这三个关键点:
- 递归终止条件 :
head == NULL || head->next == NULL。 - 快慢指针切半 :fast 走两步、slow 走一步,
prev->next = NULL断开链表。 - merge 两个有序链表:哑结点 + 比较当前节点值 + 掛剩余部分。
把这三块细节想清楚、写顺之后,这类"链表 + 归并排序"的题就比较稳了。