LeetCode Hot 100——21.合并两个有序列表
21. 合并两个有序链表
题目
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
示例 1:

输入: l1 = [1,2,4], l2 = [1,3,4]
输出: [1,1,2,3,4,4]
示例 2:
输入: l1 = [], l2 = []
输出: []
示例 3:
输入: l1 = [], l2 = [0]
输出: [0]
一、迭代法
思路
合并两个有序链表的过程,类似于合并两个有序数组:每次都从两个链表的当前节点中取出值较小的那个,接到新链表的末尾。
但是单链表操作中,需要处理一个边界问题——新链表的头节点从哪来?
常见做法是创建一个哑节点(dummy node)。哑节点是一个临时的头节点,不存储实际数据,其 next 最终指向合并后链表的真正头节点。这样做的好处是:
- 避免单独判断新链表的第一个节点是来自
list1还是list2; prev指针始终指向新链表的尾节点,统一了插入逻辑。
代码实现
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
struct ListNode dummy; // 栈上分配的哑节点
struct ListNode* prev = &dummy; // prev 指向新链表的尾节点(初始为哑节点)
dummy.next = NULL;
while (list1 && list2) {
if (list1->val <= list2->val) {
prev->next = list1; // 将 list1 接到新链表尾部
list1 = list1->next;
} else {
prev->next = list2; // 将 list2 接到新链表尾部
list2 = list2->next;
}
prev = prev->next; // prev 后移,始终指向新链表当前的尾节点
}
// 将剩余未遍历完的链表直接接上
prev->next = list1 ? list1 : list2;
return dummy.next; // 哑节点的 next 即为合并后的头节点
}
代码解释
-
struct ListNode dummy;:在栈上创建哑节点。之所以能用栈分配,是因为dummy的生命周期仅在此函数内,我们最终返回的是dummy.next(指向堆上已有节点的指针),不会返回悬垂指针。 -
struct ListNode* prev = &dummy;:prev称为前驱(或尾)指针,始终指向新链表的最后一个节点。初始时新链表为空,所以它指向哑节点。注意,这里prev保存的是dummy的地址,通过它可以修改dummy.next(第一次插入时)或后续节点的next。 -
while (list1 && list2):只要两个链表都还有节点,就进行比较。取val较小的节点接到prev->next,然后对应链表的指针后移。完成后,prev = prev->next将prev移到新的尾节点。 -
prev->next = list1 ? list1 : list2;:循环结束后,必然有一个链表已为空,另一个可能还有剩余节点。因为原链表本身就是有序的,所以直接将剩余部分接到新链表尾部即可。这是链表合并中很优雅的一步:不需要像数组那样逐个复制剩余元素。 -
return dummy.next;:哑节点的next指向合并后链表的第一个有效节点。
核心技巧:为什么用哑节点?
如果不使用哑节点,需要额外判断 prev 是否为 NULL(即是否是第一次插入):
// 不使用哑节点的写法(需单独处理头节点)
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
struct ListNode* head = NULL;
struct ListNode** prev = &head; // 二级指针,指向 head
while (list1 && list2) {
if (list1->val <= list2->val) {
*prev = list1;
list1 = list1->next;
} else {
*prev = list2;
list2 = list2->next;
}
prev = &((*prev)->next);
}
*prev = list1 ? list1 : list2;
return head;
}
可以看到,不用哑节点时需要借助二级指针 struct ListNode** 来实现同样的效果。哑节点方案在逻辑上更直观,是链表操作中的常用技巧。
复杂度分析
- 时间复杂度: $O(m + n)$,其中
m和n分别是两个链表的长度。每次循环都会将其中一个链表的指针后移,直到所有节点都被处理。 - 空间复杂度: $O(1)$,只使用了常数额外空间(哑节点在栈上分配,不随输入规模变化)。
二、递归法
思路
递归法的核心思想是:比较两个链表当前头节点的值,取较小的节点,然后将其 next 指向剩余节点合并后的结果。
递归的终止条件是:其中一个链表为空,直接返回另一个链表。
代码实现
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
// 递归终止条件:若其中一个链表为空,直接返回另一个
if (list1 == NULL) return list2;
if (list2 == NULL) return list1;
// 取较小的节点,递归合并剩余部分
if (list1->val <= list2->val) {
list1->next = mergeTwoLists(list1->next, list2);
return list1;
} else {
list2->next = mergeTwoLists(list1, list2->next);
return list2;
}
}
递归过程模拟
以 list1 = [1, 2, 4]、list2 = [1, 3, 4] 为例:
merge(1→2→4, 1→3→4)
├─ 1≤1 → list1->next = merge(2→4, 1→3→4)
│ ├─ 2>1 → list2->next = merge(2→4, 3→4)
│ │ ├─ 2≤3 → list1->next = merge(4, 3→4)
│ │ │ ├─ 4>3 → list2->next = merge(4, 4)
│ │ │ │ ├─ 4≤4 → list1->next = merge(NULL, 4)
│ │ │ │ │ └─ 返回 4
│ │ │ │ └─ 返回 4→4
│ │ │ └─ 返回 3→4→4
│ │ └─ 返回 2→3→4→4
│ └─ 返回 1→2→3→4→4
└─ 返回 1→1→2→3→4→4
复杂度分析
- 时间复杂度: $O(m + n)$,每个节点恰好被递归处理一次。
- 空间复杂度: $O(m + n)$,递归调用栈的深度等于合并后链表的长度。当链表很长时,递归可能导致栈溢出——这也是迭代法更实用的原因。

浙公网安备 33010602011771号