文章目录
本节提要
本节的主要目标是一维差分的总结, 包括一维差分, 一维等差数列差分; 二维差分和二维前缀和的相关技巧以及方法论, 还有离散化的相关技巧(其实有点类似数学里面的坐标变化), 其实关于差分跟前缀和的关系来说的话, 前缀和有点类似差分的逆运算
1. 一维差分
1.1 一维差分原理分析
其实就是在某一个特定的区间修改值, 但是我们不去用遍历的方式去修改数组, 而是仅仅修改其中的几个位置, 从而在最终返回结果的时候通过生成前缀和加工一个新的数组返回,从这里描述的也可以知道, 差分不能做到边修改边查询, 但是前缀树可以做到(以后再说)
举例:
数组的定义:
比如对于一个数组 arr = { 1 , 2 , 3 , 2 , 3 } 来说, 下标的范围是0 - 4(5个元素), 我们把对于原数组的修改变化量用一个change[ ]数组来临时接收一下, 我们的change数组目前是{ 0 , 0 , 0 , 0 ,0 }
差分过程 :
我们每次修改数组的时候, 比如我们要在left - right区间范围上, 加上a, 那我们就执行下面的操作
change[left] += a ; change[right + 1] -= a ; (这个表达式是修改的核心), 同时要注意表达式边界条件的判断
修改过程:
由于这个用文字不方便进行演示, 所以我们用一张表来进行演示
arr代表的是我们的原始数组, change是我们的增量数组, 执行结束之后的change的前缀和就是我们的真实的增量
原理分析:
其实原理也是比较好理解的, 比如我们我们在change[left] += a, 说明此刻的增量从这个位置开始, 然后直到 change[right + 1] 位置就会停止, 其实我们也可以用下面的图解的方式分析(待会等差数列的分析用这种方法会更好理解一点), 总结一下就是正推求和, 逆推求差
下面的数组是我们想要实现的, 而上面的是我们的原始数组, 我们上面的数组求和之后就会出现下面数组的样子
下面是我们实现的一个一维差分的类测试
/**
* 下面我们写的类是为了测试线性的差分
* 这种差分的结构不支持边修改边查询(其实也是最简单的一种差分)
*/
class Differential {
//初始化的原始数组
private int[] init;
//增量数组(其实就是所有元素都是0, 然后进行sum)
private int[] change;
//构造方法, 创建出来原始的数组
public Differential(int[] init) {
this.init = init;
//防止越界就直接用这样子
change = new int[init.length + 1];
}
//进行局部位置的修改
public void changeArr(int left, int right, int n) {
//change[left] += n;
//change[right + 1] -= n;
change[left] += n;
change[right + 1] -= n;
}
//进行展示(此时才正式进行数组的修改)
public void showArray() {
//首先计算增量数组的前缀和, 也就是增量的结果
for (int i = 1; i < change.length; i++) {
change[i] += change[i - 1];
}
//开始修改原数组(其实也就是原始数组加上增量数组, 然后清空增量数组)
for (int i = 0; i < init.length; i++) {
init[i] += change[i];
System.out.print(init[i] + " ");
}
System.out.println();
//不要忘记清空数组
Arrays.fill(change, 0);
}
}
1.2 一维差分例题应用
这就是一个标准的一维差分的应用题, 看懂了上面的原理解析之后相信这道题是没什么问题的, 注意的点就是编号的判断, 因为航班是从1开始的, 而且这道题的原始数组元素都是0, 所以只要一个增量数组就可以了, 代码实现如下
class Solution {
public int[] corpFlightBookings(int[][] bookings, int n) {
// 经典的一维线性差分板子题(加上一个位置)
int[] temp = new int[n + 1];
// 因为初始数组的元素也都是0, 所以增量数组的前缀和都是最终的结果
for (int[] arr : bookings) {
temp[arr[0] - 1] += arr[2];
temp[arr[1]] -= arr[2];
}
// 遍历一下增量数组
for (int i = 1; i < temp.length; i++) {
temp[i] += temp[i - 1];
}
temp = Arrays.copyOf(temp, temp.length - 1);
return temp;
}
}
这道题可以用堆做, 我们之前的题目解析里面有, 今天我们介绍的是差分的做法, 其实用差分写这道题十分的好想, 我们给定的区间的左端点其实就是我们的left, 区间右端点其实就是right + 1(从长度的角度考虑), 我们在添加一段线段的时候就让该区间的所有数值 +1 (其实就是差分, 最终遍历数组找到数值最大的位置也就是重合线段最多的地方, 代码实现如下
import java.util.*;
// 注意类名必须为 Main, 不要有任何 package xxx 信息
public class Main {
//根据数据量的大小给定一个数组的长度
private static final int MAXLEN = 100001;
private static int[] arr = new int[MAXLEN];
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int sz = in.nextInt();
for(int i = 0; i < sz; i++){
//直接读取两个数作用于数组进行差分操作
int left = in.nextInt();
int right = in.nextInt();
arr[left] += 1;
arr[right] -= 1;
}
//生成前缀和同时生成最大值
int resMax = 0;
for(int i = 1; i < MAXLEN; i++){
arr[i] += arr[i - 1];
resMax = Math.max(resMax, arr[i]);
}
//清楚静态空间数组
Arrays.fill(arr, 0);
//输出最大值
System.out.println(resMax);
}
}
2. 等差数列差分
2.1 等差数列差分原理分析
其实等差数列差分跟上面的一维线性差分原理是差不多的, 理解了线性的差分之后这种等差数列差分也是很好理解的, 举例如下…
举例:
数组的定义:
对于一个数组 arr = { 1 , 2 , 3 , 2 , 3 } 来说, 下标的范围是0 - 4(5个元素), 我们把对于原数组的修改变化量用一个change[ ]数组来临时接收一下, 我们的change数组目前是{ 0 , 0 , 0 , 0 ,0 }
修改过程:
我们希望在 left - right区间上加上一个等差数列的区间, 比如 left == 1, right == 3, 首项为2, 公差为1, 末项为4 (一定确保是等差数列的区间)
此时的change数组变为 change { 0 , 2 , 3 , 4 , 0 }
差分过程:
对于差分来说, 我们仅仅需要修改下面的几个位置即可, 然后查询的时候进行两轮的前缀求和
//start是首项, end是末项, comSub是公差
change[left] += start;
change[left + 1] += comSub - start;
change[right + 1] -= comSub + end;
change[right + 2] += end;
//修改完成之后, 进行两轮前缀和就可以求出来最终的增量
原理分析:
用的还是我们的逆推分析法, 从下往上推很直观, 见下图
初态就是我们进行差分的时候修改的数组, 进行查询的时候只需要向下进行两次求前缀和就可以了, 我们也写了一个测试类的代码, 见下
/**
* 下面的是一维的等差数列差分, 其实就是对一个数组的left, right的区间元素进行操作(加的不是常数而是一个等差数列)
* 这种结构也是不支持边修改边查询
*/
class EqualsSubDifferential {
//原始的数组
private int[] init;
//增量数组
private int[] change;
//构造方法, 传进来一个原始数组
public EqualsSubDifferential(int[] init) {
this.init = init;
//创建的修改数组其实是 length + 2 长度
change = new int[init.length + 2];
}
//修改数组的方法(其实修改的是增量数组, 增量数组的修改原理我们在下面说一下, 而且这里我们默认都是符合等差数列的)
public void changeArr(int left, int right, int start, int end, int comSub) {
//arr[left] += start
change[left] += start;
//arr[left + 1] += comSub - start
change[left + 1] += comSub - start;
//arr[right + 1] -= comSub + end;
change[right + 1] -= comSub + end;
//arr[right + 2] += end;
change[right + 2] += end;
}
//展示原始数组的方法
public void showArray() {
//首先对增量数组进行两次前缀求和
for (int i = 1; i < change.length; i++) {
change[i] += change[i - 1];
}
for (int i = 1; i < change.length; i++) {
change[i] += change[i - 1];
}
//现在我们的change数组才是我们真正的增量, 加上之后我们清空一下增量数组
for (int i = 0; i < init.length; i++) {
init[i] += change[i];
System.out.print(init[i] + " ");
}
System.out.println();
Arrays.fill(change, 0);
}
}
class Test1 {
public static void main(String[] args) {
int[] arr = {4, 5, 6, 2, 1, 3, 7, 5};
EqualsSubDifferential eq = new EqualsSubDifferential(arr);
eq.changeArr(0, 4, 2, 6, 1);
eq.changeArr(4, 7, 3, 6, 1);
eq.showArray();
eq.showArray();
}
}
2.2 等差数列差分例题应用
package class047;
// 一开始1~n范围上的数字都是0,一共有m个操作,每次操作为(l,r,s,e,d)
// 表示在l~r范围上依次加上首项为s、末项为e、公差为d的数列
// m个操作做完之后,统计1~n范围上所有数字的最大值和异或和
// 测试链接 : https://www.luogu.com.cn/problem/P4231
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
public class Code02_ArithmeticSequenceDifference {
public static int MAXN = 10000005;
public static long[] arr = new long[MAXN];
public static int n, m;
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StreamTokenizer in = new StreamTokenizer(br);
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
while (in.nextToken() != StreamTokenizer.TT_EOF) {
n = (int) in.nval;
in.nextToken();
m = (int) in.nval;
for (int i = 0, l, r, s, e; i < m; i++) {
in.nextToken(); l = (int) in.nval;
in.nextToken(); r = (int) in.nval;
in.nextToken(); s = (int) in.nval;
in.nextToken(); e = (int) in.nval;
set(l, r, s, e, (e - s) / (r - l));
}
build();
long max = 0, xor = 0;
for (int i = 1; i <= n; i++) {
max = Math.max(max, arr[i]);
xor ^= arr[i];
}
out.println(xor + " " + max);
}
out.flush();
out.close();
br.close();
}
public static void set(int l, int r, int s, int e, int d) {
arr[l] += s;
arr[l + 1] += d - s;
arr[r + 1] -= d + e;
arr[r + 2] += e;
}
public static void build() {
for (int i = 1; i <= n; i++) {
arr[i] += arr[i - 1];
}
for (int i = 1; i <= n; i++) {
arr[i] += arr[i - 1];
}
}
}
3. 二维前缀和
3.1 二维前缀和原理分析
与一维前缀和(我们在构建前缀信息那一节已经详细的分析过了)类似, 二维前缀和也是为了解决在某一个区间的二维求和问题
比如求左上角点为 ( 0 , 0 ) , 右下角点为 ( a , b ) 的矩形空间的内部求和
我们利用的核心公式为
前缀和 = 左前缀和 + 上前缀和 - 左上前缀和 + 自己(注意边界讨论)
这个公式的原理就是一个简单的容斥原理, 下面我们简单图解一下
用上面的方法可以很容易的生成一个二维的前缀和数组, 那我们如何计算左上角点为(a,b), 右下角点为(c,d)的矩形的范围求和呢, 其实还是一个简单的容斥原理, 图解如下
我们通过上面的图解转化 :
待求区间的和 = 右下角坐标的前缀和 - 左下角左侧元素的前缀和 - 右上角上方元素的前缀和 - 左上角坐标的左上角元素的前缀和
3.2 二维前缀和例题应用
这就是一道标准的二维前缀和的板子题, 根据之前我们说的, 因为二维前缀和需要边界情况的讨论, 我们一般的处理做法是
- 在左侧和上侧填上一圈0, 避开边界情况的讨论
- 直接在原数组进行讨论(多数情况可以复用原数组)
第一种写法(避开边界讨论) :
class NumMatrix {
//新增的sum数组(多增加一个半环的写法)
private int sum[][];
public NumMatrix(int[][] matrix) {
int row = matrix.length;
int col = matrix[0].length;
sum = new int[row + 1][col + 1];
//进行前缀和构造
for(int i = 1; i <= row; i++){
for(int j = 1; j <= col; j++){
sum[i][j] = sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1] + matrix[i - 1][j - 1];
}
}
}
public int sumRegion(int row1, int col1, int row2, int col2) {
return sum[row2 + 1][col2 + 1] - sum[row2 + 1][col1] - sum[row1][col2 + 1] + sum[row1][col1];
}
}
第二种写法(直接复用原数组的结构, 进行边界的讨论)
class NumMatrix {
//定义一个数组的引用, 等会直接指向老的数组然后进行复用
private int[][] sum;
public NumMatrix(int[][] matrix) {
sum = matrix; //直接指向老数组
int row = matrix.length;
int col = matrix[0].length;
//直接在原数组上复用前缀和数组(空间复杂度为o(1))
for(int i = 0; i < row; i++){
for(int j = 0; j < col; j++){
matrix[i][j] += (get(matrix, i, j -1) + get(matrix, i - 1, j) - get(matrix, i - 1, j - 1));
}
}
}
public int sumRegion(int row1, int col1, int row2, int col2) {
return sum[row2][col2] - get(sum, row2, col1 - 1) - get(sum, row1 - 1, col2) + get(sum, row1 - 1, col1 - 1);
}
//进行边界讨论的get方法
private int get(int[][] arr, int i, int j){
return (i < 0 || j < 0) ? 0 : arr[i][j];
}
}
/**
* Your NumMatrix object will be instantiated and called as such:
* NumMatrix obj = new NumMatrix(matrix);
* int param_1 = obj.sumRegion(row1,col1,row2,col2);
*/
统计一个数组中的全1子矩形, 其实就是枚举所有的右上角点和右下角点, 进行前缀和的统计, 如果在这个区间的内部我们的前缀和正好等于边长的话, 那我们就找到了一个全1子矩形, 代码实现如下
class Solution {
public int numSubmat(int[][] mat) {
int r = mat.length;
int c = mat[0].length;
// 首先生成一下二维前缀和
int cnt = build(mat);
// 遍历到每一个矩形的位置(左上角点)
for (int i = 0; i < mat.length; i++) {
for (int j = 0; j < mat[0].length; j++) {
for (int a = i; a < mat.length; a++) {
for (int b = j; b < mat[0].length; b++) {
if(a == i && b == j) continue;
if (sum(i, j, a, b, mat) == (b - j + 1) * (a - i + 1)) {
cnt++;
}
}
}
}
}
return cnt;
}
// 生成二维前缀和的时候注意边界的讨论(在原数组上进行修改)
private int build(int[][] arr) {
int cnt = 0;
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < arr[i].length; j++) {
cnt = arr[i][j] > 0 ? cnt + 1 : cnt;
arr[i][j] += get(arr, i, j - 1) + get(arr, i - 1, j) - get(arr, i - 1, j - 1);
}
}
return cnt;
}
// 获取某一个节点的值(自带边界讨论)
private int get(int[][] arr, int i, int j) {
return (i < 0 || j < 0) ? 0 : arr[i][j];
}
// 求出来某一个区间上的累加和
private int sum(int r1, int c1, int r2, int c2, int[][] arr) {
return r1 > r2 ? 0 : arr[r2][c2] - get(arr, r2, c1 - 1) - get(arr, r1 - 1, c2) + get(arr, r1 - 1, c1 - 1);
}
}
还是前缀和的思路, 不多说了, 这道题的意思比较好理解, 但是代码实现应该还是比较顺利的.
package class048;
// 边框为1的最大正方形
// 给你一个由若干 0 和 1 组成的二维网格 grid
// 请你找出边界全部由 1 组成的最大 正方形 子网格
// 并返回该子网格中的元素数量。如果不存在,则返回 0。
// 测试链接 : https://leetcode.cn/problems/largest-1-bordered-square/
public class Code02_LargestOneBorderedSquare {
// 打败比例不高,但完全是常数时间的问题
// 时间复杂度O(n * m * min(n,m)),额外空间复杂度O(1)
// 复杂度指标上绝对是最优解
public static int largest1BorderedSquare(int[][] g) {
int n = g.length;
int m = g[0].length;
build(n, m, g);
if (sum(g, 0, 0, n - 1, m - 1) == 0) {
return 0;
}
// 找到的最大合法正方形的边长
int ans = 1;
for (int a = 0; a < n; a++) {
for (int b = 0; b < m; b++) {
// (a,b)所有左上角点
// (c,d)更大边长的右下角点,k是当前尝试的边长
for (int c = a + ans, d = b + ans, k = ans + 1; c < n && d < m; c++, d++, k++) {
if (sum(g, a, b, c, d) - sum(g, a + 1, b + 1, c - 1, d - 1) == (k - 1) << 2) {
ans = k;
}
}
}
}
return ans * ans;
}
// g : 原始二维数组
// 把g变成原始二维数组的前缀和数组sum,复用自己
// 不能补0行,0列,都是0
public static void build(int n, int m, int[][] g) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
g[i][j] += get(g, i, j - 1) + get(g, i - 1, j) - get(g, i - 1, j - 1);
}
}
}
public static int sum(int[][] g, int a, int b, int c, int d) {
return a > c ? 0 : (g[c][d] - get(g, c, b - 1) - get(g, a - 1, d) + get(g, a - 1, b - 1));
}
public static int get(int[][] g, int i, int j) {
return (i < 0 || j < 0) ? 0 : g[i][j];
}
}
4. 二维差分
4.1 二维差分原理分析
二维差分跟一维差分类似, 也是对一个数组进行修改然后最后进行前缀和的累加求解, 我们假如我们要在 [a, b] - [c, d] (二者分别是左上角点和右下角点)都加上一个a, 那么我们仅仅需要下面的几个地方
change[a][b] += a;
change[c + 1][d + 1] += a;
change[c + 1][b] -= a;
change[a][d + 1] -= a;
然后进行前缀和的累加操作, 从上面也可以注意到, 我们的差分的这个操作很容易发生越界, 所以我们在进行实际操作的过程中一般都是 –
在外围补出来一圈, 这样就可以避免越界
4.2 二维差分例题应用
二维差分的模板
牛客二维差分模板链接
代码实现如下
package class048;
// 二维差分模版(牛客)
// 测试链接 : https://www.nowcoder.com/practice/50e1a93989df42efb0b1dec386fb4ccc
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
public class Code03_DiffMatrixNowcoder {
public static int MAXN = 1005;
public static int MAXM = 1005;
public static long[][] diff = new long[MAXN][MAXM];
public static int n, m, q;
public static void add(int a, int b, int c, int d, int k) {
diff[a][b] += k;
diff[c + 1][b] -= k;
diff[a][d + 1] -= k;
diff[c + 1][d + 1] += k;
}
public static void build() {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
diff[i][j] += diff[i - 1][j] + diff[i][j - 1] - diff[i - 1][j - 1];
}
}
}
public static void clear() {
for (int i = 1; i <= n + 1; i++) {
for (int j = 1; j <= m + 1; j++) {
diff[i][j] = 0;
}
}
}
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StreamTokenizer in = new StreamTokenizer(br);
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
while (in.nextToken() != StreamTokenizer.TT_EOF) {
n = (int) in.nval;
in.nextToken();
m = (int) in.nval;
in.nextToken();
q = (int) in.nval;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
in.nextToken();
add(i, j, i, j, (int) in.nval);
}
}
for (int i = 1, a, b, c, d, k; i <= q; i++) {
in.nextToken();
a = (int) in.nval;
in.nextToken();
b = (int) in.nval;
in.nextToken();
c = (int) in.nval;
in.nextToken();
d = (int) in.nval;
in.nextToken();
k = (int) in.nval;
add(a, b, c, d, k);
}
build();
for (int i = 1; i <= n; i++) {
out.print(diff[i][1]);
for (int j = 2; j <= m; j++) {
out.print(" " + diff[i][j]);
}
out.println();
}
clear();
}
out.flush();
out.close();
br.close();
}
}
贴邮票问题, 这道题是一道非常好的题目, 属于是二维差分以及前缀和结合的一道题
贴邮票问题leetcode2132 链接
class Solution {
public boolean possibleToStamp(int[][] grid, int stampHeight, int stampWidth) {
// 首先复制一个grid数组用来求出来原数组的前缀和(目标是进行可不可以贴邮票的验证)
int row = grid.length;
int col = grid[0].length;
// 进行验证数组的复制
int[][] stickup = new int[row][col];
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++) {
stickup[i][j] = grid[i][j];
}
}
// 在验证数组上构造前缀和数组
build(stickup);
// 创建差分的数组
int[][] change = new int[row + 2][col + 2];
// 遍历原始的数组并验证该位置是否可以进行差分操作
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++) {
if (grid[i][j] == 0) {
if (i + stampHeight <= grid.length &&
j + stampWidth <= grid[0].length &&
sum(i, j, i + stampHeight - 1, j + stampWidth - 1, stickup) == 0) {
change[i + 1][j + 1] += 1;
change[i + stampHeight + 1][j + stampWidth + 1] += 1;
change[i + stampHeight + 1][j + 1] -= 1;
change[i + 1][j + stampWidth + 1] -= 1;
}
}
}
}
// 进行差分数组前缀和
build(change);
// 再次遍历原数组进行验证
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++) {
if (change[i + 1][j + 1] == 0 && grid[i][j] == 0) {
return false;
}
}
}
return true;
}
// 构建前缀和的数组
private void build(int[][] arr) {
int row = arr.length;
int col = arr[0].length;
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++) {
arr[i][j] += get(i, j - 1, arr) + get(i - 1, j, arr) - get(i - 1, j - 1, arr);
}
}
}
// 获取元素的方法(边界讨论)
private int get(int i, int j, int[][] arr) {
return (i < 0 || j < 0 || i >= arr.length || j >= arr[0].length) ? 0 : arr[i][j];
}
// 获取某一段区间的值的方法
private int sum(int r1, int c1, int r2, int c2, int[][] arr) {
return r1 > r2 ? 0 : get(r2, c2, arr) - get(r2, c1 - 1, arr) - get(r1 - 1, c2, arr) + get(r1 - 1, c1 - 1, arr);
}
}