Files
leetcode-go/链表/27-合并两个有序链表.md
T

13 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
链表
双指针
递归
简单
2026-05-16 14:40

27-合并两个有序链表

题面

将两个 升序 链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

示例 1:

输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]

示例 2:

输入:l1 = [], l2 = []
输出:[]

示例 3:

输入:l1 = [], l2 = [0]
输出:[0]

提示:

  • 两个链表的节点数目范围是 [0, 50]
  • -100 <= Node.val <= 100
  • l1 和 l2 均按 非递减顺序 排列

思路

[!question] 💡 引导思考 假设你面前有两张已经排好序的扑克牌,你会如何把它们合并成一张更大的有序牌堆?——每次比较两张牌的最上面那一张,取较小的放入结果中。这就是本题的核心思想。

方法一:迭代法 — 虚拟头节点 + 尾指针 ⭐(推荐)

维护一个虚拟头节点 dummy 和一个尾指针 tail:

变量 含义
dummy 虚拟头节点,dummy.Next 始终指向结果链表的真正头部
tail 当前已合并部分的最后一个节点,用于在末尾追加

核心操作: 每次比较 l1.Val 和 l2.Val,将较小的节点连接到 tail.Next,然后该链表的指针前进一步。

flowchart TB
    subgraph Inputs["📥 输入:两条升序链表"]
        L1["l1\n1→2→4→nil"]
        L2["l2\n1→3→4→nil"]
    end

    subgraph Core["⚙️ 核心操作(循环内每轮重复)"]
        CMP["比较 l1.Val vs l2.Val"]
        Pick["选较小值节点"]
    end

    subgraph Output["📤 输出:结果链表(尾部追加)"]
        Dummy["dummy (虚拟头节点)"]
        Res["结果: 1→1→2→3→4→4→nil"]
    end

    L1 -->|当前 head| CMP
    L2 -->|当前 head| CMP
    CMP -->|l1.Val ≤ l2.Val?| Pick
    Pick -->|连接到 tail.Next| Res
    Res --> Dummy

    style Dummy fill:#e0e0ff,stroke:#666,stroke-width:2px
    style CMP fill:#fff4e6,stroke:#f90,stroke-width:2px
    style Pick fill:#d4edda,stroke:#28a,stroke-width:2px
    style Res fill:#e8f5e9,stroke:#4a4,stroke-width:1px

图示执行流程:

flowchart LR
    State0["初始\n---\ndummy → ?\nl1: 1→2→4\nl2: 1→3→4"] -->|"1≤1, 取 l1(1)"| State1["第 1 轮\n---\ndummy → [1]\nl1: 2→4\nl2: 1→3→4"]
    State1 -->|"2>1, 取 l2(1)"| State2["第 2 轮\n---\ndummy → [1→1]\nl1: 2→4\nl2: 3→4"]
    State2 -->|"2≤3, 取 l1(2)"| State3["第 3 轮\n---\ndummy → [1→1→2]\nl1: 4\nl2: 3→4"]
    State3 -->|"4>3, 取 l2(3)"| State4["第 4 轮\n---\ndummy → [1→1→2→3]\nl1: 4\nl2: 4"]
    State4 -->|"4≤4, 取 l1(4)"| State5["第 5 轮\n---\ndummy → [1→1→2→3→4]\nl1: nil\nl2: 4"]
    State5 -->|"l1 空, 接 l2"| State6["收尾\n---\ndummy → [1→1→2→3→4→4] ✅"]

    State6:::final
    classDef final fill:#4c4,color:white

以示例 1(l1 = [1,2,4], l2 = [1,3,4])为例,逐步展开:

轮次 比较对象 较小值 tail 连接 l1 移动后 l2 移动后 结果链表
初始 — — dummy 1→2→4 1→3→4 dummy → ?
第 1 轮 l1.Val(1) vs l2.Val(1) 相等,选 l1 tail→l1(1) 2→4 1→3→4 1_a
第 2 轮 l1.Val(2) vs l2.Val(1) 1 tail→l2(1) 2→4 3→4 1_a → 1_b
第 3 轮 l1.Val(2) vs l2.Val(3) 2 tail→l1(2) 4 3→4 1_a → 1_b → 2
第 4 轮 l1.Val(4) vs l2.Val(3) 3 tail→l2(3) 4 4 1→1→2→3
第 5 轮 l1.Val(4) vs l2.Val(4) 相等,选 l1 tail→l1(4) nil 4 1→1→2→3→4
l1 耗尽 — — tail→l2(4) nil nil 1→1→2→3→4→4 ✅

[!info] 🧠 关键性质

  • 因为两个输入链表本身就是有序的,所以每一步贪心选择较小的元素就能保证全局有序
  • dummy 解决了"第一个节点没有前驱可连"的边界问题——统一了所有节点的处理逻辑
  • tail 只在末尾追加,永不回头修改,所以不需要反转操作

**当某个链表提前走完时:**直接将 tail.Next 指向另一个链表的剩余部分即可——因为剩余部分本身有序,无需额外处理。

时间复杂度:O(m + n) — 每个节点恰好被访问一次。
空间复杂度:O(1) — 只使用了常数个指针变量。

方法二:递归法(更优雅但占用栈空间)

[!question] 💡 引导思考 合并两个有序链表时,第一步做什么——一定是把两个头节点中较小的那个作为结果链表的第一个节点。那第二步呢?

第二步是:用剩下的部分重复同样的过程。这正是递归的天然结构!

递归关系:

\text{merge}(l1, l2) = \begin{cases} l1 & \text{if } l2 == \text{nil} \\ l2 & \text{if } l1 == \text{nil} \\ l1.\text{Next} = \text{merge}(l1.\text{Next}, l2); \;\; return\; l1 & \text{if } l1.\text{Val} \leq l2.\text{Val} \\ l2.\text{Next} = \text{merge}(l1, l2.\text{Next}); \;\; return\; l2 & \text{otherwise} \end{cases}

执行流程拆解(以 l1=[1,2,4], l2=[1,3,4] 为例):

递:深入调用栈

每一层做出选择后,将较小节点的 Next 指向下一层递归的返回值,然后继续深入:

flowchart TD
    L1A["Merge(①, ①) → 选 l1(①)"] -->|"Next = Merge(2→4, 1→3→4)"| L1B["Merge(②, ①) → 选 l2(①)"]
    L1B -->|"Next = Merge(2→4, 3→4)"| L1C["Merge(②, ③) → 选 l1(②)"]
    L1C -->|"Next = Merge(4, 3→4)"| L1D["Merge(④, ③) → 选 l2(③)"]
    L1D -->|"Next = Merge(4, ④)"| L1E["Merge(④, ④) → 选 l1(④)"]
    L1E -->|"Next = Merge(nil, ④)"| L1F["Merge(nil, ④) ← 基准情况,返回 ④"]

    style L1F fill:#f9d,stroke:#333,stroke-width:2px
    style L1A fill:#bbf,stroke:#333

归:逐层拼接结果

在函数返回的过程中,上一层接收到的就是下一层的返回值——直接连到之前选择的节点上:

flowchart LR
    R1["Merge(nil, ④)\n返回: 4→nil"] --> R2["Merge(④, ④)\n4→Next=4→nil\n返回: 4→4→nil"]
    R2 --> R3["Merge(④, ③)\n3→Next=4→4→nil\n返回: 3→4→4→nil"]
    R3 --> R4["Merge(②, ③)\n2→Next=3→4→4→nil\n返回: 2→3→4→4→nil"]
    R4 --> R5["Merge(②, ①)\n1→Next=2→3→4→4→nil\n返回: 1→2→3→4→4→nil"]
    R5 --> R6["Merge(①, ①)\n1→Next=1→2→3→4→4→nil\n返回: 1→1→2→3→4→4 ✅"]

    style R1 fill:#bbf,stroke:#333
    style R6 fill:#4c4,color:white,stroke:#333,stroke-width:2px

[!info] 🧠 递归与迭代的本质联系 递归版本中的 l1.Next = mergeTwoLists(l1.Next, l2) 或 l2.Next = mergeTwoLists(l1, l2.Next) 就是在做和迭代版 tail.Next = smallerNode 完全相同的操作——把当前最小的节点挂到结果后面。只是递归通过函数调用栈隐式地"记住"了上下文,而迭代版用显式的 tail 变量追踪当前位置。

时间复杂度:O(m + n) — 每层递归处理一个节点,共 m + n 层。
空间复杂度:O(m + n) — 递归调用栈深度等于节点总数。


代码提示

迭代法伪代码

dummy = &ListNode{}      // 虚拟头节点
tail = dummy             // 尾指针紧跟 dummy

for l1 != nil AND l2 != nil {
    if l1.Val <= l2.Val {
        tail.Next = l1   // 把 l1 接到结果末尾
        l1 = l1.Next     // l1 往前走一步
    } else {
        tail.Next = l2   // 把 l2 接到结果末尾
        l2 = l2.Next     // l2 往前走一步
    }
    tail = tail.Next     // tail 也往前走一步(跟上)
}

// l1 或 l2 中至少有一个为空,接上非空的部分
if l1 != nil {
    tail.Next = l1
} else {
    tail.Next = l2
}

return dummy.Next        // 跳过 dummy,返回真正的头节点

递归法伪代码

func merge(l1, l2):
    if l1 == nil: return l2       // 基准情况 1
    if l2 == nil: return l1       // 基准情况 2
    
    if l1.Val <= l2.Val {
        l1.Next = merge(l1.Next, l2)
        return l1
    } else {
        l2.Next = merge(l1, l2.Next)
        return l2
    }

技巧

[!tip] 🔑 虚拟头节点(Dummy Node)万能模板 凡是涉及"在链表头部插入/连接"的场景,优先考虑加 dummy。它能避免对首个节点写特殊逻辑,让循环体内保持统一的操作模式:tail.Next = newnode; tail = tail.Next。这个模式在「移除链表元素」「分隔链表」「奇偶链表」等题目中反复出现。

[!note] 🔑 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
}

[!warning] ⚠️ 常见错误:忘记推进 tail 在连接 tail.Next = smallerNode 之后,一定要执行 tail = tail.Next。漏掉这步会导致后续所有节点都覆盖到同一个位置,最终结果只剩最后一个接入的节点。

[!tip] 🔑 相等情况的选择 当 l1.Val == l2.Val 时,选哪个都可以,结果相同。但从递归视角看——先选 l1 意味着下一轮比较的是 l1.Next vs l2,先选 l2 则比较 l1 vs l2.Next——虽然不影响最终结果,但在某些变体题(如稳定排序需求)中可能会有细微差异。

[!note] 🐹 Go 中的链表定义 LeetCode 的 Go 环境内置如下结构体定义:

type ListNode struct {
    Val  int
    Next *ListNode
}

不需要手动定义,直接在解题中使用即可。

[!info] 📊 两种方法对比

方法 时间复杂度 空间复杂度 优点 缺点
迭代法 ⭐ O(m + n) O(1) 空间最优、无栈溢出风险 需要理解 dummy + tail 的配对使用
递归法 O(m + n) O(m + n) 代码极短(4 行核心逻辑)、可读性高 栈空间消耗大;Go 不会尾递归优化,n 较大时可能栈溢出

[!success] ✅ 相关题目串联


代码

迭代法

/**
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */

func mergeTwoLists(l1 *ListNode, l2 *ListNode) *ListNode {
	// 虚拟头节点:避免对第一个节点做特殊处理
	dummy := &ListNode{}
	tail := dummy // tail 始终指向结果链表的最后一个节点

	// 只要两条链表都不为空,逐个比较、依次接入
	for l1 != nil && l2 != nil {
		if l1.Val <= l2.Val {
			tail.Next = l1 // 将较小的 l1 节点接到结果末尾
			l1 = l1.Next   // l1 前进一步
		} else {
			tail.Next = l2 // 将较小的 l2 节点接到结果末尾
			l2 = l2.Next   // l2 前进一步
		}
		tail = tail.Next // tail 跟进,保持指向末尾
	}

	// 有一条链表走完后,直接把 tail 连接到另一条的剩余部分
	// Go 的 nil 检查很直观:如果 l1 为空就接 l2,反之接 l1
	if l1 != nil {
		tail.Next = l1
	} else {
		tail.Next = l2
	}

	// 返回 dummy.Next(跳过虚拟头节点),即为合并后的真正头节点
	return dummy.Next
}

递归法

/**
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */

func mergeTwoLists(l1 *ListNode, l2 *ListNode) *ListNode {
	// 基准情况:一条链表为空,直接返回另一条
	if l1 == nil {
		return l2
	}
	if l2 == nil {
		return l1
	}

	// 递归:选较小的头节点,它的 Next 指向合并剩余部分的结果
	if l1.Val <= l2.Val {
		l1.Next = mergeTwoLists(l1.Next, l2) // 递归处理 l1 的剩余部分
		return l1
	} else {
		l2.Next = mergeTwoLists(l1, l2.Next) // 递归处理 l2 的剩余部分
		return l2
	}
}

[!success] ✅ 运行验证 这是 LeetCode 第 21 题,通过率约 60%+。作为链表入门题的代表作,它完美展示了两个经典范式:① 迭代——dummy + tail 的双指针尾部追加模式;② 递归——"选出当前最小者 + 递归处理剩余"的分治思路。建议两个版本都手写一遍,体会"显式状态追踪"和"隐式调用栈"两种编程哲学的差异。