大家好呀~ 今天来刷链表的经典入门题 ——LeetCode 21 题 “合并两个有序链表”。这题是链表操作的基础,学会它能摸清链表的核心玩法,我们一起一步步拆解,保证学得明明白白~
题目
题目是说:给你两个非递减顺序排列的链表list1和list2,请你将它们合并成一个同样按非递减顺序排列的新链表。新链表应该由原来两个链表的节点组成(不能新建节点,要复用原节点)。
举个栗子:
- 输入:list1 = [1,2,4],list2 = [1,3,4] → 输出:[1,1,2,3,4,4]
- 输入:list1 = [],list2 = [] → 输出:[]
- 输入:list1 = [],list2 = [0] → 输出:[0]
提示:两个链表的节点数目范围是 0 到 50,节点值范围是 - 100 到 100,且都按非递减顺序排列~
解题思路:双指针 + 虚拟头节点
链表题最容易踩的坑就是 “头节点处理”—— 比如刚开始不知道新链表的头是谁,每次加节点还要判断是不是第一个。这时候 “虚拟头节点” 就是救星!再配合双指针遍历两个原链表,思路直接拉满:
1.虚拟头节点:创建一个临时的 “假头”(比如叫dummy),它的next才是真正的新链表头。这样不管两个原链表是不是空的,都能统一处理加节点的逻辑。
2.双指针遍历:用p1指向list1的头,p2指向list2的头,再用cur指向dummy(负责构建新链表)。
3.比较与链接:
- 如果p1的值 ≤ p2的值,就把p1接到cur的next,然后p1往后移一步;
- 否则就把p2接到cur的next,然后p2往后移一步;
- 每次接完节点,cur也往后移一步,保持在新链表的末尾。
4.处理剩余节点:当其中一个链表遍历完(p1或p2变成null),把另一个链表剩下的节点直接接到cur的next(因为原链表是有序的,剩下的节点肯定比之前的都大)。
5.返回结果:最后返回dummy.next,就是合并后的新链表头。
代码实现(Java 版)
话不多说,上代码!注释写得超详细,跟着走一遍就懂~
// 先定义链表节点类(力扣里会自带,这里写出来方便理解)
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) {
// 1. 创建虚拟头节点,避免处理头节点为空的麻烦
ListNode dummy = new ListNode(-1);
// cur指针:负责构建新链表,初始指向虚拟头节点
ListNode cur = dummy;
// p1、p2指针:分别遍历list1和list2
ListNode p1 = list1;
ListNode p2 = list2;
// 2. 双指针遍历两个链表,直到其中一个遍历完
while (p1 != null && p2 != null) {
if (p1.val <= p2.val) {
// 把p1接到新链表末尾
cur.next = p1;
// p1往后移一步
p1 = p1.next;
} else {
// 把p2接到新链表末尾
cur.next = p2;
// p2往后移一步
p2 = p2.next;
}
// cur往后移一步,保持在新链表末尾
cur = cur.next;
}
// 3. 处理剩余节点(其中一个链表已经遍历完,剩下的直接接)
cur.next = (p1 != null) ? p1 : p2;
// 4. 返回真正的新链表头(虚拟头节点的next)
return dummy.next;
}
}
代码逐行拆解
1. 链表节点类
力扣中会默认提供ListNode类,这里写出来是为了让新手明白节点的结构:每个节点有val(值)和next(指向下一个节点的指针),三个构造方法分别对应 “空节点”“只有值的节点”“有值且有后继的节点”。
2. 初始化指针
ListNode dummy = new ListNode(-1); // 虚拟头节点,值随便设(不用管)
ListNode cur = dummy; // 构建新链表的指针
ListNode p1 = list1; // 遍历list1的指针
ListNode p2 = list2; // 遍历list2的指针
虚拟头节点的核心作用是 “统一逻辑”—— 比如当两个链表都为空时,dummy.next就是null;当其中一个为空时,直接接另一个链表,不用单独判断 “第一个节点该接谁”。
3. 双指针遍历与链接
while (p1 != null && p2 != null) {
if (p1.val <= p2.val) {
cur.next = p1;
p1 = p1.next;
} else {
cur.next = p2;
p2 = p2.next;
}
cur = cur.next;
}
- 循环条件:只有当p1和p2都不为空时,才需要比较值(如果有一个为空,就不用比了,直接接剩下的)。
- 接节点逻辑:每次选值更小的节点接到cur.next,然后对应的遍历指针(p1或p2)后移,同时cur也后移,保证cur始终在新链表的末尾。
4. 处理剩余节点
cur.next = (p1 != null) ? p1 : p2;
比如list1遍历完了(p1=null),list2还剩[3,4],就把list2剩下的节点直接接到cur.next—— 因为原链表是有序的,剩下的节点肯定比之前的都大,不用再比较。
5. 返回结果
return dummy.next;
虚拟头节点的next才是合并后的新链表头,比如例子中dummy.next就是第一个1,后面跟着1,2,3,4,4。
举个完整例子
用list1 = [1,2,4],list2 = [1,3,4]走一遍:
- 初始化:dummy(-1),cur=dummy,p1=1(list1 头),p2=1(list2 头)。
- 第一次循环:p1.val=1 ≤ p2.val=1 → cur.next=p1(dummy.next=1),p1=2,cur=1。
- 第二次循环:p1.val=2 > p2.val=1 → cur.next=p2(1.next=1),p2=3,cur=1。
- 第三次循环:p1.val=2 ≤ p2.val=3 → cur.next=p1(1.next=2),p1=4,cur=2。
- 第四次循环:p1.val=4 > p2.val=3 → cur.next=p2(2.next=3),p2=4,cur=3。
- 第五次循环:p1.val=4 ≤ p2.val=4 → cur.next=p1(3.next=4),p1=null,cur=4。
- 循环结束(p1=null):cur.next=p2(4.next=4)。
- 最终dummy.next是1→1→2→3→4→4,返回正确!
时间和空间复杂度
- 时间复杂度:O (m + n),其中 m 是 list1 的长度,n 是 list2 的长度。因为每个节点只遍历一次,没有多余操作。
- 空间复杂度:O (1)。只用到了几个指针(dummy、cur、p1、p2),没有额外开辟和链表长度相关的空间,属于 “原地合并”(复用原节点)。
常见坑点提醒
- 忘记移动 cur 指针:如果只接节点但不移动cur,cur会一直停在原地,新链表永远只有一个节点,最后结果肯定错。
- 忽略剩余节点:比如只遍历到其中一个链表为空就结束,没把剩下的节点接上,会导致结果缺失。
- 头节点处理混乱:不用虚拟头节点的话,要单独判断 “list1 为空返回 list2”“list2 为空返回 list1”“两个都不为空选小的当头”,逻辑容易绕错,建议固定用虚拟头节点。
链表知识点回顾
刷完这题,咱趁热打铁回顾下链表的核心知识点,以后遇到链表题就不怕啦~
1. 链表的基本结构
- 节点:链表的基本单位,包含 “值(val)” 和 “后继指针(next)”,next 指向 null 表示链表末尾。
- 类型:
- 单链表:每个节点只有 next 指针(本题就是单链表);
- 双链表:每个节点多一个 prev 指针,可向前遍历;
- 循环链表:末尾节点的 next 指向头节点,可循环遍历。
- 特点:
- 优点:不需要连续内存,插入 / 删除节点(不考虑查找)时间复杂度 O (1);
- 缺点:不能随机访问(要找第 k 个节点必须从头遍历),时间复杂度 O (n)。
2. 链表的核心操作
(1)遍历链表
遍历是链表操作的基础,比如找链表长度、打印链表值:
// 遍历链表并打印值
public void traverse(ListNode head) {
ListNode cur = head;
while (cur != null) {
System.out.print(cur.val + " ");
cur = cur.next; // 指针后移,不能漏!
}
}
(2)插入节点
分 “头插”“中间插”“尾插”,核心是 “先连后断”(避免节点丢失):
- 头插(在 head 前插新节点):
ListNode newNode = new ListNode(0); newNode.next = head; // 新节点先连原头 head = newNode; // 头指针指向新节点
- 尾插(在末尾加节点,需先找到尾节点):
ListNode newNode = new ListNode(5); ListNode cur = head; while (cur.next != null) { // 找尾节点(cur.next为null时cur是尾) cur = cur.next; } cur.next = newNode; // 尾节点的next连新节点
(3)删除节点
同样分 “删头”“删中间”“删尾”,核心是 “找到前驱节点”:
-
删头(删除第一个节点):
if (head != null) {
head = head.next; // 头指针直接后移,原头节点会被GC回收
}
- 删指定值的节点(比如删值为 3 的节点):
ListNode dummy = new ListNode(-1); dummy.next = head; ListNode cur = dummy; while (cur.next != null) { if (cur.next.val == 3) { cur.next = cur.next.next; // 跳过要删的节点(直接连下下个) break; } cur = cur.next; } head = dummy.next;
3. 链表题的常用技巧
- 虚拟头节点:处理头节点为空或需要头插 / 删头的场景(本题核心技巧),统一代码逻辑。
- 双指针:
- 快慢指针:找链表中点、判断环、找环入口;
- 前后指针:遍历 + 修改(比如反转链表);
- 本题的 “双指针遍历两个链表” 也属于此类。
- 递归:某些链表题用递归更简洁(比如本题也能递归实现,不过迭代法更直观)。
总结
合并两个有序链表是链表的入门题,核心是 “虚拟头节点 + 双指针”—— 虚拟头节点解决头节点处理麻烦的问题,双指针实现有序合并。掌握这两个技巧,以后遇到链表的 “合并”“拼接” 类问题都能举一反三。
最后回顾的链表知识点一定要记牢,尤其是遍历、插入、删除的基本操作,这些是所有链表题的基础。赶紧去力扣试试这题,有问题评论区问我呀,下次见~