LeetCode 21. 合并两个有序链表 超详细题解(虚拟头结点 + 逐行代码拆解)

一、题目简介

题目 :将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。输入 :两个有序单链表 list1list2输出:合并完成的有序链表头节点

核心难点

  1. 链表节点只能单向遍历,不能回退,需要持续维护当前拼接位置;
  2. 两个链表长度不一致,循环结束后需要拼接剩余未遍历完的节点;
  3. 边界处理:其中一个链表为空、两个链表都为空;
  4. 避免处理头节点特殊逻辑,写出简洁统一的代码。

最优解法

虚拟头结点(Dummy Node)迭代法

  • 时间复杂度:O(m+n),、 为两个链表长度,每个节点仅遍历一次;
  • 空间复杂度:O(1),仅新增常数个指针,无额外数组 / 容器;
  • 面试核心考点:虚拟头节点、链表指针移动逻辑。

二、算法核心思路(虚拟头结点思想)

1. 为什么要用虚拟头结点?

如果不使用虚拟节点,需要先判断 list1[0]list2[0] 的最小值作为结果链表的头,后续循环逻辑和头节点判断逻辑割裂,代码会出现大量 if 分支,可读性差。

虚拟头结点 dummy 是一个无业务值的空节点,永远作为固定起点:

  1. 用指针 cur 指向 dummy,所有节点统一挂载到 cur.next
  2. 遍历过程中只需要比较当前 list1list2 的节点值,小的节点接在 cur 后方;
  3. 遍历完成后,dummy.next 就是合并链表真正的头节点,直接返回即可,无需额外判断头部。

2. 完整流程步骤

  1. 创建虚拟头节点 dummy,定义工作指针 cur = dummy
  2. 循环:当 list1list2 都还有节点时,比较两者节点值,将更小节点挂载到 cur.next,同时移动对应链表指针和工作指针 cur
  3. 循环结束后,必有一条链表已经遍历完毕,剩余整条链表直接逐个挂载到 cur 后方;
  4. 返回 dummy.next,丢弃无意义的虚拟头节点。

三、你的手写完整代码(逐行拆解)

js

ini 复制代码
/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode} list1
 * @param {ListNode} list2
 * @return {ListNode}
 */
var mergeTwoLists = function(list1, list2) {
    // 1. 创建虚拟头节点
    const dummy=new ListNode();
    // 2. cur 为工作指针,初始指向虚拟头
    let cur=dummy;

    // 3. 双链表都存在节点时,循环比较合并
    while(list1&&list2){
        if(list1.val<list2.val){
            cur.next=list1;
            list1=list1.next
            cur=cur.next;
        }else {
            cur.next=list2;
            list2=list2.next;
            cur=cur.next;
        }
    }

    // 4. 遍历拼接 list1 剩余节点
    while(list1!==null){
        cur.next=list1;
        list1=list1.next;
        cur=cur.next;
    }
    // 5. 遍历拼接 list2 剩余节点
    while(list2!=null) {
        cur.next=list2;
        list2=list2.next;
        cur=cur.next;
    }

    // 6. 返回虚拟节点的下一个,即真实链表头
    return dummy.next;
};

四、逐行深度解析

1. 虚拟头结点初始化

js

ini 复制代码
const dummy=new ListNode();
let cur=dummy;
  • dummy:占位节点,dummy.val 无实际意义,最终不会出现在结果中;
  • cur工作游标指针 ,永远指向合并链表的最后一个节点,所有新节点都挂载在 cur.next
  • 优势:统一所有节点的挂载逻辑,不用单独处理链表头部。

2. 核心合并循环 while(list1&&list2)

循环条件 list1 && list2 含义:只有两个链表当前都存在节点时,才需要比较大小

js

ini 复制代码
if(list1.val<list2.val){
    cur.next=list1; // 将更小的list1节点接到cur后方
    list1=list1.next; // list1指针后移,准备下一轮比较
    cur=cur.next; // 工作指针后移,指向新的链表末尾
}else {
    cur.next=list2;
    list2=list2.next;
    cur=cur.next;
}

三步固定操作:

  1. 把更小节点赋值给 cur.next,完成挂载;
  2. 原链表指针 list1/list2 向后走一位;
  3. 工作指针 cur 同步后移,保证 cur 永远在链表尾部。

3. 拼接剩余节点的两段 while 循环

js

vbscript 复制代码
while(list1!==null){cur.next=list1;list1=list1.next;cur=cur.next;}
while(list2!=null) {cur.next=list2;list2=list2.next;cur=cur.next;}
  • 当第一层循环退出时,list1list2 必有一个为 null
  • 剩余链表是完整有序链,代码采用逐个遍历挂载的写法,逻辑直观,新手更容易理解链表指针移动;
  • 补充简化写法:链表节点自带完整后续链,可直接一行挂载,无需循环:cur.next = list1 || list2,性能一致,代码更简短。

4. 返回结果 return dummy.next

虚拟头节点只是临时占位,dummy.next 才是合并后链表第一个有业务值的节点。


五、两种剩余节点写法对比

写法 1:你的逐一遍历写法(新手友好,本文代码)

js

ini 复制代码
while(list1!==null){
    cur.next=list1;
    list1=list1.next;
    cur=cur.next;
}
while(list2!=null) {
    cur.next=list2;
    list2=list2.next;
    cur=cur.next;
}

✅ 优点:完全展示指针移动过程,适合初学链表、理解单链结构;❌ 缺点:代码行数更多,存在重复循环逻辑。

写法 2:一行直接挂载(面试精简标准写法)

js

ini 复制代码
cur.next = list1 ? list1 : list2;

✅ 优点:一行代码搞定,无循环,简洁高效,工业代码通用;💡 原理:链表节点的 next 自带整条后续链条,只需要挂载头部节点,后面所有节点自动跟随。

精简版完整代码(面试推荐)

js

ini 复制代码
var mergeTwoLists = function(list1, list2) {
    const dummy = new ListNode();
    let cur = dummy;
    while(list1 && list2) {
        if(list1.val < list2.val) {
            cur.next = list1;
            list1 = list1.next;
        } else {
            cur.next = list2;
            list2 = list2.next;
        }
        cur = cur.next;
    }
    // 直接挂载剩余整条链表
    cur.next = list1 || list2;
    return dummy.next;
};

六、你的代码优缺点分析

✅ 代码优点

  1. 逻辑分层清晰:主循环合并双链表 + 两次循环补全剩余节点,流程一目了然;
  2. 指针操作规范 :每次挂载节点后同步移动 list 指针和工作指针 cur,不会出现断链、死循环;
  3. 虚拟头节点使用标准:完美规避头部节点特殊判断,是链表题型通用模板;
  4. 无空指针风险 :所有循环都做非空判断,不会读取 null.valnull.next

⚠️ 可优化点

  1. 剩余链表无需循环遍历,直接一行挂载即可精简代码;
  2. 分支内 cur=cur.next 重复书写,可提取到 if-else 外部,减少冗余代码:

js

ini 复制代码
while(list1&&list2){
    if(list1.val<list2.val){
        cur.next=list1;
        list1=list1.next
    }else {
        cur.next=list2;
        list2=list2.next;
    }
    cur=cur.next; // 提取到分支外,只写一次
}

七、面试高频问答总结

Q1:虚拟头结点的作用是什么?不使用会有什么问题?

A:统一链表头部、中部、尾部的节点挂载逻辑。不使用虚拟节点时,需要额外写逻辑判断两个链表哪个头节点更小作为结果头,代码分支冗余,边界容易出错。

Q2:时间复杂度为什么是 O (m+n)?

A:两个链表每个节点只会被读取、挂载一次,没有重复遍历;虚拟节点仅占用常数空间,空间复杂度 O (1)。

Q3:递归解法和迭代(dummy)解法怎么选择?

A:递归代码简短,但存在递归调用栈,链表过长会栈溢出;面试优先写迭代虚拟头结点解法,稳定性更好,面试官更认可。

Q4:循环结束后为什么不用拼接两条链表?

A:while(list1 && list2) 循环终止条件是其中一条链表遍历完毕,只存在一条链表有剩余节点,二选一挂载即可。


八、刷题总结

合并两个有序链表是单链表入门必刷模板题,核心掌握两大思想:

  1. 虚拟头结点 Dummy:解决链表头部特殊处理问题,适用于绝大多数链表增删、合并题型;
  2. 双指针同步遍历:两个有序结构归并,是归并排序的基础思想;
  3. 剩余链直接挂载:链表是链式存储,无需逐个节点复制,直接挂载整条剩余链。

你手写的遍历剩余节点写法非常适合新手夯实指针基础,熟练后可改用一行挂载精简代码,两种写法都能通过全部测试用例。

相关推荐
oier_Asad.Chen1 小时前
【OI学习笔记】Floyed-Warshall算法解决传递闭包问题
c++·笔记·学习·算法·图论·最短路·传递闭包
国科安芯1 小时前
卫星星务计算机数据管理中SEU容错机制的技术实现研究——以AS32S601存储器ECC架构为例
网络·单片机·算法·fpga开发·架构·云计算·risc-v
孙启超1 小时前
【AI应用开发】Agent 无限 loop(反复调用同一个工具)如何规避?
人工智能·python·算法·llm·agent·loop·ai应用开发
小月土星1 小时前
LeetCode 142. 环形链表 II 超详细题解(快慢指针原理+逐行代码拆解)
算法
dok122 小时前
Johannes 《Linux内核模块与设备驱动开发:编写Linux驱动程序》 (11)kmalloc 动态内存分配和struct file中的私有数据
linux·驱动开发·算法
hansang_IR2 小时前
【题解】[COCI 2024/2025 #2] 流明 / Blistavost
c++·算法·动态规划·dp
不正经学生2 小时前
C语言指针进阶:const 和野指针——给指针加锁,向野指针宣战
c语言·开发语言·c++·算法·c#
程序喵大人2 小时前
【C++进阶】STL算法与函数对象 - 01 让算法去处理一段迭代器范围
开发语言·c++·算法·函数对象
Keven_112 小时前
算法札记:二叉树前序、中序、后序遍历及对应序列重要性质
数据结构·算法·深度优先