排序链表最优解:自底向上归并排序实现O(1)空间
1. 第一反应与标准答案之间隔着一个排序算法选型先把这道题摆出来力扣hot100第33题排序链表。题面非常简洁——给你一个链表的头节点要求按升序把它排好进阶条件有两个时间复杂度O(n log n)空间复杂度O(1)。字符串很短但杀伤力很大因为它同时考三件事链表操作基本功、排序算法的本质理解、以及空间复杂度的严格把控。我第一次做这道题的时候第一反应其实很野把链表遍历一遍把所有节点值存进数组对数组排序再按顺序串回链表。这个思路在思路上完全没问题但是直接违反进阶条件——空间复杂度O(n)不说还绕开了链表操作的核心考察点。面试官看到这种解法基本等于你在告诉他“我不太会处理链表指针”。话说回来为什么这道题能进hot100而且常年稳定在热门题单的前列因为它几乎把“链表题的通用难点”全部浓缩在了一个问题里找中点、断开链表、合并有序链表、处理边界条件。而且更重要的是它逼着你做一次排序算法的选型判断而不是无脑调用sort函数。数组排序我们可以依赖语言内置的排序函数但链表不行——链表的随机访问是O(n)很多在数组上优雅的算法直接套到链表上会变得笨拙甚至无法落地。那我们先把候选算法过一遍看看谁的复杂度匹配题目要求谁又是看似可行实则踩坑。算法时间复杂度空间复杂度链表上的可行性冒泡/插入/选择O(n^2)O(1)可行但超时n开到10^5直接等死快速排序平均O(n log n)O(log n)~O(n)需要随机访问priovt链表实现麻烦且不稳定堆排序O(n log n)O(n)建堆额外空间不符合O(1)要求归并排序递归O(n log n)O(log n)递归栈最容易想到但递归栈不算O(1)归并排序迭代/自底向上O(n log n)O(1)完全符合进阶要求这就是正解看到这个表格答案已经很明显了归并排序。但归并排序也有两个版本——自顶向下和自底向上。很多教程只讲自顶向下递归版本因为代码短、逻辑清晰看起来就很好背。但问题是面试官如果追问一句“你觉得空间复杂度达标吗”你就得拿“递归栈也算空间”这层窗户纸来说明情况。想知道这层窗户纸后面藏着什么我们先从最直观的递归版本开始拆解。2. 自顶向下归并排序最直观的解法但未必是终版2.1 归并排序到底在链路上做了什么事数组上的归并排序核心是“先分后合”把数组一分为二各自排序再把两个有序数组合并成一个。链表上做同样的事情难点不在合并而在“分”。数组可以靠下标O(1)找到中点链表不行链表找中点只能靠快慢指针快指针每次走两步慢指针每次走一步快指针到终点时慢指针正好落在中间。这是一个非常经典的前置技巧你会在很多链表题里反复见到它比如判断链表是否有环、寻找链表中间节点、以及这道题里用来切分链表。找到中点之后要做一件非常关键的事把链表从中间断成两条独立的链表。这里有个细节特别容易出错——找到中点后要把中点的前一个节点的next置为空否则你递归处理左半部分时右半部分的节点还是能通过next指针被访问到整个递归结构就乱了。实际操作中有两种做法第一种是先找到slow和fast然后用一个prev指针记录slow的前驱最后prev.next None第二种是fast先走两步、slow走一步这种双指针节奏让slow最终落在左半部分的最后一个节点上然后直接cur.next None。我用的是第二种思路找一个“左闭右开”的切分方式代码更干净。具体可以这样写def get_mid(head): if not head: return head slow, fast head, head.next while fast and fast.next: slow slow.next fast fast.next.next return slow2.2 递归归并版本的完整代码日常刷题或面试时如果时间紧迫先写递归版本是完全可以的因为它逻辑最直观、不容易出bug。合并两个有序链表的部分大家应该很熟了——用一个dummy节点作为结果链表的头然后双指针依次归并。完整代码长这样class ListNode: def __init__(self, val0, nextNone): self.val val self.next next def sortList(head): if not head or not head.next: return head mid get_mid(head) right_head mid.next mid.next None left sortList(head) right sortList(right_head) return merge(left, right) def merge(l1, l2): dummy ListNode(0) cur dummy while l1 and l2: if l1.val l2.val: cur.next l1 l1 l1.next else: cur.next l2 l2 l2.next cur cur.next if l1: cur.next l1 if l2: cur.next l2 return dummy.next这个代码的优点是结构极其清晰递归边界、分治逻辑、合并逻辑各司其职。面试时先写这个版本至少你能保证提交通过、逻辑无误。但注意我前面表格里写的那一行——递归版本的空间复杂度是O(log n)来自递归调用栈的深度。对于一个完全平衡的切分递归树深度是log n但如果你的找中点函数写歪了链表切分不均匀递归深度可能恶化甚至接近O(n)那就彻底翻车。2.3 递归版本真正的问题不在性能而在“追问”我见过很多刷题的人把递归版本背得滚瓜烂熟但面试官一句“你能让空间复杂度严格变成O(1)吗”就直接卡住。这里有一个关键认知严格意义上的O(1)空间指的是除输入输出所占空间外辅助空间的消耗是常数级别不随数据规模增长。递归栈确实是辅助空间深度是log n所以不能算O(1)。这就把正解推向了一个方向——自底向上的迭代归并排序。另外递归版本在极端输入下还有爆栈风险。Python默认递归深度大约1000层而链表的长度上限是10^5虽然完全平衡切分下log2(10^5)约等于17层理论上是安全的但如果你的切分逻辑不均衡递归深度可能远超理论值。我有一次就因为get_mid写成了slowhead, fasthead的节奏导致切出来的左右链表长度不均递归深度飙升最终在10^5级数据上直接RecursionError。这个坑后面我会详细说。所以结论是递归版本适合写出来证明思路但如果你要“稳稳拿到进阶要求的满分”必须掌握自底向上的迭代写法。这也是本文真正的主角。3. 自底向上归并排序真正满足O(1)空间的写法3.1 核心思路用子链表长度控制合并轮次自底向上的归并排序通俗地理解就是先把每个长度为1的子链表看成已经有序的两两合并成长度为2的有序链表再把长度为2的有序链表两两合并成长度为4的有序链表以此类推直到整个链表有序。整个过程不递归、不切分全靠一个外层循环控制“步长”subLength以及一堆指针在链表中穿针引线。这个思路听起来很简单但实现起来比递归版复杂得多主要在于链表不像数组那样能通过下标自由跳转每一轮合并时你得手动找到每一段的头节点、手动记录上一段的尾部还得小心处理链表断开和连接。我用生活化的方式类比一下想象你有一排小卡片每张卡片写着一个数字。第一轮你把相邻两张卡片按大小合并成一小摞有序卡片第二轮把相邻两小摞合并成一大摞每一轮结束后所有小摞内部都是有序的。重复这个“合并相邻两摞”的动作直到只剩一整摞整个队列就有序了。上面这个过程的“小摞长度”就是代码里的subLength。它从1开始每轮翻倍直到大于等于链表长度排序结束。3.2 迭代归并的完整代码与逐行解析def sortList(head): if not head or not head.next: return head # 第一步获取链表总长度 length 0 cur head while cur: length 1 cur cur.next dummy ListNode(0) dummy.next head sub_length 1 while sub_length length: prev dummy cur dummy.next while cur: # 截取第一个长度为sub_length的子链表 head1 cur for _ in range(sub_length - 1): if cur.next: cur cur.next else: break head2 cur.next cur.next None # 断开第一个子链表 cur head2 # 截取第二个长度为sub_length的子链表 for _ in range(sub_length - 1): if cur and cur.next: cur cur.next else: break if cur: next_start cur.next cur.next None # 断开第二个子链表 cur next_start else: next_start None # 合并两个子链表 merged merge(head1, head2) prev.next merged while prev.next: prev prev.next cur next_start sub_length 1 return dummy.next这段代码看着长但拆开其实就四个动作找第一段、找第二段、断开、合并、挂接。我逐个解释。第一获取链表总长度。为什么需要length因为自底向上的归并是“倍增轮次”的你总得知道什么时候该停。虽然也可以用“如果subLength大于等于链表长度就停”来判断但没有length就无法判断是否已经合并完成。这个length每轮while循环的条件判断都要用所以必须先遍历一遍链表拿下它。第二dummy节点的意义。整个排序过程中链表的头节点可能会因为合并而改变。比如原始链表的头节点如果在第一轮合并中被放到了后面你要返回的新头变成另一个节点。dummy节点保证无论头节点怎么换dummy.next始终指向当前有序链表的头。这串逻辑和你在普通合并两个有序链表时用dummy的思路完全一样只不过这里的dummy贯穿了整个排序过程。第三也是最容易写错的地方就是“找到两个待合并链表并断开”。注意我的处理顺序先让cur从当前段头出发移动subLength-1步找到第一段的尾节点此时cur.next指向第二段的头先把它记为head2再把cur.next置空断开第一段然后把cur挪到head2的位置继续移动subLength-1步找第二段的尾节点把尾节点的next置为None同时记录下一轮的起始节点next_start。我为什么反复强调“断开”因为merge函数合并两条链表时循环条件通常是while l1 and l2。如果不把两条链表的尾部封口即最后一个节点的nextNonemerge函数在合并完第一段和第二段后可能顺着next指针把后面的节点也一并带上导致排序结果完全错乱甚至出现环形引用、死循环。第四prev指针的维护。每合并完一对子链表要把合并结果挂到prev.next上然后让prev沿着合并后的节点走到这段的尾部——因为下一对子链表的合并结果要接在这个尾部后面。这里有另一种写法是维护一个tail指针始终指向已排序部分的末尾效果一样但用prev有一点好处它就是上一段的尾部天然适合作为下一个合并结果的挂载点。第五注意sub_length 1。左移一位就是乘2表示下一轮合并的步长翻倍。很多教程写subLength * 2效果完全一样但位运算在刷题党里更常见性能上也没差别看你个人习惯。3.3 merge函数里的小优化头插 vs 辅助节点迭代归并中用到的merge函数和前面递归版本中的merge函数可以完全一样都是dummy节点双指针合并。但这里有一个性能优化空间值得聊一聊。在合并两条有序链表时常规写法是def merge(l1, l2): dummy ListNode(0) cur dummy while l1 and l2: if l1.val l2.val: cur.next l1 l1 l1.next else: cur.next l2 l2 l2.next cur cur.next if l1: cur.next l1 if l2: cur.next l2 return dummy.next这个写法是通用且稳妥的。但你在每一轮合并后要重新移动prev到新链表的尾部这本身是O(sub_length)的。整个排序的轮数是log n轮每轮所有prev移动加起来是O(n)所以总体仍然是O(n log n)。这个常数开销是可以接受的不必过度优化。如果你非要追求极致性能可以做一个“尾插优化”——在merge过程中不返回头节点而是同时返回新的尾节点让prev直接指向这个尾节点省去一轮遍历。我试过这种写法代码会变长而且容易在边界条件上出错对面试和比赛来说性价比不高。除非你的代码在最后几个测试用例上确实卡在了常数级超时否则——不推荐。4. 我写这道题踩过的坑以及验证正确性的方法4.1 找中点却忘了断链排序变“串烧”这是我第一次实现递归归并时犯的错。我用快慢指针找到了mid然后直接sortList(head)和sortList(mid.next)完全没有把mid.next置空。结果是什么假设链表是4-2-1-3找中点找到节点2然后递归处理左半边4-2和右半边1-3。问题是左半边调用sortList(head)时head链表中节点2的next仍然指向节点1于是这个“左半边”实际上包含了所有剩余节点。递归下去你会发现左右子问题根本不是分的而是穿在一起互相纠缠最终要么无限递归要么合并出的结果完全乱套。排查这道问题花了很久最后我用一个非常小的用例打印链表内容的方式发现断链这个动作比“找中点”重要得多。自顶向下归并排序中“分”的关键不是找到中点而是真正把链表分成互不可达的两条独立链。4.2 快慢指针节奏slow和fast的起点关系影响中点归属快慢指针找中点的代码有很多变体最典型的两个是slow, fast head, headfast走两步、slow走一步fast到末尾时slow刚好在中点偏右的位置偶数长度时。slow, fast head, head.nextfast先走一步slow略慢slow在中点偏左的位置即第一段最后一个节点。这两个起点选择会导致“中点落在哪一侧”产生差异。如果你用第二种就天然拿到了左半部分的最后一个节点直接把slow.next置空就能完成断链不需要额外的prev指针。我推荐这种写法因为它让“断开左半边和右半边”这个动作变得不费脑。但要注意的是如果你用slow, fast head, head在偶数长度的链表上slow会落在两个“中间节点”中更靠右的那个此时你拿到的是右半部分的头节点要断链反而需要额外记录前驱。这就比较绕了。4.3 迭代归并中的经典大坑合并完成后没有把末尾置空这个坑几乎人人都会踩一次。迭代归并在每一轮结束时整个链表是“一段一段拼接起来”的。如果你在合并某两段之后没有对最后一段的next做封口处理那么当subLength翻倍后下一轮从头遍历时会把上一轮合并后的残链当成一段完整的链表处理轻则排序错乱重则无限循环。具体来说在每一轮内部当我找到head2后断开第一段时cur.next None这一步能保证第一段被切断但第二段的尾部是在后续的cur.next None中断开的。有一个隐蔽的错误是当第二轮遍历时如果当前所有剩余节点不足subLength那么最后一个子链表可能没有足够的节点来“两两配对”这时候我直接就把它挂在prev后面了——这样做其实是正确的因为最后一小段即使不配对保持原样即可反正上一轮已经保证它内部有序。但如果你在代码里忘记在break后维护好cur指针的移动轨迹这个“不足一段”的尾巴很容易被错误地再次截断导致节点丢失。4.4 边界条件自查清单写链表题边界条件永远是bug的温床。我总结了一份自检清单每次写完排序链表都逐项过一遍输入情况预期行为容易漏掉的处理head为空返回None开头必须判断if not head链表只有一个节点原样返回if not head.next两个节点正确交换找中点即左半段最后一个节点本身全部相同值顺序不变排序结果稳定合并时仍然能通过但不能出现死循环最大长度10^5不超时、不爆内存迭代归并比递归稳4.5 怎么验证自己写对了刷题网站会直接帮你跑测试用例但很多人在本地调试时不会自己构造链表。我提供一个简单的方法写一个链表转列表、列表转链表的辅助函数然后随机生成大量数组排序后和Python内置sort的结果对比。import random def list_to_linked(arr): dummy ListNode(0) cur dummy for val in arr: cur.next ListNode(val) cur cur.next return dummy.next def linked_to_list(head): res [] while head: res.append(head.val) head head.next return res for _ in range(1000): arr [random.randint(0, 100) for _ in range(random.randint(0, 50))] head list_to_linked(arr) sorted_head sortList(head) assert linked_to_list(sorted_head) sorted(arr), arr print(all passed)这个办法对初学者来说特别友好能在十秒内发现各种边界bug。比如刚才提到的断链、丢节点、排序结果不稳定等问题用随机数据撞几次基本都会现出原形。我在本地写迭代归并版本的头两天全靠这个脚本帮我抓到三个隐蔽的bug。5. 从刷题到面试排序链表的变体与扩展思路5.1 一道题串起一整套链表技能很多人刷题是孤立地刷做完一道忘一道但排序链表这道题非常适合当“母题”来串知识点。它包含了链表操作中的四大基本功遍历计数求链表长度这个动作简单但高频快慢指针找中间节点几乎所有“断链”类题目的地基虚拟头节点合并有序链表时让头节点处理变得统一指针断开与重连自底向上的迭代归并中反复操作的核心能力。如果你把这道题彻底吃透再去做合并两个有序链表、合并K个升序链表、两两交换链表中的节点、Reorder List这些题会感觉阻力小很多。它们本质上用的都是同一套指针操作语言。5.2 变体一对K个有序链表做归并排序链表这道题做完很自然会延伸出一个问题如果我有K个有序链表怎么合并它们高效办法是借助优先级队列(最小堆)时间复杂度是O(N log K)空间复杂度O(K)。这题的思路仍然和归并排序一脉相承——你先把K个链表的头节点丢进堆里每次弹出最小的然后把它的next补进堆循环直到堆空。另一种不用堆的做法就是两两合并先合并前两个得到新链表再和第三个合并……时间复杂度是O(NK)性能差得多。所以如果面试官让你写合并K个有序链表优先答优先级队列方案然后再提醒他“如果要求O(1)空间我们可以用自底向上的归并改造”——这个应答思路就能直接把排序链表里的经验迁移过来。5.3 变体二链表上的其他排序场景排序链表属于“不能随机访问”的场景常见的替代方案是归并。但还有一些链表排序题是特殊情况比如对含有重复值的链表进行快速排序这时候递归版本其实也能实现只是partition变成了值比较链表切分代码会非常繁琐。实际面试中我很少见到有人用快速排序解链表题因为链表快排的时间复杂度并不总是O(n log n)最坏情况会退化到O(n^2)。归并排序是链表排序的天然最优选择原因无他——链表的“顺序访问断链拼接”特性正好匹配归并排序的“合并有序序列”操作。5.4 迭代归并对递归归并何时胜出递归归并的优势是代码短、可读性强、不容易有逻辑漏洞劣势是递归栈空间不计入O(1)以及极端情况下有爆栈风险。迭代归并的优势是严格O(1)空间性能稳定还能顺带展示你对递归栈底层的理解劣势是代码长指针变量多边界容易写错。我的建议是面试中先写出递归版本明确说明它空间是O(log n)然后主动提出“我可以改成自底向上的迭代版本实现O(1)空间”再把迭代代码写出来。这一套组合拳打下来既展示了思维的全面性也向面试官证明你对复杂度的理解不是背模板的而是真正掌握了底层逻辑。5.5 一些实战心得这道题我写了很多遍最后总结出几个屡试不爽的经验分享给正在刷题的朋友第一dummy节点在你的排序过程中永远不要动它本身只操作dummy.next。一旦你忘了这一步后面所有指针都会乱套。把dummy当成一个固定的“哨兵”你的思维负担会小很多。第二断链操作别省。不管是用cur.next None还是prev.next None该断就断。少写一次断链可能就浪费一小时debug。第三先跑小用例再上大用例。本地调试时先测两个节点、三个节点、全部逆序、全部正序、全部相同值这五种小用例过了再去测长的随机链表。不要一上来就跑10^5的随机数据出了问题根本定位不到是哪一层循环的锅。第四迭代归并的subLength不是从0开始而是从1开始。从1开始的含义是第一轮合并的是“每个长度为1节点的有序链表”也就是把两个单节点进行合并。如果你从0开始第一轮实际上没做任何有效操作白白浪费一次循环。这个小细节我在面试模拟时被面试官问过一次从那以后就牢牢记住了。第五遇到超长时间链表时优先怀疑是循环条件出了问题。比如while cur的内层循环中如果有一个分支没有正确更新cur会导致某个节点被重复处理链表陷入局部死循环。这种bug很难通过短用例发现因为短链表可能碰巧绕过了这个分支。这也是为什么本地随机测试要跑1000次以上覆盖各种长度和各种分布的值。最后再分享一个小技巧面试讲到空间复杂度时如果你用递归归并建议主动说“递归栈深度是O(log n)如果严格讨论辅助空间它不算O(1)”——这句话说出来其实已经比80%的候选人强了。然后你再补一句“不过我们可以用自底向上的迭代归并把它变成真正的O(1)”当场把迭代版本甩出来这样面试官基本就没什么可挑的了。排序链表这道题每次重做都会有新的收获至少我在写完这篇梳理之后再遇到任何“链表排序”的变体都不会慌。把归并排序吃透链表操作的基本功就算真正过关了。