引言
欢迎来到本系列的第九篇!在之前的文章中,我们已经对链表的基础操作(如反转)有了深入的理解。今天,我们将挑战一个同样经典、同样高频的链表问题——LeetCode 21. 合并两个有序链表。
这道题是考察链表基本功的“试金石”。它不仅要求你熟练地操纵指针,更是理解虚拟头节点 (Dummy Node) 技巧以及递归思想在链表问题中应用的绝佳机会。
很多初学者在处理这道题时,常常会因为对头节点的繁琐处理而导致代码冗长且容易出错。本文将带你一起:
-
掌握使用“虚拟头节点”的迭代法,写出简洁、健壮的代码。
-
领略递归思想的魅力,用极少的代码行数解决问题。
-
深入对比两种核心方法的优劣,让你在面试中能够游刃有余地展示你的理解深度。
一、题目呈现
-
题目编号:21. 合并两个有序链表
-
题目难度:简单
-
题目描述:
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
-
示例:
-
输入: list1 = [1,2,4], list2 = [1,3,4]
-
输出: [1,1,2,3,4,4]
-
前置知识:链表节点定义
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
};
二、解法一:迭代法 —— 虚拟头节点的妙用
我的思考
最直观的思路是:同时遍历两个链表,比较当前两个节点的值,将较小的那个节点链接到我们正在构建的新链表的末尾,然后将被选中的那个链表的指针向后移动一步,重复这个过程。
这个思路的困境:
“新链表的第一个节点”是一个很麻烦的特殊情况。我们需要写额外的 if/else 来确定谁是头,并初始化我们的新链表。这会让代码变得臃肿。
豁然开朗:虚拟头节点 (Dummy Node)
为了解决这个问题,我们可以引入一个“哨兵”或“脚手架”——虚拟头节点。
-
我们先创建一个不存储任何有效数据的虚拟节点 dummy。
-
我们用一个 tail 指针,初始时指向 dummy,来追踪新链表的末尾。
-
现在,我们可以进入一个统一的循环。无论是处理第一个节点还是第十个节点,操作都完全一样:将较小的节点链接到 tail->next,然后移动 tail。
-
当所有节点都链接完毕后,我们真正想要的头节点,就是虚拟节点 dummy 的下一个节点。
C++ 代码实现 (迭代法)
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
// 1.
ListNode* dummy = new ListNode(-1);
ListNode* tail = dummy;
// 2.
while (list1 != nullptr && list2 != nullptr) {
if (list1->val <= list2->val) {
// 3.
tail->next = list1;
list1 = list1->next;
} else {
tail->next = list2;
list2 = list2->next;
}
// 4.
tail = tail->next;
}
// 5.
tail->next = (list1 != nullptr) ? list1 : list2;
// 6.
ListNode* head = dummy->next;
delete dummy;
return head;
}
};
代码逐点解释
-
ListNode* dummy = ...; ListNode* tail = dummy;: 创建虚拟头节点 dummy 和一个 tail 指针。tail 是我们的“编织针”,负责将新节点链接起来。
-
while (list1 != nullptr && list2 != nullptr): 当两个链表都还有节点时,循环继续。
-
tail->next = ...; list1 = list1->next;: 核心链接步骤。如果 list1 的节点更小,就将它链接到 tail 的后面,并让 list1 指针前进。else 块同理。
-
tail = tail->next;: 移动“编织针”。无论刚才链接的是哪个节点,tail 指针都需要移动到新链表的末尾,为下一次链接做准备。
-
tail->next = ...: 循环结束后,最多只有一个链表还有剩余节点。我们直接将这个“尾巴”整个链接到新链表的末尾。
-
ListNode* head = dummy->next; ...: 返回结果。dummy->next 才是我们真正想要的头节点。在返回之前,释放 dummy 节点的内存是一个好习惯。
三、解法二:递归法 —— 信任的连锁反应
递归提供了一种完全不同的、代码极其简洁的思维模式。
核心思想
定义 mergeTwoLists(l1, l2) 的功能为:合并 l1 和 l2,并返回新链表的头节点。
-
比较当前:我们只关心 l1 和 l2 的头节点,谁更小。
-
假设 l1 的头更小:
-
那么,最终合并链表的头节点必然是 l1。
-
l1 的 next 指针应该指向谁呢?它应该指向 “l1 的剩余部分” 和 “完整的 l2” 合并后的结果。
-
这个“合并后的结果”正是 mergeTwoLists(l1->next, l2) 的返回值!
-
-
链接与返回:我们只需将 l1->next 指向这次递归调用的结果,然后返回 l1 即可。
递归的终止条件 (Base Case):如果 l1 或 l2 有一个是空的,那么合并的结果就是另一个非空的链表。
C++ 代码实现 (递归法)
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
// 1.
if (list1 == nullptr) return list2;
if (list2 == nullptr) return list1;
// 2.
if (list1->val <= list2->val) {
// 3.
list1->next = mergeTwoLists(list1->next, list2);
return list1;
} else {
list2->next = mergeTwoLists(list1, list2->next);
return list2;
}
}
};
代码逐点解释
-
if (list1 == nullptr) ...: 递归的终止条件。如果一个链表为空,直接返回另一个。
-
if (list1->val <= list2->val): 比较当前两个节点的值,决定谁是这一层递归的“胜者”(即当前合并后的头节点)。
-
list1->next = mergeTwoLists(...): 核心递归步骤。我们将“胜者”的 next 指针,链接到对“剩余部分”进行递归合并的结果上。我们信任这次递归调用会返回一个正确的、已经合并好的子链表。
-
return list1;: 将“胜者”作为本层递归的结果返回。这个返回值会被上一层递归的 ...->next = ... 语句接收并链接起来。
四、总结与对比
解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
迭代法 (虚拟头节点) | O(m + n) | O(1) | 空间效率最高,思维线性直观 | 代码相对递归法稍长 |
递归法 | O(m + n) | O(m + n) | 代码极其简洁、优雅,体现分治思想 | 空间开销较大,可能导致栈溢出 |