
第一次在力扣刷到“排序链表”这道题时我下意识觉得这不就是把数组排序换个容器吗真正动手之后才发现链表这层壳把我们在数组上习以为常的操作全堵死了。LeetCode 148 题“排序链表”官方标的是中等难度但它承载的信息量比很多困难题还大快慢指针、断链、递归/迭代分治、有序链表合并全被塞进同一道题里。很多人数组排序玩得飞起归并、快排、堆排背得滚瓜烂熟一碰到链表就懵卡点大概率就是这个题。这篇把 148 的完整思路和细节都撸一遍顺便分享我在调试链式归并时攒下来的经验适合正在刷力扣热题100、准备算法面试以及刚学完链表基础想进阶的读者。1. 题目拆解排序链表到底在考什么1.1 原始需求与复杂度红线题面很简短给你一个乱序的单链表头节点按升序把它排好返回排序后的头节点。难点从来不在题面而在约束条件里那一句希望在 O(n log n) 时间复杂度和常数级空间复杂度下完成。这句话直接把几个常见思路枪毙了。先用最容易想到的把链表遍历一遍把所有节点值存进数组用数组自带的排序再串回链表。时间上没问题但空间是 O(n)不满足要求。虽然很多人用这个办法能过题但面试官问一句“你能原地完成吗”当场就露馅了。再看 O(n^2) 的那些排序。冒泡排序在链表上写起来反而比数组还自然因为交换节点比交换值更直观插入排序也能写LeetCode 147 就是这么考的。但问题是 n 一大O(n^2) 直接超时。力扣的判题数据不会给一条只有几十个节点的链表让你混过去一旦跑大样例复杂度红线就是硬伤。所以 148 这道题真正想考你的其实是两件事第一你是否理解链表数据结构对排序算法的约束第二你是否能在链表的存储模型下实现一个 O(n log n) 的排序方案。1.2 数组排序与链表排序的本质差异数组能做快速排序、堆排序、希尔排序底层依赖的东西都差不多随机访问。数组通过下标在 O(1) 时间内拿到任意元素所以基于下标换位、基于下标维护堆结构都是可行的。链表呢你要访问第 k 个节点只能从头节点 next 过去一次 O(k)。这种访问模型决定了任何依赖高频随机访问的排序策略在链表上都会被打回原形。举个例子堆排序在数组上性能很好堆化过程中要不断通过下标找父节点、子节点。这个操作在数组里是 O(1) 的在链表里就得沿着 next 一路走。把堆排序直接搬到链表上时间复杂度会从 O(n log n) 退化到 O(n log^2 n) 甚至更差得不偿失。反过来看归并排序。归并排序的核心操作是什么把两个已经有序的序列合并成一个有序序列这个合并过程只需要比较两个序列当前头部的值选小的取走然后移动到各自的下一个节点。这不就是链表最擅长的操作吗顺序访问、断链、重接全程不需要随机访问任何一个中间节点。所以链表排序的标准答案几乎必然是归并排序至少在通用场景下它是综合最优的选择。1.3 一道题涵盖三条高频考点链路仔细拆一下 148 题你会发现它其实是好几道基础题拼起来的找链表中点是 876 题“链表的中间结点”合并两个有序链表是 21 题“合并两个有序链表”断链、接链、用 dummy 节点处理头节点变化是链表类题目最常见的基本功。这三块单独拿出来都不算难但拼在一起就得考虑递归时如何保证切分均匀、切完后如何正确断开、归并后如何把结果接回原链表。任何一环出了岔子整个排序就会在某个隐蔽的地方翻车。所以我不太赞成把 148 简单归类成“背一个模板”。它是那种你画过一遍指针图才真正理解链表的题。下面两种解法我都会拆到指针级别来说。2. 解法一自顶向下归并排序2.1 分治三步切、排、合自顶向下归并的思路其实就是标准的“分治”思想三个阶段先把当前链表从中间切成两半这个过程在链表里靠快慢指针完成递归地对左半部分和右半部分分别排序把两个已经有序的子链表合并成一个有序链表整段链表就排好了。很多人递归版写不好不是归并逻辑不会而是第一步“切”没切干净。数组做归并切分只是计算下标不需要真的把数组拆开链表做归并你必须在中间节点把 next 指空否则左右两半还粘连在一起合并的时候会形成环或者重复遍历程序直接超时或者报错。2.2 快慢指针找中点到底怎么走找中点的经典办法是快慢指针慢指针每次走一步快指针每次走两步快指针到末尾时慢指针正好到中点。但这里有个细节很多人第一次写会栽进去循环条件怎么写决定了 slow 停在中点的左边还是右边也会影响切分出来的左半段是否可能为空。我个人推荐配合 fast head.next 的写法代码是这样的# 快慢指针找中点slow 会停在前半段的最后一个节点 slow, fast head, head.next while fast and fast.next: slow slow.next fast fast.next.next # 从中间切断 mid slow.next slow.next None为什么推荐这种写法用几个长度试一下你就明白了。链表长度是 2 时节点依次是 A - B。此时 fast Bwhile 条件 fast and fast.next 不成立B.next 是空循环不进入slow 停在 Amid 就是 B左右各一个节点均匀切分。链表长度是 3 时A - B - C。fast BB.next 存在进入循环slow 到 Bfast 到 C.next 也就是空。循环结束slow 停在 Bmid 是 C。左半段是 A - B右半段是 C虽然长度不均但足够处理。链表长度是 4 时A - B - C - D。fast B第一次循环slow 到 Bfast 到 D再判断 fast.next为空循环结束。slow 停在 Bmid 是 C左右各两个节点完美均匀。如果你用 slow head、fast head 的写法长度 4 时 slow 会停在中点的右邻居位置切出来的是左半段三个节点、右半段一个节点的不均匀状态递归深度会变差。虽然最终也能排对但既然有更好的写法没必要给自己埋坑。2.3 递归切分与双路归并的完整代码剩下的步骤就自然了。递归终止条件是链表为空或者只有一个节点已经是排好序的直接返回。完整代码如下语言用 Python力扣环境可以直接跑class Solution: def sortList(self, head: ListNode) - ListNode: if not head or not head.next: return head # 快慢指针找中点slow 停在前半段的最后一个节点 slow, fast head, head.next while fast and fast.next: slow slow.next fast fast.next.next # 从中间切断left 和 right 是两个独立的链表 mid slow.next slow.next None left self.sortList(head) right self.sortList(mid) # 合并两个有序链表 dummy ListNode(0) cur dummy while left and right: if left.val right.val: cur.next left left left.next else: cur.next right right right.next cur cur.next cur.next left if left else right return dummy.next这个合并部分的逻辑就是完全复用了第 21 题“合并两个有序链表”的思路。用 dummy 节点承接合并结果的头节点两路指针从两个有序链表头部逐个比较谁小就接谁直到其中一个链表走空再把剩下的直接接到结果尾部。为什么递归版本也要用 dummy因为合并后的头节点是 left 和 right 中值较小的那个而你在循环开始前并不知道是哪一个。如果不引入 dummy你就得先单独判断一次代码会多出一截而且容易在边界漏判。dummy 的思路等价于“先占一个头节点的位置最后返回它的 next”能统一处理头节点变化的小问题。2.4 复杂度分析与递归栈空间自顶向下归并的时间复杂度是 O(n log n)。递归树每一层都要把所有节点扫一遍做归并一共 log n 层每层 O(n)总时间就是 O(n log n)。空间上有个点要特别注意递归版严格来说空间不是 O(1)。虽然我们只用到了若干个指针变量但递归调用深度是 O(log n)系统栈会占空间。如果力扣题目里“常数级空间”这个要求抠字眼那么递归版并不满足。不过话说回来绝大多数刷题讨论里递归版也能被接受因为 log n 的栈空间对于常规数据量来说非常小。但如果你正儿八经去面试面试官追问一句“你确定你的空间是常数级吗”你得把它讲清楚递归版空间 O(log n)想要严格的 O(1) 空间要看下面这种迭代版写法。3. 解法二自底向上迭代归并3.1 为什么说迭代版才是标准答案自底向上归并的思路和数组的迭代归并完全一致先把链表看成 n 个长度为 1 的有序子链表然后相邻的两两归并得到若干个长度为 2 的有序子链表再相邻两两归并得到长度为 4 的有序子链表依此类推直到整个链表有序。这个过程不需要递归不需要找中点只需要一个外层循环控制子链表长度再用一个内层循环遍历整个链表两两切出子链表并归并。由于全程没有递归栈只用常数个指针变量空间复杂度严格是 O(1)。这也是力扣题面“常数级空间复杂度”最标准的解法。实现自底向上的关键在于两个点第一怎么正确切出固定长度的子链表第二怎么把归并结果挂回原链表的正确位置。3.2 核心机制size 翻倍 cut merge先看一个辅助函数 cut(head, step)它的作用是从 head 开始数 step 个节点在第 step 个节点处把链表断开返回后半段的头节点。def cut(self, head: ListNode, step: int) - ListNode: while head and step 1: head head.next step - 1 if not head: return None next_head head.next head.next None return next_head注意这里 while 循环的条件是 step 1也就是说 head 实际只走了 step - 1 步最后停在目标段的最后一个节点上然后把这个节点后面的 next 置空返回后半段。为什么不是走 step 步因为 cut 的输入 head 本身就是这段的第一个节点你再走 step - 1 步就到了第 step 个节点停留位置正是这段的末尾。如果你写 while head and step 0就会让 head 停在下一段的第一个节点断的位置就错了。有了 cut外层主循环就清晰了class Solution: def sortList(self, head: ListNode) - ListNode: if not head or not head.next: return head # 1. 统计链表长度 n 0 cur head while cur: n 1 cur cur.next dummy ListNode(0, head) # 2. 子链表长度 size 从 1 开始翻倍 size 1 while size n: prev dummy cur dummy.next while cur: # 切出第一段 size 长度的子链表 left cur right self.cut(left, size) # 切出第二段 size 长度的子链表 cur self.cut(right, size) # 合并 left 和 right挂到 prev 后面 prev.next self.merge(left, right) # prev 移动到合并结果的末尾 while prev.next: prev prev.next size * 2 return dummy.next这段代码最需要理解的地方是内层 while cur 循环中三个指针的交替当前这一段的头是 cur。第一次 cut 后left 是第一段right 是第二段的头同时 left 内部已经被切断。第二次 cut 从 right 开始切出当前对中的第二段并把 cur 推进到第三段的头也就是下一轮要处理的起点。如果 right 不足 sizecut(right, size) 会返回 None第二段就是空链表合并函数能正常处理这种情况。然后调用 merge(left, right) 把两段合并接到 prev 后面。prev 再移动到合并结果的末尾为下一轮合并做好挂接准备。等内层循环把所有相邻子链表都归并完一个外层循环就结束了。此时把所有子链表长度翻倍再从链头开始重新两两归并。3.3 一个完整的 merge 辅助函数merge 和递归版里那段逻辑一模一样抽出来单独放def merge(self, left: ListNode, right: ListNode) - ListNode: dummy ListNode(0) cur dummy while left and right: if left.val right.val: cur.next left left left.next else: cur.next right right right.next cur cur.next cur.next left if left else right return dummy.next这里要确认一个边界如果 right 为空merge(left, None) 会直接把 left 原封不动返回只是中间走了一个 dummy 节点。这不影响正确性因为单链表有序子链表本质已经有序和一个空链表合并等于没合并。3.4 迭代版常见的理解误区我见过不少朋友第一次写自底向上归并代码看起来没问题但一跑就超时或者结果错乱问题基本出在两个地方。第一个误区忘记计算链表总长度 n。外层循环 while size n 依赖 n 来判断什么时候结束如果改成 while cur 之类的方式很容易在 size 接近 n 的时候多转一轮导致不必要的操作甚至断链。第二个误区prev 指针没有正确移动到合并结果的末尾。整个外层循环里prev 始终指向“当前已经排好序并挂回结果链表的部分的尾节点”。如果你把 prev 留在原地下一对子链表合并完直接接上来就会覆盖之前的结果链表会乱成一团。移动 prev 的过程看起来是个 while 循环其实就是在合并结果的那个链上走到最后一个节点这是自底向上归并最容易漏的一步。4. 进阶对比为什么不是快排和堆排4.1 快速排序在链表上容易退化很多人会问快排在数组上平均也是 O(n log n)空间也差不多为什么不能搬到链表上快排在数组上的核心操作是 partition选定一个 pivot然后两个指针从两端向中间移动把小于 pivot 的元素换到左边大于 pivot 的换到右边。这个过程依赖两个能力从两端同时向中间逼近以及通过下标原地交换元素。链表只有单向 next 指针从尾部向前遍历做不到双指针相向而行也做不到。当然有链表的快速排序变体取头节点当 pivot把整条链表拆成小于、等于、大于三个子链表然后递归处理小于和大于两个部分再拼起来。这个思路能跑通但问题也很明显如果链表原本就接近有序或者有很多重复值快排依然会退化成 O(n^2)比归并排序稳定地差。而且这个变体在拆链和拼接时的指针操作比归并复杂不少面试时手写很容易出 bug。既然归并排序已经是稳定的 O(n log n)没有任何理由去选快排的变体。4.2 堆排序的访问模型和链表不匹配堆排序的核心动作是“下沉”和“上浮”每次都要根据当前节点的下标找到它的父节点和子节点然后进行值交换。在数组里下标就是地址找子节点就是 index * 2 和 index * 2 1O(1) 完成。在链表里你根本没有“下标”这个概念要找到某个节点的子节点老实说只能从头部一路 next 过去。这样每做一次堆调整都要花 O(n) 的时间去找目标节点整个堆排序的时间复杂度直接恶化到接近 O(n^2 log n) 的规模。所以堆排序在链表上连“能用”都算不上最多是学术上的讨论话题。如果真的想用堆来给链表排序工程上常见的做法是用一个优先队列把链表所有节点塞进去再依次弹出并重建链接。这种做法时间和空间都是 O(n)违反题目限制不过它是“合并 K 个升序链表”那道题的核心技巧在别的场景下非常有用。4.3 插入排序、选择排序在链表中算什么水平有一个特殊情况值得单独说插入排序在链表上的实现其实是 LeetCode 147 题的原题。它的思路是维护一个有序前缀然后每次从未排序部分取出一个节点在有序前缀中找到合适的位置插入。链表天然适合插入操作所以插入排序的链表版比数组版写起来还顺手但时间复杂度依然是 O(n^2)。为什么我会专门提它因为如果面试官问你链表的插入排序答案很简单但如果你主动在 148 题里回答“用插入排序”基本是踩雷。只有当链表已经基本有序时插入排序的常数比较小可以用一用通用排序场景归并才是最优解。选择排序在链表上也可以做每次找到最小值节点然后从剩余部分摘下来接到结果尾部写起来也很直观但同样是 O(n^2)而且频繁找最小值节点性能很差。4.4 常见排序算法在链表上的适配性速查排序方法数组上时间复杂度链表上可行性空间开销结论归并排序递归O(n log n)高递归栈 O(log n)推荐但空间非严格 O(1)归并排序迭代O(n log n)高O(1)最优解面试首选快速排序O(n log n) 平均低容易退化 O(n^2)O(log n) 递归栈不推荐在链表用堆排序O(n log n)极低退化 O(n^2 log n)O(1)不推荐插入排序O(n^2)实现简单O(1)适合近似有序小链表选择排序O(n^2)实现简单O(1)性能差几乎不用5. 常见问题与调试实录5.1 递归版最容易踩的坑链表没断干净递归版最常见的错误是找完中点之后忘了写 slow.next None直接把原来的 head 和 mid 丢进递归。后果在数据量小的时候看不出来一旦链表长度上去了递归调用时左半段尾部还连着右半段合并两个子链表时指针会在两个区域之间来回乱窜最终表现为两种情况一是程序跑很久不结束力扣直接给你一个超时二是某个节点被访问了两次形成环代码报错或者递归栈溢出。我调这种问题时的经验是写一个小工具函数把链表从头到尾打印一遍看看它有没有环或者在哪个地方断开。你如果看到打印结果里同一个节点出现两次十有八九就是断链没断干净。5.2 迭代版最容易踩的坑cut 切到 None自底向上版我调试过程中碰到最多的是 cut 函数返回 None 之后代码下一步没有正确处理导致空指针异常。举个例子当前链表还剩最后一段长度只有 2但 size 已经翻倍到 4。此时 cut(left, size) 能正常切出 leftcut(right, size) 因为 right 长度不足返回 None。如果你在调用 merge 之前想当然地以为 right 一定非空代码就会崩。解决办法就是 merge 函数本身要能处理空链表入参我已经在代码里用 while left and right 天然规避了这个问题。5.3 快慢指针边界问题为什么我的中点总不对快慢指针找中点的循环条件最常见的写法是 while fast and fast.next:。这个条件本身是对的关键是 fast 的初始值会影响中点位置。如果你用 fast head那么偶数长度链表slow 会停在中间偏右的位置切出来的左半段比右半段长一个节点如果你用 fast head.nextslow 会停在偏左的位置切出来的左右更均衡。两种写法都能排序成功但建议统一用 fast head.next 配合 while fast and fast.next这样在长度较小的链表上表现更稳。另外提醒一句有些简化的写法会用 fast.next 作为 while 条件却没有判断 fast 本身是否为空。在链表长度只有 1 或者 2 的时候这种写法会直接触发空指针异常。递归版里我第一行就做了 if not head or not head.next 的判断这一道保险不能省。5.4 调试现场实录用随机数组验证两种解法给大家一个我亲测好用的验证思路。先用随机数生成一个数组转成链表分别调用递归版和迭代版 sortList再把结果转回数组和 Python 自带的 sorted 排序结果做对比。如果两版结果一致说明代码大概率没问题如果某一种解法结果不对就缩小数据规模用固定几个数组反复试。我当时就是用这个办法抓到了一个自底向上版里 prev 更新位置错误的问题合并完第一对子链表后prev 没有移动到合并结果的末尾导致后面所有归并的结果都接错了位置。这个 bug 靠肉眼读代码很难发现因为语法完全正确只有跑起来结果不对。先打印链表再对比输出定位就快多了。6. 刷题心得这道题到底帮你打通了什么6.1 为什么它能在热题100里占一个位置“排序链表”在力扣热题100里出现我觉得一点不意外。它不是一个孤立的模板题而是把链表专题几个最高频的技巧组合在了一起。如果你能不看题解独立把这道题做出来说明你对快慢指针的理解、对断链操作、对归并排序的掌握都已经过了“背代码”的阶段达到了“理解原理”的层次。反过来如果你卡在这道题上正好说明你的链表基本功还有缺口补上之后后面做“合并 K 个升序链表”“排序数组”之类的题都会轻松很多。热词里有人提出“力扣简单题”这个概念不过 148 真的不简单至少它在思维上一个弯都不少。别因为它是中等题就轻视它刷题攻略里经常强调链表类题目分值高、梯度大148 正是从基础操作过渡到综合问题的分水岭。6.2 从 148 延伸出去的题目地图这道题把归并思想吃透以后你可以串起好几道经典题第 21 题“合并两个有序链表”就是 148 里的 merge 部分单独拎出来考过无数次第 876 题“链表的中间结点”就是 148 里的快慢指针部分第 147 题“对链表进行插入排序”是 148 的 O(n^2) 对比解法第 23 题“合并 K 个升序链表”可以看成 148 的多路扩展版用优先队列或者多路归并都行第 143 题“重排链表”也需要找中点、反转后半段、再交错合并技巧和 148 高度重合。如果你在准备面试建议把这几个题连着刷。链表题做多了你会发现翻来覆去就是快慢指针、反转链表、合并链表、断链重接这几种操作的排列组合。148 最大的价值就是逼你把它们揉在一起用一遍。6.3 一点个人经验画图比看题解快得多我见过太多人刷链表题上来就开 IDE敲两行发现指针绕晕了又回去翻题解。说实话链表这种数据结构靠脑补指针移动真的不如在纸上画。我自己做题的习惯是先从 head 开始把每个节点标成小盒子画好 next 指向然后在上面手涂指针移动过程。特别是自底向上归并这种切完一段又一段的写法你只要画一轮 size 1 和一轮 size 2马上就明白 prev、cur、left、right 各自指向哪里了。画完之后再写代码速度比硬记快一倍也不容易写出错。还有一个小技巧写完代码不要急着提交先在本地构造几条链表长度分别是 1、2、3、4、5把每种情况跑一遍再上随机数组做对比。链表的边界错就错在长度小的那几种情况把这几个 case 覆盖住提交基本就是一遍过。最后再分享一个观点递归版和迭代版千万别只背一个。面试时候先讲递归版思路清晰代码好写等面试官问“能不能降低空间复杂度”你再把迭代版搬出来。两个版本都熟练说明你对归并排序的理解确实到位了这比单独背任何一个模板都有说服力。