LeetCode 21:合并两个有序链表——双指针与哑节点详解

LeetCode 21「合并两个有序链表」是一道经典的链表算法题,主要考察链表节点操作、双指针、哑节点(Dummy Node)以及边界条件处理。

这道题的核心思路是:同时遍历两个有序链表,每次取出较小的节点,连接到新链表的末尾,直到所有节点合并完成。

题目描述

给定两个已经按升序排列的链表 list1 和 list2,将它们合并成一个新的升序链表,并返回合并后链表的头节点。

例如:

复制代码
list1:1 → 2 → 4 → null

list2:1 → 3 → 4 → null

合并后:

复制代码
1 → 1 → 2 → 3 → 4 → 4 → null

需要注意,题目要求合并的是链表节点,不是把节点的值取出来放进数组再排序。

理解链表的节点结构

在 JavaScript 中,链表节点可以理解成一个对象:

复制代码
const node = {
  val: 1,
  next: null
}

其中:

  • val 保存当前节点的值。

  • next 保存下一个节点的引用。

例如:

复制代码
const node1 = { val: 1, next: null }
const node2 = { val: 2, next: null }

node1.next = node2

此时:

复制代码
node1             node2
┌─────┬──────┐     ┌─────┬──────┐
│  1  │ next │ ──→ │  2  │ null │
└─────┴──────┘     └─────┴──────┘

所以:

复制代码
node1.val       // 1
node1.next      // node2 对象
node1.next.val  // 2

理解这个结构非常重要。

因为后面代码中的:

复制代码
cur.next = list1

并不是把 list1 的数字复制过去,而是让当前节点的 next 指向 list1 所指向的节点对象。

为什么使用双指针?

因为两个链表本身已经有序,所以没有必要重新排序。

我们只需要分别使用:

复制代码
list1
list2

两个指针,指向当前还没有合并的节点。

例如:

复制代码
list1:1 → 2 → 4
       ↑

list2:1 → 3 → 4
       ↑

比较:

复制代码
list1.val
list2.val

谁的值更小,就先把谁连接到结果链表中。

随后将对应指针移动到下一个节点,继续比较。

这样就能保证每次加入结果链表的节点都是当前可选节点中较小的,合并后的链表自然保持升序。

为什么还需要 dummy 哑节点?

假设我们准备创建一个结果链表,但一开始不知道它的第一个真实节点是谁。

如果不使用哑节点,就需要额外判断第一个节点应该来自 list1 还是 list2,并单独维护头节点。

为了简化这个过程,可以先创建一个临时节点:

复制代码
const dummy = new ListNode(-1)

这里的 ListNode 是 LeetCode 提供的链表节点构造函数,-1 只是临时占位值,不参与最终结果。

然后:

复制代码
let cur = dummy

让 cur 指向这个临时节点。

初始关系:

复制代码
dummy
  ↓
[-1] → null
  ↑
 cur

后面每找到一个较小节点,就将其接到 cur.next,再让 cur 向后移动。

因此,两个变量的作用不同:

  • dummy:始终保存结果链表的起始位置。

  • cur:始终指向结果链表的最后一个节点,负责连接新节点。

这就是哑节点最大的作用:统一头节点和后续节点的连接逻辑,减少边界判断。

JavaScript 完整实现

javascript 复制代码
var mergeTwoLists = function(list1, list2) {
  const dummy = new ListNode(-1)
  let cur = dummy

  while (list1 && list2) {
    if (list1.val < list2.val) {
      cur.next = list1
      list1 = list1.next
    } else {
      cur.next = list2
      list2 = list2.next
    }

    cur = cur.next
  }

  cur.next = list1 || list2

  return dummy.next
}

这段代码可以分成三个关键过程:比较两个节点、连接较小节点,以及处理剩余链表。

代码执行过程

首先初始化:

复制代码
const dummy = new ListNode(-1)
let cur = dummy

然后进入循环:

复制代码
while (list1 && list2)

这里的 && 表示:

只有两个链表都还有未处理节点时,才继续比较。

因为我们需要同时取出两个节点的值:

复制代码
list1.val
list2.val

如果其中一个链表已经为空,就不需要继续逐个比较了。

接着执行:

复制代码
if (list1.val < list2.val) {
  cur.next = list1
  list1 = list1.next
}

如果 list1 当前节点更小,就执行:

复制代码
cur.next = list1

将 list1 当前节点连接到结果链表末尾。

然后:

复制代码
list1 = list1.next

让 list1 指向原来的下一个节点,准备后续比较。

如果 list2 更小,或者两者相等,则进入 else:

复制代码
cur.next = list2
list2 = list2.next

逻辑完全相同,只是连接的是 list2 当前节点。

每轮连接完成后,都要执行:

复制代码
cur = cur.next

让 cur 移动到刚才连接的节点。

这样下一次才能继续在结果链表末尾添加节点。

使用示例完整演示

假设:

复制代码
list1:1 → 2 → 4

list2:1 → 3 → 4

开始时:

复制代码
dummy → -1
         ↑
        cur

第一次比较:

复制代码
list1.val = 1
list2.val = 1

因为代码使用的是:

复制代码
list1.val < list2.val

两个值相等时,条件不成立,所以先连接 list2 的 1。

复制代码
dummy → 1
         ↑
        cur

这里展示的是合并部分;dummy 仍然指向最前面的临时节点,cur 已移动到真实节点 1。

接着 list2 移动到节点 3,再次比较:

复制代码
list1.val = 1
list2.val = 3

这次 list1 更小,连接 list1 的节点 1。

继续比较,整个过程如下:

比较节点 选择节点 已合并部分
1 和 1 list2 的 1 1
1 和 3 list1 的 1 1 → 1
2 和 3 list1 的 2 1 → 1 → 2
4 和 3 list2 的 3 1 → 1 → 2 → 3
4 和 4 list2 的 4 1 → 1 → 2 → 3 → 4

此时 list2 已经遍历结束,但 list1 还剩下节点 4。

所以接下来就要处理剩余链表。

为什么最后要写 cur.next = list1 || list2?

代码:

复制代码
cur.next = list1 || list2

这里使用的是 JavaScript 的逻辑或运算符 ||。

它会返回第一个真值操作数;如果第一个是假值,就返回第二个操作数。

在这道题中,list1 和 list2 是链表节点引用或 null。

因此:

复制代码
list1 || list2

可以理解成:

如果 list1 还有节点,就使用 list1;否则使用 list2。

例如:

复制代码
已合并:

1 → 1 → 2 → 3 → 4

list1 剩余:
4 → null

list2:
null

因为 list1 还有节点,所以:

复制代码
cur.next = list1

直接把剩余节点连接到结果链表末尾。

最终得到:

复制代码
1 → 1 → 2 → 3 → 4 → 4 → null

为什么可以直接连接,不需要继续逐个比较?

因为两个原始链表都是升序排列的。其中一个链表已经为空时,另一个链表剩余节点的顺序本身就是正确的,而且不会小于之前已经连接的最后一个节点。

因此可以直接连接剩余部分。

为什么返回 dummy.next,而不是 dummy?

最后的代码:

复制代码
return dummy.next

这里需要特别注意:

复制代码
dummy

是最开始创建的临时节点:

复制代码
dummy
 ↓
-1 → 1 → 1 → 2 → 3 → 4 → 4 → null
     ↑
   真正头节点

我们创建 dummy 的目的只是方便连接节点,它本身不属于结果链表。

所以:

复制代码
return dummy

会把多余的 -1 节点也返回。

而:

复制代码
return dummy.next

返回的是第一个真正的链表节点。

因此:

dummy 负责记住链表起点,cur 负责向后连接,最终通过 dummy.next 找到真正的头节点。

即使两个输入链表都为空,dummy.next 也会是 null,结果依然正确。

时间复杂度与空间复杂度

假设两个链表的长度分别为 m 和 n。

时间复杂度:O(m + n)

每次循环至少会让一个链表指针向后移动一位,两个链表的节点最多处理一次,因此整体时间复杂度为 O(m + n)。

额外空间复杂度:O(1)

算法只额外创建了一个哑节点,并使用少量指针变量:

复制代码
dummy
cur
list1
list2

没有创建额外的结果数组,也没有为每个元素创建新节点,而是直接连接原有节点,因此额外空间复杂度为 O(1)。

需要注意,这种写法会修改原链表节点之间的连接关系。

总结

合并两个有序链表的核心就是双指针比较节点大小,并通过哑节点维护结果链表。

完整代码:

javascript 复制代码
var mergeTwoLists = function(list1, list2) {
  const dummy = new ListNode(-1)
  let cur = dummy

  while (list1 && list2) {
    if (list1.val < list2.val) {
      cur.next = list1
      list1 = list1.next
    } else {
      cur.next = list2
      list2 = list2.next
    }

    cur = cur.next
  }

  cur.next = list1 || list2

  return dummy.next
}

整个过程可以概括为:

复制代码
创建 dummy 哑节点
        ↓
cur 指向 dummy
        ↓
比较 list1 和 list2 的当前节点
        ↓
将较小节点连接到 cur.next
        ↓
对应链表指针向后移动
        ↓
cur 移动到刚连接的节点
        ↓
重复比较,直到一个链表为空
        ↓
连接另一个链表的剩余节点
        ↓
返回 dummy.next

这道题需要重点记住三句代码:

复制代码
cur.next = list1       // 连接当前节点
list1 = list1.next     // 移动输入链表指针
cur = cur.next         // 移动结果链表指针

它们分别完成连接节点、继续遍历、更新结果链表尾部三个动作。

一句话总结:

两个指针负责比较,cur 负责连接,dummy 负责保存头节点。利用两个链表本身有序的特点,就能在 O(m+n) 时间、O(1) 额外空间内完成合并。

理解这道题之后,后续学习「合并 K 个升序链表」「排序链表」等题目会更加容易。

相关推荐
徐小黑ACG9 小时前
Golang 基础05 结构体struct
开发语言·算法·golang
Navigator_Z9 小时前
LeetCode //C - 1286. Iterator for Combination
c语言·算法·leetcode
北极有牛10 小时前
cpp学习笔记--常量指针
java·开发语言·算法
GPU实战笔记10 小时前
云端 GPU 训练的账户余额提醒:它能说明什么,不能说明什么?
深度学习·算法·成本管理·gpu云计算·云端训练
浮链序10 小时前
怎么证明"你这个模型是偷我的"——把蒸馏变成取证工具
算法·安全·llm
小O的算法实验室10 小时前
IEEE TMC,面向异构物联网的多目标量子行为拓扑优化算法
算法
圣保罗的大教堂11 小时前
leetcode 301. 删除无效的括号 困难
leetcode
liliangcsdn11 小时前
派息率指标的应用探索和分析
大数据·算法