Files
leetcode-go/链表/33-排序链表.md
T

20 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
链表
分治
归并排序
递归
中等
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] ✅ 相关题目串联


代码

方法一:自顶向下归并排序(递归)⭐

/**
 * 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] ⚠️ 迭代版的关键细节

  1. dummy 节点必须在外层循环外创建一次,而非每层新建——这样才能把所有层合并的结果串联起来
  2. prev 需要更新到合并后链表的尾部,而不是简单地 prev = prev.Next。因为合并可能改变长度(两段等长则合并后长度为 2*gap,prev 需要前进到这个新段末尾)
  3. nextGroup 必须在合并前保存——因为 split 会修改节点的 Next 指针