☰
链表排序为何首选归并?快慢指针与自底向上O(1)空间详解
2026/10/16 4:00:31 网站建设 项目流程

LeetCode Hot 100第148题“排序链表”,我前后做了至少五遍,每一遍都推翻一点之前的自以为是。最初能AC就觉得已经会了,后来被面试官追问“能不能把空间压到O(1)”才意识到递归版只是及格线;再后来认真琢磨为什么这道题的最优解偏偏是归并排序而不是快排、堆排,才算把链表排序这件事彻底想透。这道题表面上是“给一条乱序链表排序”,实际一次性考完了链表操作里最核心的三板斧——快慢指针找中点、dummy节点合并有序链表、按步长切链。如果你刚刷到链表专题,或者想在面试前把链表基本功打扎实,这篇文章值得你花二十分钟读完;如果你已经AC过,建议重点看后面关于自底向上和踩坑的部分,那里是大多数人容易漏掉的细节。

1. 为什么“给链表排序”比给数组排序麻烦这么多

这个问题我在第三遍刷的时候才认真想过。以前刷数组排序题很顺手:快排、堆排、希尔排序随便挑,写起来都有现成的套路。可是一到链表上,这些套路全部失效或严重打折。原因不是某个排序算法本身不好,而是数组这个数据结构给算法提供了太多隐性红利,链表一样都给不了。

1.1 数组排序里被习以为常的三张底牌

第一张底牌是O(1)随机访问。数组按下标访问任意位置,开销和访问第一个元素一样。快排需要随时跳转到左右两端,堆排需要从下标i找到它的左孩子2i+1和右孩子2i+2,希尔排序需要按某个gap跳着取元素,这些操作在数组上都像呼吸一样自然。

第二张底牌是原地交换。数组排序最重要的动作就是“换”,swap两个下标对应的值,不需要移动其他元素。快排的partition、堆排的建堆和调整,全是建立在“我能直接把两个位置的元素换掉”这个前提上的。

第三张底牌是区间的天然表达。数组可以用[start, end]两个下标描述一段连续区间,分治的时候直接mid = (start + end) / 2,左右边界清清楚楚。递归传递的只是整数,开销极小,逻辑上也几乎不可能出错。

这三种红利在排序算法里几乎是默认配置,大家写惯了数组排序,潜意识里会把“二分区间”“交换元素”“按下标跳转”当作理所当然。一旦切换到链表,这三张牌全部抽走,很多在数组上简单的操作就会变得非常别扭。

1.2 经典排序算法迁移到链表时的逐个体检

先说快速排序。快排的核心是partition:选一个pivot,让小于pivot的都在左边,大于的都在右边。数组上可以用双指针从两端向中间扫描,边扫边换。单链表只有next指针,想从右端往前扫根本做不到,只能改成“用两个链分别收集小于和大于pivot的节点,最后拼接”。思路没问题,但每层partition都要完整遍历一遍链表,pivot又不像数组那样可以随机选得很均匀,最坏情况下递归深度变成O(n),整体还是O(n²)。而且每次递归要生成两个新链表头,代码比数组版复杂得多,面试时讲解也费劲。

再看堆排序。堆排序在数组上几乎不需要额外空间,因为堆的结构是隐式存在数组下标里的。链表的节点分布在内存各处,你想找某个节点的左孩子,必须从它的位置出发沿着next指针走过去——这本质上已经退化成“每次访问子节点都是O(1)”以外的操作。就算用最小堆把所有链表节点塞进去再依次弹出,空间复杂度直接变成O(n),完全违背了堆排序省空间的初衷。

再看希尔排序。它需要按固定增量跳跃访问元素,链表上虽然可以通过走step步实现,但每走一步都是O(step),整体常数极大,工程上没人这么写。

最后说插入排序。插入排序在链表上其实不差,因为链表插入一个节点只需要改动两根指针,不需要像数组那样搬移元素。但它的时间复杂度仍然是O(n²),数据规模到了10⁵量级就顶不住了。LeetCode这道题的数据范围可以到几万个节点,O(n²)大概率TLE。

1.3 结论:归并排序为什么是链表的天选算法

归并排序在这份体检里几乎全部过关。它最核心的操作是“合并两个有序链表”,而合并只需要同时看两个链表的头节点,谁小就取谁,从头到尾顺序访问,不需要随机跳转,不需要交换,不需要下标区间。它要的分治区间也不依赖下标——快慢指针走一遍就能把链表从中间切两半。换句话说,归并排序的底层能力要求是“顺序访问”,链表给得起;快排和堆排要求的“随机访问”和“就地交换”,链表给不起。算法和数据结构的匹配关系就是这么现实:不是某个算法绝对好,而是它适不适合你手上的这个结构。

2. 自顶向下归并:递归版完整实现

想通归并是链表本命之后,第一版解法就是自顶向下递归。思路和数组归并排序几乎一一对应:把链表平均分成两半,分别排序,再合并。差别只在“怎么分”和“怎么合”。

2.1 分治落地链表的两个关键动作

数组分治靠下标算中点,链表分治靠快慢指针。合并的时候,数组要靠一个临时数组来缓存结果,链表不需要额外空间,直接在原节点上改next指针就能接成一条新链。这两个差异构成了链表归并的全部难点和全部魅力:难在找中点和切断,妙在合并时零额外空间。

很多教程会把“找中点”和“合并”当成两个并列知识点,但实际编码时还有一个第三动作容易被忽略——切断。只找到中点还不够,如果左半段和右半段还藕断丝连,后面的递归和合并会乱套。正确的步骤是:先找到左半段的最后一个节点,把它的next置为None,让左右两半彻底分家。

2.2 快慢指针找中点:这样写最稳

快慢指针找中点的标准做法是:慢指针每次走一步,快指针每次走两步,快指针走到末尾时,慢指针正好停在某个中点附近。问题在于“哪个中点”——奇数长度的时候慢指针恰好停在正中间,偶数长度的时候慢指针停在哪,取决于初始化方式和循环条件。

我最推荐的是这样写:

slow = head fast = head.next while fast and fast.next: slow = slow.next fast = fast.next.next

这套写法里,fast先人一步出发。链表长度为偶数时,循环结束时slow恰好停在左半段的最后一个节点上;长度为奇数时,slow恰好停在正中间。不管哪种情况,slow.next一定属于右半段。于是右半段直接从slow.next开始,再把slow.next置为None,两段长度最多相差1,分割非常均衡。

如果把fast也初始化为head,循环条件还写成while fast and fast.next,遇到偶数长度时slow会滑到右半段的起点,导致左半段多包含一个节点。这样最终结果不会错,但分割不均衡,最坏情况下递归深度退化的风险在极端输入里会出现。另一个常见错误是把循环条件写成while fast.next,遇到两个节点的链表时fast会直接滑到末尾再外访问fast.next,直接抛空指针异常。

2.3 dummy节点合并两条有序链表

合并是这道题另一个必须熟练到肌肉记忆的操作。思想很朴素:用两个指针分别指向两条链的头部,每次比较当前两个节点的值,把较小的那个接到结果链尾部,然后指针后移,直到有一条链为空,剩下的整段直接拼接。

为什么需要dummy节点?因为结果链的头节点不确定——它可能是左链的头,也可能是右链的头,取决于哪边的值更小。与其在循环外面做一堆if判断头节点到底是谁,不如先造一个假的头节点,让所有节点统一挂在它后面。循环结束后返回dummy.next,既不用特判,也不容易错。

def merge(self, 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 cur.next = l1 if l1 else l2 return dummy.next

这里有个小细节我一开始没注意:cur = cur.next必须写在if-else外面,表示“本轮接到结果链上的节点已经成为新的尾部”,不管选中了谁,尾部都要向后走一格。如果我在每个分支里都写一遍cur = cur.next,逻辑上也没错,但代码冗余,容易漏写导致死循环。

2.4 自顶向下完整代码与复杂度分析

把上面两块拼起来,加上递归出口,就是完整的自顶向下归并排序:

class Solution: def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]: if not head or not head.next: return head # 快慢指针找中点 slow = head fast = head.next while fast and fast.next: slow = slow.next fast = fast.next.next right = slow.next slow.next = None # 切断左右两半 left = self.sortList(head) right = self.sortList(right) return self.merge(left, right) def merge(self, 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 cur.next = l1 if l1 else l2 return dummy.next

复杂度可以用递推式说明:T(n) = 2T(n/2) + O(n)。找中点的快慢指针要遍历半条链,合并两条链要遍历两段,单层操作是O(n);递归深度是O(log n),所以总时间复杂度稳定在O(n log n)。空间上,因为合并是原地改指针,没有辅助数组,额外空间只来自递归调用栈,深度O(log n),所以空间O(log n)。

有一点值得说清楚:这里的时间复杂度是“稳定”的O(n log n),不会像快排那样退化到O(n²)。原因在于链表分割用的是快慢指针,天然对半分,不依赖pivot的质量。这算是链表归并比数组快排更让人安心的地方。

2.5 为什么递归版空间是O(log n)而不是O(n)

我见过有人把数组归并的O(n)辅助空间直接套到链表归并上,认为这道题空间也是O(n)。这是错的。数组归并的O(n)是为了缓存两个有序子数组的合并结果,链表合并不需要这个缓存——我只是把节点之间的next指针重新连接了一遍,节点本身还是原来的节点,没有复制。所以链表归并的空间只取决于递归深度,也就是O(log n)。这个区别在面试里很值得主动讲出来,能体现出你是真的理解归并排序,而不是背模板。

3. 自底向上归并:把空间压到O(1)的迭代版

递归版能AC,而且代码简洁,但题目进阶要求是O(1)空间复杂度。面试官大概率会在这个地方追问一句:“能不能不用递归,把额外空间降到常数级?”如果你只准备了递归版,卡在这一下就很可惜。

3.1 进阶要求与递归版的空间瓶颈

递归版的O(log n)空间其实已经很小,但只要用了递归,就必然占用系统调用栈。数据规模大到一定级别、或者递归深度因为写法问题被拉长时,栈仍然是风险点。O(1)空间的解法要用迭代模拟归并,也就是自底向上的归并排序——不是从整条链往下拆,而是从长度为1的小段开始,两两合并成2,再两两合并成4,直到整条链有序。

3.2 步长翻倍的整体流程拆解

自底向上的核心是控制一个归并步长step。第一轮step=1,把链表看成若干个长度为1的节点,相邻的两个节点两两合并,得到若干长度为2的有序段;第二轮step=2,把这若干个长度为2的有序段两两合并,得到长度为4的有序段;第三轮step=4,以此类推。每轮结束step翻倍,直到step大于等于链表长度,此时整条链只剩一个有序段,排序完成。

这个思路比递归版更贴近“分治”的本来面目:分治不一定要用递归实现,只要你保证每一轮都是从左到右把相邻等长的两个有序块合并,效果和递归完全一样。

要在链表上实现这个流程,有两个难点。第一,怎么从当前链表里切出长度正好为step的两段;第二,合并完的结果怎么接回已经排好的尾部,同时还得记住剩余部分的起点。这两件事用一个cut函数和一条主循环就能解决。

3.3 cut切链函数:自底向上的灵魂

cut函数的作用是:从head节点开始,取长度为step的子链,并把这条子链与后面的剩余链断开,返回后半段的起点。代码很短,但值得逐行看:

def cut(self, head, step): if not head: return None while step > 1 and head.next: head = head.next step -= 1 rest = head.next head.next = None return rest

“前进step-1步”是为了停在子链的最后一个节点上。比如step=2,从第一个节点走一步就到了第二个节点,此时head.next指向第三个节点,把它设为None,前两个节点就独立成段了。如果链本身不足step步,循环会因为head.next为空提前结束,这时head就是原链最后一个节点,rest自然是None,处理方式完全一样。

这个函数是典型的“用指针换信息”的例子:它不返回子链本身,因为外部早就保存了子链的起点;它只负责两件事——切断子链尾部,把剩余链的起点告诉调用者。

3.4 自底向上完整代码与边界处理

主循环的写法是这道题的精髓。每一轮迭代,都要把整条链表从头到尾重新“串”一遍:

class Solution: def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]: if not head or not head.next: return head # 先统计链表长度 n = 0 p = head while p: n += 1 p = p.next dummy = ListNode(0) dummy.next = head step = 1 while step < n: prev = dummy # 上一轮已排好链表的尾部 cur = dummy.next # 本轮待处理的链表头 while cur: left = cur right = self.cut(left, step) # 切出第一段,返回第二段起点 cur = self.cut(right, step) # 切出第二段,返回第三段起点 prev.next = self.merge(left, right) # 让prev移动到合并结果的末尾 while prev.next: prev = prev.next step *= 2 return dummy.next def cut(self, head, step): if not head: return None while step > 1 and head.next: head = head.next step -= 1 rest = head.next head.next = None return rest def merge(self, 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 cur.next = l1 if l1 else l2 return dummy.next

这段代码的边界情况很值得展开说。第一,如果第二段不存在,也就是right为None,cut(right, step)会直接返回None,merge(left, None)会把left原样返回,看上去这轮合并什么也没做,但left已经被切断成独立的一段,并且接到了prev后面,后续循环会继续处理剩余部分,不会死循环。第二,prev每轮必须更新到当前合并结果的最后一个节点,否则下一轮p.next会把上一段节点覆盖掉,链表就断了。第三,外层循环条件step < n,如果链表长度为5,step会依次取1、2、4,当step变到8时循环退出,此时5个节点已经全部有序。这个条件不能用step <= n,否则会多余地跑一整轮空归并。

3.5 递归版和迭代版到底怎么选

对比维度自顶向下递归版自底向上迭代版
实现思路递归拆分成两半,先排序再合并从长度为1的块开始,按步长两两合并
额外空间O(log n),递归调用栈O(1),只用常数个指针
代码简洁度逻辑清晰,写起来快需要理解cut和prev的衔接,容易出错
实际运行数据量小时差别不大多了一次统计链长的遍历,常数略高
面试推荐第一版答案,容易讲清楚追问优化空间时的进阶答案

我个人的建议是:面试时先稳稳地写递归版,讲清楚O(n log n)时间和O(log n)空间;如果面试官继续追问“能不能做到O(1)空间”,再切换到迭代版。两个版本都提前准备好,展示出你既懂递归分治的直观,也懂迭代分治对空间的控制,这道题基本就稳了。

4. 这题的坑,我基本都踩过:排查链路实录

很多题是“看着答案觉得简单,自己写就报错”。148题尤其如此,它的坑不在算法思想上,全在指针操作的细节里。下面这几个坑是我自己实际踩过、并且帮别人排查过很多次的。

4.1 快慢指针初始化与循环条件的组合陷阱

先说最典型的错误写法:把slow和fast都初始化为head,循环条件写成while fast and fast.next。这种写法在奇数长度链表上没问题,但在偶数长度链表上,slow最终会停在右半段的起点。比如链表[4,2,1,3],慢指针会滑到值为1的节点上,左半段变成[4,2,1],右半段变成[3]。分割不均衡,递归版的空间复杂度虽然理论上还是O(log n),但最坏情况下递归深度会退化。我一开始没意识到这个问题的严重性,直到用超长随机链表对拍时才看到偶尔的超时波动。

更严重的问题是空指针异常。写成while fast.next时,如果链表只有两个节点,fast初始在第二个节点,fast.next为空但循环体还要执行fast = fast.next.next,这一步会直接访问None.next。排查这种问题最快的方法就是取一个长度为2的链表手工走一遍循环。

正确的组合我已经在前面给了,用fast = head.next这个“错开一个身位”的初始化,配合while fast and fast.next。这样无论奇偶,slow都能停在左半段的最后一个节点上,保证左右两段分割均衡、不重叠、不漏节点。

4.2 忘记切断链表导致的死循环与资源耗尽

如果找完中点后忘了写slow.next = None,会发生什么?递归调用会继续对left和right做sortList,但left的尾节点还连着right的头节点,两条链根本是“你中有我,我中有你”。合并时,merge函数会同时遍历这两条互相纠缠的链,可能出现永远到不了空指针的情况——结果就是程序疯狂空转,直到卡死或内存耗尽。

这种错误特别隐蔽,因为小规模样例可能侥幸没触发,节点一多就现形。排查方法也很粗暴:在代码里每轮合并后打印当前链表长度,如果长度没变或者某个段越合越长,八成就是没切干净。记住一句话:链表分治和数组分治最大的差别,不是找中点,而是找完中点必须“物理上拆开”。

4.3 自底向上写法里prev指针为什么总写错

自底向上的代码里,最容易被忽略的是合并完成后让prev前进到段尾。很多人写完prev.next = self.merge(left, right)后就直接进入下一次while循环,结果下一轮开始时prev还是老位置,新合并的段把上一轮已经排好的部分覆盖了,链表从中间断掉。

正确的做法是把prev更新到合并结果的最后一个节点。我见过两种实现:一是像我上面那样用一个while prev.next循环走到尾;二是维护一个tail指针,在merge函数里顺便返回最后一个节点。第二种写法能少一次遍历,但会让merge函数签名变复杂。实际测试下来,用while prev.next的写法代码更直白,虽然每次多走一遍段尾,但所有轮的总体复杂度仍然是O(n log n),完全可以接受。

另一个相关的小坑是:每轮内层循环开始时,cur必须重新从dummy.next开始。如果错误地沿用了上一轮结束后的cur值,后面就会漏掉已经排好的节点,甚至直接陷入死循环。

4.4 递归深度和超长链表的真实风险

有人担心递归版在超长链表上会爆栈,这个担心要分情况。对于这道题的数据范围,链表长度最多几万,递归深度是log₂n,大概只有十几层,根本到不了Python默认递归上限。真正危险的情形是分割不均衡——如果快慢指针写错导致每次只卸下一个节点,递归深度就变成O(n),几万层递归直接RecursionError。换句话说,递归深度的风险不是来自选递归还是迭代,而是来自你找中点写没写对。

不过这题练完之后,我还是更看好自底向上版。它不需要递归,也就彻底不依赖语言对递归深度的容忍度;在内存受限的真实环境中,O(1)空间是一个实打实的优势。

4.5 一个实用的调试与对拍思路

我自己调试链表题有个固定套路,先写一个把链表转成Python list的小函数,再写一个从list重建链表的函数。排序完成后把结果转回list,和直接用内置sorted对原list排序的结果对比。对拍样本生成用随机数,节点数从1到几千都跑一遍,基本能覆盖大部分指针边界问题。

def linked_to_list(head): res = [] while head: res.append(head.val) head = head.next return res

这个方法不是用来“作弊绕过链表排序”的,而是作为验证正确性的工具。真正提交的时候,一定还是要用链表操作实现排序,否则就失去了刷这道题的意义。

5. 从148题带走的可复用模板与后续扩展

刷题最怕的是“刷一道忘一道”。148题最大的价值,是它把链表操作里最常用的三个模板集中考了一遍。把这些模板单独抽出来,你会发现后面的很多链表题都在重复它们。

5.1 三个链表操作模板

第一个模板是快慢指针找中点。这个模板在求链表中间节点、判断回文链表、重排链表里都会用到。核心是双指针错位和循环条件的匹配,建议直接把fast = head.next + while fast and fast.next固定成肌肉记忆。

第二个模板是dummy节点合并两条有序链表。合并两个有序链表这道题本身就是Hot 100的常客,而且合并K个升序链表里也要反复调用它。dummy节点能省掉所有“头节点到底是谁”的特判,这个习惯适用于几乎所有需要动态构建新链的场景。

第三个模板是cut切链。它比前两个稍微冷门一点,但作用很大:按固定长度把链表切成独立段,是自底向上归并、K个一组翻转链表这类题的基础。能用好cut,说明你对“改next指针就是改变链表结构”这件事已经真正理解了。

5.2 与148题联动的经典题目路径

如果你按顺序刷链表专题,我个人建议的路线是:先做合并两个有序链表,再做链表的中间节点,然后把这两题合起来就是148的自顶向下版;之后做排序链表,把迭代版吃透;再做合并K个升序链表,感受把merge复用到多路归并的场景;最后做K个一组翻转链表,体会cut切链的进阶用法。

这条路线的好处是每一题都在复用前面练过的模板,不会出现“学了新忘旧”的问题。148题就像是这些模板的汇总考试,能独立AC它,说明链表基础操作已经过关。

5.3 面试深挖方向:为什么不是快排和堆排

面试官大概率会问一个衍生问题:“这题能用快排做吗?”准备思路很清晰:快排依赖partition,partition在数组上靠双指针从两端扫描和交换,单链表只有单向next,要么改成“小链+大链+中间链”的三链拆分,要么每层都要完整遍历,实现复杂且最坏O(n²)。而链表的插入成本低,反而更适合归并——这正好把问题引导回你的解法上。

“为什么不能用堆排序”也是常见的追问点。数组堆排序的子节点位置靠下标计算,链表没有随机访问,维护堆会变成O(n)访问,得不偿失;如果非要用堆,只能把所有节点装进额外堆结构,空间O(n),不满足进阶要求。回答完这两个问题,再主动补一句“链表归并的空间优势来自合并是原地改指针,不需要辅助数组”,面试观感会好很多。

5.4 工程启示:归并思想在真实场景里的身影

这道题看着很“竞赛”,其实归并思想在工程里随处可见。大文件排序用的外部排序,就是先把大文件切成能装进内存的块,块内排好序,再做多路归并;数据库做排序合并连接时,底层也是类似的多路归并算法。链表的演化版本——比如分布在不同磁盘块上的有序段——本质上也需要这种“分段有序再逐层合并”的思路。

刷题能带给工程最大的价值,不是让你记住某个题的答案,而是让你看到算法和数据结构之间的适配关系:数组上最强的排序算法,迁移到链表上可能水土不服;反过来,链表上最自然的归并思想,在文件系统和大数据场景里依然是最底层的那根支柱。

最后分享一个我个人的刷题习惯:这道题的递归版和迭代版,我都会在笔记里各保留一份,并且标注两者的空间差异。面试被追问时,我从来不需要现场想,直接就能从“递归版O(log n)栈开销”讲到“迭代版为何能做到常数空间”。如果你也想彻底吃透这道题,建议你也用自己的话把第3节里cut函数和prev指针的关系讲一遍——讲得出来,才是真会了。

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

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

立即咨询