LeetCode 上的经典入门题------21. 合并两个有序链表,这道题是链表操作的基础题型,不仅考察对链表结构的理解,还能帮我们熟练掌握递归和迭代两种核心编程思路,适合新手入门练习,也适合老司机回顾基础。
先来看题目要求,帮大家梳理清楚核心考点:
一、题目解析
题目描述:将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的,不允许创建新的节点(仅拼接原有节点)。
核心要点拆解:
-
输入:两个升序的单链表(可能为空链表,即 list1 或 list2 为 null);
-
输出:一个新的升序单链表,节点全部来自两个输入链表,保持升序顺序;
-
关键约束:不能创建新节点,只能通过调整原有节点的 next 指针实现拼接;
-
难度:简单(入门级),核心考察「链表遍历」「递归/迭代逻辑」。
先给出题目中定义的链表节点类(TypeScript 版本),后续两种解法均基于该类实现:
typescript
class ListNode {
val: number
next: ListNode | null
constructor(val?: number, next?: ListNode | null) {
this.val = (val === undefined ? 0 : val)
this.next = (next === undefined ? null : next)
}
}
二、解法一:递归解法(mergeTwoLists_1)
2.1 解题思路
递归的核心思想是「分解问题」------将"合并两个长链表"的大问题,分解为"合并两个更短链表"的小问题,直到触发终止条件(其中一个链表为空)。
具体逻辑:
-
终止条件:如果 list1 为空,直接返回 list2(剩下的节点都来自 list2);如果 list2 为空,直接返回 list1(剩下的节点都来自 list1);
-
递归逻辑:比较 list1 和 list2 的当前节点值,选择值更小的节点作为当前拼接节点;
-
递归调用:被选中节点的 next 指针,指向「剩余链表」的合并结果(即递归调用自身,传入被选中节点的下一个节点和另一个链表);
-
返回值:每次返回当前选中的节点,最终拼接成完整的升序链表。
举个简单例子帮助理解:
list1 = [1,3,5],list2 = [2,4,6] → 第一次比较 1 和 2,选 1,1 的 next 指向 mergeTwoLists_1([3,5], [2,4,6]);
第二次比较 3 和 2,选 2,2 的 next 指向 mergeTwoLists_1([3,5], [4,6]);
依次递归,直到其中一个链表为空,拼接剩余节点,最终得到 [1,2,3,4,5,6]。
2.2 完整代码
typescript
function mergeTwoLists_1(list1: ListNode | null, list2: ListNode | null): ListNode | null {
// 终止条件:其中一个链表为空,返回另一个
if (list1 === null) {
return list2;
}
if (list2 === null) {
return list1;
}
// 递归逻辑:选择当前值更小的节点,拼接剩余链表
if (list1.val < list2.val) {
list1.next = mergeTwoLists_1(list1.next, list2);
return list1;
} else {
list2.next = mergeTwoLists_1(list1, list2.next);
return list2;
}
};
2.3 解法分析
-
时间复杂度:O(m + n),其中 m、n 分别是两个链表的长度。每个节点只会被访问一次,递归调用次数等于节点总数;
-
空间复杂度:O(m + n),递归调用会占用栈空间,栈的深度等于两个链表的总长度(最坏情况,如两个链表完全有序且串联,递归深度为 m+n);
-
优点:代码简洁、逻辑清晰,无需手动遍历链表,符合"分而治之"的思想,容易理解;
-
缺点:栈空间占用较高,若链表过长,可能会出现栈溢出(但 LeetCode 测试用例不会出现这种极端情况,日常练习可放心使用)。
三、解法二:迭代解法(mergeTwoLists_2)
3.1 解题思路
迭代解法的核心思想是「手动遍历」------通过一个指针,依次比较两个链表的当前节点,将值更小的节点拼接在新链表后面,直到其中一个链表遍历完毕,再将剩余节点拼接在末尾。
这里有个小技巧:使用「虚拟头节点(dummy node)」。因为新链表的头节点不确定(可能是 list1 的第一个节点,也可能是 list2 的第一个节点),虚拟头节点可以简化头节点的处理,无需单独判断头节点的情况。
具体逻辑:
-
创建虚拟头节点 dummy(val 可任意,此处设为 0),再创建一个 curr 指针,指向 dummy(curr 用于遍历和拼接新链表);
-
循环遍历:当 list1 和 list2 都不为空时,比较两者当前节点值;
-
拼接节点:将值更小的节点赋值给 curr.next,然后将该链表的指针后移(list1 或 list2 后移),同时 curr 指针也后移;
-
处理剩余节点:循环结束后,必有一个链表为空,将剩余链表直接拼接在 curr.next 后面;
-
返回结果:返回 dummy.next(虚拟头节点的下一个节点,即新链表的真实头节点)。
3.2 完整代码
typescript
function mergeTwoLists_2(list1: ListNode | null, list2: ListNode | null): ListNode | null {
// 虚拟头节点,简化头节点处理
const dummy = new ListNode(0);
// curr 指针用于拼接新链表
let curr = dummy;
// 循环遍历两个链表,直到其中一个为空
while (list1 && list2) {
if (list1.val < list2.val) {
curr.next = list1;
list1 = list1.next; // list1 后移
} else {
curr.next = list2;
list2 = list2.next; // list2 后移
}
curr = curr.next; // curr 后移,准备拼接下一个节点
}
// 拼接剩余节点(其中一个链表已为空)
curr.next = list1 || list2;
// 返回新链表的真实头节点
return dummy.next;
};
3.3 解法分析
-
时间复杂度:O(m + n),与递归解法一致,每个节点只被访问一次,循环次数等于节点总数;
-
空间复杂度:O(1),仅使用了 dummy 虚拟节点和 curr 指针,没有额外占用空间(不考虑输入链表本身的空间);
-
优点:空间效率高,不会出现栈溢出问题,适合处理长链表,逻辑直观,容易调试;
-
缺点:代码比递归稍长,需要手动控制指针移动,容易出现指针操作错误(如忘记移动 curr 指针)。
四、两种解法对比 & 实战建议
| 对比维度 | 递归解法 | 迭代解法 |
|---|---|---|
| 时间复杂度 | O(m + n) | O(m + n) |
| 空间复杂度 | O(m + n)(栈空间) | O(1)(常数空间) |
| 代码简洁度 | 高(无需手动控制指针) | 中等(需手动移动指针) |
| 适用场景 | 链表较短,追求代码简洁 | 链表较长,追求空间效率 |
| 易错点 | 递归终止条件遗漏 | 指针移动遗漏(如 curr 未后移) |
实战建议:
-
面试时:优先写迭代解法!因为迭代解法空间复杂度更低,且不会有栈溢出风险,更能体现对指针操作的掌握(面试官更看重这一点);
-
日常练习:两种解法都要掌握!递归解法能锻炼"分而治之"的思维,迭代解法能夯实链表操作基础,两者结合能更深入理解题目;
-
调试技巧:遇到指针操作错误时,可手动模拟指针移动过程(如拿纸画链表节点和指针位置),快速定位问题。
五、总结
LeetCode 21 题看似简单,但涵盖了链表操作的核心考点------虚拟头节点、指针移动、递归/迭代逻辑,是入门链表的绝佳题目。
核心总结:
-
递归解法:利用终止条件分解问题,代码简洁,空间复杂度较高;
-
迭代解法:利用虚拟头节点+指针遍历,空间效率高,适合实战;
-
无论哪种解法,核心都是「依次比较两个链表的节点,保持升序拼接」,关键是处理好"空链表"和"指针移动"这两个细节。