算法刷题打卡 | 今天刷到了 LeetCode 21. 合并两个有序链表,这道题和昨天的反转链表一样,都是链表的经典入门题,做完之后发现思路和归并排序里的合并步骤几乎一模一样,把迭代和递归两种写法都理清楚了,做个笔记记录一下,防止之后回头就忘。

题目回顾
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
示例
示例 1:
Plain
输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]

示例 2:
Plain
输入:l1 = [], l2 = []
输出:[]
示例 3:
Plain
输入:l1 = [], l2 = [0]
输出:[0]
节点定义
题目中的单链表节点定义如下:
java
/**
* Definition for singly-linked list.
*/
public class ListNode {
int val;
ListNode next;
ListNode() {}
ListNode(int val) { this.val = val; }
ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}
解法一:迭代(双指针)法
这道题的推荐解法,空间复杂度可以做到 O (1),也是生产环境中最常用的写法。
核心思路
我们用两个指针分别遍历两个有序链表,每次比较两个指针指向的节点的值,把更小的那个节点接到我们的结果链表后面,然后移动对应的指针,直到其中一个链表遍历完,最后把剩下的节点直接接到结果链表的末尾就可以了。
这里有个链表题的神器:虚拟头节点(dummy node),用它可以完美解决头节点不好处理的问题,不用单独判断哪个链表的头节点更小,所有节点的处理逻辑完全统一。

步骤拆解
我自己整理的操作要点,每一步都很清晰:
-
创建虚拟头节点 :先初始化一个值为 0 的伪节点
dum,然后用游标指针cur指向它,最后我们要返回的是dum.next,也就是真正的结果链表的头节点。 -
循环遍历 :循环的结束条件是两个原链表都遍历完,也就是
list1 != null && list2 != null,只要还有节点没处理,就继续。 -
比较选择更小的节点:
-
如果
list1.val < list2.val,说明当前 list1 的节点更小,把它接到结果链表的后面:cur.next = list1 -
然后把 list1 的指针向后移动一位:
list1 = list1.next -
否则就处理 list2 的节点,同理,把节点接过去,然后移动 list2 的指针
-
处理完之后,把结果链表的游标指针 cur 也向后移动一位,准备接下一个节点
-
处理剩余节点:循环结束后,肯定有一个链表已经遍历完了,另一个还有剩余节点,直接把剩下的整个链表接到 cur 的后面就可以了,因为剩下的节点本身就是有序的。
-
返回结果 :最后返回
dum.next,也就是虚拟头节点的下一个节点,就是我们合并后的新链表的头节点。
代码实现
java
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
ListNode dum = new ListNode(0);
ListNode cur = dum;
while(list1 != null && list2 != null){
if(list1.val < list2.val){
cur.next = list1;
list1 = list1.next;
}else{
cur.next = list2;
list2 = list2.next;
}
cur = cur.next;
}
cur.next = list1 == null ? list2 : list1;
return dum.next;
}
}
踩坑感悟
一开始做这道题的时候,我没用 dummy 节点,自己去判断哪个链表的头节点更小,作为结果的头节点,结果处理空链表的时候出了一堆问题,比如两个都是空的,或者其中一个是空的,要写一堆 if 判断,代码又乱又长。后来才知道用 dummy 节点,一下子就把所有情况都统一了,不管头节点是谁,不管有没有空链表,代码逻辑完全不用变,太香了!这应该是链表题里最实用的小技巧了。
复杂度分析
-
时间复杂度:O (n + m),n 和 m 是两个链表的长度,我们需要遍历完两个链表的所有节点,每个节点处理一次。
-
空间复杂度:O (1),我们只是复用了原来的节点,只用到了几个指针变量,常数级的额外空间。
解法二:递归法
个人笔记:递归法不推荐在生产环境使用,代码虽然简洁,但是有栈溢出的风险。
核心思路
递归的思路其实就是分治:每次我们选两个链表头节点里更小的那个,然后这个节点的 next,就是剩下的两个链表合并后的结果,这样一层层递归下去,直到其中一个链表为空,就直接返回另一个剩下的链表。
步骤拆解
我整理的递归要点:
-
先写递归终止条件 :如果
list1 == null,说明 list1 已经遍历完了,直接返回剩下的 list2 就可以了;反之如果list2 == null,就返回剩下的 list1,因为剩下的节点本身就是有序的,直接接上去就行。 -
递归处理剩余部分:比较两个链表的头节点的值,更小的那个节点,它的 next 指针,就指向剩下的两个链表递归合并后的结果,然后返回这个更小的节点,作为当前层的结果。
代码实现
java
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
if(list1==null){
return list2;
}
else if(list2==null){
return list1;
}
else if(list1.val<list2.val){
list1.next=mergeTwoLists(list1.next,list2);
return list1;
}else{
list2.next=mergeTwoLists(list1,list2.next);
return list2;
}
}
}
复杂度分析
-
时间复杂度:O (n + m),同样要处理所有的节点,递归 n+m 次。
-
空间复杂度:O (n + m),递归调用的栈深度最多是 n+m,因为最坏情况下,我们要递归到其中一个链表的最后一个节点,所以会占用这么多的栈空间,这也是为什么不推荐在生产环境用的原因,链表长了很容易栈溢出。
刷题笔记与感悟
做完这道题,最大的收获就是搞懂了虚拟头节点的用法,之前做链表题总头疼头节点的特殊处理,现在发现只要加个 dummy 节点,所有节点的处理逻辑都能统一,代码一下子就简洁了,再也不用写一堆边界判断了。
而且这道题其实就是归并排序里的「合并」步骤,之前学排序的时候只知道归并排序要合并两个有序数组,现在用链表实现了一遍,一下子就把两个知识点串起来了,原来归并的思路不管是数组还是链表都是通用的!学会了这道题,之后的「合并 K 个有序链表」也就有了基础,本质上就是把多个合并成两个,再用这个思路处理。
另外我也测试了边界情况:两个都是空链表、其中一个是空链表、两个链表长度不一样,两种方法都能正确处理,不用额外加判断,代码的鲁棒性还是很好的。
总结
这道题作为链表的基础题,真的太经典了,两种方法各有优劣:
-
迭代法:空间复杂度低,没有栈溢出的风险,效率高,生产环境首选,也是面试的时候最推荐写的解法,尤其是 dummy 节点的用法,面试官很喜欢看你会不会用这个技巧。
-
递归法:代码非常简洁,几行就写完了,很能体现分治的思想,但是空间复杂度高,有栈溢出的风险,适合用来练习递归思维,实际项目里还是少用。
搞定了这道题,链表的基础思想又掌握了一个。