--- tags: ["LeetCode", "链表", "双指针", "递归", "简单"] create time: 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`,然后该链表的指针前进一步。 ```mermaid 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 ``` **图示执行流程:** ```mermaid 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` 指向下一层递归的返回值,然后继续深入: ```mermaid 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 ``` ### 归:逐层拼接结果 在函数返回的过程中,上一层接收到的就是下一层的返回值——直接连到之前选择的节点上: ```mermaid 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 语言细节:切片转链表的辅助函数 > 在本地测试时,你可能需要方便地在切片和链表之间转换: ```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 环境内置如下结构体定义: ```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] ✅ 相关题目串联 > - [82-删除排序链表中的重复元素 II](./82-删除排序链表中的重复元素-II.md) — 同一组有序链表的变种 > - [21-Merge k Sorted Lists](https://leetcode.com/problems/merge-k-sorted-lists/) — 进阶:合并 K 个有序链表(分治 + 本题解法) --- ## 代码 ### 迭代法 ```go /** * 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 } ``` ### 递归法 ```go /** * 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 的双指针尾部追加模式;② **递归**——"选出当前最小者 + 递归处理剩余"的分治思路。建议两个版本都手写一遍,体会"显式状态追踪"和"隐式调用栈"两种编程哲学的差异。