[从零开始面试算法] (09/100) LeetCode 21. 合并两个有序链表:迭代与递归的优雅

引言

欢迎来到本系列的第九篇!在之前的文章中,我们已经对链表的基础操作(如反转)有了深入的理解。今天,我们将挑战一个同样经典、同样高频的链表问题——LeetCode 21. 合并两个有序链表

这道题是考察链表基本功的“试金石”。它不仅要求你熟练地操纵指针,更是理解虚拟头节点 (Dummy Node) 技巧以及递归思想在链表问题中应用的绝佳机会。

很多初学者在处理这道题时,常常会因为对头节点的繁琐处理而导致代码冗长且容易出错。本文将带你一起:

  1. 掌握使用“虚拟头节点”的迭代法,写出简洁、健壮的代码。

  2. 领略递归思想的魅力,用极少的代码行数解决问题。

  3. 深入对比两种核心方法的优劣,让你在面试中能够游刃有余地展示你的理解深度。


一、题目呈现

  • 题目编号:21. 合并两个有序链表

  • 题目难度:简单

  • 题目链接21. 合并两个有序链表 - 力扣 (LeetCode)

  • 题目描述

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

  • 示例

    • 输入: 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)
为了解决这个问题,我们可以引入一个“哨兵”或“脚手架”——虚拟头节点

  1. 我们先创建一个不存储任何有效数据的虚拟节点 dummy。

  2. 我们用一个 tail 指针,初始时指向 dummy,来追踪新链表的末尾。

  3. 现在,我们可以进入一个统一的循环。无论是处理第一个节点还是第十个节点,操作都完全一样:将较小的节点链接到 tail->next,然后移动 tail。

  4. 当所有节点都链接完毕后,我们真正想要的头节点,就是虚拟节点 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;
    }
};

代码逐点解释
  1. ListNode* dummy = ...; ListNode* tail = dummy;: 创建虚拟头节点 dummy 和一个 tail 指针。tail 是我们的“编织针”,负责将新节点链接起来。

  2. while (list1 != nullptr && list2 != nullptr): 当两个链表都还有节点时,循环继续。

  3. tail->next = ...; list1 = list1->next;核心链接步骤。如果 list1 的节点更小,就将它链接到 tail 的后面,并让 list1 指针前进。else 块同理。

  4. tail = tail->next;移动“编织针”。无论刚才链接的是哪个节点,tail 指针都需要移动到新链表的末尾,为下一次链接做准备。

  5. tail->next = ...: 循环结束后,最多只有一个链表还有剩余节点。我们直接将这个“尾巴”整个链接到新链表的末尾。

  6. ListNode* head = dummy->next; ...返回结果。dummy->next 才是我们真正想要的头节点。在返回之前,释放 dummy 节点的内存是一个好习惯。


三、解法二:递归法 —— 信任的连锁反应

递归提供了一种完全不同的、代码极其简洁的思维模式。

核心思想

定义 mergeTwoLists(l1, l2) 的功能为:合并 l1 和 l2,并返回新链表的头节点。

  1. 比较当前:我们只关心 l1 和 l2 的头节点,谁更小。

  2. 假设 l1 的头更小

    • 那么,最终合并链表的头节点必然是 l1。

    • l1 的 next 指针应该指向谁呢?它应该指向 “l1 的剩余部分” 和 “完整的 l2” 合并后的结果

    • 这个“合并后的结果”正是 mergeTwoLists(l1->next, l2) 的返回值!

  3. 链接与返回:我们只需将 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;
        }
    }
};

代码逐点解释
  1. if (list1 == nullptr) ...递归的终止条件。如果一个链表为空,直接返回另一个。

  2. if (list1->val <= list2->val): 比较当前两个节点的值,决定谁是这一层递归的“胜者”(即当前合并后的头节点)。

  3. list1->next = mergeTwoLists(...)核心递归步骤。我们将“胜者”的 next 指针,链接到对“剩余部分”进行递归合并的结果上。我们信任这次递归调用会返回一个正确的、已经合并好的子链表。

  4. return list1;: 将“胜者”作为本层递归的结果返回。这个返回值会被上一层递归的 ...->next = ... 语句接收并链接起来。


四、总结与对比

解法时间复杂度空间复杂度优点缺点
迭代法 (虚拟头节点)O(m + n)O(1)空间效率最高,思维线性直观代码相对递归法稍长
递归法O(m + n)O(m + n)代码极其简洁、优雅,体现分治思想空间开销较大,可能导致栈溢出
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值