最近在刷 LeetCode 热门 100 题的时候,把 148. 排序链表反复做了几遍,这道题值得单独拿出来聊聊。它表面上只是一个“给链表排序”的题目,实际上把链表操作里最典型的几个技巧全串起来了:快慢指针找中点、有序链表合并、递归与迭代的取舍,还有空间复杂度的控制。如果你正在准备面试,或者刷题刷到链表这一块总觉得“会做但容易写崩”,这篇文章应该能帮你把思路彻底理顺。
先说结论:这道题的最优解是归并排序,时间复杂度 O(n log n),空间复杂度可以做到 O(1)(迭代式自底向上归并)。我一开始也走过弯路,想着“这不就是个排序吗”,结果直接用数组排序的思路去写,发现压根行不通。等你把链表的指针操作捋清楚了,会意识到这道题考的不是排序本身,而是你对链表这种数据结构的掌控力。
1. 题目核心与前置认知
1.1 题目到底在考什么
给你一个链表的头节点 head,要求按升序排序,然后返回排序后的链表头。示例很简单,比如输入 4->2->1->3,输出 1->2->3->4。看起来人畜无害,但题目明确要求:在 O(n log n) 时间复杂度和常数级空间复杂度下完成。
这里有个关键信息:O(n log n) 时间。这意味着冒泡排序、插入排序这类 O(n^2) 的算法直接淘汰。能到 O(n log n) 的排序算法,耳熟能详的就那几个:快速排序、堆排序、归并排序。堆排序在数组上很好用,但在链表上建堆太别扭;快速排序在链表上实现也行,但 partition 操作要反复遍历,而且链表的随机访问是 O(n),快排的优势很难发挥。归并排序天然适合链表,因为归并操作只需要修改指针,不需要额外的数组空间。
所以这道题其实是在考三个能力:
- 能不能识别出“链表排序得用归并”这个方向
- 能不能熟练写出找链表中间节点和合并两个有序链表的代码
- 能不能理解递归归并与迭代归并的空间复杂度差异
1.2 为什么不能“复制到数组排完再塞回去”
我第一次刷这道题的时候,脑子里第一个念头是:遍历链表存到数组里,用 sort 排序,再重建链表。这写法思路简单,代码也就二十行,但有个致命问题:空间复杂度是 O(n)。题目要求常数级空间,所以这个方案直接被否。
更重要的是,从面试的角度看,面试官就是要看你懂不懂链表的指针操作。你用数组排序绕开链表操作,相当于这道题白刷了。我后来帮朋友 mock 面试的时候也见过这种情况,候选人用数组排序写完了,面试官追问一句“那如果不允许用额外空间呢”,直接就卡住了。
所以这道题的意义在于:它逼着你用纯链表操作的方式完成排序。归并排序的合并过程,本来就是链表的强项——两个有序链表合并,只需要比较节点值,然后修改 next 指针,完全不需要额外空间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法选型:归并排序的两条技术路线
2.1 自顶向下:先递归拆,再回溯合并
自顶向下的归并排序是大多数人的第一反应。思路非常直白:
- 找到链表中点,把链表分成左右两半
- 递归地对左右两半分别排序
- 合并两个有序链表,返回结果
找中点用的是快慢指针:快指针每次走两步,慢指针每次走一步,快指针到末尾时,慢指针恰好在中间。这里有个细节:如果链表长度是偶数,慢指针会停在中间偏右的位置,所以要在递归前用 slow.next 把右半部分的头保存下来,然后把 slow.next 设为 null,把链表真正切断。
递归的终止条件是 head == null 或 head.next == null,也就是空链表或只剩一个节点时,天然有序,直接返回。
自顶向下的优点是思路直观,代码写起来很顺畅。但它有个隐藏的成本:递归深度是 O(log n),虽然时间复杂度没变,但系统栈会占用 O(log n) 的空间。严格来说,这不满足“常数级空间”的要求。很多题解和面试官其实能接受这个写法,因为 O(log n) 的栈空间在绝大多数场景下不是问题,但如果你追求完美,就需要下面的自底向上写法。
2.2 自底向上:直接迭代,省掉递归栈
自底向上的归并排序是面试加分项。它不是先拆到底再合并,而是反过来:先把链表想象成若干个长度为 1 的有序子链表,然后两两合并成长度为 2 的有序子链表,再两两合并成长度为 4 的……直到合并成完整链表。
这里的关键操作是 cut:给定一个链表头和一个长度 n,把这个链表从第 n 个节点之后切断,返回后半部分的头。有了 cut 操作,就可以精确地按步长切分链表,然后做合并。
如果你能写出自底向上的版本,说明你对链表的掌控已经到了“指哪打哪”的程度。我面试过不少候选人,能写递归归并的很多,能写迭代归并的凤毛麟角。这道题如果你能顺带写出迭代版,会非常加分。
3. 自顶向下归并的完整实现与关键细节
3.1 快慢指针切分链表:边界条件要抠细
实现找中点的代码不难,难在边界条件。我见过不少人在这一步栽跟头,最常见的错误是死循环。
cpp复制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;
}
注意这里的 while 条件跟“找链表中间节点”的常规写法不一样。常规写法是 while (fast && fast->next),找到的中点偏右;这里用 fast->next && fast->next->next,找到的中点偏左。为什么要偏左?因为我们需要把链表从中间切开,左半部分至少要有 1 个节点,否则递归没法进行。
举个例子:链表只有两个节点,head->1->2。如果用 while (fast && fast->next),slow 会走到第二个节点,切完之后左半部分只有第一个节点,没问题。但如果链表有 4 个节点呢?1->2->3->4。常规写法 slow 会停在 3 的位置,切完之后左半部分是 1->2,右半部分是 4。这样也行,但递归次数变多了,而且奇数长度链表的切分位置会偏,容易出现子链表大小不一致的情况。用 fast->next && fast->next->next 这个条件,slow 会停在 2 的位置,左半部分是 1->2,右半部分是 3->4,均匀切分,递归树更平衡。
3.2 合并两个有序链表:基础功要扎实
合并两个有序链表是这道题的地基。如果你写过 LeetCode 21. 合并两个有序链表,那这段代码应该是条件反射级别的。
cpp复制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;
}
注意我用的是 dummy 节点。这是链表操作里的经典技巧:当你需要构建一个新链表,且不确定第一个节点是谁时,用一个虚拟头节点可以省去大量判空逻辑。dummy 节点不会被返回,只是作为哨兵存在。合并完成后,tail->next 接上剩余的那段链表,因为剩下的节点本来就是有序的,直接拼接即可。
3.3 完整代码与复杂度
把两个辅助函数拼起来,自顶向下的归并排序就是下面这个模板:
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;
}
};
时间复杂度:递归深度 O(log n),每层合并总耗时 O(n),总复杂度 O(n log n)。
空间复杂度:递归调用栈 O(log n)。如果你严格按题目要求“常数级空间”,这个版本是打折扣的。但对于绝大多数实际场景,这个空间开销完全可接受。
4. 自底向上归并:空间 O(1) 的进阶写法
4.1 核心思想:把链表按步长切块
自底向上的思路是:从长度为 1 的子链表开始,两两合并,得到长度为 2 的有序子链表;再两两合并,得到长度为 4 的有序子链表;以此类推。这个过程需要三个核心变量:
step:当前子链表的长度,初始为 1,每轮翻倍head:每一轮排序后链表的头节点preTail:上一轮合并后的尾节点,用于把当前合并的结果接到结果链表后面
每一步的操作流程是:从当前待处理链表中切下长度为 step 的 left 子链表,再切下长度为 step 的 right 子链表,然后合并 left 和 right,接到已排好序的链表末尾。如果剩余链表长度不足 step,直接作为最后一段。
这里最关键的辅助函数就是 cut:
cpp复制ListNode* cut(ListNode* head, int n) {
if (!head) return nullptr;
ListNode* p = head;
while (--n && p) {
p = p->next;
}
if (!p) return nullptr;
ListNode* next = p->next;
p->next = nullptr;
return next;
}
cut(head, n) 会返回从 head 开始数 n 个节点之后的后半部分头节点,同时把前半部分的末尾置空。如果链表长度不足 n,返回 nullptr。这个函数是整个迭代归并的基石。
4.2 完整实现与关键变量说明
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 step = 1; step < length; step <<= 1) {
ListNode* preTail = &dummy;
ListNode* cur = dummy.next;
while (cur) {
ListNode* left = cur;
ListNode* right = cut(left, step);
cur = cut(right, step);
preTail->next = merge(left, right);
while (preTail->next) {
preTail = preTail->next;
}
}
}
return dummy.next;
}
private:
ListNode* cut(ListNode* head, int n) {
if (!head) return nullptr;
ListNode* p = head;
while (--n && p) {
p = p->next;
}
if (!p) return nullptr;
ListNode* next = p->next;
p->next = nullptr;
return next;
}
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;
}
};
关键变量逐个解释:
length:链表总长度,决定了外层循环的轮数。step 从 1 开始,每次左移一位(乘以 2),直到不小于总长度。preTail:每轮排序的“结果链表”的最后一个节点。初始指向 dummy,之后随着合并结果不断后移。cur:当前待处理的子链表头。每次循环从 cur 开始切分 left 和 right。
内层 while 循环的精髓在于:
left = cur拿到第一段right = cut(left, step)切出第二段,同时 left 变成了完整的一段长度为 step 的链表cur = cut(right, step)拿到下一轮的起点,同时 right 变成一段独立链表preTail->next = merge(left, right)合并这两段并接到结果链表上- while (preTail->next) 把 preTail 移动到结果链表末尾
这里有个特别容易踩的坑:cut 返回 nullptr 的情况。当剩余链表长度不足 step 时,cut 会返回 nullptr,此时 right 为 nullptr,merge(left, nullptr) 会直接返回 left,不影响正确性。cur 为 nullptr 时内层循环结束。
4.3 两种实现的核心对比
| 维度 | 自顶向下 | 自底向上 |
|---|---|---|
| 实现难度 | 较好理解,代码量少 | 略绕,需要理解 cut 操作 |
| 时间复杂度 | O(n log n) | O(n log n) |
| 空间复杂度 | O(log n) 递归栈 | O(1) |
| 面试友好度 | 高,推荐先写这个 | 加分项,体现深度 |
| 适用场景 | 大多数面试场景 | 对空间有严格要求的场景 |
我自己在实战中的建议是:先用自顶向下把思路理清,再花点时间把自底向上写熟练。面试的时候先给递归版,如果面试官追问“能不能优化空间”,再亮出迭代版,效果会很好。
5. 实战经验与高频坑点记录
5.1 我踩过的几个典型坑
第一个坑:合并时忘记移动 tail。这是我早期写合并函数经常犯的错误。合并两个链表时,每次接上一个节点后,tail 必须往后移动一位,否则下一次赋值会覆盖掉之前的连接,最终只保留最后一个节点。我当时调试了半天,打印出来发现链表只剩一个节点了,才反应过来是 tail 没移动。
第二个坑:自底向上版本中 cut 之后忘记把后半部分置空。cut 的返回值是后半部分的头,前半部分必须置空,否则会出现环。比如 cut(left, step) 之后,left 链表的最后一个节点必须指向 nullptr,否则四个节点的链表在合并两个长度 2 的子链表时,后面的节点会形成环,程序直接死循环。LeetCode 的判题系统对死循环没有明确报错,只会显示“Time Limit Exceeded”,非常容易让人摸不着头脑。
第三个坑:递归版里没有判断 head 是否为空。有次我把链表切断之后递归调用 sortList,传入的后半部分是 nullptr,然后代码里直接访问 head->val,直接段错误。其实 merge 函数里已经处理了 nullptr 的情况,但 sortList 的入口没有判空,导致递归到空节点时崩溃。所以我在 sortList 开头统一加上 if (!head || !head->next) return head;,这个习惯一直保持到现在。
5.2 测试用例与调试技巧
这道题的测试用例,除了题目给的 4->2->1->3 和 -1->5->3->4->0 这两个示例,我还建议你自测这么几个边界情况:
- 空链表:输入 nullptr,应该返回 nullptr
- 单节点链表:输入 1,应该返回 1
- 两个节点:输入 2->1,应该返回 1->2
- 所有节点值相同:输入 1->1->1->1,应该返回原链表(但也要走完完整流程)
- 已经有序的链表:输入 1->2->3->4,排序后应该不变
- 逆序链表:输入 4->3->2->1,排序后应该是 1->2->3->4
调试链表的技巧,我一直用“打印链表”这个方法。写一个辅助函数:
cpp复制void printList(ListNode* head) {
while (head) {
cout << head->val << " ";
head = head->next;
}
cout << endl;
}
在关键节点调用 printList,能快速定位问题出在切分环节还是合并环节。我一般会在 mid = getMid(head) 之后、sortList(left) 之前打印一下左右两半,能立刻看出切分是否正确。
5.3 面试官的追问思路与应对
面试官考完这道题,往往会顺着往下问几个问题,提前准备一下很有必要:
问:快排能不能用来排序链表?答案是能,但要小心实现。链表的 partition 要改成用两个虚拟头节点分别收集小于和大于 pivot 的节点,然后再拼接。最坏情况时间复杂度 O(n^2),但对链表来说实现不够优雅,所以这道题的标准解法是归并。
问:如果链表是双向链表,能不能用插入排序?能。双向链表插入排序比单链表直观得多,因为可以方便地向前查找插入位置。但时间复杂度仍是 O(n^2),只适合面试时对比讨论。
问:如果数据量特别大,递归栈会不会溢出?会。这就是为什么自底向上的迭代归并更稳妥的原因。对于超大链表,递归深度 O(log n) 虽然通常够用,但在异常情况下(比如内存紧张)可能会有问题。
问:能不能用堆排序?理论上可以,把链表节点放进堆里然后逐个弹出重建链表,时间复杂度 O(n log n),但空间复杂度 O(n)。如果面试官只要求时间,这个方案最快能交差,但思路比较“作弊”,不推荐作为主答案。
6. 一点个人体会
LeetCode 148 这道题,我前后应该写了不下五遍。第一遍写的时候磕磕绊绊,递归版写出来已经费了很大劲;后来为了搞懂自底向上,我在纸上画了半个小时的链表指针变化图,才真正明白每一行代码的作用。这个“画图理解指针”的过程,比盲目刷十道题都有用。
如果你正准备面试,我的建议是:先花十分钟独立写递归版,卡住了再回头看文章;然后第二天不看书,再把自底向上写一遍。两遍都能流畅写出来,这道题才算真正过关。刷题不需要贪多,像这种一题串起多个核心技巧的题目,值得反复咀嚼。
