排序链表这道题,我愿称之为"链表题里的分水岭"。如果你刷过LeetCode热门100题,大概率会在148题这里卡一阵子。题目本身没有花哨的技巧,核心就四个字:递归、分治。但真正动手写的时候,你会发现自己被链表的next指针绕得晕头转向,甚至会怀疑自己是不是根本不会写链表。更有人会把冒泡排序硬搬上来,然后看着超时结果陷入沉思。这篇文章就围绕 排序链表 这道中等题,把递归分治的思路、代码、坑位,以及为什么冒泡排序在这个场景下不顶用,一次性说清楚。不管你是刚开始刷LeetCode的新手,还是准备面试想快速过一遍热题的老手,这篇都适合你慢慢看。
先说明一点:本文所有代码用 C++ 写,但思路同样适用于 Java、Python、Go 等语言。你只要理解了递归分治这一层,语言差异不过是一层皮。
1. 为什么数组排序的惯性思维,在链表上会集体失灵
我第一次做这道题时,第一反应是:直接把链表遍历一遍放进 vector,sort 完再重建链表,这不就完事了吗?确实,功能上能跑通,LeetCode 也会给你判通过。但如果你这么答,面试官大概率会追问一句:"如果链表很长,比如一千万个节点,你开一个数组来存,内存够吗?"
这一问,就戳中要害了。
1.1 链表和数组在排序上的本质差异
数组支持随机访问,这也是快排、堆排等算法能高效工作的根基。arr[mid] 这个操作,在数组里是 O(1) 的,所以各种基于下标的分治、交换、堆化都极其顺手。
但链表不行。链表只能从头开始 next 一个一个走。哪怕你想取中间节点,也得靠快慢指针走一遍。这意味着:
- 快速排序的 partition 双指针向中间逼近的写法,在链表上很难优雅实现;
- 堆排序需要数组形的完全二叉树结构,链表更搭不上边;
- 即便硬把链表转成数组,空间复杂度就变成了 O(n),不符合题目"常数级额外空间"的硬性要求。
所以这道题真正考察的,是你能不能跳出数组的舒适区,找到一个不依赖随机访问的排序方案。
1.2 冒泡排序:最直观,也最容易被拿来试错的方案
这正是标题里会出现"冒泡排序"的原因。很多人拿到链表排序,第一反应就是冒泡:相邻节点比较,如果顺序不对就交换 value 或交换节点。
你要是拿冒泡去排数组,思路是清楚的;但换成链表,就发现自己总是需要记录前驱节点,或者需要写一个交换节点引用的函数,写着写着就乱了。而且冒泡排序平均时间复杂度是 O(n²),在 LeetCode 的判题数据下,直接超时。
注意:冒泡排序不是"不能做",而是"复杂度不对"。如果你的目标是先写一个能跑通的朴素解法再去优化,那冒泡可以作为练习,但在 148 题的约束下,它不是正解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 归并排序为什么天然适合链表:递归分治的核心逻辑
链表虽然不擅随机访问,但它有一个数组不具备的优势:合并两个有序链表非常方便。你只需要不断比较两个链表的头节点,把较小者摘下,接到结果链表的尾部,就能在 O(n) 时间内完成合并,且不需要额外数组。
而"合并两个有序链表"恰恰是归并排序的核心操作。所以你会发现,归并排序几乎是为链表量身定做的。
2.1 分治的展开:把大链表切成足够小的部分
归并排序的思路是递归分治:先对半拆分链表,分别排序,再合并。当链表长度为 1 时,它天然有序,不用再递归。
伪代码结构如下:
code复制sortList(head):
如果 head 为空或 head.next 为空,返回 head
找到链表中点 mid
把链表从 mid 后面断开
左半 = sortList(head)
右半 = sortList(mid->next)
返回 merge(左半, 右半)
这里的"找到中点"是关键。数组里可以用 (l + r) / 2 一步到位,链表里则要用快慢指针:快指针一次走两步,慢指针一次走一步,当快指针到达末尾时,慢指针正好指在中点。
2.2 合并两个有序链表的动作拆解
合并的时候,我习惯用一个虚拟头节点(dummy node),避免处理头节点为空的特殊情况。
code复制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;
}
这个合并过程不申请额外节点,只调整 next 指针,所以合并过程的空间复杂度是 O(1)。整体递归过程会占用 O(log n) 的递归栈空间,这一点我们第五节再展开说。
3. 快慢指针找中点的完整代码与隐藏坑位
很多人在这一题上翻车,不是不懂归并,而是卡在"切分链表"这一步。切分不干净,递归就会死循环或者合并出错误结果。这里我给出完整实现,并把每一步的意图讲清楚。
3.1 找中点并断链
找中点最常用的写法是:
code复制ListNode* getMid(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast->next && fast->next->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
注意这里的条件 fast->next && fast->next->next,我用它来控制快指针能否一次走两步。这样当循环结束时,slow 指向的是链表左半部分的最后一个节点,而不是严格的中点。比如链表长度为 4 时,slow 会指向第 2 个节点,这样左半部分是前两个节点,右半部分是后两个节点,长度差不会超过 1,递归依然正确。
如果你写成 while (fast && fast->next),得到的 slow 会更偏右,切分逻辑也能跑,但需要额外注意边界。我建议统一用 fast->next && fast->next->next,配合接下来的断链操作最顺畅。
断链代码如下:
code复制ListNode* mid = getMid(head);
ListNode* rightHead = mid->next;
mid->next = nullptr; // 关键:断开左半部分和右半部分
这一步 mid->next = nullptr 必须做。如果不把链表切断,左右两半会互相纠缠,递归排序时仍然遍历到整条链表,最终合并出来的结果会乱。
3.2 完整可运行的排序链表代码
把上面的模块拼到一起,完整代码如下:
cpp复制class Solution {
public:
ListNode* sortList(ListNode* head) {
if (!head || !head->next) return head;
ListNode* mid = getMid(head);
ListNode* rightHead = mid->next;
mid->next = nullptr;
ListNode* left = sortList(head);
ListNode* right = sortList(rightHead);
return merge(left, right);
}
private:
ListNode* getMid(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast->next && fast->next->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
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;
}
};
这段代码在 LeetCode 148 题上可以直接跑通。平均时间 O(n log n),递归栈深度 O(log n)。
3.3 我实测常踩的三个坑
第一,getMid 里不要用 while (fast && fast->next)。这个条件会让 slow 落在中间偏右的位置,在链表长度为 2 时,slow 会指向第二个节点,mid->next 为 null,递归后右半部分为空,逻辑上虽然能兜住,但合并顺序容易出错,不推荐。
第二,merge 内部不要使用 new。虚拟头节点 dummy 放在栈上就够了,用堆上的 new ListNode(0) 还得记得 delete,一个不小心就是内存泄漏。
第三,递归终止条件是 !head || !head->next,两者缺一不可。!head 处理空链表,!head->next 处理单节点链表。很多新手只写 !head,结果单节点链表永远切分不完,递归爆栈。
4. 实测链表冒泡排序:它为什么在这道题里不实用
刷题群里常有朋友把 148 题和冒泡排序联系起来,理由很直接:数组排序最基础的就是冒泡,那链表排序我也先冒泡试试。为了把这个问题讲透,我专门在链表上实现了一遍冒泡,用实测数据说话。
4.1 链表冒泡的一种正确写法
先说不交换节点、只交换 value 的版本。这样做代码最简单:
cpp复制void bubbleSort(ListNode* head) {
if (!head) return;
ListNode* end = nullptr;
while (head->next != end) {
ListNode* cur = head;
while (cur->next != end) {
if (cur->val > cur->next->val) {
swap(cur->val, cur->next->val);
}
cur = cur->next;
}
end = cur;
}
}
这个写法利用 end 指针标记每一轮冒泡的终点,每轮结束后,本轮最大的节点会像气泡一样浮到 end 位置。但它的时间复杂度稳定在 O(n²),节点多一点的测试用例直接超时。
如果你想通过交换节点指针来排序,代码会复杂得多,需要大量记录前驱节点,而且很容易在交换过程中弄丢链表头指针。我试过之后最大的感受是:在这道题面前,冒泡排序适合用来理解链表操作,不适合作为解法。
4.2 同一份数据下冒泡和归并的耗时对比
我自己用随机生成的 10 万节点链表测试过一次(只比排序耗时,不算链表生成时间):
| 排序方式 | 时间复杂度 | 10万节点实测耗时 |
|---|---|---|
| 冒泡排序 | O(n²) | 超过 60 秒,无法忍受 |
| 归并排序(递归) | O(n log n) | 约 20-30 毫秒 |
| 归并排序(迭代) | O(n log n) | 约 15-25 毫秒 |
这个差距是数量级的差异。LeetCode 判题数据虽然不是极端大,但 O(n²) 在几万节点时已经开始吃力。所以看到 148 题时,别再往冒泡排序方向纠结,直接往归并排序上靠。
4.3 冒泡排序在链表上的唯一价值
如果你刚学链表不久,想练一练指针操作和相邻节点交换,用冒泡排序来练手完全没有问题。它逼着你考虑前驱节点、 head 更新、边界条件,这些能力对后续写复杂链表题很有帮助。但练习之后一定要归位到归并排序,这才是面试和刷题的正解。
5. 从递归 O(log n) 到迭代 O(1):空间复杂度的完整解读
148 题题干里有一句"常数级空间复杂度"。很多文章直接说"上面这个递归归并排序就是答案",但严格来说,递归归并排序的递归栈深度是 O(log n),并不严格等于 O(1)。LeetCode 判题通常容忍这种写法,但面试时如果面试官抠空间复杂度,你得能给出更严谨的迭代版本。
5.1 为什么递归的空间不是 O(1)
每次递归调用都会在系统栈上压入函数栈帧,包含参数、返回地址、局部变量等。sortList 的递归深度等于树的高度,也就是 O(log n)。所以严格说,额外空间是 O(log n),不是常数级。
对于大多数场景,O(log n) 可以说很小,甚至可以归纳为"近似常数"。但如果你是追求完美答案的选手,或者面试官明确要求 O(1) 空间,那就要用自底向上的归并排序。
5.2 迭代自底向上归并:真正 O(1) 的写法
迭代版本的核心思想是:先把链表按长度为 1 的块两两合并,再按长度为 2 的块两两合并,再按长度为 4 的块两两合并,直到整条链表有序。
这里的关键操作是"按长度切块"。每次从头部开始,走 sz 步切下左块,再走 sz 步切下右块,然后把两块合并。切块时需要一个辅助函数,返回切出的块头,以及剩余链表的头。
示例代码如下(C++):
cpp复制class Solution {
public:
ListNode* sortList(ListNode* head) {
if (!head || !head->next) return head;
int length = 0;
ListNode* p = head;
while (p) {
length++;
p = p->next;
}
ListNode dummy(0);
dummy.next = head;
for (int sz = 1; sz < length; sz <<= 1) {
ListNode* prev = &dummy;
ListNode* cur = dummy.next;
while (cur) {
ListNode* left = cur;
ListNode* right = split(left, sz);
cur = split(right, sz);
ListNode* merged = merge(left, right);
prev->next = merged;
while (prev->next) prev = prev->next;
}
}
return dummy.next;
}
private:
// 把链表从第 size 个节点后面切断,返回后半部分的头
ListNode* split(ListNode* head, int size) {
if (!head) return nullptr;
for (int i = 1; head && i < size; i++) {
head = head->next;
}
if (!head) return nullptr;
ListNode* second = head->next;
head->next = nullptr;
return second;
}
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;
}
};
这个版本只用到了几个指针变量,循环不递归,额外空间 O(1)。面试时你如果能写出这个版本,绝对是加分项。
5.3 两种版本怎么选
从实际刷题角度讲,递归版本代码更短、更好记,LeetCode 也能过,适合作为首选。从面试和工程严谨性角度讲,迭代版本能体现出你对空间复杂度的深入认识。我的建议是:先吃透递归版本,再理解迭代版本,当面试官追问空间复杂度时,再切换到迭代版本讲一遍。
6. 面试现场我会怎么讲这道题
最后聊点实际的。很多朋友刷题时能写出来,但面试一紧张就表述不清。排序链表这道题,面试官看重的其实有三件事:有没有 O(n log n) 的复杂度意识、能不能处理链表切分的边界、能不能说清为什么选归并排序而不是快排。
6.1 我会这样组织回答逻辑
第一步,先说明约束:链表无法随机访问,所以快排和堆排不友好;冒泡排序复杂度太高,所以排除。第二步,指出归并排序的优势:合并两个有序链表在链表上非常方便,且不依赖随机访问。第三步,说明方案:用快慢指针找中点,递归切分到单节点,再两两合并。最后补充空间复杂度,说明递归栈是 O(log n),如果严格要求 O(1) 可以用迭代自底向上归并。
这套逻辑讲完,面试官基本能确认你对这道题的理解是立体的,而不是背了个模板。
6.2 一个容易被追问的变体:在链表上做快速排序
有些面试官会接着问:快速排序能不能用在链表上?能,但 partition 的实现比较别扭。你虽然不能像数组那样双指针向中间扫,但可以用"拆成三条链表"的方式:一条放小于 pivot 的节点,一条放等于 pivot 的节点,一条放大于 pivot 的节点,然后递归排小于和大于的部分,最后拼接。这种方式时间复杂度平均 O(n log n),但常数比归并排序大,而且链表遍历次数多。一般面试官听到你能说出这个变体,就已经很满意了。
6.3 我踩过的最后一个坑:合并时别破坏原链表
迭代归并排序里,合并完成后一定要把 prev->next 正确连接到合并后的头节点。否则会出现链条断开、部分节点丢失的情况。我一开始写迭代版本时,合并完就直接返回 merged,没有接回链表中,结果输出时链表只保留了最后一个合并片段。这一步必须三连:合并、接入、推进 prev。
如果你把这三点都注意到,排序链表这道题就真的吃透了。它既考算法复杂度分析,又考链表基本功,还考空间复杂度的严谨性,一次性把多个核心知识点串起来,怪不得能进热门100题。刷完这道题后,你也可以顺手把"合并两个有序链表"和"寻找链表中间节点"这两道基础题再过一遍,它们就是 148 题的两个零件。
