[前缀和][差分数组][3356. 零数组变换 II]由3356. 零数组变换 II引发的差分数组思考 -- 差分数组思想学习笔记

1. 前言

首先要感谢labuladong老师, 【labuladong】前缀和/差分数组技巧精讲, 在2025/05/21做每日一题3356. 零数组变换 II的时候, 我之前拙劣的 O ( n 2 ) O(n^2) O(n2)甚至更高的复杂度算法始终也过不了一个大用例, 答案中给出的方法用到了二分查找差分数组, 二分查找很好理解, 这个是之前掌握的, 但是差分数组是个什么东西? 为什么只需要简单的在左加右减就可以实现区间的全加呢? 这是什么黑科技? 于是乎我决定去批站上搜索一下, 在刚才提到的视频里找到了想要的答案. 找到答案以后也希望分享一下给其他跟我一样的小白, 很多视频都得靠自己悟, 多一些学习笔记, 可以少一些弯路.
示例采用的是java写作, 但其实c系列选手也可以很轻松的看明白, 因为for循环都是差不多的, 因此只提供该语言示例(本身语言部分也不多).

急着看算法的可以直接跳到3. 和4.

2. 3356. 零数组变换 II

3356. 零数组变换 II 这个题目就不赘述了, 感兴趣的小伙伴们可以自己去做一下, 我们截取刚才提到的答案中最关键(二分查找部分不是重点, 因此忽略)的一部分:

private boolean check(int[] nums, int[][] queries, int k) {
    int n = nums.length;
    long[] diff = new long[n + 1];

    // 构造差分
    for (int i = 0; i < k; i++) {
        int l = queries[i][0];
        int r = queries[i][1];
        int x = queries[i][2];
        diff[l] += x;
        if (r + 1 < n) diff[r + 1] -= x;
    }

    // 扫描前缀和并比对
    long sum = 0;
    for (int i = 0; i < n; i++) {
        sum += diff[i];
        if (sum < nums[i]) {
            return false;
        }
    }
    return true;
}

题目要求说是执行kqueries里的操作以后, 可以让整个nums变成零数组, 求最小的k值, 如果做不到的话返回-1. 像我一样的菜鸡肯定会好奇, 这个差分数组是干嘛的呀?是个啥? 下面扫描前缀和凭什么可以保障他可以让k最小? 先卖个关子, 我们带着这两个问题往下看.

3. 前缀和

上过高中的朋友们肯定知道等比数列, 等差数列, 其中有一个很关键的拿分点:数列求和和他的变型, 我们经常可以拿到一些题型, 形如 S n = a n + a n + 1 S_n = a_n + a_{n+1} Sn=an+an+1之类的怪题, 折磨的我们痛不欲生, 在编程的世界里面, 很快他们又要折磨我们一遍.

3.1 什么是前缀和?

前缀和很简单大家就能想到, 无非就是把前n个数组里的东西加起来得到 S n S_n Sn嘛, 这样就很方便的求出来 a n a_n an元素了, 我反正之前是这样想的, 然后并不知道他有什么用处, 看完了视频, 才发现他很容易应用于 a l e f t a_{left} aleft a r i g h t a_{right} aright之间的总和, 特别是结合滑动窗口和频繁的要找 a l e f t a_{left} aleft a r i g h t a_{right} aright之间区间和之类的题目, 如刚刚提到的那题3356. 零数组变换 II.
简单说一下就是 S n = a 0 + a 1 + . . . + a n S_n = a_0+a_1+...+a_n Sn=a0+a1+...+an, S 0 = a 0 S_0 = a_0 S0=a0, 放到代码里的话稍微有点变化, 如下图
S 0 = 0 S_0 = 0 S0=0, S 1 = 0 + a 0 S_1 = 0 + a_0 S1=0+a0, S 2 = 0 + a 0 + a 1 S_2 = 0 + a_0 + a_1 S2=0+a0+a1, S n = 0 + a 0 + a 1 + a 2 . . . + a n − 1 S_n = 0 + a_0 + a_1 + a_2 ... + a_{n-1} Sn=0+a0+a1+a2...+an1
[前缀和][差分数组][3356. 零数组变换 II]由3356. 零数组变换 II引发的差分数组思考 -- 差分数组思想学习笔记_第1张图片
这样可以用于很方便的求某个分数区间有多少人之类的题目
这里主要分享的是差分数组, 前缀和这里就先稍微展开一下不细讲

4. 差分数组

4.1 什么是差分数组?

差分数组前缀和的变型, 刚才我们知道了前缀和是什么, 既然可以每个都加和, 那我们自然也可以每个都做差, 通过初始值和每一段的差值, 我们可以计算出每一个数组中的元素. 啥是差分数组呢? 就是第一个元素照抄原数组 a 0 ′ = a 0 a_0^{\prime} = a_0 a0=a0, 然后其他的元素等于 a n ′ = a n − a n − 1 a_n^{\prime} = a_n-a_{n-1} an=anan1. ( a ′ a\prime a代表新数组, a a a代表原数组)
举个例子:
[前缀和][差分数组][3356. 零数组变换 II]由3356. 零数组变换 II引发的差分数组思考 -- 差分数组思想学习笔记_第2张图片
先填入8, 然后填入 2 − 8 = − 6 2-8=-6 28=6, 再填入-4, 以此类推.

4.2 差分数组反推原数组

怎么反推原数组呢?
很简单.
先把8拿出来, 然后8+(-2)=2填到第二个, 然后再填入2+4=6填到第三个, 以此类推.

4.3 那么差分数组有啥用呢?

我们可以利用差分数组很方便的在一个区间里面全部加x, x为任意实数.
举个例子, 我希望在刚才的数组中的第1个第3个 (8第0个元素)内都加1, 先看下正常要怎么搞:

  • 遍历第1个第3个元素, 逐个加1, 显而易见需要一个循环, 复杂度为 O ( n ) O(n) O(n)

代码为

for(int i = 1; i <= 3; i++) {
	nums[i] += 1;
}

泛化提炼一下就是

for(int i = left; i <= right; i++) {
	nums[i] += x;
}

如果我要用差分数组呢?

  • 直接在第1个元素加1, 在第4个元素减1.

代码为diff[1] += 1;diff[4] -=1;
泛化提炼一下就是diff[left] += x;diff[right+1] -=x;

我只需要操作2个地方, 复杂度显而易见是常数 O ( k ) O(k) O(k)
复杂度降了这么多! 但是复杂度不会凭空消失, 他只是转移到计算第n个元素上的值了而已, 亦或是另外开辟了内存, 使用了更多的空间复杂度而已., 但不管怎么样, 如果批量修改区间数组元素的话, 节省的复杂度远远超过增加的复杂度
然后肯定会有很多小伙伴们会问:
1. 为什么是这样子呢?
2. 你的数学证明在哪里?
3. 我不信你这个可以这么简单!
4. 还有你为什么要在右边加1?
5. 你为什么不给左边所有元素都加1?
6. 我理解不了啊!

我们来举例讲解一下为什么可以得出这样的结论:
假设我们在第1个 差分数组的元素加了1, 那么我们可以发现第1个 还原出来的数组(也就是通过差分数组的前缀和计算出来的数组)元素和第1个 还原出来的数组的元素后面的全部元素都被加了1!
这很好理解吧, 因为我只拉大了还原出来的数组 第1个元素和还原出来的数组 第2个元素之间的差距, 后面的还原出来的数组的元素计算都是基于还原出来的数组 第2个元素来计算的, 因此第1个 还原出来的数组元素之后的所有还原出来的数组的元素都加了1.
示例

8+(-5)(原先是-6) = 3
3+4 = 7
7+(-3) = 4
4+(-2)=2

可以发现确实如此. 得到83742
再次注意, 这个时候没有减去右边的1, 从第一个元素开始, 数组整体都被加了1

然后为什么我们要在后面减1呢? 因为我们不希望区间外的元素也被增加!

8+(-5)(原先是-6) = 3
3+4 = 7
7+(-3) = 4
4+(-3)(原先是-2, 被我们减回来了1)=1(符合原数组的`第4个`元素)

得到83741, 符合我们的预期.

通过这个例子可以发现, 我们想要对区间进行集体加和的操作的话, 就在left下标加x, 在right+1的下标减去x.

可能还是有小伙伴不明白, 时间为什么节省了, 你可以自己尝试一下, 如果要对各种区间内进行总共10000次操作, 用循环和这个方法进行对比一下, 或者可以带入3356. 零数组变换 II中的queries来看看, 很明显如果用循环的话至少是 O ( n 2 ) O(n^2) O(n2)以上, 如果用差分数组的常数加法的话只需要 O ( n ) O(n) O(n)就可以加完了.

4.4 结论

我们通过刚才的例子还可以发现, 差分数组本身的前缀和就可以得到原数组本身! 这也是为什么我说差分数组前缀和的变种.

知道了原理, 那么, 有什么用呢?
对于刚才讲到的那道题3356. 零数组变换 II, 我们可以发现, 我们非常需要频繁的对区间元素进行操作, 如果我们有了前缀和差分数组的话呢?

5. 回收开头

我们在二分查找之后, 可以利用直接提供的queries来组成我们的差分数组, 什么意思呢?
就是说把所有的queries元素都进行加和来得到我们的差分数组, 用于计算第i个元素能减去的最大值. 结合我们刚才所说的知识, 可以很容易理解这个操作. 为什么不用nums[0]呢? 因为不需要, 我们只需要使用queries就可以了, 因为我们要求的是纯净的对应元素的扣减最大值.nums[i]需要放到后面去比较, 在这没用.

    for (int i = 0; i < k; i++) {
        int l = queries[i][0];
        int r = queries[i][1];
        int x = queries[i][2];
        diff[l] += x;
        if (r + 1 < n) diff[r + 1] -= x;
    }

然后, 我们可以一边求这个差分数组前缀和, (这里差分数组的前缀和求的是第i个nums[i]元素理论扣减的最大值, 而不是nums[i], 不是一个东西, 不要混淆), 一边判断和原数组第i个元素谁更大.为什么可以这么干呢? 因为差分数组求的是当前这个数我们可以减去的最大数. 什么意思呢? 比如有queries[0]queries[1]都包含了第0个元素的话, 如[0, 2, 2][0, 2, 2], 那么在第0个元素我们得到了2+2=4, 如果这个数大于nums[0]的话, 证明我们可以完全减去nums[0], 这个元素就可以跳过, 来比较下一个元素. 否则的话, 针对于这次进来的k, 第i个元素的nums[0]的值就没法抵扣, 和题目发生冲突了, 无法实现目标. 为啥? 因为在当前k情况下, 我们全部叠加完了啊, 针对于这个元素没有其他更多的扣减值了, 用完了你都不行, 那肯定得继续搜索下一个k来看能不能满足了. 如果都不行, 那就返回-1了.

    long sum = 0;
    for (int i = 0; i < n; i++) {
        sum += diff[i];
        if (sum < nums[i]) {
            return false;
        }
    }

6. 结束语

打了半天字, 用最蠢的语言写下了这篇分享, 希望可以帮助到大家. 如果觉得有帮助的话随意转载, 欢迎转载, 如果觉得有问题的话可以留言指出, 感谢大家

你可能感兴趣的:(学习,笔记,算法,java)