在算法刷题的旅程中,"合并两个有序链表" (LeetCode 21题)是一道经典的中等难度题目。它不仅考察了对链表结构的理解,还巧妙运用了递归思想,用极简的代码实现了复杂的功能。今天,我们就从问题分析、代码逻辑、递归过程、复杂度分析等方面,深入剖析这道题目的解法。
一、题目理解
题目要求:将两个升序链表 合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
举个例子:
-
输入:
l1 = [1,2,4],l2 = [1,3,4] -
输出:
[1,1,2,3,4,4]
链表的结构是单向的,每个节点包含一个值(val)和一个指向下一节点的指针(next)。我们需要通过指针操作,将两个有序链表的节点"按序拼接"。
二、递归解法的核心思路
递归的本质是**"将大问题分解为小问题,直到小问题可以直接解决"**。对于合并两个有序链表,我们可以这样思考:
-
基准情况(递归终止条件):如果其中一个链表为空,直接返回另一个链表(因为空链表和任何链表合并,结果都是另一个链表)。
-
递归逻辑 :比较两个链表头节点的值,选择较小的那个作为"当前合并链表的头节点",然后递归地合并"当前头节点的下一个节点"和"另一个链表的头节点",并将结果赋值给当前头节点的
next。
三、代码逐行解析(C++实现)
先给出完整的代码,再逐行讲解:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
// 基准情况1:l1为空,返回l2(剩余l2已有序)
if (l1 == nullptr) return l2;
// 基准情况2:l2为空,返回l1(剩余l1已有序)
if (l2 == nullptr) return l1;
// 比较两个链表的头节点值,选择较小的作为当前合并链表的头
if (l1->val <= l2->val) {
// l1的头节点更小,所以l1的next需要合并"l1的下一个节点"和"l2"
l1->next = mergeTwoLists(l1->next, l2);
// 返回l1作为当前合并链表的头
return l1;
} else {
// l2的头节点更小,所以l2的next需要合并"l1"和"l2的下一个节点"
l2->next = mergeTwoLists(l1, l2->next);
// 返回l2作为当前合并链表的头
return l2;
}
}
};
1. 链表节点结构定义(第2-9行)
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
这是C++中链表的节点定义,包含:
-
val:节点存储的整数值。 -
next:指向下一个节点的指针。 -
三个构造函数:支持无参初始化、单值初始化、值和指针同时初始化,方便创建链表节点。
2. 主函数mergeTwoLists(第11-27行)
函数接收两个链表的头指针l1和l2,返回合并后的链表头指针。
递归终止条件(第13-14行)
if (l1 == nullptr) return l2;
if (l2 == nullptr) return l1;
-
如果
l1为空,说明l1已经没有节点了,直接返回l2(此时l2的剩余部分已经是升序的)。 -
如果
l2为空,同理返回l1。这是递归的"出口",确保递归不会无限进行。
递归逻辑(第16-25行)
if (l1->val <= l2->val) {
l1->next = mergeTwoLists(l1->next, l2);
return l1;
} else {
l2->next = mergeTwoLists(l1, l2->next);
return l2;
}
-
比较头节点值 :判断
l1和l2的头节点哪个更小。 -
选择较小的头节点:
-
如果
l1->val <= l2->val,则l1作为当前合并链表的头节点。此时,l1的next需要指向"l1的下一个节点"和l2合并后的结果(递归调用mergeTwoLists(l1->next, l2))。 -
如果
l2->val < l1->val,则l2作为当前合并链表的头节点。此时,l2的next需要指向"l1"和"l2的下一个节点"合并后的结果(递归调用mergeTwoLists(l1, l2->next))。
-
-
返回当前头节点 :无论选择
l1还是l2,都将其作为当前合并链表的头返回,供上一层递归使用。
四、递归过程可视化(以示例1为例)
示例1输入:l1 = [1,2,4], l2 = [1,3,4]
我们用栈帧的方式模拟递归过程:
-
初始调用:
mergeTwoLists(l1=1→2→4, l2=1→3→4)-
比较
1和1,l1->val <= l2->val成立。 -
执行
l1->next = mergeTwoLists(l1->next=2→4, l2=1→3→4),进入下一层递归。
-
-
第二层调用:
mergeTwoLists(l1=2→4, l2=1→3→4)-
比较
2和1,l1->val > l2->val。 -
执行
l2->next = mergeTwoLists(l1=2→4, l2->next=3→4),进入下一层递归。
-
-
第三层调用:
mergeTwoLists(l1=2→4, l2=3→4)-
比较
2和3,l1->val <= l2->val成立。 -
执行
l1->next = mergeTwoLists(l1->next=4, l2=3→4),进入下一层递归。
-
-
第四层调用:
mergeTwoLists(l1=4, l2=3→4)-
比较
4和3,l1->val > l2->val。 -
执行
l2->next = mergeTwoLists(l1=4, l2->next=4),进入下一层递归。
-
-
第五层调用:
mergeTwoLists(l1=4, l2=4)-
比较
4和4,l1->val <= l2->val成立。 -
执行
l1->next = mergeTwoLists(l1->next=nullptr, l2=4),进入下一层递归。
-
-
第六层调用:
mergeTwoLists(l1=nullptr, l2=4)- 触发基准条件,返回
l2=4。
- 触发基准条件,返回
-
回到第五层:
l1->next = 4(第六层的返回值),返回l1=4。 -
回到第四层:
l2->next = 4(第五层的返回值),返回l2=3→4。 -
回到第三层:
l1->next = 3→4(第四层的返回值),返回l1=2→3→4。 -
回到第二层:
l2->next = 2→3→4(第三层的返回值),返回l2=1→2→3→4。 -
回到第一层:
l1->next = 1→2→3→4(第二层的返回值),返回l1=1→1→2→3→4→4。
最终,合并后的链表为1→1→2→3→4→4,与示例输出一致。
五、复杂度分析
时间复杂度:O(n+m)
其中 n和 m分别是两个链表的长度。每个节点都会被递归访问一次,因此总的时间复杂度是两个链表长度之和。
空间复杂度:O(n+m)(递归栈的空间)
递归的深度最多为 n+m(当两个链表长度分别为 n和 m时,最坏情况下需要递归 n+m层)。因此,递归栈的空间复杂度为 O(n+m)。
(如果使用迭代法,空间复杂度可以优化到 O(1),因为只需要几个指针变量。但递归法的代码更简洁,逻辑更清晰。)
六、总结
合并两个有序链表的递归解法,核心在于**"分解问题"**:每次选择较小的头节点,然后递归处理剩余部分。这种思路不仅代码简洁,还能很好地体现递归"自顶向下、逐步细化"的思想。
在实际刷题或面试中,递归法适合快速写出逻辑清晰的代码;如果需要优化空间,也可以尝试迭代法(用指针逐个拼接节点)。但无论哪种方法,理解"如何比较两个节点并选择下一个节点"的逻辑是关键。
希望这篇博客能帮你彻底掌握这道经典题目的递归解法!如果有疑问,欢迎在评论区交流~ 🚀