题目1:合并两个有序链表(迭代版)
1、题目核心定义
- 问题描述:将两个升序排列的单链表合并为一个新的升序单链表;
- 输入:两个升序单链表的头节点
l1、l2(可能为空); - 输出:合并后的升序单链表的头节点。
2、核心逻辑(底层原理)
核心思想:双指针 + 虚拟头节点,分两步完成合并:
- 双指针遍历:用游标指针
prev构建新链表,逐个比较 L1、L2的当前节点,将更小的节点接入新链表; - 处理剩余节点:若其中一个链表先遍历完,直接将另一个链表的剩余节点接在新链表末尾(原链表是升序,剩余节点必大于已合并部分)。
关键技巧:虚拟头节点(prehead)------ 避免单独处理 "新链表头节点" 的边界问题,简化代码逻辑。
3、标准模板代码(迭代版)
/**
* 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 l1, ListNode l2) {
// 虚拟头节点:简化头节点的边界处理
ListNode prehead = new ListNode(-1);
// 游标指针:用于构建新链表
ListNode prev = prehead;
// 第一步:双指针遍历,逐个接入更小的节点
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
prev.next = l1; // 接入l1的当前节点
l1 = l1.next; // l1指针后移
} else {
prev.next = l2; // 接入l2的当前节点
l2 = l2.next; // l2指针后移
}
prev = prev.next; // 游标指针后移
}
// 第二步:处理剩余节点(最多一个链表有剩余)
prev.next = (l1 == null)? l2 : l1;
// 返回虚拟头节点的下一个节点(新链表的实际头)
return prehead.next;
}
}
4、代码关键细节
4.1 虚拟头节点(prehead)的作用
- 解决 "空链表合并" 的边界问题:比如 L
1=null时,直接返回L2(无需额外判断); - 统一新链表的构建逻辑:所有节点都通过
prev.next接入,无需单独处理 "第一个节点"。
✅ 关键:虚拟头节点不是 null (如果是 null,就没法通过 prev.next 接第一个节点了),必须是一个实实在在的节点,只是值无意义。
4.2 双指针遍历的核心规则
- 循环条件
l1 != null && l2 != null:仅处理两个链表都有节点的情况; - 比较逻辑
l1.val <= l2.val:保证新链表的升序性(相等时优先接l1,不影响结果); - 指针移动:接入节点后,对应链表的指针和游标指针都要后移,保证遍历不遗漏。
4.3 剩余节点的处理
- 逻辑:
prev.next = l1 == null ? l2 : l1------ 利用三元运算符,一行代码处理 "剩余节点接入"; - 正确性:原链表是升序,剩余节点的所有值都大于已合并部分,直接接入不破坏升序。
5、复杂度分析
| 维度 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(n+m) | n、m 是两个链表的长度,需遍历所有节点 |
| 空间复杂度 | O(1) | 仅用虚拟头节点和游标指针,无额外空间 |
6、典型场景验证
以 l1=1→2→8→9、l2=1→3→5→7 为例:
- 双指针遍历阶段:依次接入
1(l1)、1(l2)、2(l1)、3(l2)、5(l2)、7(l2); - 剩余节点阶段:
l1剩余8→9,接入新链表末尾; - 最终结果:
1→1→2→3→5→7→8→9,严格升序。
7、面试答题话术
"我采用双指针 + 虚拟头节点的迭代法合并两个有序链表:
- 用虚拟头节点简化头节点的边界处理,用游标指针构建新链表;
- 双指针遍历两个链表,逐个将更小的节点接入新链表;
- 遍历结束后,将剩余未处理的链表直接接在新链表末尾;
- 该方法时间复杂度 O (n+m)、空间复杂度 O (1),无递归栈溢出风险,是更实用的生产级写法。"
总结
这个迭代版本的优势非常明显:逻辑清晰、无栈溢出、空间效率高,是合并有序链表的 "最优实践";核心细节(虚拟头节点、剩余节点处理)是链表操作的通用技巧,掌握后可以解决大部分链表拼接类问题。
✅ 虚拟头节点是「真实存在的节点」(有自己的 val,比如 - 1),也确实是新链表物理上的第一个节点;
✅ 但我们只把它当工具 ,最终返回时直接跳过它(取prehead.next),让它的下一个节点成为新链表 "逻辑上的头节点",相当于 "用完就扔"。
物理链表(真实存在):-1 → 1 → 1 → 2
逻辑链表(我们要的结果):1 → 1 → 2
- 虚拟头节点:值任意(-1 只是习惯)、非 null,作用是简化头节点边界处理;
- 游标操作顺序:先
prev.next = 节点接元素,再prev = prev.next移游标; - 剩余节点:利用三元运算符一步接入,依赖 "原链表升序" 的前提;
- 返回值:必须返回
prehead.next(跳过虚拟头节点); - 游标节点(prev):核心作用是 "逐个拼接节点,构建新链表"。
题目 2:两数相加(迭代版)
1、题目核心定义
问题描述:将两个逆序存储数字的单链表,按位相加后返回逆序存储结果的单链表(链表逆序存储:个位在表头,百位在表尾);输入:两个逆序存储数字的单链表的头节点 l1、l2(可能为空,节点值为 0-9 的整数);输出:逆序存储相加结果的单链表的头节点。
2、核心逻辑(底层原理)
核心思想:双指针 + 虚拟头节点 + 进位变量,分两步完成相加
疑惑 1:为啥不用反转链表?直接算结果一样吗?
解答:逆序链表天生契合竖式加法规则(表头就是个位,不用从尾开始算),直接遍历相加和手工列竖式顺序完全一致;反转链表反而要多做两次反转操作,增加时间复杂度,属于画蛇添足。
- 双指针遍历:用游标指针 cur 构建新链表,逐个取 L1、L2 的当前节点值(空节点补 0),计算「当前位和 + 进位」,将个位值接入新链表,更新进位;
- 处理尾进位:若遍历完所有节点后仍有进位(carry=1),需单独将进位节点接在新链表末尾(唯一区别于合并链表的核心逻辑)。
关键技巧:
- 虚拟头节点(dummy)------ 避免单独处理 "新链表头节点" 的边界问题,简化代码逻辑;
- 进位变量(carry)------ 模拟手工竖式加法的 "满 10 进 1" 规则,处理跨位进位。
3、标准模板代码(迭代版)
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 addTwoNumbers(ListNode l1, ListNode l2) {
// 虚拟头节点:简化头节点的边界处理
ListNode dummy = new ListNode(0);
// 游标指针:用于构建新链表
ListNode cur = dummy;
// 进位变量:模拟竖式加法的"满10进1"
int carry = 0;
// 第一步:双指针遍历,逐个计算并接入当前位结果
// 疑惑2:为啥循环条件要加 carry != 0?
// 解答:为了处理"所有位数加完但还有进位"的边界场景(比如999+999=1998),少了这个条件会漏掉最后一位进位
while (l1 != null || l2 != null || carry != 0) {
// 疑惑3:这行代码啥意思?
// 解答:给短链表补0,避免位数不够加不了(比如99+999,加第三位时99没数了就补0)
int num1 = l1 != null ? l1.val : 0;
int num2 = l2 != null ? l2.val : 0;
// 疑惑4:这段数学逻辑为啥这么算?
// 解答:完全模拟手工竖式加法:sum=当前位和+上一位进位;carry=sum/10取进位(0或1);curVal=sum%10取个位
int sum = num1 + num2 + carry;
carry = sum / 10; // 更新进位(0或1)
int curVal = sum % 10; // 当前位结果(取个位)
cur.next = new ListNode(curVal); // 接入当前位结果节点
cur = cur.next; // 游标指针后移
// 思考:l1/l2遍历完后,这里就不会执行了,但进位还在,所以必须靠carry != 0兜底
if (l1 != null) l1 = l1.next;
if (l2 != null) l2 = l2.next;
}
// 第二步:尾进位已在循环中处理(无需额外代码)
// 思考:这里和合并链表的"处理剩余节点"不同,合并是接剩余链表,这里是靠循环条件处理尾进位
// 返回虚拟头节点的下一个节点(新链表的实际头)
return dummy.next;
}
}
4、代码关键细节
4.1 虚拟头节点(dummy)的作用
解决 "空链表相加" 的边界问题:比如 L1=null、L2=[0] 时,直接通过循环逻辑返回 [0](无需额外判断);统一新链表的构建逻辑:所有结果节点都通过 cur.next 接入,无需单独处理 "第一个节点"。✅ 关键:虚拟头节点不是 null,必须是一个实实在在的节点(值设 0 仅为占位,无业务意义),否则无法通过 cur.next 接第一个结果节点。
4.2 双指针遍历的核心规则
循环条件 l1 != null || l2 != null || carry != 0:
l1 != null || l2 != null:处理两个链表的所有节点(长短链表自动补 0);carry != 0:兜底 "所有节点遍历完但仍有进位" 的边界场景(如 999+999);数值处理逻辑:- 空节点补 0:
num1 = l1 != null ? l1.val : 0,保证位数不同时能正常相加; - 求和规则:
sum = num1 + num2 + carry,必带上一轮进位,符合竖式加法规则; - 结果拆分:
carry = sum / 10(取进位)、curVal = sum % 10(取个位),仅处理 0-9 的节点值;指针移动:接入结果节点后,游标指针必后移,原链表指针仅非空时后移,保证遍历不遗漏。
**核心总结:**首次运算 carry=0 → sum = 两数之和 → carry=sum/10 判断是否进位 → curVal=sum%10 是当前节点值 → 下次运算 sum 会带上进位,完美契合手工加法。
4.3 尾进位的处理
逻辑:尾进位已融合在循环条件中(carry != 0),无需额外代码;正确性:所有节点遍历完后,若 carry=1,会触发最后一次循环,生成值为 1 的节点并接入,符合 "满 10 进 1" 规则。
**思考:**百位算完后 l1/l2 都变成 null,不会再后移,但进位 1 还在,此时靠 carry != 0 进入循环,补全最后一个节点。
| 对比维度 | 合并两个有序链表 | 两数相加 |
|---|---|---|
| 核心框架 | 虚拟头节点 + 双指针遍历 | 虚拟头节点 + 双指针遍历(完全一致) |
| 循环条件 | l1 != null && l2 != null | l1 != null || l2 != null || carry!=0 |
| 节点处理逻辑 | 选更小的节点接入 | 算和 + 进位,取个位接入 |
| 收尾逻辑 | 接剩余链表 | 处理尾进位(循环内完成) |
| 返回值 | prehead.next | dummy.next(完全一致) |
5、复杂度分析
| 维度 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(n+m) | n、m 是两个链表的长度,最多遍历 max(n,m)+1 次(含尾进位) |
| 空间复杂度 | O(1) | 仅用虚拟头节点、游标指针、进位变量,结果链表是输出要求不计入 |
6、典型场景验证
以 l1=[9,9,9,9,9,9,9](9999999)、l2=[9,9,9,9](9999)为例:
你的疑惑验证: 这个例子刚好触发尾进位,完美验证 carry != 0 的作用双指针遍历阶段:
- 个位:9+9+0=18 → 接入 8,carry=1;
- 十位:9+9+1=19 → 接入 9,carry=1;
- 百位:9+9+1=19 → 接入 9,carry=1;
- 千位:9+9+1=19 → 接入 9,carry=1;
- 万位:9+0+1=10 → 接入 0,carry=1;
- 十万位:9+0+1=10 → 接入 0,carry=1;
- 百万位:9+0+1=10 → 接入 0,carry=1;尾进位处理阶段:
- 所有节点遍历完,carry=1 → 触发循环,sum=0+0+1=1 → 接入 1,carry=0;最终结果:[8,9,9,9,0,0,0,1](对应 10009998),完全符合加法规则。
7、面试答题话术
"我采用双指针 + 虚拟头节点 + 进位变量的迭代法实现两数相加:
- 利用链表逆序存储的特性,无需反转链表,直接从表头(个位)开始相加,契合竖式加法规则;
- 用虚拟头节点简化头节点边界处理,用游标指针构建结果链表,用进位变量模拟'满 10 进 1';
- 循环条件加入
carry != 0兜底尾进位场景,避免漏掉最后一位; - 空节点自动补 0,保证长短链表都能正常相加;该方法时间复杂度 O (n+m)、空间复杂度 O (1),无递归栈溢出风险,是处理链表加法的最优实践。"
总结
这个迭代版本的优势:逻辑清晰(完全贴合手工加法规则)、无栈溢出、空间效率高,是两数相加的 "最优实践";核心细节(虚拟头节点、进位处理、空节点补 0)是链表数值运算的通用技巧,掌握后可解决大部分链表加法 / 减法类问题。
✅ 你的所有思考闭环:
- 不用反转链表 → 逆序契合加法规则;
- 补 0 代码 → 给短链表补位;
- 数学逻辑 → 模拟竖式加法;
- carry != 0 → 处理尾进位;
- 和合并链表的关联 → 同框架,不同节点逻辑。
题目3:删除链表的倒数第 n 个节点
1、题目核心定义
问题描述:给定一个单链表的头节点head,删除链表的倒数第 n 个节点 ,并返回修改后的链表头节点。输入:单链表头节点head(节点值为整数,可能为空)、整数n(保证 1≤n≤链表长度);输出:删除指定节点后的链表头节点。
2、核心逻辑(底层原理)
核心思想:双指针 + 虚拟头节点,分两步完成删除
- 疑惑 1:为啥不用 "先遍历数长度,再遍历删节点"?解答:双指针法只需遍历一次链表(时间复杂度更优),避免两次遍历的冗余操作;虚拟头节点统一 "删头节点" 和 "删中间节点" 的逻辑。
- 双指针拉开间距:快指针先出发走
n步,与慢指针形成n个节点的固定间距; - 同步遍历定位前驱:快慢指针同速遍历,快指针到链表末尾时,慢指针停在 "倒数第 n 个节点的前驱节点";
- 删除目标节点:修改慢指针的
next指向,跳过目标节点。
关键技巧:
- 虚拟头节点(dummy)------ 避免单独处理 "删除头节点" 的边界问题,简化代码逻辑;
- 双指针固定间距 ------ 一次遍历定位目标节点的前驱,保证时间效率。
3、标准模板代码
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 removeNthFromEnd(ListNode head, int n) {
// 虚拟头节点:统一头节点与中间节点的删除逻辑
ListNode dummy = new ListNode(0, head);
// 双指针初始化:均从虚拟头节点出发
ListNode fast = dummy;
ListNode slow = dummy;
// 第一步:快指针先走n步,拉开n个节点的间距
for (int i = 0; i < n; i++) {
fast = fast.next;
}
// 第二步:快慢指针同步遍历,直到快指针到末尾
while (fast.next != null) {
fast = fast.next;
slow = slow.next;
}
// 第三步:删除目标节点(慢指针的next即为倒数第n个节点)
slow.next = slow.next.next;
// 返回新链表的实际头节点
return dummy.next;
}
}
4、代码关键细节
4.1 虚拟头节点(dummy)的作用
解决 "删除头节点" 的边界问题:比如链表为[1]、删除倒数第 1 个节点时,无需单独修改head指针;统一链表的修改逻辑:所有节点的删除都通过 "修改前驱节点的 next" 完成,无需区分头 / 中间节点。✅ 关键:虚拟头节点是一个实际节点(值设为 0 仅占位),保证slow.next能正常操作第一个节点。
4.2 双指针遍历的核心规则
- 快指针先走
n步:制造 "n 个节点的固定间距",为后续定位做准备; - 循环条件
fast.next != null:快指针停在 "最后一个有效节点" 时,慢指针恰好停在 "目标节点的前驱"(避免多走一步); - 指针移动逻辑:快指针先走
n步后,快慢指针同速移动,保证间距始终为n。
4.3 目标节点的定位逻辑
数学关系:慢指针位置 = 链表长度 - n → 慢指针的next即为 "倒数第 n 个节点"。例:链表[1→2→3→4→5]、n=2:快指针先走 2 步→停在2;同步遍历后快指针停在5,慢指针停在3;3的next是4(倒数第 2 个节点),直接删除。
5、复杂度分析
| 维度 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(L) | L 为链表长度,仅遍历一次 |
| 空间复杂度 | O(1) | 仅用虚拟头节点和双指针,无额外空间 |
6、典型场景验证
以链表[1→2→3→4→5]、删除倒数第2个节点为例:
- 快指针先走 2 步:从
dummy→1→2; - 同步遍历:快指针→
3→4→5(fast.next=null停止),慢指针→1→2→3; - 删除节点:
3的next指向5,节点4被删除; - 结果:
[1→2→3→5],符合预期。
7、面试答题话术
我采用双指针 + 虚拟头节点的方法实现 "删除链表倒数第 n 个节点":
- 利用虚拟头节点简化 "删除头节点" 的边界处理,避免单独分支判断;
- 让快指针先出发走
n步,与慢指针形成固定间距; - 快慢指针同速遍历,快指针到末尾时,慢指针恰好定位到目标节点的前驱;
- 修改慢指针的
next完成删除,该方法时间复杂度 O (L)、空间复杂度 O (1),是最优实践。
总结
这个模板的优势:逻辑清晰(基于 "固定间距定位")、无冗余操作、覆盖所有边界场景;核心细节(虚拟头节点、双指针间距)是链表 "定位 + 修改" 类题目的通用技巧,掌握后可解决 "找链表中点""删除指定节点" 等问题。
题目4:两两交换链表中的节点
1、题目核心定义
问题描述:给定一个单链表的头节点head,两两交换 链表中相邻的节点,返回交换后的链表头节点(要求:不能修改节点的值,只能修改指针指向;不新建链表,仅原地操作)。输入:单链表头节点head(节点值为整数,可能为空 / 只有 1 个节点);输出:交换后的链表头节点。
示例:
- 输入:
1→2→3→4→ 输出:2→1→4→3 - 输入:
1→2→3→ 输出:2→1→3 - 输入:
1/null→ 输出:1/null
2、核心逻辑(底层原理)
核心思想:虚拟头节点 + 锚点指针 + 局部指针交换,把 "整体交换" 拆成 "逐对交换"
- 锚点定位:用
cur指针作为 "锚点",始终指向 "待交换两个节点的前驱",保证交换时不丢失链表上下文; - 局部交换:对每一对节点,通过 3 步指针操作完成交换(仅修改指向,不新建节点);
- 迭代推进:交换完一对后,把
cur移到这对节点的末尾,继续处理下一对,直到无完整节点对可交换。
关键技巧:
- 虚拟头节点(dummy)------ 统一 "交换头节点" 和 "交换中间节点" 的逻辑,避免单独处理边界;
- 临时指针(post)------ 保存待交换的第一个节点,避免交换时指针丢失,导致链表断裂;
- 逐对处理 ------ 把复杂问题拆分为 "重复处理两个节点",降低逻辑复杂度。
3、标准模板代码
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 swapPairs(ListNode head) {
// 1. 虚拟头节点:统一交换逻辑,避免单独处理头节点
ListNode dummy = new ListNode(0, head);
// 2. 锚点指针:始终指向待交换节点对的前驱,初始在dummy
ListNode cur = dummy;
// 3. 临时指针:保存待交换的第一个节点,避免指针丢失
ListNode post = null;
// 4. 循环条件:确保有至少两个节点可交换(避免空指针)
while (cur.next != null && cur.next.next != null) {
// 5. 保存待交换的第一个节点
post = cur.next;
// 6. 核心三步:交换当前两个节点(仅修改指针指向)
cur.next = post.next; // 步骤1:前驱指向第二个节点
post.next = post.next.next; // 步骤2:第一个节点指向第二个节点的后继
cur.next.next = post; // 步骤3:第二个节点指向第一个节点
// 7. 锚点后移:处理下一对节点
cur = post;
}
// 8. 返回交换后的链表头(跳过虚拟头节点)
return dummy.next;
}
}
4、代码关键细节
4.1 虚拟头节点(dummy)的作用
- 解决 "交换头节点" 的边界问题:比如交换
1→2时,无需单独修改head指针,直接通过dummy.next指向 2 即可; - 保证链表上下文连续:无论交换哪一对节点,都能通过
cur(锚点)关联到前序链表,避免链表断裂; - 最终返回
dummy.next:直接得到交换后的新链表头,无需额外判断。
✅ 关键:dummy 仅为 "占位锚点",不参与实际业务逻辑,值设为 0 仅为规范。
4.2 核心三步交换的逻辑拆解(以cur→a→b→c为例)
| 操作步骤 | 代码 | 作用(对应a和b交换) |
|---|---|---|
| 步骤 1 | cur.next = post.next | cur→b(把 b 提到 a 前面) |
| 步骤 2 | post.next = post.next.next | a→c(a 指向 b 的原后继,保留后续链表) |
| 步骤 3 | cur.next.next = post | b→a(b 指向 a,完成 a 和 b 的交换) |
交换后结构:cur→b→a→c,链表上下文完全保留。
4.3 循环条件的必要性
cur.next != null && cur.next.next != null:
- 必须同时满足 "有第一个节点" 和 "有第二个节点",才执行交换;
- 若只判断
cur.next != null,当只剩 1 个节点时,cur.next.next会触发空指针异常; - 边界适配:自动处理 "节点数为奇数" 的情况(最后一个节点不交换)。
4.4 锚点指针cur的移动逻辑
cur = post:
post是交换后的第二个节点(比如 a),把cur移到这里,下一轮循环就能以 a 为前驱,处理下一对节点(c 和 d);- 保证每一轮循环的 "锚点位置" 一致(待交换节点对的前驱),逻辑统一。
5、复杂度分析
| 维度 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(n) | n 为链表长度,仅遍历一次 |
| 空间复杂度 | O(1) | 仅用虚拟头节点和 3 个指针,无额外空间 |
6、典型场景验证(输入1→2→3→4)
- 初始状态:
dummy→1→2→3→4,cur=dummy; - 第一次交换:
post=1→cur.next=2→1.next=3→2.next=1→cur=1;- 链表变为:
dummy→2→1→3→4;
- 第二次交换:
post=3→cur.next=4→3.next=null→4.next=3→cur=3;- 链表变为:
dummy→2→1→4→3;
- 循环终止(
cur.next=null),返回dummy.next=2,结果符合预期。
7、面试答题话术
我采用虚拟头节点 + 锚点指针的方法实现 "两两交换链表节点":
- 用虚拟头节点统一交换逻辑,避免单独处理头节点的边界问题;
- 以
cur为锚点指针,始终指向待交换节点对的前驱,保证链表上下文连续; - 对每一对节点,通过 3 步指针操作完成原地交换(仅修改指向,不新建节点);
- 迭代推进锚点指针,直到无完整节点对可交换,该方法时间复杂度 O (n)、空间复杂度 O (1),是最优实践。
总结
- 核心逻辑:把 "整体交换" 拆分为 "逐对交换",每次仅处理两个节点,通过 3 步指针操作完成原地交换;
- 关键技巧:虚拟头节点简化边界、临时指针避免链表断裂、锚点指针保证迭代推进;
- 空间优势:无新建链表 / 节点,仅用常数级额外空间,是链表交换类题目的通用最优解法。