20 KiB
tags, create time
| tags | 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))
- 找中点可以用快慢指针一次遍历完成,不需要随机访问
方法一:自顶向下归并排序(递归)⭐(推荐,面试首选)
归并排序的核心三步:分 → 治 → 合。
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 恰好位于中点。
slow, fast := head, head.Next
for fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
}
此时 slow 指向左半段的最后一个节点(偶数长度时偏左),将链表从中断开:
mid := slow.Next // 右半段的起点
slow.Next = nil // 断开左半段 ← 关键!必须切断,否则会死循环
以 4→2→1→3 为例:
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-合并两个有序链表 中的迭代合并逻辑。
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
}
第三步:完整递归框架
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] 为例的完整执行树:
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:
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 代码
/**
* 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-合并两个有序链表 — 归并排序的合并步骤直接使用此题的解法
- 148-排序链表 — LeetCode 同题,可用相同解法
- 23-合并K个升序链表 — 进阶扩展:用优先队列优化 K 路合并
代码
方法一:自顶向下归并排序(递归)⭐
/**
* 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] 🔧 本地测试辅助函数 以下辅助函数可以将切片与链表互相转换,方便编写单元测试:
// 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) 空间)
/**
* 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] ⚠️ 迭代版的关键细节
dummy节点必须在外层循环外创建一次,而非每层新建——这样才能把所有层合并的结果串联起来prev需要更新到合并后链表的尾部,而不是简单地prev = prev.Next。因为合并可能改变长度(两段等长则合并后长度为 2*gap,prev 需要前进到这个新段末尾)nextGroup必须在合并前保存——因为split会修改节点的Next指针