--- tags: ["LeetCode", "链表", "分治", "归并排序", "递归", "中等"] create time: 2026-05-18 14:30 --- # 33-排序链表 ## 题面 给你链表的头结点 `head`,请将其按 **升序** 排列并返回排序后的链表。 **示例 1:** ``` 输入:head = [4,2,1,3] 输出:[1,2,3,4] ``` **示例 2:** ``` 输入:head = [-1,5,3,4,0] 输出:[-1,0,3,4,5] ``` **示例 3:** ``` 输入:head = [] 输出:[] ``` **提示:** - 链表中节点的数目在范围 `[0, 5 * 10^4]` 内 - `-10^5 <= Node.val <= 10^5` - **进阶:** 你可以在 `O(n log n)` 时间复杂度和常数级空间复杂度下,对链表进行排序吗? --- ## 思路 > [!question] 💡 引导思考 > 对数组排序,你最熟悉的是什么算法?快速排序、归并排序、堆排序——它们的平均时间复杂度都是 O(n log n)。但有一个关键差异:**数组支持随机访问**,可以通过下标 O(1) 拿到中间元素;而**链表只能顺序遍历**。这意味着基于二分查找思想的算法在链表上会遇到困难。 > [!warning] ⚠️ 为什么这些常见排序算法不适合链表? | 排序算法 | 链表上的问题 | |---------|------------| | **快速排序** | 分区时需要双向遍历,链表只支持单向移动;且最坏情况 O(n²),不稳定 | | **堆排序** | 堆的底层是数组,需要通过下标访问子节点,链表无法做到 O(1) | | **插入排序** | ✅ 可行,但时间复杂度 O(n²),不满足进阶要求 | | **归并排序** | ✅ 天然适合链表——只需顺序遍历找中点 + 指针重连,不需要随机访问 | > [!info] 🧠 为什么归并排序是链表的最优选择? > - **数组归并**需要额外 O(n) 空间存临时数组 > - **链表归并**只需要修改指针,可以做到 O(1) 额外空间(递归版本因调用栈为 O(log n),迭代版本可达真正的 O(1)) > - 找中点可以用快慢指针一次遍历完成,不需要随机访问 ### 方法一:自顶向下归并排序(递归)⭐(推荐,面试首选) 归并排序的核心三步:**分 → 治 → 合**。 ```mermaid flowchart TD subgraph DIVIDE["① 分:递归拆分"] A["4→2→1→3"] -->|"找中点"| B["4→2 和 1→3"] B -->|"继续分"| C["4, 2, 1, 3"] end subgraph CONQUER["② 治:单节点即有序"] D["4 ✓ 2 ✓\n1 ✓ 3 ✓"] end subgraph MERGE["③ 合:逐层合并"] E["2→4 和 1→3"] --> F["1→2→3→4 ✓"] end A --> C --> D --> E --> F style F fill:#4c4,stroke:#333,stroke-width:2px,color:white ``` #### 第一步:用快慢指针找中点 > [!question] 💡 回顾一下:如何只用一趟遍历找到链表的"中间位置"? 使用经典的快慢指针法:`fast` 每次走两步,`slow` 每次走一步。当 `fast` 到达末尾时,`slow` 恰好位于中点。 ```go slow, fast := head, head.Next for fast != nil && fast.Next != nil { slow = slow.Next fast = fast.Next.Next } ``` 此时 `slow` 指向**左半段的最后一个节点**(偶数长度时偏左),将链表从中断开: ```go mid := slow.Next // 右半段的起点 slow.Next = nil // 断开左半段 ← 关键!必须切断,否则会死循环 ``` 以 `4→2→1→3` 为例: ```mermaid flowchart LR subgraph "断开前" A["4→2→1→3"] end subgraph "slow定位到节点2\nfast到达nil" B["4→2 ★"] -.断开.-> C["1→3 ★"] end subgraph "结果:两个独立子链表" L["left: 4→2→nil"] R["right: 1→3→nil"] end B --> L B --> R style L fill:#d4edda,stroke:#28a style R fill:#cce5ff,stroke:#28a ``` > [!tip] 🔑 为什么要执行 `slow.Next = nil`? > 如果不切断,左右两半仍然相连,递归调用 `sortList(slow)` 时会无限深入到底,导致栈溢出。这是本题最容易忽略的细节! #### 第二步:合并两个有序链表 这一步直接复用 [27-合并两个有序链表](./27-合并两个有序链表.md) 中的迭代合并逻辑。 ```go func merge(l1, l2 *ListNode) *ListNode { dummy := &ListNode{} tail := dummy for l1 != nil && l2 != nil { if l1.Val <= l2.Val { tail.Next = l1 l1 = l1.Next } else { tail.Next = l2 l2 = l2.Next } tail = tail.Next } if l1 != nil { tail.Next = l1 } else { tail.Next = l2 } return dummy.Next } ``` #### 第三步:完整递归框架 ```go func sortList(head *ListNode) *ListNode { // 基准情况:空链表或单节点,本身就是有序的 if head == nil || head.Next == nil { return head } // ① 找中点并断开 slow, fast := head, head.Next for fast != nil && fast.Next != nil { slow = slow.Next fast = fast.Next.Next } mid := slow.Next slow.Next = nil // 切断,分左右两半 // ② 递归排序左右两半 left := sortList(head) right := sortList(mid) // ③ 合并结果 return merge(left, right) } ``` 以 `[4,2,1,3]` 为例的完整执行树: ```mermaid flowchart TD subgraph "顶层 sortList(4→2→1→3)" SPLIT["拆分: 4→2 | 1→3"] end subgraph "左半边 sortList(4→2)" LSPLIT["拆分: 4 | 2"] LMERGE["合并: 2→4 ✓"] end subgraph "右半边 sortList(1→3)" RSPLIT["拆分: 1 | 3"] RMERGE["合并: 1→3 ✓"] end subgraph "顶层合并" FINAL["合并 2→4 和 1→3\n→ 1→2→3→4 ✓"] end SPLIT --> LSPLIT --> LMERGE SPLIT --> RSPLIT --> RMERGE LMERGE --> FINAL RMERGE --> FINAL style FINAL fill:#4c4,color:white,stroke:#333,stroke-width:2px ``` 逐步展开 `[4,2,1,3]` 的执行过程: | 阶段 | 操作 | 状态 | |------|------|------| | sortList([4,2,1,3]) | 拆分为 [4,2] 和 [1,3] | — | | sortList([4,2]) | 拆分为 [4] 和 [2] | 两半均为单节点,直接返回 | | merge([4], [2]) | 2 < 4,先接 2 再接 4 | 返回 2→4 | | sortList([1,3]) | 拆分为 [1] 和 [3] | 两半均为单节点,直接返回 | | merge([1], [3]) | 1 < 3,先接 1 再接 3 | 返回 1→3 | | merge([2,4], [1,3]) | 逐一对比合并 | 返回 **1→2→3→4** ✅ | **时间复杂度:O(n log n)** — 深度为 log n 层,每层的合并总代价为 O(n)。 **空间复杂度:O(log n)** — 递归调用栈的深度等于树的深度。 > [!note] 🤔 进阶问题的答案 > 题目要求"常数级空间复杂度",递归版本的 O(log n) 来自调用栈。严格来说不满足"常数量级"。但在面试中这个版本已经完全够用,因为:① 代码简洁清晰;② log n 在实际规模下极小(n=5×10⁴ 时 log₂n ≈ 16)。如果追求理论最优的 O(1) 空间,见下方迭代版本。 --- ### 方法二:自底向上归并排序(迭代)🏆(严格 O(1) 空间) > [!question] 💡 如何消除递归调用栈的开销? > 观察递归版的调用树——它是一棵满二叉树,从叶子往根方向一层层合并。如果我们**放弃递归**,改为从最小的子区间(长度为 1)开始,逐级扩大子区间长度(1→2→4→8...),就能用迭代模拟相同的过程。 #### 核心思想 从长度为 `1` 的子链表开始,逐层合并,直到子链表长度 `>= n`: ```mermaid flowchart LR STEP1["gap=1\n4|2|1|3"] --> STEP2["gap=2\n2→4 | 1→3"] STEP2 --> STEP3["gap=4\n1→2→3→4 ✓"] style STEP1 fill:#e8f5e9,stroke:#28a style STEP2 fill:#fff4e6,stroke:#f90 style STEP3 fill:#4c4,color:white,stroke:#333 ``` 每一层的步骤相同:从头到尾扫描整个链表,每次取两段长度为 `gap` 的子链表进行合并。 #### 关键难点:如何分段 给定当前 `gap`,需要找出四个关键点: ``` [BH]───第1段(gap个)───[H]───第2段(gap个)───[TN]──────────剩余───────────→ BH H TN BH = Before Head 上一段尾部(下一段的头部) H = Head 当前第1段的头部 TN = Tail Next 第2段尾部的下一个节点(用于重新拼接) ``` | 变量 | 含义 | 如何计算 | |------|------|---------| | `h` | 第 1 段头部 | 从 `split(bh, gap)` 获得 | | `t` | 第 1 段尾部 | 同上,同时返回尾部 | | `h2` | 第 2 段头部 | `split(t, gap)` 获得 | | `tn` | 拼接点 | `split(t2, gap)` 获得第 2 段尾部后,取其 `Next` | 其中辅助函数 `split(head, gap)` 的作用是:从 `head` 出发往后走 `gap` 步,将第 `gap` 个节点的 `Next` 置为 `nil`(切断),并返回 `(head, tail)`。 #### 完整伪代码 ``` n = 计算链表总长度 gap = 1 while gap < n { bh = nil // 虚拟头节点的前驱 cur = dummy // dummy 挂在原链表头部前方 while cur != nil { h, t = split(cur.Next, gap) // 取出第 1 段 h2, t2 = split(t.Next, gap) // 取出第 2 段 tn = t2.Next // 保存下一段的起点 t.Next = nil // 断开第 1 段尾部 t2.Next = nil // 断开第 2 段尾部 merged = merge(h, h2) // 合并两段 bh.Next = merged // 接到前一段后面 bh = t2 // 更新前驱 cur = tn // 跳到下一组 } gap *= 2 // 翻倍,进入下一层 } return dummy.Next ``` #### 完整 Go 代码 ```go /** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func sortList(head *ListNode) *ListNode { // 边界检查 if head == nil || head.Next == nil { return head } // ① 计算链表长度 n := 0 for node := head; node != nil; node = node.Next { n++ } // 虚拟头节点:简化边界处理 dummy := &ListNode{Next: head} // ② 自底向上逐层合并:gap = 1, 2, 4, 8, ... for gap := 1; gap < n; gap *= 2 { prev, cur := dummy, dummy // ③ 在当前层从头到尾扫描,每次取两段长度为 gap 的子链表合并 for cur.Next != nil { h1, t1 := split(cur.Next, gap) // 第 1 段 h2, t2 := split(t1.Next, gap) // 第 2 段 next := t2.Next // 保存下一组的起点 t1.Next, t2.Next = nil, nil // 断开分段 merged := merge(h1, h2) // 合并两段 prev.Next = merged // 接上前一段 prev = tailOf(merged) // 更新 prev 到合并后链表的尾部 cur = next // 跳到下一组 } } return dummy.Next } // split 从 head 出发截取 length 个节点 // 返回 (头部, 尾部),并将尾部 Next 保持原样(由调用者负责断开) func split(head *ListNode, length int) (*ListNode, *ListNode) { if head == nil { return nil, nil } h := head t := head for i := 1; i < length && t.Next != nil; i++ { t = t.Next } next := t.Next // 记录切断后的下一段起点 t.Next = nil // 切断 return h, next // 注意:这里返回的是切断后的下一段作为"尾部"的代理 } ``` > [!warning] ⚠️ 迭代版实现技巧 > 上述伪代码中 `split` 的签名做了一点调整以适应实际代码。下面给出可直接提交的完整版本,包含精妙的实现细节。 --- ## 代码提示 ### 递归版伪代码 ``` func sortList(head): // 基准情况 if head == nil or head.Next == nil: return head // ① 找中点并断开 slow, fast = head, head.Next while fast != nil and fast.Next != nil: slow = slow.Next fast = fast.Next.Next mid = slow.Next slow.Next = nil // ← 关键!切断避免死循环 // ② 递归排序 left = sortList(head) right = sortList(mid) // ③ 合并 return merge(left, right) ``` ### 迭代版伪代码 ``` n = 计算链表长度 dummy = &ListNode{Next: head} for gap = 1; gap < n; gap *= 2: prev = dummy cur = dummy while cur.Next != nil: h1, t1 = 取 gap 个节点 h2, t2 = 再取 gap 个节点 nextGroup = t2.Next 断开 t1.Next, t2.Next merged = merge(h1, h2) prev.Next = merged prev = merged的尾部 cur = nextGroup ``` --- ## 技巧 > [!tip] 🔑 归并排序的通用模板(适用于任何可顺序遍历的结构) > > ``` > 递归版: > func Sort(head): > if 太短: return head > mid = FindMiddle(head) > left = Sort(head) > right = Sort(mid) > return Merge(left, right) > > 迭代版: > for gap = 1; gap < N; gap *= 2: > 遍历整条链,每次取两段 gap 长度的子序列合并 > ``` > > 这套模板可以推广到:二叉树 flattening(转成有序链表)、有序流合并等场景。 > [!tip] 🔑 为什么链表排序不用快排? > 快排的核心优势是原地分区和缓存局部性——这两个优势在链表上都消失了: > - 分区时需要前后双向移动指针,链表只能单向遍历,效率打折 > - 链表节点分散在堆内存中,无缓存友好性 > - 快排最坏 O(n²)(虽然可以用三数取中等 trick 缓解),而归并稳定保证 O(n log n) > > **结论:链表排序的标准答案就是归并排序。** > [!note] 🔑 找中点的三种初始化方式对比 | 初始化 | slow 最终位置(偶数 n)| 特点 | |--------|----------------------|------| | `slow=head, fast=head` | 第 n/2 个 | 左半段少一个节点 | | `slow=head, fast=head.Next` | 第 n/2 个 | 标准写法,推荐 ✅ | | `slow=head, fast=head.Next.Next` | 第 n/2+1 个 | 左半段多一个节点 | 对于归并排序而言,选哪种都可以,只要保证左右分配合理即可。本文采用 `fast=head.Next`。 > [!tip] 🔑 合并时为什么要复制节点值而不是创建新节点? > 在 `merge` 过程中,我们直接重用了原有节点的引用(`tail.Next = l1/l2`),没有创建新节点。这保证了空间复杂度为 O(1)。如果每次都创建新节点,空间会退化到 O(n)。 > [!info] 📊 两种方法对比 | 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | |------|-----------|-----------|------|------| | **递归归并 ⭐** | **O(n log n)** | **O(log n)** | 代码简洁、易理解、面试首选 | 调用栈占用 O(log n) | | **迭代归并 🏆** | **O(n log n)** | **O(1)** | 严格满足常数空间 | 代码繁琐、指针操作复杂 | > [!success] ✅ 相关题目串联 > - [27-合并两个有序链表](./27-合并两个有序链表.md) — 归并排序的合并步骤直接使用此题的解法 > - [[148-排序链表]] — LeetCode 同题,可用相同解法 > - [[23-合并K个升序链表]] — 进阶扩展:用优先队列优化 K 路合并 --- ## 代码 ### 方法一:自顶向下归并排序(递归)⭐ ```go /** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func sortList(head *ListNode) *ListNode { // 基准情况:空链表或只有一个节点,本身就是有序的 if head == nil || head.Next == nil { return head } // ① 快慢指针找中点 —— fast 从 head.Next 开始 slow, fast := head, head.Next for fast != nil && fast.Next != nil { slow = slow.Next fast = fast.Next.Next } // ② 从中点断开,分成两个独立的子链表 mid := slow.Next slow.Next = nil // ← 关键!切断连接,否则递归不会终止 // ③ 递归排序左右两部分 left := sortList(head) right := sortList(mid) // ④ 合并两个有序子链表 return merge(left, right) } // merge 合并两个有序链表,返回新的头节点 // 复用 27-合并两个有序链表的迭代实现 func merge(l1, l2 *ListNode) *ListNode { dummy := &ListNode{} tail := dummy for l1 != nil && l2 != nil { if l1.Val <= l2.Val { tail.Next = l1 // 选较小的节点接到末尾 l1 = l1.Next } else { tail.Next = l2 l2 = l2.Next } tail = tail.Next } // 接上剩余部分(至少有一条链表为空) if l1 != nil { tail.Next = l1 } else { tail.Next = l2 } return dummy.Next } ``` **时间复杂度:O(n log n)** — 共 log n 层递归,每层所有合并操作的总代价为 O(n)。 **空间复杂度:O(log n)** — 递归调用栈的最大深度等于归并树的高度,为 log n。 > [!tip] 🔧 本地测试辅助函数 > 以下辅助函数可以将切片与链表互相转换,方便编写单元测试: ```go // sliceToList: 将切片转为链表,方便构造测试用例 func sliceToList(vals []int) *ListNode { dummy := &ListNode{} tail := dummy for _, v := range vals { tail.Next = &ListNode{Val: v} tail = tail.Next } return dummy.Next } // listToSlice: 将链表转为切片,方便打印验证结果 func listToSlice(head *ListNode) []int { var result []int for head != nil { result = append(result, head.Val) head = head.Next } return result } ``` > [!success] ✅ 运行验证 > 这是 LeetCode 第 148 题,通过率约 55%+。递归归并排序是面试中最常用的解答——它完美契合"链表适合顺序访问"的特性,代码量适中(~25 行),且能在面试现场流畅推导。建议在 15 分钟内能白板写出。 --- ### 方法二:自底向上归并排序(迭代)🏆(严格 O(1) 空间) ```go /** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func sortList(head *ListNode) *ListNode { if head == nil || head.Next == nil { return head } // ① 计算链表长度 n := 0 for node := head; node != nil; node = node.Next { n++ } // 虚拟头节点:统一处理,无需对首节点特殊判断 dummy := &ListNode{Next: head} // ② 自底向上,逐层扩大合并区间的大小 for gap := 1; gap < n; gap *= 2 { prev, cur := dummy, dummy // 从头到尾扫描,每次取两段长度为 gap 的子链表合并 for cur.Next != nil { // 从 cur.Next 开始取 gap 个节点为第 1 段 h1, t1 := split(cur.Next, gap) // 从 t1.Next 开始取 gap 个节点为第 2 段 h2, t2 := split(t1.Next, gap) // 保存下一组数据的起点 nextGroup := t2.Next // 合并两段 t1.Next, t2.Next = nil, nil merged := merge(h1, h2) // 将合并后的链表接回主链表 prev.Next = merged // prev 更新到合并后链表的尾部 prev = t1 if merged != h1 { prev = t2 } // cur 跳到下一组 cur = nextGroup } } return dummy.Next } // split 从 head 开始截取最多 length 个节点 // 返回值:(段头, 段尾的下一个节点) // 并将段尾的 Next 设为 nil(切断) func split(head *ListNode, length int) (*ListNode, *ListNode) { if head == nil { return nil, nil } h := head t := head for i := 1; i < length && t.Next != nil; i++ { t = t.Next } next := t.Next // 记录切断后的下一段起点 t.Next = nil // 切断 return h, next } // merge 合并两个有序链表 func merge(l1, l2 *ListNode) *ListNode { dummy := &ListNode{} tail := dummy for l1 != nil && l2 != nil { if l1.Val <= l2.Val { tail.Next = l1 l1 = l1.Next } else { tail.Next = l2 l2 = l2.Next } tail = tail.Next } if l1 != nil { tail.Next = l1 } else { tail.Next = l2 } return dummy.Next } ``` **时间复杂度:O(n log n)** — gap 从 1 倍增到 n,共 log n 轮,每轮遍历全链表 O(n)。 **空间复杂度:O(1)** — 只使用了常数个指针变量,无递归调用栈。 > [!note] 🤔 迭代版 vs 递归版的取舍 > - 面试中**优先写递归版**——简洁明了,容易调试 > - 如果面试官追问"能否做到 O(1) 空间",再用迭代版展示深度 > - 实际工程中,两者性能差异可忽略(log n 级别的栈空间在现代 CPU 上微不足道) > - 迭代版的价值在于展示了"自底向上"的动态规划思维,这在算法设计中是一个重要的范式 > [!warning] ⚠️ 迭代版的关键细节 > 1. **`dummy` 节点必须在外层循环外创建一次**,而非每层新建——这样才能把所有层合并的结果串联起来 > 2. **`prev` 需要更新到合并后链表的尾部**,而不是简单地 `prev = prev.Next`。因为合并可能改变长度(两段等长则合并后长度为 2*gap,prev 需要前进到这个新段末尾) > 3. **`nextGroup` 必须在合并前保存**——因为 `split` 会修改节点的 `Next` 指针