☰
排序链表最优解:自底向上归并排序实现O(1)空间
2026/9/28 12:21:43 网站建设 项目流程

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 slow

2.2 递归归并版本的完整代码

日常刷题或面试时,如果时间紧迫,先写递归版本是完全可以的,因为它逻辑最直观、不容易出bug。合并两个有序链表的部分大家应该很熟了——用一个dummy节点作为结果链表的头,然后双指针依次归并。完整代码长这样:

class ListNode: def __init__(self, val=0, next=None): 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写成了slow=head, fast=head的节奏,导致切出来的左右链表长度不均,递归深度飙升,最终在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。如果不把两条链表的尾部封口(即最后一个节点的next=None),merge函数在合并完第一段和第二段后,可能顺着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, head:fast走两步、slow走一步,fast到末尾时,slow刚好在中点偏右的位置(偶数长度时)。
  • slow, fast = head, head.next:fast先走一步,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)”,当场把迭代版本甩出来,这样面试官基本就没什么可挑的了。

排序链表这道题每次重做都会有新的收获,至少我在写完这篇梳理之后,再遇到任何“链表排序”的变体,都不会慌。把归并排序吃透,链表操作的基本功就算真正过关了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询