1. 题目与考点:为什么每个刷题的人都绕不开它
1.1 原题回顾与输入输出
先花十秒钟过一下题目本身。LeetCode 148 排序链表,给你单链表的头结点head,要你把它按升序排好,返回新的头结点。样例很简单:
输入:head = [4,2,1,3] 输出:[1,2,3,4] 输入:head = [-1,5,3,4,0] 输出:[-1,0,3,4,5]约束条件里有两处非常关键:节点数量范围是 [0, 5 * 10^4],节点值范围是 [-10^5, 10^5]。节点数上限五万,说明纯 O(n^2) 的暴力方案大概率超时,你必须上 O(n log n) 的排序。同时节点值可以是负数,可以有大量重复,这两点直接决定你测试用例必须覆盖到。题目还有一个经典 follow-up:能不能在 O(n log n) 时间复杂度和 O(1) 常数级空间复杂度下完成?
第一次做这题的人,最常见的反应是“这有什么难的”,然后打开编辑器,发现单链表没法按下标取数,也没法像数组那样直接 swap,瞬间就卡住了。这正是这道题被放进 Hot 100 的原因:它把链表操作和分治思想缝合在一起,考察的是你有没有真正理解排序的本质,而不是背几个 API。
1.2 链表的排序难点到底在哪
数组排序为啥简单?因为数组支持随机访问。a[i]、a[j]想取就取,二分、堆排、快排的 partition 全都可以靠下标铺开。链表不行,它只有 next 指针,你只能从头往后走,想拿到某个中间节点,要么遍历 O(n) 次临时计数,要么用快慢指针边跑边找。
单链表的排序难点,我总结下来有三个:
- 没有随机访问能力,所有依赖下标的算法思路都要重新包装。
- 指针操作太多,一不小心就把链表拆断、接错,甚至形成环,一调试就是一下午。
- 空间复杂度容易被忽略,很多人写递归归并时没意识到递归栈也是空间开销。
还有一个隐藏难点:排序算法本身的“稳定性”。数组排序时稳定性靠下标和值比较就能保证,链表排序时一旦你处理不当,相同值的节点顺序可能被打乱。面试官如果追问“你这个归并稳定吗”,你最好能立刻答上来。
1.3 解法选型的思路推导
我先把自己的思考过程摊开讲,这比直接背代码有用得多。
拿到链表排序,脑子里应该过一遍候选算法:
- 插入排序:O(n^2) 最坏,但容易写,LeetCode 147 专门考它。适合基本有序的数据,不适合这里。
- 快速排序:数组上很香,但链表上 pivot 的 partition 很别扭。单链表只能往前走,经典 Hoare 双指针法没法用,只能改成“拆两个链表再合并”的变种,最坏还是 O(n^2)。后面第 5 章我会细讲为什么不推荐。
- 堆排序:建堆过程依赖随机访问,链表上建堆得反复从头遍历,基本没法做。
- 归并排序:拆分、合并都是链表擅长的事。找中点用快慢指针,合并两个有序链表又是链表题的基操,逻辑天然契合。
所以结论很清晰,归并排序就是这道题的正解。它有两种实现路线:自顶向下递归,和自底向上迭代。递归版代码短、思路直观;迭代版能真正做到 O(1) 空间,正是面试官 follow-up 想要的答案。两条路线我都会给你讲透。
2. 自顶向下归并:最直观的 AC 路线
2.1 快慢指针拆链的原理
自顶向下归并的核心步骤是三板斧:找中点、断链、递归排序两边、合并。
找中点用的是快慢指针,慢指针一次走一步,快指针一次走两步,快指针走到末尾时,慢指针正好落在中间。这个技巧本身不难,但有一个魔鬼细节:快指针的初始值必须设为head->next,不能设为head。
我用一个四节点链表[4,2,1,3]给你走一遍。如果fast = head,两个指针都在 4 上,循环条件是fast && fast->next:
- 第 1 轮:fast=4,fast->next=2,slow 走到 2,fast 走到 2
- 第 2 轮:fast=2,fast->next=1,slow 走到 1,fast 走到 1
- 第 3 轮:fast=1,fast->next=3,slow 走到 3,fast 走到 3
- 第 4 轮:fast=3,fast->next=null,循环结束
此时 slow 停在最后一个节点 3 上,slow->next已经是 null,你根本切不出右半部分,左半部分还是整条链表,递归直接死循环。而fast = head->next时,slow 会停在第一个中间节点(偶数长度时靠左的那个),正好能干净地切出左右两半:
- 第 1 轮:slow 从 4 到 2,fast 从 2 到 1
- 第 2 轮:slow 从 2 到 1,fast 从 1 到 3
- 第 3 轮:fast=3,fast->next=null,循环结束
slow 落在 1,mid = slow->next取到 3,然后把slow->next = nullptr断开,左半[4,2,1],右半[3],完美。
奇数长度的道理同理,[1,2,3]用fast = head->next走完,slow 在 2,mid 在 3,切成[1,2]和[3]。这个初始值差异,面试当场能写对的人不多,但一旦你理解了 slow 的落点规则,就再也不会错。
2.2 两个有序链表的合并要点
归并排序的另一半是合并两个有序链表。这其实是 LeetCode 21 原题,链表题入门必刷。核心思路是维护一个哑节点dummy和一个尾指针tail,每次比较两个链表的当前节点值,谁小就把谁接到 tail 后面,再让对应指针前移。循环结束后,把还没走完的那条链表直接接到后面。
这里要特别强调哑节点的作用。很多新手直接用一个head变量接收第一个节点,结果要单独写 if 判断“第一个节点到底选谁”,代码立刻又臭又长。用ListNode dummy(0)之后,所有节点都统一走tail->next = ...这条逻辑,省去特判,返回时直接取dummy.next即可。
合并的稳定性也在这里体现:比较时写成l1->val <= l2->val,相等值优先取左链表节点,算法就是稳定的。如果你写成了严格<,稳定性就丢了。面试官很可能拿这个细节试探你。
2.3 完整代码与边界条件
下面给一版完整的 C++ 实现,代码注释里我会标出每个关键位置的作用:
class Solution { public: ListNode* sortList(ListNode* head) { // 空链表或单节点,天然有序 if (!head || !head->next) return head; // 找中点:慢指针一步,快指针两步 ListNode* slow = head; ListNode* fast = head->next; // 注意这里必须是 head->next while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } // 切断中点,分成左右两条独立链表 ListNode* mid = slow->next; slow->next = nullptr; // 递归排序左右两半 ListNode* left = sortList(head); ListNode* right = sortList(mid); // 合并两个有序链表 return merge(left, right); } ListNode* merge(ListNode* l1, ListNode* l2) { ListNode dummy(0); ListNode* tail = &dummy; while (l1 && l2) { if (l1->val <= l2->val) { tail->next = l1; l1 = l1->next; } else { tail->next = l2; l2 = l2->next; } tail = tail->next; } tail->next = l1 ? l1 : l2; return dummy.next; } };边界条件只有两行:开头判断!head || !head->next。注意不要只判断!head,因为单节点链表不需要排序,直接返回即可,否则后面找中点的逻辑会对长度为 1 的链表出错。
时间复杂度是 O(n log n),每次递归把问题规模减半,合并过程线性扫描。空间复杂度是 O(log n),来自递归栈,不是 O(1)。这个 O(log n) 正是 follow-up 要求 O(1) 空间时,你要跨越的那道坎。
3. 自底向上归并:O(1) 空间的 Follow-up 解法
3.1 为什么递归版的空间不是 O(1)
很多人以为归并排序“不需要额外数组”就代表 O(1) 空间,这是理解误区。自顶向下递归版每次调用都会压一层栈帧,栈帧里保存了局部变量和返回地址,五万节点的链表,递归深度大概是 log2(50000) ≈ 16 层。16 层看起来不多,但它确实随 n 增长,从算法分析的角度这不算常数级。
要满足 follow-up 的 O(1) 空间,就得把递归去掉,改成自底向上的迭代思路:先把链表拆成长度为 1 的小段,两两合并;再拆成长度为 2 的小段,两两合并;以此类推,每一轮合并完,有序块的长度翻倍,直到整条链表有序。
这个思路可以类比成体育比赛的分组:第一轮两两 PK,第二轮四个一组,第三轮八个一组,每轮晋级者都是各自小组里的最强者,最终决出总冠军。迭代归并就是让“冠军”的含金量逐轮提升,直到整条链表只有一个大组。
3.2 迭代版的核心流程
自底向上归并需要两个辅助函数。第一个是split(head, k),从head开始数 k 个节点,把它切成一段,返回后面剩余的链表头;第二个是merge(l1, l2, prev),把两个有序段合并,接到prev后面,并返回合并后段的末尾节点。
主循环的结构是这样的:外层循环控制步长step,从 1 开始,每轮翻倍;内层循环从左到右把链表切成若干个长度为step的块,每两块合并一次。因为切块和合并会改变链表结构,所以需要一个哑节点dummy始终指向当前轮的链表头,prev指向已合并部分的末尾。
这里有个容易绕晕的地方:split函数不仅要切出左块,还要在合并前提前把右块之后的“剩余部分”接回主流程。我在代码里用了一个技巧——先取left,再取right,最后把cur更新为右块之后的剩余部分,这样内层循环才能继续处理下一对块。
3.3 完整代码与走查验证
class Solution { public: ListNode* sortList(ListNode* head) { if (!head || !head->next) return head; // 第一遍:统计链表长度 int n = 0; ListNode* cur = head; while (cur) { n++; cur = cur->next; } ListNode dummy(0, head); // 步长从 1 开始,每轮翻倍 for (int step = 1; step < n; step <<= 1) { ListNode* prev = &dummy; ListNode* cur = dummy.next; while (cur) { // 切出左块 step 个节点 ListNode* left = cur; // 切出右块 step 个节点,并拿到剩余部分 ListNode* right = split(left, step); cur = split(right, step); // 合并左右两块,接到 prev 后面 prev = merge(left, right, prev); } } return dummy.next; } ListNode* split(ListNode* head, int k) { // 从 head 出发走 k-1 步,切断并返回剩余链表的头 while (head && k > 1) { head = head->next; k--; } if (!head) return nullptr; ListNode* rest = head->next; head->next = nullptr; return rest; } ListNode* merge(ListNode* l1, ListNode* l2, ListNode* prev) { ListNode* tail = prev; while (l1 && l2) { if (l1->val <= l2->val) { tail->next = l1; l1 = l1->next; } else { tail->next = l2; l2 = l2->next; } tail = tail->next; } tail->next = l1 ? l1 : l2; while (tail->next) tail = tail->next; return tail; } };我用[4,2,1,3]手动走一遍,帮你验证流程。
第一轮,step=1。链表初始状态:4→2→1→3。第一次内层循环,left=[4],right=[2],cur 更新为 1,合并 [4] 和 [2] 得到 2→4;第二次内层循环,left=[1],right=[3],cur 更新为 null,合并得到 1→3。第一轮结束,链表变成 2→4→1→3。
第二轮,step=2。第一次内层循环,left=[2,4],right=[1,3],cur 更新为 null,合并得到 1→2→3→4。step 翻倍为 4 后不小于 n=4,外层循环结束。最终返回 dummy.next,结果正确。
注意一个细节:内层循环里cur = split(right, step)这一行,只有当right为 null 时才会得到 null 并结束循环;如果right的剩余部分不足 step 个节点,split会在到末尾时自然返回 null,同样结束循环。这个逻辑覆盖了所有奇偶长度的情况。
空间复杂度上,这个版本没有递归栈,只有几个临时指针,做到了 O(1) 空间,时间复杂度仍然是 O(n log n)。面试时能写出这版,基本就是满分答案。
4. 实操踩坑实录:这些 Bug 我全都踩过
4.1 断链没断干净导致的死循环
自顶向下递归版最容易犯的错,是找到中点后忘了写slow->next = nullptr。别小看这一行,少了它,左半部分递归排序时链表根本不会变短,因为head还是能一路走到mid后面的节点。结果就是递归永不终止,程序直接爆栈或超时。
我调试这个 bug 时的经验是:用三个节点的链表[3,2,1]手推一遍,很快就发现左半部分明明应该只有两个节点,实际却包含三个,问题立刻定位。所以遇到超时,先检查是不是断链处出了问题。还有一次我把断链写成了mid->next = nullptr,想着“断开右半的尾巴”,结果正好相反,左半部分完好无损,右半变成空链表,排序结果全乱。记住:要断的是慢指针 slow,也就是左半部分的末尾,而不是 mid。
迭代版的 split 函数同样有这个问题,如果head->next = nullptr忘了写,两个块之间还连着,合并时就会出现环形引用,输出结果要么死循环要么丢节点。
4.2 fast 指针初始值的选择
第 2 章已经详细讲过,fast = head会导致偶数长度链表切分失败。我再补充一个更隐蔽的变体:有人把循环条件写成while (fast->next && fast->next->next),然后让fast = head。这个写法配合head->next初始化其实是等价的,但还是有很多人搞混。
给你一个判断口诀:快指针从第二个节点出发,慢指针最终落在中间节点;快指针从头节点出发,慢指针最终落在最后一个节点。面试时如果忘了一时半会推不出来,就当场拿[1,2]这个双节点链表验证。万一 fast 初始化错了,排序函数会对同一段链表无限递归,直接在编辑器里卡死。
4.3 递归深度与栈空间的权衡
自顶向下归并有 O(log n) 的递归栈空间。五万节点时深度 16 层,没问题;但如果你在递归函数内部声明了大数组或者某条链表的长度极不均衡,栈溢出还是可能发生的。比如某次我把找中点的逻辑写错,导致每次只切掉一个节点,递归深度直接变成 O(n),五万层的栈帧瞬间爆掉。
这让我养成了一个习惯:拿到链表题,先看数据范围。节点数超过十万时,我默认优先写迭代版归并,不给自己留栈溢出的隐患。虽然 O(log n) 深度理论上很安全,但谁知道你看的代码里有没有藏着别的递归调用呢。
4.4 测试用例建议清单
链表排序这种题,光靠题目给的样例根本不够。我自己的测试清单是这样的:
| 场景 | 用例 | 用途 |
|---|---|---|
| 空链表 | [] | 验证基础边界 |
| 单节点 | [1] | 验证返回自身 |
| 两个节点 | [2,1] | 验证合并最小规模 |
| 已升序 | [1,2,3,4] | 验证不破坏有序性 |
| 逆序 | [4,3,2,1] | 验证最坏情况 |
| 全部相同 | [2,2,2,2] | 验证稳定性与重复值 |
| 负数混合 | [-1,5,3,4,0] | 验证负数比较 |
| 大量数据 | 随机生成 50000 个节点 | 验证时间和空间上限 |
尤其要跑一遍全部相同值的用例,很多人在合并时写成严格小于,虽然结果不报错,但稳定性被破坏,面试追问时答不上来。随机大数据的用例主要用来验证超时问题,如果递归版写错导致递归深度爆炸,跑这个用例马上能看出来。
5. 面试延伸:链表快排、插入排序与相关题串联
5.1 为什么链表上不推荐快速排序
我见过不少面试者,数组排序背得很熟,一上链表就硬套快排,结果栽了。数组快排能高效工作,靠的是a[i]和a[j]的随机访问以及原地交换;链表上这两样都享受不到。单链表只能从头走,做 partition 时你没法让两个指针同时从两端向中间移动,经典 Hoare 分区直接失效。
如果硬要做,可以把 Lomuto 分区改成链表版:选最后一个节点当 pivot,用两个指针把链表扫一遍,小于 pivot 的接到前一段,大于等于的接到后一段,最后再把 pivot 放中间。但问题来了:pivot 选取不好时最坏情况 O(n^2),而且这个过程不稳定。链表归并排序天然稳定,最坏也是 O(n log n),为什么还要去折腾快排呢?面试时主动说“链表排序我更倾向于归并,因为不需要随机访问且能保证稳定”,这本身就是加分回答。
5.2 插入排序链表与类似思路的取舍
LeetCode 147 专门考链表插入排序。它的做法是从第二个节点开始,每次把当前节点摘下来,在已经有序的前半部分里从头找到合适位置插入。最坏 O(n^2) 时间,但空间 O(1),而且对“基本有序”的链表非常快。
这让我想到工程里的一个细节:很多工业级排序算法是混合式的,数据量小的时候切到插入排序。归并排序递归到小规模子问题时,也可以改成插入排序提前返回,减少递归层数和合并开销。LeetCode 上大多数题目数据规模不用玩这么花,但面试时提到这个优化点,会显得你对排序的理解不止于刷题。
5.3 相关题目的串联学习路径
148 排序链表其实是一堆链表题的核心套路集合,我建议按照这个顺序串联学习:
- LeetCode 21 合并两个有序链表:148 的 merge 函数直接来自这里,基础中的基础。
- LeetCode 876 链表的中间结点:148 的快慢指针找中点就是它的变体。
- LeetCode 143 重排链表:需要先找中点,再反转后半段,最后交叉合并,是 148 三个技巧的综合运用。
- LeetCode 234 回文链表:同样用快慢指针找中点,再配合反转链表。
- LeetCode 23 合并 K 个升序链表:归并思路的进阶版,配合优先队列实现。
把这些题放在一起刷,你会发现链表题的核心动作其实就三四个:快慢指针、反转链表、断链、合并。148 把它们全部串起来考了一遍,这也是它能进 Hot 100 的原因。
我个人刷这道题的体会是:递归版一定要自己从头推导一遍,理解每一步为什么这么写;迭代版则建议关掉题解自己默写两遍,第一遍大概率会在 split 的返回值上卡壳,第二遍就顺多了。面试时可以先给递归版方案,然后主动提一句“如果需要 O(1) 空间,我可以改成自底向上的迭代归并”,这句话比你写完代码再解释十分钟都管用。最后一个小技巧:提交代码前,把示例里的[4,2,1,3]和[1,2]各脑内走一遍,绝大多数断链引发的死循环都能在这个环节被提前发现。