news 2026/10/20 12:58:22

链表排序最优解:自底向上归并排序,O(1)空间实现单链表排序

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
链表排序最优解:自底向上归并排序,O(1)空间实现单链表排序

链表排序,这题在剑指 Offer II 077 里算是一道很有代表性的链表操作题。LeetCode 原题叫“Sort List”,要求对单链表排序,时间 O(n log n)、空间 O(1)。第一次看到这个空间限制,很多人会愣一下:数组排序随手用 Arrays.sort,但链表不能随机访问,很多思路得从头想。这篇文章会把这道题的完整思路、实现细节、边界处理和踩坑经验一次说清楚,适合正在刷题的开发者,也适合想系统梳理链表操作的人。

这道题看起来只是“排序”,实际上考的是三件事:能不能想到用归并排序、会不会把递归改成迭代、能不能把链表切断再接回去而不出环。每一个点都不难,但串在一起,就够让人写半天了。我当初第一次写,代码基本是缝缝补补过样例的,直到把底层的分段逻辑理顺,才觉得真正掌握了。

1. 先看清这道题的三个核心难点

1.1 题目到底在问什么

题目给一个单链表的头节点,要求把它按升序排好,返回新的头节点。输入输出都是链表,所以不能开数组存下来排序再重建,至少不能当作最优解。

一个最直接的想法是:把链表遍历一遍,val全塞进数组,排序之后重新构造链表。这样做逻辑很简单,但额外空间是 O(n),不符合题目的空间要求。题目说“进阶:在 O(n log n) 时间复杂度和常数级空间复杂度下,对链表进行排序”,这就把很多方法挡在门外了。

另外要注意,链表节点的定义只有val和next,没有前驱指针,也没有长度属性。很多在数组里看起来自然的操作,在链表上都会变得别扭。

1.2 链表的三个“麻烦点”

链表排序和数组排序的区别,核心在于三个特性:

  • 不能随机访问:数组可以花 O(1) 取中间元素,链表快慢指针找中点要 O(n)。
  • 没有长度信息:数组有length,链表只有一个head,你不知道后面还有多少个节点。
  • 拼接容易绕成环:合并两个有序链表的时候,如果 tail.next 指向没有及时断开,就可能出现循环引用,导致程序卡死。

这三个麻烦,正是这道题设计出来要考你的地方。你掌握了链表的基本操作——遍历、断链、拼接,这道题就只是一道普通的归并题;如果这些操作不够熟练,就会在边界条件上反复吃亏。

2. 解法选型:为什么最终落在自底向上归并

2.1 常见排序算法在链表上的表现

先过一遍能用和不能用的方案,不是做无用功,是为了真正理解归并排序在这里的不可替代性。

算法时间空间在链表上的表现
插入排序O(n²)O(1)最基础的链表排序写法,没有额外空间,但时间不达标
快速排序O(n log n) 平均O(log n) 平均链表中点不好取,partition 需要频繁交换节点,效率不高
递归归并排序O(n log n)O(log n)思想正确,但递归栈会占用额外空间,不满足常数空间
迭代归并排序O(n log n)O(1)满足所有条件,是这道题的理想解法

你会注意到,快速排序在链表上不是不能用,而是不好用。数组快排最自然的方式是双指针从两端往中间走,链表没有前驱指针,双指针操作起来非常别扭。即使改成只用一个指针遍历的写法,partition 时找枢纽节点、交换节点也远比数组麻烦。

递归归并排序思想没问题,但题目说的是“常数级空间”,递归栈会存 O(log n) 的开销,严格来说不达标。面试里你说“我用递归归并,空间 O(log n)”,通常能过,但这道题既然明确写了进阶要求,自底向上迭代归并才是更完整的回答。

2.2 自底向上的整体思路

自底向上归并的思想,一句话总结:先把链表拆成每段长度为 1 的有序链表,两两合并,得到长度为 2 的有序段;再把长度为 2 的有序段两两合并,得到长度为 4 的有序段;重复下去,直到整个链表有序。

举个具体例子。链表是4 -> 2 -> 1 -> 3:

  • 第一轮,按步长 1 分段:[4]和[2]合并成2 -> 4,[1]和[3]合并成1 -> 3,现在链表是2 -> 4 -> 1 -> 3。
  • 第二轮,按步长 2 分段:[2 -> 4]和[1 -> 3]合并成1 -> 2 -> 3 -> 4。

这里不需要递归,只用循环控制步长,每轮从头开始遍历链表,切两段、合并一次、把结果接到结果链表的末尾。

这个思路的好处是:空间可以做到 O(1),只靠指针移动就能完成所有操作;时间还是 O(n log n),因为每一轮要把所有节点扫描一遍,一共 log n 轮。

3. 一步步把自底向上归并写出来

3.1 先求链表长度,确定需要多少轮

自底向上的第一步是统计链表长度,因为要按长度判断“后面还有没有足够的节点”,这个长度贯穿整个算法。

int length = 0; ListNode node = head; while (node != null) { length++; node = node.next; }

没有这一步,你没法知道循环该到什么时候结束。也可以每轮内部单独判断剩余节点数,但先求出总长度会更清晰,代码也更不容易出错。

3.2 分割链表:核心中的核心

分割操作非常容易写错。我的做法是把“从某个节点开始,切出长度为 s 的一段”封装成一个独立方法:

private ListNode cut(ListNode head, int size) { if (head == null) { return null; } ListNode cur = head; while (--size > 0 && cur.next != null) { cur = cur.next; } ListNode next = cur.next; cur.next = null; // 切断 return next; // 返回下一段的头节点 }

这个方法的返回值是“切完之后剩下的那个头节点”,而切下来的这一段,被截断后只剩下从原头到cur的部分。调用的时候要注意,size可能比实际剩余的节点多,这时切出来的段可能不足size个节点,没关系,合并的时候照常合并即可。

这个方法是整道题的“发动机”,很多人的问题就出在这一步的返回值设计上。如果返回的是切下来的段头,你就没法知道接下来该从哪里继续遍历了;如果返回的是剩下的头,每次合并前还要额外找段头,显得啰嗦。返回值设计成“剩余链表的头”,配合外部循环里的cur指针,一切都顺了。

3.3 合并两个有序链表的实现

合并两个有序链表是基础操作,但这里有一个细节值得注意:合并前后的链表可能不是一个完整的独立链表,而是从整条链上切下来的一段,所以合并不用创建新节点,只需要重新组织指针。

private ListNode merge(ListNode l1, ListNode l2) { ListNode dummy = new ListNode(0); ListNode tail = dummy; while (l1 != null && l2 != null) { if (l1.val <= l2.val) { tail.next = l1; l1 = l1.next; } else { tail.next = l2; l2 = l2.next; } tail = tail.next; } tail.next = (l1 != null) ? l1 : l2; return dummy.next; }

这里的dummy节点有几个作用:一是避免每次合并都要特判“第一个节点到底该接谁”;二是返回合并后的头节点时不用保存两个链表的头引用。这个写法虽然简单,但它是整个自底向上归并的关键支撑。

3.4 主循环:把分段和合并串起来

有了length、cut和merge,剩下的主循环就不复杂了:外层循环控制步长,内层循环依次处理每两个段。

public ListNode sortList(ListNode head) { if (head == null || head.next == null) { return head; } int length = 0; ListNode node = head; while (node != null) { length++; node = node.next; } ListNode dummy = new ListNode(0, head); for (int step = 1; step < length; step <<= 1) { ListNode pre = dummy; ListNode cur = dummy.next; while (cur != null) { ListNode left = cur; ListNode right = cut(left, step); cur = cut(right, step); pre.next = merge(left, right); while (pre.next != null) { pre = pre.next; } } } return dummy.next; }

这段代码的关键在while (cur != null)里。cut(left, step)之后返回的是第二段的头,cut(right, step)之后返回的是下一轮的第一段头。这里必须把这个返回值存好,因为紧接着要作为cur开始下一段合并。我写的时候一开始总是弄混这几个返回值,调试了很久才发现是这里的问题。

外层循环控制步长,每轮结束时step要左移一位,相当于长度加倍。循环终止条件是step < length,因为当步长大于等于链表总长度时,说明整个链表已经成了一整段有序链表,不需要再合并了。

3.5 一个更容易读懂的版本

上面的版本是极力压缩代码的写法,但如果你还不熟练,建议写一个变量名更明确的版本。下面这种写法我在调试的时候更愿意用:

public ListNode sortList(ListNode head) { if (head == null || head.next == null) { return head; } int len = 0; for (ListNode cur = head; cur != null; cur = cur.next) { len++; } ListNode dummy = new ListNode(0); dummy.next = head; for (int step = 1; step < len; step *= 2) { ListNode prev = dummy; ListNode cur = dummy.next; while (cur != null) { ListNode firstHead = cur; ListNode secondHead = cut(firstHead, step); cur = cut(secondHead, step); ListNode merged = mergeTwo(firstHead, secondHead); prev.next = merged; while (prev.next != null) { prev = prev.next; } } } return dummy.next; }

差别只在变量命名上,逻辑完全一致。刷题的时候没必要追求所有代码都挤在一起,拆开写、变量名直白,反而更容易在面试中边走边说。

4. 刷这道题最容易踩的坑

4.1 场景一:链表的长度不足一步长

如果step = 4,但当前剩余节点只有 2 个,会发生什么?cut(left, 4)只会从头遍历到最后一个节点,然后返回null。也就是说,right是null,cur也是null。这时merge(left, null)直接返回left本身,相当于把剩下的一段原样接上,不会出错。

但你要意识到,这里的left可能已经经过了之前几个 step 的合并,它内部是基本有序的,直接接上是安全的。

4.2 场景二:切断链表的时机不对

网上很多版本的 cut 写法不一样:

// 这种写法有问题吗? private ListNode cut(ListNode head, int size) { if (head == null) { return null; } ListNode cur = head; while (size > 1 && cur.next != null) { cur = cur.next; size--; } ListNode next = cur.next; cur.next = null; return next; }

这个写法实际上是正确的。但如果你改成while (size > 0 && cur != null),并且让cur = cur.next,在cur.next == null时会多走一步,导致把已经最后一个节点的 next 置成 null,没有危害,但会让代码变得难以分析。我用这个思路,把 cur 遍历的终止条件写反过,然后找了一个小时 bug,后来才意识到是切段的时候多跳了一个。

4.3 场景三:pre 指针移动位置不对

每次合并完一段,pre要移动到合并后链表的末尾,等待接下一段。如果你忘了移动,下一段合并完之后会直接覆盖掉之前的结果,链表最终残缺不全。

这里有一个经验:合并完之后,不要直接pre = merged,因为 merged 的长度不一定刚好是 2 * step,可能比 step 短(如果到了链表末尾)。用while (pre.next != null) pre = pre.next;是更稳的写法。

4.4 常见问题速查表

问题现象原因分析处理办法
排序后链表变短合并前没有把整个剩余链表接着合并后的部分检查pre.next = merged是否在每次合并后执行
while 循环死循环cut 方法没有切断 next 引用确认 cut 里cur.next = null
结果正确但超时每轮都从 head 开始重新计算长度长度只算一次,用step < len控制轮数
中间出现空指针右侧链表为空时 merge 的处理不当merge 方法里用l1 != null ? l1 : l2
返回值不对用 dummy.next 判断错误排序整个链表后返回dummy.next
合并内容顺序混乱left/right 分配颠倒记住 left 是前面的段,right 是后面的段

4.5 要不要先写递归归并版本

我自己刷题时,有意先写了递归版本,再改成迭代版本。这样做有三个好处:

  • 递归版本能帮你更直观地理解“划分—合并”这个过程。
  • 把递归改成显式步长控制的版本,能让你感受到空间从 O(log n) 变 O(1) 的差异。
  • 面试中如果时间紧,先写递归版本保底,至少思路正确。

递归版本:

public ListNode sortList(ListNode head) { if (head == null || head.next == null) { return head; } ListNode slow = head; ListNode fast = head; ListNode prev = null; while (fast != null && fast.next != null) { prev = slow; slow = slow.next; fast = fast.next.next; } prev.next = null; ListNode left = sortList(head); ListNode right = sortList(slow); return merge(left, right); }

这个版本用快慢指针找中点,然后把左半段的 next 断开。递归下去,直到只剩一个节点,再回溯合并。它好写,但不好解释复杂度,因为递归栈超过常数范围。所以最后一定要回到迭代版本。

5. 边界场景与复杂度验证

5.1 长度只有 1 或 2 的情况

长度为 1 的链表根本不需要排序,直接返回。

长度为 2 的链表,第一次循环 step = 1,left 是第一个节点,right 是第二个节点,merge 后返回有序的 2 节点链表。此时 step 变成 2,不满足step < 2,退出循环,返回结果。这个过程虽然只有一次合并,但整个框架仍然正确运行。

如果你用递归版本,长度为 2 时还是需要切分成两个单独节点,再合并回来,多一层调用栈。

5.2 全部逆序和全部相同

全部逆序是归并排序的“最佳输入”之一,每一轮依然稳定地 O(n),所以时间还是 O(n log n)。

全部相同的链表要特别注意稳定性。题目没有要求稳定排序,<=和<都可以,但在合并时如果用<=,可以保证相同 val 的节点保持相对顺序;用<也不影响正确性,因为值相等时交换位置,结果仍然是升序。我建议统一用<=,这样万一题目扩展成稳定排序的要求,代码也能直接用。

5.3 空间复杂度的严谨讨论

迭代归并排序的额外空间主要来自几个局部变量:dummy、pre、cur、left、right、merge 里的 tail。这些都是常数数量级的引用,不随链表长度增长,所以空间是 O(1)。

递归版本虽然思路正确,但递归深度是 log n,意味着调用栈占用 O(log n) 空间。如果编译器不做尾递归优化,这就是真实的空间开销。题目写了“常数级空间”,所以要选择迭代版本。

时间上,每一轮 step 都翻倍,共 log n 轮;每轮遍历 n 个节点。总时间是 O(n log n)。注意,步骤里的找中点、找长度都是每轮重复的,所以这一轮虽然略像数组归并,但常数略高,实际刷题时完全可接受。

5.4 链表长度很大的时候

如果链表的长度接近几十万,迭代归并的代码稳定性优势就体现出来了。递归版本可能因为递归深度导致栈溢出(虽然 log n 通常不大,但在极端条件下有风险),迭代版本则不存在这个问题。

另外,由于我们预先算了一次总长度,step <<= 1不会写成step << 1这种把步长翻倍错误的方式,循环结束条件才有保证。

6. 从这道题延伸出去的链表操作能力

6.1 链表题的四个基本动作

这道题本质上考的,是链表操作的四个基本动作,拆开来看:

  • 遍历:走一遍拿到长度,是最基本的。
  • 断链:cut 方法里把一个节点 next 置为 null,这个动作非常关键。
  • 合并:merge 操作把两个有序链表串成一条。
  • 拼接:在 pre 后面接上合并结果,实现整条链的重组。

这四个动作,在后面的很多链表题里都会反复出现。比如 K 个一组翻转链表、合并 K 个升序链表,本质上都是重复使用这些基本能力。

6.2 和数组归并排序的对比

数组归并排序的思路和链表版一模一样,但实现方式差别很大。数组版要开辅助数组,空间 O(n);链表版可以原地只需要改指针,空间 O(1)。数组版每一步都要拷贝元素,链表版只要重新指向。

这也是为什么面试官愿意在链表题上考归并排序——它逼着你从“值操作”切换到“指针操作”。数组排序你习惯用下标交换值,链表排序必须改变引用,这背后是对数据结构本身的掌握程度。

6.3 一个更深入的问题:为什么快速排序不常用

快速排序在达到平均时间复杂度上同样优秀,但链表的快排实现要比归并复杂不少。问题主要出在 partition 上:你选定一个 pivot 值,把链表拆成“小于”、“等于”、“大于”三段,再把三段拼接。这看起来不难,但实际写起来要注意维护六个指针,而且链表的随机访问特性决定了 pivot 的选择很难优化,最坏情况容易退化到 O(n²)。所以链表排序优先选归并。

6.4 变形:如果链表有环怎么办

链表排序的前提是单链无环。实际面试中,有个变种题会让“判断链表是否有环”,如果这道题和排序题综合在一起,就必须先判断是否有环,再用快慢指针找环入口,然后断开环再排序。

这个过程又复杂了一些。但只要你熟练掌握了基本的链表操作,比如快慢指针找中点和环入口,再结合 cut 和 merge,就能在环断开后复用同样的归并框架。链表题的很多题都是这么结合出来的。

7. 个人经验与最后的建议

7.1 我踩过的三个具体坑

第一,cut返回之后,我没有更新cur,导致下一轮合并还是从原位置开始。这个问题让我印象最深,因为我当时的调试思路一直在看 merge 是否正确,直到用了小数据手动模拟才发现是 cur 没有更新。链表问题里最可怕的就是这种“指针没有往前走”的错误,它不会立刻报错,但在循环里会遇到奇怪的输出。

第二,用for (int step = 1; step <= len; step <<= 1),把<=写成了<。当链表长度正好是 2 的幂时,最后一轮合并会漏掉。这个问题很隐蔽,因为大多数样例长度都不是 2 的整数幂。

第三,合并两个链表时,tail.next = (l1 != null) ? l1 : l2,我把这个语句漏了。合并的结果会被截断,最后一段丢失。这个问题在单步调试时能一眼看出来,但如果只看最终结果,你会以为是后面的拼接有问题。

7.2 推荐的做题顺序

如果你是第一遍刷这题,我建议按这个顺序来:

  1. 先会用递归归并排序做出来,也就是快慢指针 + 递归 merge。
  2. 再重复写一次迭代归并排序,也就是本文的完整版本。
  3. 尝试自己从零推导一遍主循环,不看任何参考代码。
  4. 写完之后,故意破坏一个边界条件(比如漏了断链或漏了 pre 移动),看能不能靠测试样例自己找出来。

第四个步骤听起来有点自虐,但非常有效。链表题本来就是一个“找引用错误”的训练,提前感受这些错误,后面遇到真的容易很多。

7.3 面对面试的解释思路

面试场景下,不需要一上来就写代码。可以先说:时间是 O(n log n) 的排序,我选归并排序;因为链表不支持随机访问,快排不适合;为了保证空间 O(1),我用迭代归并。然后简单画一个步长变化的示意,再开始写。

写的时候分块说,先写cut,再写merge,最后写主循环。这样面试官能看到你的思路,也方便在你卡壳的时候给提示。

7.4 延伸思考

刷完这道题,还可以顺手看看这几类相关题目:

  • K 个一组翻转链表,体会链表拼接对循环的控制。
  • 排序链表加随机指针的复制问题,感受指针维护复杂度。
  • 双向链表的归并排序,思考前驱指针是否简化了断链操作。

从我的实际体验看,链表排序这道题的价值不在“排序”本身,而在它把所有链表基本操作都压到同一道题里。只要把这四件事——遍历、断链、合并、拼接——练到条件反射,后面再去刷其他链表题会顺很多。最后分享一个小技巧:碰到链表题,先在纸上画四个节点,把所有指针变更都标出来,再写代码,基本不会出错。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 12:58:21

C#接入微信支付V3:签名、回调验签与小程序调起支付全攻略

小程序接微信支付&#xff0c;最容易被卡住的不是写代码&#xff0c;而是理清楚V3版那一整套签名、证书、回调验签的规则。尤其是后端用C#的时候&#xff0c;网上能搜到的完整示例少&#xff0c;能讲明白“为什么这么写”的更少&#xff0c;很多开发者照着旧版V2的代码套V3&…

作者头像 李华
网站建设 2026/10/20 12:57:49

基于Spring Boot的红色知识学习平台开发实战

前一阵我们团队接到一个需求&#xff0c;要做一套红色知识学习平台。刚听到这个题目时&#xff0c;我第一反应是&#xff1a;这不就是一个内容管理系统吗&#xff1f;无非是文章列表、用户登录、后台发文章。等真正把需求聊透之后才发现&#xff0c;这里面的坑比想象中多得多。…

作者头像 李华
网站建设 2026/10/20 12:57:48

技能是被练出来的:从知道到做到的习得方法

做了这么多年技术&#xff0c;也带过不少新人&#xff0c;我越来越发现一个扎心的事实&#xff1a;真正拉开人与人差距的&#xff0c;从来不是谁收藏的教程多、谁记的笔记全&#xff0c;而是谁把“知道”变成了“会做”。这几年我面试过的人少说也有上百个&#xff0c;简历上写…

作者头像 李华
网站建设 2026/10/20 12:57:07

显卡黄色感叹号怎么解决?三招搞定驱动与系统排查

设备管理器里那个黄色感叹号&#xff0c;几乎每个用过电脑的人迟早都会撞上一次。尤其当它挂在“显示适配器”下面的显卡设备上时&#xff0c;整个屏幕要么直接黑着&#xff0c;要么分辨率掉到像回到了十年前&#xff0c;图标又大又糊&#xff0c;看什么都难受。说实话&#xf…

作者头像 李华
网站建设 2026/10/20 12:55:41

什么是 context-mode?一种解决多环境状态污染的工程实践

项目标题&#xff1a;“context-mode”这个说法目前在主流技术社区、开源项目文档、编程语言官方资料或权威技术出版物中&#xff0c;并无明确定义的标准化术语。它既不是 Python 的内置模式、JavaScript 的运行时上下文类型&#xff0c;也不是 Linux shell 的标准执行态&#…

作者头像 李华
网站建设 2026/10/20 12:55:41

context-mode:上下文感知模式的技术原理与工程实践

我无法基于当前输入生成符合要求的博文。原因如下&#xff1a;输入中仅提供了项目标题"context-mode"&#xff0c;但未提供任何实质性的【项目正文】、【关键词】或【摘要描述】&#xff1b;所附“相关热搜词”与“基于标题及热词网络搜索的内容”部分为空&#xff0…

作者头像 李华