[算法] LC21 – 合并两个有序链表


https://leetcode.cn/problems/merge-two-sorted-lists

含 C 和 JAVA 两种语言的题解

1. 题目描述

题目:

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。 

输入/输出样例:

C
// 样例 1
输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]

// 样例 2
输入:l1 = [], l2 = []
输出:[]

// 样例 3
输入:l1 = [], l2 = [0]
输出:[0]

提示:

  • 两个链表的节点数目范围是 [0, 50]
  • -100 <= Node.val <= 100
  • l1 和 l2 均按 非递减顺序 排列

2. 【方法一】双指针

@Krahets 题解,查看原文

一、思路

两个链表 l1、l2 都是递增的,可以想到用双指针 l1 和 l2 遍历两链表,根据 l1.val 和 l2.val 的大小关系确定添加顺序。

两个节点指针交替前进,直到遍历完毕。

因为初始状态下,合并链表(结果链表)中无节点,循环第一轮时无法将节点添加到合并链表中,所以需要初始化一个伪头节点 dum(Dummy Node 的缩写),将各节点添加到 dum 之后。

伪头节点也叫哨兵节点,其 val 值是随便给的,这里用 0 。伪头节点和数据结构中常引入的头节点(Head Node)不一样:

  • 伪头节点是辅助性的、临时性的,用完即弃,不作为结果的一部分;
  • 而数据结构教材中常提到的头节点,本身是链表的一部分

二、算法流程

  1. 初始化:伪头节点 dum,节点 cur 指向 dum (在 C 语言里就是让 cur 和 dum 存放相同的内存地址,类似“引用传递”的感觉)。
  2. 循环合并:当 l1 或 l2 为空时跳出。
    1. 当 l1.val < l2.val 时:cur 的后继节点指定为 l1,并 l1 向前走一步。
    2. 当 l1.val >= l2.val 时:cur 的后继节点指定为 l2,并 l2 向前走一步。
    3. 节点 cur 向前走一步,即 cur = cur.next。
  3. 合并剩余尾部:跳出时有两种情况,即 l1 为空 或 l2 为空。
    1. 若 l1 != null:将 l1 添加至节点 cur 之后。
    2. 否则:将 l2 添加至节点 cur 之后。
  4. 返回值:合并链表在伪头节点 dum 之后,因此返回 dum.next 即可。

三、代码 & 复杂度分析

C 代码:

C
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */

struct ListNode* mergeTwoLists(struct ListNode* l1, struct ListNode* l2) {
    struct ListNode* dum = (struct ListNode*)malloc(sizeof(struct ListNode));
    struct ListNode* cur = dum;

    while(l1 != NULL && l2 != NULL) {
        if (l1->val >= l2->val) {
            cur->next = l2;
            l2 = l2->next;
        }
        else {
            cur->next = l1;
            l1 = l1->next;
        }
        cur = cur->next;
    }

    cur->next = l1 != NULL ? l1 : l2;

    return dum->next;
}

Java 代码(@Krahets):

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; }
 * }
 */
class Solution {
    public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
        ListNode dum = new ListNode(0), 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 ? list1 : list2;
        return dum.next;
    }
}

复杂度分析:

  • 时间:因为需要遍历 l1、l2 两个链表,假设它们的长度为 M, N,那么时间复杂度就是 O(M + N);
  • 空间:空间复杂度为 O(1),仅需要 dum,cur 两个常数大小的额外空间。


3. 【方法二】递归法

@腐烂的橘子 + 官方题解


@腐烂的橘子

一、思路

我不看题解完全想不到这个方法,甚至有的题解只要四行就能解决问题(可读性为 0)。不过递归法的时间复杂度仍为 O(M + N),而空间复杂度也为 O(M + N),似乎并没有比双指针法更好。

对于本题目:

  • 终止条件:两个链表都为空,表示链表已经合并完成;
  • 如何递归:我们判断 l1 和 l2 的头节点哪个更小,然后让较小结点的 next 指向其余结点的合并结果。
初始情况
每次都让较小的头节点指向其余结点
最终结果

写好一个递归就是考虑清楚三个问题:

  1. 这个问题的子问题是什么(原问题是合并 l1 和 l2,子问题就是【合并 l1.next 和 l2 两个链表,或合并 l2->next 和 l1 两个链表】);
  2. 当前层要干什么事情(选出值较小的头节点,作为合并后链表的头结点,把 next 指向剩余两个链表合并后的结果,即递归调用的返回值,然后将这个结果返回给本层比出的较小头节点);
  3. 递归出口(两个链表有一个为空)。

不能习惯地往深处想,不断地想下一层,这样永远想不清楚。

二、代码 & 复杂度分析

C 代码:

C
// 节点定义略
struct ListNode* mergeTwoLists(struct ListNode* l1, struct ListNode* l2) {
    // 递归出口
    if (l1 == NULL) {
        return l2;
    }
    if (l2 == NULL) {
        return l1;
    }

    // 当前层
    if (l1->val < l2->val) {
        l1->next = mergeTwoLists(l1->next, l2);
        return l1;
    } else {
        l2->next = mergeTwoLists(l1, l2->next);
        return l2;
    }
}

Java 代码(@腐烂的橘子):

Java
class Solution {
    public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        if (l1 == null) {
            return l2;
        }
        else if (l2 == null) {
            return l1;
        }
        else if (l1.val < l2.val) {
            l1.next = mergeTwoLists(l1.next, l2);
            return l1;
        }
        else {
            l2.next = mergeTwoLists(l1, l2.next);
            return l2;
        }

    }
}

复杂度分析:

  • 时间:递归函数每次去掉一个元素,直到两个链表都为空,因此需要调用 R = O(m+n) 次。而在递归函数中我们只进行了 next 指针的赋值操作,复杂度为 O(1),故递归的总时间复杂度为 O(T) = R ∗ O(1) = O(m+n) 。
  • 空间:因为一共调用了 m+n 次,所以使用了 m+n 个栈帧,故空间复杂度为 O(m+n)。