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 个升序链表」「排序链表」等题目会更加容易。