【算法修炼】树状数组、差分数组(矩阵)、差分前缀和公式的巧妙理解


在蓝桥杯中,用到树状数组或者线段树的,基本都是属于难题,省赛倒数一二道,或者国赛倒数几道,整体难度是比较大的。

一、树状数组介绍

树状数组可以解决可以转化为前缀和问题的问题,这是一类用以解决动态前缀和的问题。
一定要明白树状数组有什么用,才会知道怎么应用,不然只会树状数组的三个函数,是没有意义的!
在这里插入图片描述
上图是树状数组每一个下标存储的值,它有几个下标是存储的前缀和,有些则是存储自己一个

可以看到下标1负责下标1的和,下标2负责下标1-2的和,下标4负责1-4的和,下标8负责1-8的和…

**从上面可以发现规律,下标负责的数的个数 =  **

管辖区间:包含本节点在内的,所需要考虑的节点总数
d[2] = a1 + a2,因为2的二进制=0010,只有1个0,所以负责2个元素的和。


如果我们想要13这个位置的前缀和,即1 + 2 + 3 + … + 13,13的二进制=1101,可以拆分成1101、1100、1000(就是依次去除末尾的1),会发现下标13的前缀和就等于1101(2) + 1100(2) + 1000(2),这几个下标的值之和。

所以求前缀和的过程就是找到末尾1,然后抹掉末尾1,再加上该数下标对应的数,直到下标=0。


找末尾的1:lowbit算法

int lowbit(int x) {
	return x & (-x);
}

举个例子,12=1100,-12在计算机中补码存储,也就是反码+1=0100,1100 & 0100 = 0100,就找到了末尾第一个1。


现在我们要找下标x的前缀和,怎么写?

int lowbit(int x) {
	return x & (-x);
}
int query(int x) {
	int sum = 0;
	while (x != 0) {
		sum += num[x];
		// 用lowbit找到最后一个1,再减掉
		x -= lowbit(x);
	}
	return sum;
}

上面介绍了查询前缀和的方法,如果我要修改一个数,如何更新其它树状数组值呢?

假设,要对3这个位置修改(加v),3的二进制=0011,如何找其覆盖的区间呢?(包括4、8、16)。上面我们是逐个抹掉最后一个1,现在依次往末尾为1的位置+1,11 -> 0100就是4,0100 + 0100 = 1000就是8,1000 + 1000 = 10000就是16(+1的位置由lowbit来确定)。

int lowbit(int x) {
	return x & (-x);
}
void add(int x, int v, int n) {
	// 对下标x添加值v,总共有n个数
	while (x <= n) {
		d[x] += v;
		x += lowbit(x);
	}
}

注意,下标都是从1开始。

既然是前缀和,那肯定有区间和,跟之前普通数组的前缀和一样,先查左右下标对应的前缀和,再相减即可得到区间和,[5-8]区间的前缀和 = query(8) - query(4)。


前面说了前缀和、区间和等,还没有说树状数组本身的构造问题,怎么构造树状数组?

我们可以把最开始的树状数组全都看成0,那么构造树状数组的过程,就是遍历所有的元素,调用add函数,实现初始化。


在这里插入图片描述

下面这道题可以检验自己搞懂没有~

1264、动态求区间和(简单)

在这里插入图片描述
就是考察树状数组,写好那三个函数就行。

import java.util.Scanner;

public class Main {
	// 记录前缀和
	static int[] treeNum;
	static int[] nums;
	public static void main(String[] args) {
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		treeNum = new int[n + 1];
		int m = scan.nextInt();
		nums = new int[n + 1];
		for (int i = 1; i <= n; i++) {
			nums[i] = scan.nextInt();
		}
		// 构造树状数组
		for (int i = 1; i <= n; i++) {
			add(i, nums[i], n);
		}
		// m个查询
		int k, a, b;
		for (int i = 0; i < m; i++) {
			k = scan.nextInt();
			a = scan.nextInt();
			b = scan.nextInt();
			if (k == 0) {
				System.out.println(query(b) - query(a - 1));
			} else {
				add(a, b, n);
			}
		}
	}
	// 找末尾第一个1的位置
	static int lowbit(int x) {
		return x & (-x);
	}
	// 查询下标x的前缀和 
	static int query(int x) {
		int sum = 0;
		while (x != 0) {
			sum += treeNum[x];
			// 抹掉最后一个1
			x -= lowbit(x);
		}
		return sum;
	}
	// 下标x的值 + v,总共有n个数
	static void add(int x, int v, int n) {
		while (x <= n) {
			// 修改前缀和数组
			treeNum[x] += v;
			// 用lowbit找下一个需要修改的前缀和下标
			x += lowbit(x);
		}
	}
}
1265、数星星(中等)

在这里插入图片描述
看这题目的描述是不是跟这个树状数组的样子很像,正左、正下的点的数目:
在这里插入图片描述
以C[4]为例,我们把数组A全部初始化为1,那么C[4]的前缀和 - 1,不就是C[4]星星的级数吗,因为需要统计每颗星星的等级,而每次读入星星坐标,是y递增的,y相同,x递增,所以,区间和也是在动态变化的,都指向树状数组!

树状数组的三个函数一定要写熟练!

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.Scanner;

public class Main {
	// 记录答案
	static int[] ans;
	// 树状数组
	static int[] treeNums;
	// 加速打印
	static BufferedWriter log = new BufferedWriter(new OutputStreamWriter(System.out));
	// 加速输入
	static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
	public static void main(String[] args) throws IOException {
		int n = Integer.parseInt(reader.readLine().trim());
		ans = new int[n];
		// 树状数组下标从1开始算
		treeNums = new int[32010];
		for (int i = 0; i < n; i++) {
			String[] s = reader.readLine().split(" ");
			// 避免x从0开始
			int x = Integer.parseInt(s[0]) + 1;
			int y = Integer.parseInt(s[1]);
			// 因为y是递增输入的,所以不用管y,只用看x
			// 不算自己这个星星,查询到的前缀和就是星星等级
			ans[query(x)]++;
			// 初始化树状数组(每个星星记为1),树状数组的下标范围看x
			add(x, 1, 32010);
		}
		for (int i = 0; i < n; i++) {
			log.write(ans[i] + "\n");
		}
		log.flush();
		log.close();
		reader.close();
	}
	static int lowbit(int x) {
		return x & -x;
	}
	// 查询前缀和
	static int query(int x) {
		int sum = 0;
		while (x != 0) {
			sum += treeNums[x];
			x -= lowbit(x);
		}
		return sum;
	}
	// 添加数
	static void add(int x, int v, int n) {
		while (x <= n) {
			treeNums[x] += v;
			x += lowbit(x);
		}
	}
}

注意,至此我们只实现了树状数组的单点修改 + 区间查询(也就是最简单的一种),后续根据题目要求,会介绍单点查询、区间修改等功能。

※1270、数列区间最大值(简单)(树状数组维护区间最大值)

在这里插入图片描述
本题不再要求去求前缀和了,而是要查询区间最大值,区间查询问题刚好就可以用树状数组解决! 这类求区间最大值的问题叫做:(Range Minimum/Maximum Query),就是字面意思,区间查询最大、最小值。解决此类问题的算法很多,这里只介绍树状数组(树状数组就是线段树的阉割版,更容易书写)。

题目要求查询区间最大值,那我们就记录区间最大值,在初始化树状数组的时候会有所变化,每个下标仍然像下图一样,负责自己的部分,该部分就是该区间的最大值(最小值问题也同样)。

在这里插入图片描述
根据之前的代码可以改编成这样:

void updata(int i, int val)
{
	while (i <= n)
	{
		h[i] = max(h[i], val);
		i += lowbit(i);
	}
}

但是区间查询和,怎么改成区间查询最大值???前缀和能直接查询是因为有那么一个性质:query(i) - query(j - 1) = [i,j]区间的和。区间最大值显然没有这个特性,该怎么办呢?

下面是正常的区间查询下标i的前缀和:

int query(int i)
{
	int ans = 0;
	while (i > 0)
	{
		ans += h[i];
		i -= lowbit(i);
	}
	return ans;
}

查询区间最大值,也是通过查询比较 x - lowbit(x),x,实现的,下标x负责的区间中的数的下标为:x - lowbit(x),当然需要循环更新来遍历所有数。显然这需要两层循环,给定区间 [i,j] 查询该区间的最大值,最外层循环遍历 j 到 i 的下标,内层遍历当前下标负责的区间中的下标(当然不能超过i)。


在这里插入图片描述
为方便理解,我们再看看求前缀和的过程:

如果我们想要13这个位置的前缀和,即1 + 2 + 3 + … + 13,13的二进制=1101,可以拆分成1101、1100、1000(就是依次去除末尾的1),会发现下标13的前缀和就等于1101 + 1100 + 1000,这几个下标的前缀和之和。

把前缀和换成求区间最大值,如果要找5 - 13的最大值,我们可以先把13下标的数作为开始比较元素,下标–,变成12,12负责9-12的最大值,9还没有超过5,那我们就可以用下标12的值来更新最大值(如果可以更新),12 - lowbit(12) = 8,下面跳到了8,同样,我们可以直接先把8这个位置取下来,再让下标–,变成7,7继续更新变成6,6负责5-6区间最大值,6继续更新,变成4,4超出了5,就不用找了!

仔细看上面的过程,实质上还是通过lowbit(x)实现遍历。用lowbit是为了找到该下标负责区间的下一个区间结束下标。例如下标6,负责5-6区间计算,6 - lowbit(6) = 4。

同时,一定要注意上面下标 - 1的精髓所在,因为每次更新完后一定会跳到下一个区间的结束下标,如果不 - 1,例如下标8,那它根本没办法更新到5,直接就会更新到0,因为8 - lowbit(8) = 0,所以说 - 1是十分关键的。


int query(int x, int y) {
	int ans = 0;
	while (y >= x) {
		// 先把下标j对应的元素作为最大值
		ans = max(ans, a[y]);
		// 下标移动
		y--;  // 这一步非常重要
		// 开始寻找之后覆盖区间的最大值
		// 如果可以跳过一整个区间,那就直接跳过,不能的话就只能让y一步步走
		while (y - lowbit(y) >= x) {  // 这步判断也非常重要
			// 避免例如下标 8,8 - lowbit(8)会直接跳到下标0(结束),中间的其它数没办法用于比较
		 	ans = max(ans, h[y]);
		 	// 更新到下一个区间的结束下标
		 	y -= lowbit(y);
		}
	}
	return ans;
}

在这里插入图片描述

注意,包含的区间范围:[y - lowbit(y) + 1, y]!!!


在这里插入图片描述
再回到这道题,应该很简单了,先构建树状数组,再用查询方法去查询最大值。

基础框架是下面这样了,读入读出的加速根据题目需求更改。如果求最大值,树状数组默认值为最小值。

import java.util.*;
import java.io.*;

public class Main {
    static BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
    static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
    static int N = (int)1e5 + 10;
    // 存储树状数组
    static int[] tr = new int[N];
    static int[] nums = new int[N];
    public static void main(String[] args) throws IOException {
        String[] input = reader.readLine().trim().split(" ");
        int n = Integer.parseInt(input[0]);
        int m = Integer.parseInt(input[1]);
        Arrays.fill(tr, Integer.MIN_VALUE);
        input = reader.readLine().trim().split(" ");
        for (int i = 0; i < n; i++) {
            nums[i + 1] = Integer.parseInt(input[i]);
            add(i + 1, nums[i + 1]);
        }
        while (m-- > 0) {
            input = reader.readLine().trim().split(" ");
            int x = Integer.parseInt(input[0]);
            int y = Integer.parseInt(input[1]);
            writer.write(query(x, y) + "\n");
        }
        writer.flush();
    }
    static int lowbit(int x) {
        return x & -x;
    }
    static void add(int x, int v) {
        while (x <= N) {
            tr[x] = Math.max(tr[x], v);
            x += lowbit(x);
        }
    }
    static int query(int x, int y) {
        int ans = Integer.MIN_VALUE;
        while (y >= x) {
            if (y - lowbit(y) >= x) {
                // 可以拿到整个区间,就直接跳过一整段区间
                ans = Math.max(ans, tr[y]);
                y -= lowbit(y);
            } else {
                // 如果不能拿到整个区间,只能一个个查原数组
                ans = Math.max(ans, nums[y]);
                y--;
            }
        }
        return ans;
    }
}

※1215、小朋友排队(中等)

在这里插入图片描述
输入样例:
3
3 2 1
输出样例:
9

这题是逆序对问题,(i < j && A[i] > A[j])

例如:样例输入 (3, 2, 1) 中,有 3 个逆序对—— (3, 2) , (3, 1) , (2, 1) ,使用使小朋友不高兴之和最小的排序方法后,即首先交换身高为 3 和 2 的小朋友,再交换身高为 3 和 1的小朋友,再交换身高为 2 和 1 的小朋友,每一个逆序对的两个元素都被要求交换了一次,而与 3 有关的逆序对为 (3, 2) 和 (3, 1) ,与 2 有关的逆序对为 (3, 2) 和 (2, 1) ,与 1 有关的逆序对为 (3, 1) 和 (2, 1) ,显然可以看出每个小朋友都被要求交换的两次,每个小朋友被交换的次数也就是当前小朋友的逆序对个数,也就是说光求出逆序对数量是不够的,还要知道所有逆序对下,每个小朋友的交换次数,交换了几次这个小朋友的不高兴程度就是1-交换次数的累加和。

综上所述,题目的关键是求出小朋友的身高在所有逆序对中出现的次数,也就是包含这个元素的逆序对的个数,更直接的说法是对每一个元素,求出在它前面大于它的元素的个数和在它后面小于它的元素的个数之和。最后计算出每个元素的不高兴程度并相加即可。

题目中的最小值,其实是想让我们只考虑最优的排队交换方式,因为如果光是为了达到题目含义,可以有多种交换可能。


让树状数组以身高为下标,每读取到一个身高,add进树状数组,统计从1-当前身高的个数(相当于前缀和),统计完成之后,怎么找逆序对呢,就是找前面严格大于当前身高的身高个数,以及后面严格小于当前身高的身高个数。

从前往后读入身高,用(1-最大身高)个数 - (当前身高)个数,就可以得到前面严格大于当前身高的身高的个数,
当然这个过程必须边读入边进行,否则无法实现“统计前面”。

从后往前读入身高,直接统计(1-当前身高-1)的身高数,即是后面严格小于当前身高的身高的个数。

正反逆序对个数都找到了,再用求和公式,针对每个身高一个个求和即可。

import java.util.*;
import java.io.*;

public class Main {
	static int N = 1000010;
	// 普通数组
	static int[] nums;
	// 树状数组
	static int[] treeNums;
	// 统计正反逆序对个数
	static int[] cnt;
	// 加速读入、打印
	static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
	static BufferedWriter log = new BufferedWriter(new OutputStreamWriter(System.out));
	public static void main(String[] args) throws IOException {
		String[] tmp = reader.readLine().trim().split(" ");
		int n = Integer.parseInt(tmp[0]);
		tmp = reader.readLine().trim().split(" ");
		nums = new int[N];
		cnt = new int[N];
		// 树状数组下标从1开始
		treeNums = new int[N];
		// 树状数组以身高为下标,记录身高区间的个数
		for (int i = 1; i <= n; i++) {
			// +1,避免身高为0情况
			nums[i] = Integer.parseInt(tmp[i - 1]) + 1;
			// 前面大于当前身高的个数
			// N - 1避免超出树状数组范围,同时保证是身高的最大范围
			cnt[i] = query(N - 1) - query(nums[i]);
			add(nums[i], 1, n);
		}
		// 还要统计后面小于当前身高的个数
		treeNums = new int[N];
		for (int i = n; i >= 1; i--) {
			// -1 是因为要严格小于
			cnt[i] += query(nums[i] - 1);
			add(nums[i], 1, n);
		}
		// 求和
		long ans = 0;
		for (int i = 1; i <= n; i++) {
			ans += 1L * (1 + cnt[i]) * cnt[i] / 2;
		}
		log.write(ans + "\n");
		log.flush();
	}
	// 获取末尾1
	static int lowbit(int x) {
		return x & -x;
	}
	// 添加数
	static void add(int x, int v, int n) {
		// 因为不知道后面的身高大小,必须得把所有身高值都进行填补
		while (x < N) {
			treeNums[x] += v;
			x += lowbit(x);
		}
	}
	// 求前缀和
	static int query(int x) {
		int sum = 0;
		while (x != 0) {
			sum += treeNums[x];
			x -= lowbit(x);
		}
		return sum;
	}
}

这道题实质是通过树状数组可以快速动态修改单点值的性质,进而统计逆序对的个数实现的。

二、差分、差分矩阵

对于原数组a[n],构造出一个数组b[n],使a[n]为b[n]的前缀和。 一般用于快速对整个数组进行操作,比如对将a数组中[l,r]部分的数据全部加上c。使用暴力方法的话,时间复杂至少为O(n),而使用差分算法可以将时间复杂度降低到O(1)。

在这里插入图片描述
可以观察出a数组(原数组)其实是b数组的前缀和
a[1]=b[1]
a[2]=b[1]+b[2]
a[3]=b[1]+b[2]+b[3]
a[4]=b[1]+b[2]+b[3]+b[4]…
a[i]=b[1]+b[2]+b[3]…+b[i-1]+b[i]

满足这样关系的数组,我们就称作差分数组,由此也可以看出差分其实是前缀和的逆运算。

如果给a数组中每个元素+c,只需要对b[1] + c即可,a[1] + c = b[1] + c,a[2] + c = b[1] + c + b[2]。

如果想将a数组中[l,r]部分的数据全部加上c,例如[4,6],a[4] = b[1] + b[2] +…+b[4],对b[4] + c,由于只对区间[4,6]加c,所以在区间之后还要还原成之前的状态,那就再-c,对b[7] - c即可,可以得到规律:b[l]+c,b[r+1]-c

b[l]+c后,l后面的数组都会加c。r后面的数据也会被改变,要改回来就得b[r+1]-c

如何构造差分数组?

构造差分数组的过程,就相当于原始a数组全=0,然后在[1,1]部分插入nums[1],在[2,2]部分插入nums[2],以此类推,本质还是利用b[l] + c, b[r + 1] - c进行构造,只不过区间大小变为1,同时要注意数组越界问题。

来个题目练手~

797、差分(简单)

在这里插入图片描述

import java.util.*;
import java.io.*;

public class Main {
	static int[] d;
	static BufferedWriter log = new BufferedWriter(new OutputStreamWriter(System.out));
	public static void main(String[] args) throws IOException {
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		int m = scan.nextInt();
		// 原始数组
		int[] nums = new int[n + 1];
		// 差分数组
		d = new int[n + 2];
		for (int i = 1; i <= n; i++) {
			// 这里不记录nums数组也是可以的
			nums[i] = scan.nextInt();
			// 构造差分数组
			d[i] += nums[i];
			d[i + 1] -= nums[i];
		}
		while ((m--) > 0) {
			int l = scan.nextInt();
			int r = scan.nextInt();
			int c = scan.nextInt();
			// 修改差分数组
			d[l] += c;
			d[r + 1] -= c;
		}
		// 因为原始数组的每一个元素实则是差分数组的前缀和
		// 用d[i] = d[i - 1] + d[i]就可以求得前缀和,直接在差分数组上更新更简洁
		for (int i = 1; i <= n; i++) {
			d[i] = d[i - 1] + d[i];
			log.write(d[i] + " ");
		}
		log.flush();
	}
}
※798、差分矩阵(简单)

题目描述:

输入一个n行m列的整数矩阵,再输入q个操作,每个操作包含五个整数x1, y1, x2, y2, c,其中(x1, y1)和(x2, y2)表示一个子矩阵的左上角坐标和右下角坐标。

每个操作都要将选中的子矩阵中的每个元素的值加上c。

请你将进行完所有操作后的矩阵输出。

输入格式

第一行包含整数n,m,q。

接下来n行,每行包含m个整数,表示整数矩阵。

接下来q行,每行包含5个整数x1, y1, x2, y2, c,表示一个操作。

输出格式

共 n 行,每行 m 个整数,表示所有操作进行完毕后的最终矩阵。

数据范围

1≤n,m≤1000,
1≤q≤100000,
1≤x1≤x2≤n,
1≤y1≤y2≤m,
−1000≤c≤1000,
−1000≤矩阵内元素的值≤1000

输入样例

3 4 3
1 2 2 1
3 2 2 1
1 1 1 1
1 1 2 2 1
1 3 2 3 2
3 1 3 4 1

输出样例

2 3 4 1
4 3 4 1
2 2 2 2

和前缀和一样,差分也有一维二维,这道题考察二维的差分矩阵。

先复习下之前的二维前缀和:
在这里插入图片描述
差分数组(矩阵),的前缀和是原始数组(矩阵)

设有二维数组a[m][n],s[i][j]为a[i][j]及其之前所有元素的前缀和,比如下图中,从a[1][1]到a[i][j]矩形所有元素的和为s[i][j],s[i - 1][j]表示直线b上边所有元素的和,s[i][j - 1]表示直线a左边所有元素之和,则s[i][j] = s[i - 1][j] +s[i][j - 1] - s[i - 1][j -1] + a[i][j],因为a的左边与b的上边元素求和时对该区域内元素求了两遍和,所以需要减去s[i - 1][j - 1]。

现在来看二维差分数组,我们唯一能够知道的是,差分数组的前缀和=原始数组,s[i][j] = s[i - 1][j] +s[i][j - 1] - s[i - 1][j -1] + a[i][j],这是二维前缀和的公式,设有二维数组a[m][n]及它的差分数组b[m][n],那么上面公式变为:a[i][j] = a[i - 1][j] + a[i][j - 1] - a[i - 1][j - 1] + b[i][j]。变形得b[i][j] = a[i][j] + a[i - 1][j - 1] - a[i - 1][j] - a[i][j - 1],b[i][j]的值就等于以a[i][j]为右下顶点的正方形的正对角线上两个端点的和减去副对角线上两个端点的和。

我们可以得出一个结论,对于二维数组a,如果a[i][j],a[i - 1][j - 1],a[i - 1][j],a[i][j - 1]都加上c,b[i][j]不变,如果只有一条边上的两个端点加上c,比如a[i][j]和a[i][j - 1],b[i][j]的值也不变。

如果是一条边(不是对角线)上的节点都+c,例如a[i][j] + c,a[i-1][j] + c 或者是 a[i][j - 1] + c,也不会改变b[i][j]的值。

对矩形区域内所有的元素都加上c后,b数组受影响的只有四个点,分别是b[x1][y1] ,b[x2 + 1][y1],b[x1][y2 + 1] , b[x2 + 1][y2 + 1],具体变化为b[x1][y1] += c、b[x2 + 1][y1] -= c、b[x1][y2 + 1] -= c、b[x2 + 1][y2 + 1] += c

把b[x1[y1]看作 0 0
把b[x2 + 1][y1]看作1 0
把b[x2 + 1][y2 + 1]看作1 1,那么可以得到规律,坐标和为偶数的+c,坐标和为奇数的-c,并且+1都只会对第二个坐标+1,不会对第一个坐标+1,那其实根本不用记加减哪个坐标,我们把二位二进制的排列找到:00 01 10 11,有1的地方就需要对第二个坐标+1,为0的地方就是第一个坐标,坐标和为偶数就+c,坐标和为奇数-c。

那么,二维差分数组的构造,就相当于是在(1,1,1,1)这个矩阵内+c,跟一维差分数组构造一样。注意数组越界问题。

import java.util.*;
import java.io.*;

public class Main {
	// 差分矩阵
	static int[][] d;
	// 原始矩阵
	static int[][] num;
	public static void main(String[] args) throws IOException {
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		int m = scan.nextInt();
		int q = scan.nextInt();
		// 避免数组越界
		d = new int[n + 10][m + 10];
		num = new int[n + 1][m + 1];
		for (int i = 1; i <= n; i++) {
			for (int j = 1; j <= m; j++) {
				// 这里也可以不记录num矩阵
				num[i][j] = scan.nextInt();
				// 构建差分矩阵 00 01 10 11,相当于是在(i,j,i,j)的子矩阵内加了数
				// 偶 +,奇 -
				d[i][j] += num[i][j];
				d[i][j + 1] -= num[i][j];
				d[i + 1][j] -= num[i][j];
				d[i + 1][j + 1] += num[i][j];
			}
		}
		while ((q--) > 0) {
			int x1 = scan.nextInt();
			int y1 = scan.nextInt();
			int x2 = scan.nextInt();
			int y2 = scan.nextInt();
			int c = scan.nextInt();
			// 根据规律插入数 00 01 10 11
			d[x1][y1] += c;
			d[x1][y2 + 1] -= c;
			d[x2 + 1][y1] -= c;
			d[x2 + 1][y2 + 1] += c;
		}
		// 存储更新后的数组
		num = new int[n + 1][m + 1];
		// 求差分数组前缀和,得到原始数组
		for (int i = 1; i <= n; i++) {
			for (int j = 1; j <= m; j++) {
				num[i][j] = num[i - 1][j] + num[i][j - 1] - num[i - 1][j - 1] + d[i][j];
				System.out.printf("%d ", num[i][j]);
			}
			System.out.println();
		}
	}
}
三维的差分数组该如何推导?

同样的,我们分析长度为3的二进制串,000,001,010,011,100,101,110,111,有1的代表对第二个坐标操作(x2,y2,z2),0代表对第一个坐标操作(x1,y1,z1),坐标值之和为偶数则+c,为奇数则-c,就这么简单,掌握这个规律就好做,不要去记具体修改x1、x2、y1、y2..太难记了,直接看二进制串。

三维的前缀和数组如何推导?

S(x,y,z) = b(x,y,z) + S(x-1,y,z) + S(x,y-1,z) - S(x-1,y-1,z) + S(x,y,z-1) - S(x-1,y,z-1) - S(x,y-1,z-1) + S(x-1,y-1,z-1)​。其实就是每个坐标依次-1相加,有两次-1的就相减,全部-1的就相加。注意别忘了当前数组本身值b(x,y,z)。

好了三维都会了,有了之前的基础,现在终于可以来攻克这道难题了!

注意,偶加奇减,只是针对差分数组而言,对于前缀和数组也有其专门的规律特性。

※※※1232、三体攻击(困难)

在这里插入图片描述
在这里插入图片描述
题目中有着很浓烈的动态修改数组的味道,那就赶紧用差分,又问你找到第几轮?赶紧用二分

import java.util.*;
import java.io.*;

public class Main {
	// 飞船体积
	static int a, b, c;
	// 飞船血量
	static int[][][] boat;
	// 差分数组
	static int[][][] cha;
	// 每轮攻击
	static int[][] attack;
	static int[][][] backUp;
	// 加速读取
	static BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
	public static void main(String[] args) throws IOException {
		String[] input = in.readLine().trim().split(" ");
		a = Integer.parseInt(input[0]);
		b = Integer.parseInt(input[1]);
		c = Integer.parseInt(input[2]);
		// m轮攻击
		int m = Integer.parseInt(input[3]);
		// 原始数组
		boat = new int[a + 2][b + 2][c + 2];
		// 差分数组
		cha = new int[a + 2][b + 2][c + 2];
		backUp = new int[a + 2][b + 2][c + 2];
		// 血量数组
		int[] d = new int[a * b * c + 2];
		input = in.readLine().trim().split(" ");
		for (int i = 1; i <= a * b * c; i++) {
			d[i] = Integer.parseInt(input[i - 1]);
		}
		// 把血量依次存入对应的飞船
		for (int i = 1; i <= a; i++) {
			for (int j = 1; j <= b; j++) {
				for (int k = 1; k <= c; k++) {
					// 根据索引找血量
					boat[i][j][k] = d[((i - 1) * b + (j - 1)) * c + (k - 1) + 1];
					// 构造差分数组 000 001 010 011 100 101 110 111
					// 偶加,奇减
					cha[i][j][k] += boat[i][j][k];
					cha[i][j][k + 1] -= boat[i][j][k];
					cha[i][j + 1][k] -= boat[i][j][k];
					cha[i][j + 1][k + 1] += boat[i][j][k];
					cha[i + 1][j][k] -= boat[i][j][k];
					cha[i + 1][j][k + 1] += boat[i][j][k];
					cha[i + 1][j + 1][k] += boat[i][j][k];
					cha[i + 1][j + 1][k + 1] -= boat[i][j][k];
				}
			}
		}
		// 注意,这里会对差分数组造成”伤害“,也就是减(而不是加)
		attack = new int[m + 1][7];  // N轮攻击,每轮7个参数
		int index = 1;
		while (index <= m) {
			input = in.readLine().trim().split(" ");
			int aa = Integer.parseInt(input[0]);
			int bb = Integer.parseInt(input[1]);
			int cc = Integer.parseInt(input[2]);
			int dd = Integer.parseInt(input[3]);
			int ee = Integer.parseInt(input[4]);
			int ff = Integer.parseInt(input[5]);
			int gg = Integer.parseInt(input[6]);
			// 由于是攻击,所以把攻击力修改为负数,在之后用差分求前缀和时,就更方便
			attack[index++] = new int[] {aa, bb, cc, dd, ee, ff, -gg};
		}
		// 由于要找第几轮攻击后爆炸的,很容易想到二分答案
		int left = 1;
		int right = m;
		int mid;
		int ans = 0;
		while (left <= right) {
			mid = left + (right - left) / 2;
			if (check(mid)) {
				ans = mid;
				// 往左边逼近攻击次数
				right = mid - 1;
			} else {
				left = mid + 1;
			}
		}
		System.out.println(ans);
	}
	// check当前攻击是否有飞船爆炸
	static boolean check(int cnt) {
		// 不要更改原始差分数组
		for (int i = 0; i < a + 2; i++) {
			for (int j = 0; j < b + 2; j++) {
				backUp[i][j] = Arrays.copyOf(cha[i][j], c + 2);
			}
		}
		// 遍历每轮攻击
		for (int i = 1; i <= cnt; i++) {
			// 更新差分数组 000 001 010 011 100 101 110 111
			backUp[attack[i][0]][attack[i][2]][attack[i][4]] += attack[i][6];
			backUp[attack[i][0]][attack[i][2]][attack[i][5] + 1] -= attack[i][6];
			backUp[attack[i][0]][attack[i][3] + 1][attack[i][4]] -= attack[i][6];
			backUp[attack[i][0]][attack[i][3] + 1][attack[i][5] + 1] += attack[i][6];
			backUp[attack[i][1] + 1][attack[i][2]][attack[i][4]] -= attack[i][6];
			backUp[attack[i][1] + 1][attack[i][2]][attack[i][5] + 1] += attack[i][6];
			backUp[attack[i][1] + 1][attack[i][3] + 1][attack[i][4]] += attack[i][6];
			backUp[attack[i][1] + 1][attack[i][3] + 1][attack[i][5] + 1] -= attack[i][6];
		}
		// 根据差分数组求前缀和
		boat = new int[a + 2][b + 2][c + 2];
		for (int i = 1; i <= a; i++) {
			for (int j = 1; j <= b; j++) {
				for (int k = 1; k <= c; k++) {
					boat[i][j][k] = backUp[i][j][k] + boat[i - 1][j][k] + boat[i][j - 1][k] + boat[i][j][k - 1] + boat[i - 1][j - 1][k - 1] - boat[i - 1][j - 1][k] - boat[i - 1][j][k - 1] - boat[i][j - 1][k - 1];
					// 超过生命值才返回
					if (boat[i][j][k] < 0) {
						return true;
					}
				}
			}
		}
        return false;
	}
}
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

@u@

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值