从有序链表合并看链表算法的指针设计:LeetCode 21「合并两个有序链表」深度解析

在链表类算法中,「合并两个有序链表」是一道非常经典的基础题。它看似简单,只是把两个已经升序排列的链表合并成一个新的升序链表,但实际上,这道题集中体现了链表算法中的几个核心思想:

第一,如何使用指针连接节点;

第二,如何避免复杂的头节点特判;

第三,如何理解"原地拼接"与"新建节点"的区别;

第四,如何在迭代和递归两种思维之间切换。

本文将以 LeetCode 21 题为例,系统讲解这道题的解法、代码实现、执行过程和背后的算法设计思想。


一、题目描述

题目要求如下:

给定两个升序链表 list1list2,将它们合并为一个新的升序链表并返回。

新链表是通过拼接给定两个链表中的所有节点组成的。

例如:

复制代码
输入:
list1 = [1, 2, 4]
list2 = [1, 3, 4]

输出:
[1, 1, 2, 3, 4, 4]

如果其中一个链表为空,那么直接返回另一个链表即可。

例如:

复制代码
输入:
list1 = []
list2 = [0]

输出:
[0]

二、问题本质分析

这道题的本质是:

合并两个已经有序的线性结构。

如果把链表换成数组,这个问题其实就是归并排序中的"归并"过程。

例如:

复制代码
A = [1, 2, 4]
B = [1, 3, 4]

合并过程就是每次比较两个序列当前元素,选择较小的那个放入结果序列。

对于数组来说,我们通常使用下标 ij

对于链表来说,我们使用指针 list1list2

数组合并是移动下标,链表合并是移动节点指针。


三、为什么不能像数组一样直接访问?

链表和数组最大的区别在于:

数组支持随机访问:

复制代码
nums[i]

而链表不支持随机访问。

链表只能通过 next 指针逐个向后遍历:

复制代码
node = node->next;

所以链表问题的关键不是"下标移动",而是"指针连接"。

在这道题中,我们要做的不是创建一个数组,也不是排序所有节点,而是利用两个链表本身已经有序的性质,通过指针重新连接节点。


四、核心思想:双指针 + 虚拟头节点

我们设置三个指针:

复制代码
list1
list2
cur

其中:

list1 指向第一个链表当前待处理节点;
list2 指向第二个链表当前待处理节点;
cur 指向合并后链表的尾部。

每次比较:

复制代码
list1->val 和 list2->val

如果 list1->val 更小,就把 list1 当前节点接到结果链表后面。

否则,把 list2 当前节点接到结果链表后面。

然后移动对应链表的指针。


五、虚拟头节点 dummy 的作用

在链表题中,虚拟头节点是一个非常常用的技巧。

如果不用虚拟头节点,那么我们需要单独处理结果链表的第一个节点。

例如,我们可能需要写出这样的逻辑:

复制代码
ListNode* head = nullptr;
ListNode* cur = nullptr;

if (list1->val <= list2->val) {
    head = list1;
    cur = list1;
    list1 = list1->next;
} else {
    head = list2;
    cur = list2;
    list2 = list2->next;
}

这样代码会比较繁琐,而且容易出错。

使用虚拟头节点后,代码会变得非常统一:

复制代码
ListNode dummy(0);
ListNode* cur = &dummy;

dummy 本身不是最终链表的一部分,它只是一个辅助节点。

所有真实节点都接在 dummy.next 后面。

最终返回:

复制代码
return dummy.next;

这样就避免了对头节点的特殊判断。


六、迭代法代码实现

复制代码
/**
 * 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* list1, ListNode* list2) {
        ListNode dummy(0);
        ListNode* cur = &dummy;

        while (list1 != nullptr && list2 != nullptr) {
            if (list1->val <= list2->val) {
                cur->next = list1;
                list1 = list1->next;
            } else {
                cur->next = list2;
                list2 = list2->next;
            }

            cur = cur->next;
        }

        cur->next = list1 != nullptr ? list1 : list2;

        return dummy.next;
    }
};

七、代码逐行解析

先创建虚拟头节点:

复制代码
ListNode dummy(0);

这个节点的值没有实际意义,它只是为了统一链表拼接逻辑。

然后定义尾指针:

复制代码
ListNode* cur = &dummy;

cur 永远指向当前合并链表的最后一个节点。

接下来进入循环:

复制代码
while (list1 != nullptr && list2 != nullptr)

只要两个链表都没有遍历完,就继续比较它们的当前节点。

如果第一个链表当前节点更小:

复制代码
if (list1->val <= list2->val) {
    cur->next = list1;
    list1 = list1->next;
}

这里做了两件事:

第一,把 list1 当前节点接到结果链表后面;

第二,让 list1 指针向后移动。

如果第二个链表当前节点更小:

复制代码
else {
    cur->next = list2;
    list2 = list2->next;
}

同理,把 list2 当前节点接到结果链表后面,然后移动 list2

每接入一个节点后,都要移动结果链表的尾指针:

复制代码
cur = cur->next;

当循环结束时,说明至少有一个链表已经遍历完。

由于两个链表本身都是升序的,所以剩下的链表可以直接整体接到结果链表后面:

复制代码
cur->next = list1 != nullptr ? list1 : list2;

最后返回真正的头节点:

复制代码
return dummy.next;

八、执行过程演示

假设输入为:

复制代码
list1 = 1 -> 2 -> 4
list2 = 1 -> 3 -> 4

初始状态:

复制代码
dummy -> nullptr

list1 -> 1 -> 2 -> 4
list2 -> 1 -> 3 -> 4
cur   -> dummy

第一次比较:

复制代码
list1->val = 1
list2->val = 1

因为 list1->val <= list2->val,所以接入 list1 当前节点:

复制代码
dummy -> 1

list1 -> 2 -> 4
list2 -> 1 -> 3 -> 4
cur   -> 1

第二次比较:

复制代码
list1->val = 2
list2->val = 1

接入 list2 当前节点:

复制代码
dummy -> 1 -> 1

list1 -> 2 -> 4
list2 -> 3 -> 4
cur   -> 1

第三次比较:

复制代码
list1->val = 2
list2->val = 3

接入 list1 当前节点:

复制代码
dummy -> 1 -> 1 -> 2

list1 -> 4
list2 -> 3 -> 4
cur   -> 2

继续比较,依次接入 34,最终得到:

复制代码
dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4

返回:

复制代码
dummy.next

即:

复制代码
1 -> 1 -> 2 -> 3 -> 4 -> 4

九、为什么最后可以直接接上剩余链表?

这是因为输入链表本身已经是升序排列的。

假设循环结束时,list2 已经为空,而 list1 还有剩余节点:

复制代码
list1 = 4 -> 5 -> 8
list2 = nullptr

由于 list1 本身有序,并且当前结果链表最后一个节点一定不大于 list1 当前节点,所以可以直接接上:

复制代码
cur->next = list1;

没有必要继续一个节点一个节点地比较。

这一步既简洁,又提高了代码可读性。


十、递归解法

这道题也可以用递归来写。

递归思路是:

如果 list1 的当前节点更小,那么 list1 当前节点应该作为合并后链表的头节点。

它后面的部分应该由:

复制代码
mergeTwoLists(list1->next, list2)

继续合并。

代码如下:

复制代码
class Solution {
public:
    ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
        if (list1 == nullptr) {
            return list2;
        }

        if (list2 == nullptr) {
            return list1;
        }

        if (list1->val <= list2->val) {
            list1->next = mergeTwoLists(list1->next, list2);
            return list1;
        } else {
            list2->next = mergeTwoLists(list1, list2->next);
            return list2;
        }
    }
};

十一、递归代码解析

递归终止条件是:

复制代码
if (list1 == nullptr) {
    return list2;
}

if (list2 == nullptr) {
    return list1;
}

如果某一个链表已经为空,那么合并结果就是另一个链表。

递归主体是:

复制代码
if (list1->val <= list2->val) {
    list1->next = mergeTwoLists(list1->next, list2);
    return list1;
}

这段代码的含义是:

当前 list1 节点较小,因此它应该放在合并后链表的最前面。

至于它后面的节点应该如何排列,交给递归函数继续处理。

同理:

复制代码
else {
    list2->next = mergeTwoLists(list1, list2->next);
    return list2;
}

表示当前 list2 节点更小,因此返回 list2 作为当前阶段的头节点。


十二、迭代法与递归法对比

方法 优点 缺点
迭代法 空间复杂度低,执行过程直观,适合工程代码 代码略长
递归法 代码简洁,逻辑优雅 存在递归栈开销

对于这道题,链表节点数最多只有 50,所以递归法完全可以通过。

但是从工程实践角度来看,更推荐迭代法。

原因是迭代法不会产生额外的递归栈空间,在链表长度较大时更加稳定。


十三、复杂度分析

假设两个链表长度分别为 mn

1. 时间复杂度

无论使用迭代法还是递归法,每个节点最多被访问一次。

所以时间复杂度为:

复制代码
O(m + n)

2. 空间复杂度

迭代法只使用了常数个指针变量:

复制代码
O(1)

递归法需要函数调用栈,最坏情况下递归深度为 m + n

复制代码
O(m + n)

十四、常见错误分析

1. 忘记移动 cur 指针

错误写法:

复制代码
cur->next = list1;
list1 = list1->next;

如果没有执行:

复制代码
cur = cur->next;

那么后续节点会一直覆盖 cur->next,导致链表连接错误。


2. 忘记接上剩余链表

错误写法:

复制代码
while (list1 != nullptr && list2 != nullptr) {
    ...
}

return dummy.next;

如果循环结束后直接返回,就会丢失未遍历完的剩余节点。

必须补上:

复制代码
cur->next = list1 != nullptr ? list1 : list2;

3. 不使用 dummy 导致头节点逻辑复杂

不使用虚拟头节点当然也可以写,但需要额外判断新链表是否为空。

这会增加代码分支,也更容易出现空指针问题。

在链表题中,只要涉及"构造新链表""删除节点""分割链表"等操作,都可以优先考虑使用虚拟头节点。


十五、这道题背后的算法意义

虽然 LeetCode 21 是简单题,但它的重要性很高。

它不仅是链表基础操作题,也是很多高级问题的基础模块。

例如:

合并 K 个升序链表;

归并排序链表;

链表排序;

链表分治算法;

外部排序中的多路归并;

数据库和搜索系统中的有序流合并。

尤其是 LeetCode 23「合并 K 个升序链表」,本质上就是 LeetCode 21 的扩展版本。

如果能熟练掌握两个有序链表的合并,那么之后理解多链表合并、链表归并排序会轻松很多。


十六、最终推荐写法

综合可读性、空间复杂度和工程稳定性,最推荐的写法是迭代法:

复制代码
class Solution {
public:
    ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
        ListNode dummy(0);
        ListNode* cur = &dummy;

        while (list1 != nullptr && list2 != nullptr) {
            if (list1->val <= list2->val) {
                cur->next = list1;
                list1 = list1->next;
            } else {
                cur->next = list2;
                list2 = list2->next;
            }

            cur = cur->next;
        }

        cur->next = list1 != nullptr ? list1 : list2;

        return dummy.next;
    }
};

这段代码具有几个优点:

逻辑清晰;

没有复杂特判;

不额外创建新节点;

空间复杂度为 O(1)

适合作为链表合并类问题的模板。


十七、总结

「合并两个有序链表」是一道典型的链表双指针题。

它的核心不是排序,而是利用两个链表已经有序的性质,通过比较当前节点,将较小节点接入结果链表。

整道题的关键点有三个:

第一,使用两个指针分别遍历两个链表;

第二,使用虚拟头节点简化结果链表的构造;

第三,循环结束后直接接上剩余链表。

从算法思想上看,这道题本质上是归并过程在链表结构上的体现。

从代码技巧上看,它是学习链表指针操作、虚拟头节点和递归思想的重要基础题。

掌握这道题之后,再学习「合并 K 个升序链表」「排序链表」「链表归并排序」等问题,会更加自然。

相关推荐
yuan1999715 分钟前
欧拉梁静力与屈曲计算的 MATLAB 实现(有限差分法 + 解析解)
开发语言·算法·matlab
FL162386312934 分钟前
[cmake]基于C++使用纯opencv部署ppocrv5v6的onnx模型
开发语言·c++·opencv
玖玥拾39 分钟前
C/C++ 数据结构(六)链表迭代器与底层
c语言·数据结构·c++·链表·stl库
郭wes代码1 小时前
Win10 拒绝访问、长期关机自动维护与声音图标灰色故障解决记录
windows·python·开源
牛油果子哥q1 小时前
AVL平衡树与红黑树深度精讲对比,平衡因子、四大旋转原理、着色规则、平衡策略、性能差异与面试手撕全解
数据结构·c++·面试
汉克老师1 小时前
GESP7级C++考试语法知识(二、指数函数(3、综合练习)
c++·算法·数学建模·指数函数·gesp7级·复利
C++ 老炮儿的技术栈1 小时前
Ubuntu root账号自动登陆
linux·运维·服务器·c语言·c++·ubuntu·visual studio
林间码客2 小时前
04 ROC曲线与AUC:从零开始手动计算
大数据·人工智能·算法
Irissgwe2 小时前
map/set/multimap/multiset 的底层逻辑与实现
数据结构·c++·算法·二叉树·stl·c·红黑树
圣保罗的大教堂2 小时前
leetcode 2130. 链表最大孪生和 中等
leetcode