1. 这道题到底在考什么?——需求拆解与方案选型
“排序链表”这四个字,LeetCode 148题,放在链表题单里属于那种“一看就会,一写就废”的经典题目。第一次刷到它的人,第一反应往往是:这不就是个排序吗?我直接把链表转成数组,sort 一下再转回去不行吗?行,但面试官会追问你一句:如果链表特别长呢?如果这道题要求你不能用额外空间呢?追问到这里,思路就必须切换到正轨了。
题目本身的约束其实非常明确:在 O(n log n) 时间复杂度和常数级空间复杂度下,对链表进行排序。这个要求等于把选择排序、插入排序、冒泡排序全部排除——它们的时间复杂度都在 O(n²)。快排平均是 O(n log n) 但最坏情况退化到 O(n²),而且链表做 partition 需要反复遍历,实现起来又啰嗦又容易出边界问题。堆排序需要随机访问,链表天然不擅长。算来算去,真正适合链表的 O(n log n) 排序算法,就只剩下归并排序。
为什么归并排序跟链表是绝配?因为归并的核心操作是“把两个有序序列合并成一个有序序列”,这个过程只需要顺序遍历,不需要随机访问,跟链表的存储结构完全匹配。而且链表版归并不需要额外的 O(n) 辅助数组——数组归并排序需要临时数组来存合并结果,但链表可以通过修改指针直接完成节点重组。这也是这道题要求“常数级空间”时,归并排序能成为标准答案的根本原因。
另外一个容易忽视的点是:这道题不仅仅是考“你会不会写归并排序”,而是在考“你会不会在链表这种特殊数据结构上灵活改造算法”。数组归并排序你背得再熟,到了链表上如果不知道用快慢指针找中点、不知道用 dummy node 处理头节点变更、不知道递归返回后必须切断节点之间的旧连接,一样会写得漏洞百出。所以我在带人刷题的时候常说:这道题刷一遍,等于同时复习了链表六脉神剑里面的“快慢指针”“哑节点”“指针重组”三招。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 链表归并的两个关键子问题:找中点与合并有序链表
2.1 快慢指针找中点:循环条件有讲究
递归版归并排序的第一步,是把链表对半切开,切成 left 和 right 两段。数组里直接下标一算就是中点,链表不行,必须用快慢指针去扫。经典写法是:慢指针 slow 每次走一步,快指针 fast 每次走两步,fast 走到末尾时,slow 刚好在中点。
这里有一个非常容易踩的坑:快指针的循环条件到底怎么写?我见过两种写法,都有人用,但效果略有差别。第一种是 while (fast != nullptr && fast->next != nullptr),第二种是 while (fast->next != nullptr && fast->next->next != nullptr)。区别在于 slow 的落点:前者在偶数长度链表上,slow 会停在后半段第一个节点(即右中点);后者 slow 会停在左中点上。
对于归并排序来说,我们需要把链表切成两段,左半段从 head 到 slow(不含 slow),右半段从 slow 开始。如果 slow 落在右中点,那对偶数长度链表来说,左右两段长度正好相等;如果 slow 落在左中点,左半段会比右半段少一个节点。两种都能工作,但为了后面 cut 操作时不会出现环,我建议统一采用“slow 停在左中点(或中点偏左)”的策略,这样在从中间断开时,左边那段的尾节点 slow 本身就可以作为断开点,而 slow->next 就是右段的头。
我用得最多的写法是配合一个 prev 指针记录 slow 的前驱,找到中点后直接 prev->next = nullptr 切断。这样逻辑最清晰,不用在递归函数里再处理“慢指针的下一个才是右半段头节点”这种绕来绕去的关系。如果你直接返回 slow 作为右段头节点,那左段需要在函数里找尾节点切断,稍不注意就会忘记切断,导致递归里出现环,死循环到天荒地老。
cpp复制ListNode* findMiddle(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
ListNode* prev = nullptr;
while (fast != nullptr && fast->next != nullptr) {
prev = slow;
slow = slow->next;
fast = fast->next->next;
}
// 切断左半段和右半段的连接
if (prev != nullptr) {
prev->next = nullptr;
}
return slow; // 右半段的头节点
}
2.2 合并两个有序链表:dummy node 是标配
第二个子问题是合并。这个其实比找中点简单,很多人在做第21题“合并两个有序链表”时已经写过。核心思路是用一个 dummy 哑节点当新链表的头,然后用 tail 指针一路往后串,每次从两个链表头里挑一个较小的节点接上去。
dummy node 为什么是标配?因为合并后的链表头节点可能是原来 left 链表的头,也可能是 right 链表的头,如果不用哑节点,你得在循环外面单独处理“谁是新头”的逻辑,代码会多出好几个 if else。哑节点直接把头节点当普通节点处理,最后返回 dummy->next 就行了,简单粗暴且不容易错。
还有一个小细节:合并结束以后,两个链表里可能还有一个没走完,这时直接把 tail->next 指向剩余那段即可。这个操作是 O(1) 的,不需要循环补全,因为剩下的那段本身就是有序链表。
cpp复制ListNode* merge(ListNode* l1, ListNode* l2) {
ListNode dummy(0);
ListNode* tail = &dummy;
while (l1 != nullptr && l2 != nullptr) {
if (l1->val <= l2->val) {
tail->next = l1;
l1 = l1->next;
} else {
tail->next = l2;
l2 = l2->next;
}
tail = tail->next;
}
tail->next = (l1 != nullptr) ? l1 : l2;
return dummy.next;
}
2.3 递归骨架:把两个子问题串起来
有了以上两个函数,排序链表本身只差最后一步——在递归里组合它们。递归的退出条件是:链表为空,或者只有一个节点,直接返回。然后:
- 用快慢指针找到中点,切断链表,得到 left 和 right 两段。
- 递归排序 left。
- 递归排序 right。
- merge 两个有序子链。
这个流程跟数组归并排序几乎一一对应,只不过数组用下标切分,链表用指针切断。如果你写过数组归并排序,再看这段代码应该觉得非常顺。
cpp复制ListNode* sortList(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return head;
}
// 找中点并切断,返回右半段头节点
ListNode* mid = findMiddle(head);
ListNode* left = sortList(head);
ListNode* right = sortList(mid);
return merge(left, right);
}
3. 递归版实现的完整推演:核心代码与复杂度计算
3.1 完整代码:照着写都能跑通
我把整个代码合在一起,方便你直接调试。这里用 C++ 写,但思路是通用的,换 Python、Java、Go 都没问题。
cpp复制#include <iostream>
struct ListNode {
int val;
ListNode* next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode* next) : val(x), next(next) {}
};
class Solution {
private:
// 找中点,并切断左半段与右半段的连接,返回右半段头
ListNode* findMiddle(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
ListNode* prev = nullptr;
while (fast != nullptr && fast->next != nullptr) {
prev = slow;
slow = slow->next;
fast = fast->next->next;
}
if (prev != nullptr) {
prev->next = nullptr;
}
return slow;
}
// 合并两个有序链表
ListNode* merge(ListNode* l1, ListNode* l2) {
ListNode dummy(0);
ListNode* tail = &dummy;
while (l1 != nullptr && l2 != nullptr) {
if (l1->val <= l2->val) {
tail->next = l1;
l1 = l1->next;
} else {
tail->next = l2;
l2 = l2->next;
}
tail = tail->next;
}
tail->next = (l1 != nullptr) ? l1 : l2;
return dummy.next;
}
public:
ListNode* sortList(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return head;
}
ListNode* mid = findMiddle(head);
ListNode* left = sortList(head);
ListNode* right = sortList(mid);
return merge(left, right);
}
};
3.2 关键步骤的现场推演
我拿 4 -> 2 -> 1 -> 3 这个链表来手动推演一遍递归过程,你会更清楚指针是怎么变动的。
第一层递归,head 指向4。快慢指针从4出发:slow=4、fast=4;第一次循环,slow=2、fast=1;第二次循环,slow=1、fast=nullptr,停止。prev 指向2,所以把 2->next 置空,链表被切成 4 -> 2 和 1 -> 3 两段。
左半段 4 -> 2 进入递归。同样快慢指针,slow=4、fast=4;一次循环后 slow=2、fast=nullptr,prev=4,切断 4->next,得到 4 和 2 两个单节点。单节点直接返回,merge(4, 2) 得到 2 -> 4。
右半段 1 -> 3 也做同样的操作,得到 1 -> 3。
回到第一层,merge(2 -> 4, 1 -> 3),从头比较:1 比 2 小,先接1;3 比 2 大,接2;3 比 4 小,接3;最后接4。结果就是 1 -> 2 -> 3 -> 4。
整个过程中指针的移动次数总和是 O(n log n)。每一层归并,所有节点都会被遍历一次,合并的代价是 O(n)。递归树有 log n 层,所以总时间复杂度 O(n log n)。空间复杂度方面,递归栈深度是 O(log n),在 LeetCode 通常认为这满足“常数级空间”的面试要求,因为严格意义上讲递归栈不算“额外数据结构”。
3.3 为什么这个解法是“最优解”
有人会问:快排平均也是 O(n log n),为什么不用?链表快排的问题在于 partition 操作。数组快排可以通过下标交换实现原地 partition,但链表只能通过节点指针重组,每做一次 partition 都要从头遍历找比 pivot 小的节点,实际运行效率并不高。更麻烦的是,如果 pivot 选得不好,最坏情况会退化到 O(n²),这在面试里很难自圆其说。
堆排序就更不用提了,二叉堆本质上是满二叉树,需要数组下标快速访问父子节点,链表里建堆本身就是灾难。
所以归并排序是“时间上满足 O(n log n)”、“实现上匹配链表结构”、“空间上可控”的三重最优解。这也是为什么官方题解、主流算法教材、面试标准答案全都指向归并排序。你不需要在面试现场发明新算法,把归并排序的链表实现写对,就已经是标准答案了。
4. 进阶挑战:自底向上迭代归并,彻底做到 O(1) 空间
4.1 为什么还要学迭代版
递归版代码短、思路清晰,面试的时候优先写递归版完全没问题。但如果面试官追问“能不能不用递归,做到真正的 O(1) 额外空间”,你就要拿出迭代版。迭代版的思想是自底向上的多路归并:先不递归切分,而是直接把链表看成一个个长度为1的有序子链表,两两归并成长度为2的有序子链表,再归并成长度为4的……直到整个链表有序。
这个过程不需要递归栈,只需要几个指针变量,所以空间复杂度严格 O(1)。缺点是代码比递归版长不少,逻辑也容易绕。我建议先把递归版吃透,再花时间啃迭代版——面试考迭代版的概率不大,但如果你能写出来,绝对是加分项。
4.2 迭代版的三个核心变量
迭代版需要维护几个关键信息:
step:当前子链表的长度,初始为1,每轮循环翻倍。dummy:整个链表排序后的虚拟头节点,用于最后返回结果。tail:当前已经归并好的部分链表的尾节点,用于串接下一段归并结果。
每一轮的处理逻辑是:从头开始,把链表切成若干个长度为 step 的子链表,相邻两个一组进行 merge,merge 结果接到 tail 后面,然后继续处理下一组。如果链表最后剩下一个孤零零的子链表,长度不足 step,那这一轮它就不用参与合并,直接接到结果尾部即可。
4.3 手写 cut 函数:链表的“切段”利器
迭代版最需要动脑筋的是“按长度切段”。我封装一个 cut 函数,输入一个链表头节点和长度 n,返回切掉前 n 个节点之后剩余部分的头节点,同时把切出来的子链表尾部置空。这个函数是迭代版的地基。
cpp复制ListNode* cut(ListNode* head, int n) {
ListNode* p = head;
while (--n && p != nullptr) {
p = p->next;
}
if (p == nullptr) {
return nullptr;
}
ListNode* next = p->next;
p->next = nullptr; // 切断
return next;
}
注意这里的细节:while (--n && p != nullptr) 表示从 head 开始走 n-1 步,正好停留在第 n 个节点上。如果链表不够长,p 会变成 nullptr,说明这段子链表没有 n 个节点,那就返回 nullptr 代表没有剩余部分了。
4.4 迭代归并的完整实现
有了 cut 函数,主逻辑就清晰了:
cpp复制ListNode* sortList(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return head;
}
// 统计链表长度
int length = 0;
ListNode* p = head;
while (p != nullptr) {
++length;
p = p->next;
}
ListNode dummy(0);
dummy.next = head;
for (int step = 1; step < length; step <<= 1) {
ListNode* tail = &dummy;
ListNode* cur = dummy.next;
while (cur != nullptr) {
ListNode* left = cur;
ListNode* right = cut(left, step);
cur = cut(right, step);
tail->next = merge(left, right);
while (tail->next != nullptr) {
tail = tail->next;
}
}
}
return dummy.next;
}
逐行解释一下最内层的 while 循环:
- 先取 left 为当前子链表头。
- 从 left 往后切 step 个节点,剩余部分的头赋给 right。
- 再从 right 往后切 step 个节点,剩余部分的头赋给 cur,作为下一组的起点。
- 把 left 和 right 两个有序子链表 merge,接到 tail 后面。
- 移动 tail 到 merge 结果的末尾,方便下一组继续接。
整个过程从链表头开始,一批一批地处理,每轮 step 翻倍,直到 step 超过链表总长度,说明整个链表已经有序。最终返回 dummy.next。
对比递归版和迭代版:递归版是“自顶向下”,先分到底再层层合并;迭代版是“自底向上”,直接从最小单元开始合并。写迭代版时最容易犯的错误是:忘记把 merge 结果的尾节点正确接回原链表,导致下一轮遍历时 cur 指针丢掉。我在写的时候习惯每轮循环开始前打印一下当前链表状态,能快速定位问题。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 递归死循环,程序卡死 | 找中点后没有切断左右链表,导致递归子问题里出现环 | 在 findMiddle 里用 prev 记录前驱并置空 prev->next |
| 排序结果丢节点 | merge 函数里 tail 更新时机不对,或链表尾部没接上 | 检查 merge 结尾的 tail->next = (l1 != nullptr) ? l1 : l2 是否漏掉 |
| 无限循环,内存暴涨 | 递归出口条件错误,空指针判断缺失 | 确保 `head == nullptr |
| 迭代版结果顺序错乱 | cut 函数计数边界错误,切的长度与 step 不一致 | 用 while (--n && p != nullptr) 而不是 while (n--) |
| 单节点链表返回错误 | 递归版没有处理单节点直接返回的情况 | sortList 开头加一行单节点判断 |
5.2 快慢指针的边界条件到底怎么记
快慢指针找中点的循环条件,我见过不下五种变体,每种都有人踩坑。最稳妥的记忆方式是“fast 两步走,前提是 fast 本身不为空且 fast 的下一个节点不为空”。也就是:
code复制while (fast != nullptr && fast->next != nullptr)
这样 fast 每次移动两步,不会出现空指针解引用。如果链表长度是奇数,slow 会停在正中间;如果是偶数,会停在右中点。无论哪种情况,配合 prev 指针切断都能正确分片。我自己在 leetcode 上验证过:[1,2,3,4,5],slow 停在3;[1,2,3,4],slow 停在3,prev 是2。切断后左段 [1,2],右段 [3,4],完全正确。
5.3 递归深度问题:一万个节点会爆栈吗
LeetCode 上测试链表的长度一般不会超过 10^4,递归深度 log2(10^4) ≈ 14 层,完全不会爆栈。但如果你自己压测一个 10^6 长度的链表,递归版就可能出问题——虽然 log2(10^6) ≈ 20 层其实也不深,但因为我的 findMiddle 实现里快慢指针只扫描了半条链表就返回,递归树的实际深度会略高,但工程上仍是安全的。真正需要担心递归深度的是那些“每个递归只减少一个节点”的算法,比如朴素递归反转链表,那种才是真的会爆栈。归并排序这种 log n 深度,基本不用担心。
5.4 构造测试用例的独家方法
刷这种指针操作题,我强烈建议你别只依赖 LeetCode 自带的用例,自己写一个本地测试函数,用数组构造链表、打印链表、比对结果,能帮你快速定位问题。尤其要覆盖这些用例:
- 空链表:
[] - 单节点:
[1] - 两个节点:
[2,1] - 逆序:
[5,4,3,2,1] - 含负数:
[-1,-2,2,-3] - 完全相等的节点:
[1,1,1,1] - 已经有序的链表:
[1,2,3,4]
我每次改完代码都会跑一遍这个用例集,基本上能挡住 90% 的边界错误。
6. 从这题引申出去:链表排序的完整知识图谱
6.1 同类题目的横向对比
链表排序这个考点,在 LeetCode 里围绕它至少有三道题值得一起刷:
-
- 合并两个有序链表:148 的子问题,必须掌握。
-
- 合并 K 个升序链表:可以用优先队列逐节点取最小,也可以分治两两合并。做完 148 之后你会自然想到把 K 个链表两两 merge,复杂度同样是 O(n log k)。
-
- 对链表进行插入排序:O(n²) 复杂度,但是链表插入排序的经典写法,适合理解“在链表中插入一个节点”的指针操作。虽然面试不会让你用它替代归并排序,但作为对比题非常合适。
如果你是准备面试,建议把 21 和 148 连续刷;如果你刷题时间充足,再把 23 也加上。这三题串起来,链表排序的主流考法基本就全覆盖了。
6.2 为什么这道题能进“热门100题”
排序链表常年挂在 LeetCode 热门 100 题榜单上,是有原因的。它不像动态规划那样需要大量数学推导,也不像图论那样需要背模板,它考察的是工程师日常编码中最基础也最容易出错的能力——对指针的精确控制。一个候选人能不能把链表排序写对、写快、写简洁,几乎可以直接映射到他在真实工程里处理链表、树等递归结构时是否可靠。
更重要的是,这道题给了你一个“从数组思维切换到链表思维”的绝佳训练场。很多人学算法的时候用数组思维思考一切问题,到了链表才发现很多数组里的“常识”都不成立了——没有下标、不知道长度、不能随机访问。排序链表逼着你放弃数组的那套舒适区,用指针、用遍历、用递归去解决问题。这种思维转换,是刷一百道数组题都换不来的。
6.3 延伸思考:如果链表是双向的呢?如果是循环链表呢?
双向链表排序其实比单链表简单,因为可以双向遍历,快慢指针找中点后断开时不需要 prev 指针,直接用 slow 的前驱指针就能操作。循环链表的归并排序也不难,只要在切分时注意“链表的尾部其实是 head 的前驱”,稍微处理一下环的断开即可。
这些变体在面试中出现的概率不高,但如果你想展示自己的深度,可以在聊到排序链表时主动提一句。“如果是双向链表,我的实现会这样优化……如果是循环链表,只需要多处理一个环节。”这种细节往往比算法本身更能给面试官留下深刻印象。
7. 最后再分享一个小技巧:手写链表调试函数
我个人在实际操作中体会最深的一件事是:每道链表题,都应该准备三个调试工具函数,它们是链表题的“调试三件套”:
ListNode* createList(vector<int>& arr):用数组构造链表。void printList(ListNode* head):打印链表完整内容。bool isSorted(ListNode* head):校验链表是否有序。
这三个函数本身不超过 30 行,却能让你在本地反复调试指针操作。没有它们,你在纸上推演一万遍,都不如在终端里亲眼看到一次指针是怎么断开的。很多读者私信问我“为什么我递归版代码逻辑看着完全正确,就是超时”,我一看代码就发现 findMiddle 里忘了切断链表,形成一个环,跑下去永远走不完——这种问题,光靠眼睛盯是盯不出来的,debug 一下秒懂。
把这个习惯养成之后,你会发现排序链表这题其实一点不难:拆开就是“找中点 + 合并两个有序链表”两个基础操作,合起来就是递归或迭代的归并排序。难的是把基础操作在边界条件下写对,而这个能力,正是靠反复调试和积累练出来的。
