
作者:逆境不可逃
技术永无止境
希望我的内容可以帮助到你!!!!
这篇文章整理两道很典型的指针题:
- 88. 合并两个有序数组:重点是选择正确的写入方向,避免覆盖尚未处理的数据。
- 92. 反转链表 II:重点是保存边界,在局部反转后重新连接链表。
两道题看起来一个操作数组、一个操作链表,但底层思路很接近:
修改原数据结构之前,先保证未处理的数据不会丢失,再处理边界连接。
一、88. 合并两个有序数组

题目理解
给定两个非递减数组:
nums1 = [1,2,3,0,0,0]
m = 3
nums2 = [2,5,6]
n = 3
nums1 的前 m 个位置存放有效元素:
[1,2,3]
后面 n 个位置是专门为合并结果预留的空间:
[0,0,0]
要求把 nums2 合并进 nums1,最终得到:
[1,2,2,3,5,6]
结果不需要返回,而是直接写入 nums1。
为什么不能直接从前往后写
如果从数组开头开始比较,很容易覆盖 nums1 中还没有处理的元素。
例如:
nums1 = [1,2,3,0,0,0]
nums2 = [2,5,6]
第一个位置放 1 没有问题,第二个位置应该放 2。但此时 nums1 里原来的 2 还需要参与后续比较,如果直接覆盖,就需要额外保存或移动剩余元素。
从前往后合并通常需要:
- 新建一个临时数组;
- 或者不断向后移动
nums1的元素。
这两种做法都没有利用好 nums1 后面已经预留的空间。
从后往前合并
两个数组已经有序,所以它们的最大值一定出现在各自有效区域的末尾。
我们可以从后往前比较,把较大的数字放到 nums1 最后一个空位。
定义三个指针:
int p1 = m - 1; // nums1 有效区域的最后一个元素
int p2 = n - 1; // nums2 的最后一个元素
int p = m + n - 1; // nums1 最终结果的最后一个位置
初始状态:
nums1 = [1,2,3,0,0,0]
↑ ↑
p1 p
nums2 = [2,5,6]
↑
p2
比较:
nums1[p1] = 3
nums2[p2] = 6
6 更大,所以放到 nums1[p]:
[1,2,3,0,0,6]
然后:
p2--;
p--;
继续比较 3 和 5:
[1,2,3,0,5,6]
再比较 3 和 2:
[1,2,3,3,5,6]
接下来继续从后往前填充,最终得到:
[1,2,2,3,5,6]
整个过程中,我们只会写入当前未使用的尾部空间,不会覆盖 nums1 中尚未比较的有效元素。
完整代码
class Solution {
public void merge(int[] nums1, int m, int[] nums2, int n) {
int p1 = m - 1;
int p2 = n - 1;
int p = m + n - 1;
while (p2 >= 0) {
if (p1 >= 0 && nums1[p1] > nums2[p2]) {
nums1[p--] = nums1[p1--];
} else {
nums1[p--] = nums2[p2--];
}
}
}
}
为什么循环条件只判断 p2 >= 0
这段代码最值得理解的是:
while (p2 >= 0)
为什么不用:
while (p1 >= 0 || p2 >= 0)
因为两种数组处理完后的情况不同。
nums2 先处理完
如果:
p2 < 0
说明 nums2 中的元素已经全部放入 nums1。
此时,即使 nums1 前面还有元素,它们也已经位于正确位置,不需要再次复制。
例如:
nums1 = [1,2,3,0,0,0]
nums2 = [4,5,6]
从后往前填充完 4、5、6 后:
[1,2,3,4,5,6]
nums1 中剩余的 1、2、3 原本就在正确位置。
nums1 先处理完
如果:
p1 < 0
nums2 中可能还有元素没有复制。
例如:
nums1 = [4,5,6,0,0,0]
nums2 = [1,2,3]
当 nums1 的三个有效元素处理完后,nums2 中的 1、2、3 仍然需要写入数组前面。
判断条件:
if (p1 >= 0 && nums1[p1] > nums2[p2])
当 p1 < 0 时,p1 >= 0 为假。由于 && 具有短路特性,代码不会访问 nums1[p1],而是直接进入 else:
nums1[p--] = nums2[p2--];
这样就能把 nums2 中剩余元素全部复制到 nums1。
边界情况
nums2 为空
nums1 = [1]
m = 1
nums2 = []
n = 0
初始化后:
p2 = -1
循环不会执行,nums1 保持不变。
nums1 没有有效元素
nums1 = [0]
m = 0
nums2 = [1]
n = 1
初始化后:
p1 = -1
p2 = 0
p = 0
判断:
p1 >= 0
结果为假,所以直接复制 nums2[0]:
nums1 = [1]
复杂度分析
三个指针只会向前移动,不会回退。
时间复杂度:O(m + n)
空间复杂度:O(1)
算法直接在 nums1 上完成合并,没有使用额外数组,也达到了题目的进阶要求。
一句话记忆
三个指针都从后往前移动,比较
p1和p2,谁大谁放到p;只要nums2还没有处理完,就继续合并。
二、92. 反转链表 II

题目理解
给定一个单链表,以及两个位置 left 和 right,只反转这段区间,其他部分保持不变。
例如:
1 -> 2 -> 3 -> 4 -> 5
↑ ↑
left=2 right=4
需要反转:
2 -> 3 -> 4
得到:
4 -> 3 -> 2
最终链表是:
1 -> 4 -> 3 -> 2 -> 5
这道题需要解决两个问题:
- 如何反转
[left, right]这段链表? - 反转后怎样把它重新接回原链表?
用 dummy 统一处理头节点
先创建一个虚拟头节点:
ListNode dummy = new ListNode(0, head);
链表变成:
dummy -> 1 -> 2 -> 3 -> 4 -> 5
为什么需要 dummy?
如果:
left = 1
那么反转区间从原头节点开始。原头节点反转后会发生变化,直接处理容易增加特殊判断。
加入 dummy 后,无论 left 是否等于 1,反转区间前面都有一个节点:
dummy -> 原头节点
这样所有情况都可以使用同一套连接逻辑。
找到反转区间的前一个节点
定义:
ListNode p0 = dummy;
然后向后移动 left - 1 次:
for (int i = 0; i < left - 1; i++) {
p0 = p0.next;
}
以:
1 -> 2 -> 3 -> 4 -> 5
left = 2
为例,循环结束后:
p0
↓
1 -> 2 -> 3 -> 4 -> 5
↑
left
p0 始终指向反转区间的前一个节点。
如果 left = 1,循环一次也不会执行:
p0 = dummy
仍然符合要求。
反转 [left, right]
初始化:
ListNode pre = null;
ListNode cur = p0.next;
此时:
pre = null
cur = left 对应的节点
反转区间长度为:
right - left + 1
所以执行相同次数的普通链表反转:
for (int i = 0; i < right - left + 1; i++) {
ListNode next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
每一轮包含四步:
1. 保存 cur.next
2. 修改 cur.next 的方向
3. pre 前进
4. cur 前进
为什么要先保存:
ListNode next = cur.next;
因为执行:
cur.next = pre;
后,原来的后继节点会丢失。如果没有提前保存,就无法继续向后遍历。
反转过程演示
需要反转:
2 -> 3 -> 4
初始:
pre = null
cur = 2
第一轮
保存:
next = 3
反转:
2 -> null
移动指针:
pre = 2
cur = 3
第二轮
保存:
next = 4
反转:
3 -> 2 -> null
移动:
pre = 3
cur = 4
第三轮
保存:
next = 5
反转:
4 -> 3 -> 2 -> null
移动:
pre = 4
cur = 5
反转结束后的状态是:
4 -> 3 -> 2 5
↑ ↑ ↑
pre 原left cur
此时:
pre指向反转后的头,也就是原来的right。cur指向反转区间后面的第一个节点。- 原来的
left已经变成反转区间的尾节点。 p0仍然指向反转区间前面的节点。
重新连接链表
反转后还缺少两条连接:
前半段 -> 反转后的头
反转后的尾 -> 后半段
对应代码是:
p0.next.next = cur;
p0.next = pre;
先连接后半段
反转前:
p0
↓
1 -> 2 -> 3 -> 4 -> 5
↑
p0.next
虽然节点 2 的 next 已经在反转过程中改变,但:
p0.next
仍然指向原来的节点 2。
反转以后,节点 2 是反转区间的尾部,所以:
p0.next.next = cur;
表示:
2 -> 5
把反转后的尾节点接到后半段。
再连接前半段
此时:
pre
指向反转后的头节点 4,所以:
p0.next = pre;
表示:
1 -> 4
最终得到:
1 -> 4 -> 3 -> 2 -> 5
为什么连接顺序不能交换
当前代码先执行:
p0.next.next = cur;
再执行:
p0.next = pre;
这个顺序很重要。
在第二句执行之前:
p0.next
仍然指向原来的 left 节点,也就是反转后的尾节点。
如果先写:
p0.next = pre;
那么 p0.next 就会改为指向反转后的头节点。此时再执行:
p0.next.next = cur;
修改的将是反转后头节点的 next,会破坏已经完成的反转结构。
也可以提前保存尾节点,让代码更容易阅读:
ListNode tail = p0.next;
反转完成后:
tail.next = cur;
p0.next = pre;
完整代码
class Solution {
public ListNode reverseBetween(
ListNode head, int left, int right) {
ListNode dummy = new ListNode(0, head);
ListNode p0 = dummy;
// p0 移动到 left 的前一个节点
for (int i = 0; i < left - 1; i++) {
p0 = p0.next;
}
ListNode pre = null;
ListNode cur = p0.next;
// 反转 [left, right],共 right-left+1 个节点
for (int i = 0; i < right - left + 1; i++) {
ListNode next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// 原 left 已经成为反转区间的尾节点
p0.next.next = cur;
// pre 是反转区间的新头节点
p0.next = pre;
return dummy.next;
}
}
边界情况
left 等于 right
例如:
1 -> 2 -> 3
left = 2
right = 2
反转区间只有一个节点。
循环执行一次后,再通过两条连接恢复链表,最终结构不变:
1 -> 2 -> 3
不需要单独判断。
从头节点开始反转
例如:
1 -> 2 -> 3
left = 1
right = 2
此时:
p0 = dummy
反转完成后:
dummy -> 2 -> 1 -> 3
返回:
dummy.next
结果就是新的头节点 2。
反转到链表末尾
如果:
right = 链表长度
反转完成后:
cur = null
执行:
p0.next.next = cur;
相当于让反转后的尾节点指向 null,链表仍然能够正确结束。
复杂度分析
寻找 p0 需要走 left - 1 步,反转区间需要走:
right - left + 1
步,总共访问到位置 right:
时间复杂度:O(right)
从整个链表长度来看,也可以写成:
时间复杂度:O(n)
除了几个指针和一个虚拟头节点,没有使用与链表长度相关的额外空间:
空间复杂度:O(1)
这也是一次扫描完成局部反转。
一句话记忆
先找到
left前一个节点p0,把[left, right]按普通链表反转,再补上两条连接:原left接后半段,p0接原right。
其中最值得记住的是:
p0.next.next = cur; // 反转后的尾部接回后半段
p0.next = pre; // 前半段接到反转后的头部
三、两道题放在一起看
这两道题分别操作数组和链表,但都在解决同一个问题:
原地修改数据结构时,怎样避免破坏还没有处理的数据?
合并两个有序数组
数组的问题是覆盖。
解决方式:
利用 nums1 尾部空位,从后往前写
三个指针的职责:
p1:nums1 尚未处理的最大元素
p2:nums2 尚未处理的最大元素
p :下一个写入位置
反转链表 II
链表的问题是断链。
解决方式:
修改 cur.next 之前,先保存原来的 next
几个指针的职责:
p0:反转区间前一个节点
pre:已经反转部分的头
cur:当前处理节点
next:尚未处理的后续链表
可以把两题共同的方法压缩成三步:
1. 找到安全的操作方向或操作边界
2. 修改前保存后续仍然需要的数据
3. 操作完成后重新连接整体结构
这也是很多数组、链表双指针题真正考查的内容。
