双指针与局部反转:LeetCode 88 + 92 详解

作者:逆境不可逃

技术永无止境

希望我的内容可以帮助到你!!!!

这篇文章整理两道很典型的指针题:

两道题看起来一个操作数组、一个操作链表,但底层思路很接近:

修改原数据结构之前,先保证未处理的数据不会丢失,再处理边界连接。


一、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--;

继续比较 35

复制代码
[1,2,3,0,5,6]

再比较 32

复制代码
[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 上完成合并,没有使用额外数组,也达到了题目的进阶要求。

一句话记忆

三个指针都从后往前移动,比较 p1p2,谁大谁放到 p;只要 nums2 还没有处理完,就继续合并。


二、92. 反转链表 II

题目理解

给定一个单链表,以及两个位置 leftright,只反转这段区间,其他部分保持不变。

例如:

复制代码
1 -> 2 -> 3 -> 4 -> 5
     ↑         ↑
   left=2    right=4

需要反转:

复制代码
2 -> 3 -> 4

得到:

复制代码
4 -> 3 -> 2

最终链表是:

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

这道题需要解决两个问题:

  1. 如何反转 [left, right] 这段链表?
  2. 反转后怎样把它重新接回原链表?

用 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

虽然节点 2next 已经在反转过程中改变,但:

复制代码
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. 操作完成后重新连接整体结构

这也是很多数组、链表双指针题真正考查的内容。

相关推荐
phltxy4 小时前
C语言操作符详解
java·c语言·算法
aqiu1111115 小时前
【LeetCode 902】最大为 N 的数字组合 - 详细题解与数位组合思路
数据结构·算法·leetcode·蓝桥杯·数位dp
辰烨chenye6 小时前
LeetCode Hot 100 题解 · 哈希篇
算法·leetcode·哈希算法
罗西的思考6 小时前
DreamZero 与 DreamDojo:世界模型与策略的分层协同综合分析与对比
人工智能·算法·机器学习
玖玥拾7 小时前
LeetCode 219 存在重复元素 II
算法·leetcode·哈希算法·散列表
知无不研8 小时前
c语言中循环的介绍与简单应用
c语言·开发语言·算法·循环·for·while
a187927218319 小时前
【算法】动态规划第四篇:背包收官——min 哨兵、计数世界与组合排列分水岭
算法·leetcode·动态规划·dp·01背包·算法讲解·决策合并
2601_962297489 小时前
在python3中、下列输出变量a的正确写法是_2020超星大数据Python免费答案
数据结构·python·算法·编程·字符串操作
辰烨chenye9 小时前
LeetCode Hot 100 题解 · 子串篇
算法·leetcode·职场和发展