学习记录#2——区间的维护:一维差分和树状数组

为我暑假中途的爽玩感到惭愧······但不得不说确实玩得爽哈哈哈哈(废话)。

本文若有错误之处敬请斧正。


目录

一、引入

二、一维差分

2.1、“有序的”区间修改+单点查询

2.2、一维差分的局限性

三、树状数组

3.1、树状数组的实现

3.2、树状数组的基本应用

3.2.1、单点修改 + 区间查询

3.2.2、区间修改 + 单点查询

3.2.3、区间修改 + 区间查询

四、总结


一、引入

在我们痛苦地打算法竞赛的时候,考虑以下场景:

(1)给定一个长度为 n 的一维数组,数组内的每个元素均有初始值。

(2)区间修改:针对数组 [ L, R ] 范围内的元素进行相同的修改,如同时加上或减去一个数。

(3)单点修改:修改数组内某一个元素。

(4)区间查询:针对数组 [ L, R ] 范围内的元素进行询问操作,往往是求这个范围内的数字总和。

(5)单点查询:访问数组内某一个元素的值。

如果只是简单地执行 (3)(5) 操作,就连我的idol雷雨禾都知道要怎么写,复杂度为O(1),但若是执行 (2)(5) 操作,她可就要犯难了,使用暴力法编码,每次修改的复杂度为O(m),那么 k 次修改的复杂度可就为O(k*m),效率感人。倘若查询的次数不止一次呢?甚至修改和查询的顺序是随机的呢?再进一步,如果换成 (2)(4) 操作,又该如何是好呢?此时,idol一拍脑袋,知道需要一维差分和树状数组的登场了。她真的太强了QWQ。

二、一维差分

2.1、“有序的”区间修改+单点查询

介绍差分,首先简述一下前缀和运算:对于数组 a[n] 而言,它的前缀和数组中 s[n] 就是 a[0] 到 a[n] 的和,如果计算出了前缀和数组,就能够快速计算出数组的任意区间和,即 a[i] + …… + a[j] = s[j] - s[i - 1],说明复杂度为 O(n) 的区间计算优化到了 O(1) 的单次计算。

前缀和的一个具体应用就是差分,简而言之,差分是前缀和的逆运算。

体现在:对于数组 d[n],数组 a[n] 就是它的前缀和数组,即 a[n] 是 d[0] 到 d[n] 的和,可以想象,在一条数轴上,a[n] 就是一个个端点,而 d[n] 则是两个端点之间相邻的线段,这些小线段相加就得到了一条长线段。

那么如何用差分实现区间修改呢?

实际上,进行引入中的(2)操作时,只需要对差分数组进行以下操作:

1、把 d[L] 加上 k。

2、把 d[R + 1] 减去k。

由差分数组和原数组的关系(a[n] = d[0] + … + d[n])可知,在 d[L] + k 之后,元素 a[x] (x < L) 并不会发生变化,因为它在计算时并没有加上 d[L]。而元素 a[x] (x > L) 都会加一次 d[L] ,此时就需要让 d[R + 1] - k,使得元素 a[x] (x > R) 在计算时均减去一次 k,以抵消之前加 k 的影响。

还可以进行这样的思考以辅助理解:

想象施工队要盖一栋楼,例如楼层编号为 1、2、3、4、5、6、7。

现在中间三层的户主想把楼层编号加一,那么就把3楼变成4楼,4楼变成5楼……依次类推下去,新楼的楼层编号就变成了1、2、4、5、6、7、8。

但这可不行,最上面两层的户主可不想改变楼层号,于是乎,我们把7楼降回6楼,8楼降回7楼。从而把编号变成1、2、4、5、6、6、7。

恭喜!我们实现了中间三层户主的要求,他们的楼层编号加一了,尽管编号乱七八糟的,6楼住户还可能走错……但是这不重要,毕竟楼层编号只是一个形式嘛!

现在,我们彻底将楼层编号打乱,可能不按顺序,甚至可能随机出现数字,如第圆周率pi层、第114514层、第葛立恒数 G(64) 层之类,再进行上述操作,就是差分的实现了。

此时,每次操作就只需要修改端点处的差分数组的值,复杂度降为了 O(1) ,最后使用 d[n] 计算 a[n],复杂度为 O(n),在 m 次修改之后进行查询,过程是有序的,总复杂度为O(m + n),比O(m*n)要好太多了。

下面给出一道简单的例题: HDU P1556 Color the ball


N个气球排成一排,从左到右依次编号为 1,2,3....N。每次给定 2 个整数 a、b (a <= b),lele便骑上他的“小飞鸽"牌电动车从气球 a 开始到气球 b 依次给每个气球涂一次颜色。但是 N 次以后lele已经忘记了第 i 个气球已经涂过几次颜色了,你能帮他算出每个气球被涂过几次颜色吗?

Input:

第一行为一个整数N,(N <= 100000)。接下来的 N 行,每行包括2个整数 a、b(1 <= a <= b <= N)。N等于0时,输入结束。

Output:

每个测试实例输出一行,包括N个整数,第 i 个数代表第 i 个气球总共被涂色的次数。


这个题比较简单,因为气球一开始都是没有被涂过的,甚至连差分数组都不需要计算,直接初始化为0即可。

#include<iostream>
using namespace std;

const int N = 100010;
int d[N];

int main() {
	int n;
	while (~scanf("%d", &n)) {
		memset(d, 0, sizeof(d));
		for (int i = 1; i <= n; i++) {
			int l, r; cin >> l >> r;
			d[l]++;
			d[r + 1]--;		//区间修改
		}
		for (int i = 1; i <= n; i++) {
			d[i] += d[i - 1];		//累加求原数组
			if (i != n)cout << d[i] << " ";
			else cout << d[i] << endl;
		}
	}
	return 0;
}

这里使用了一个小技巧,不直接定义数组 a[n],而是直接使用 d[n] 当场计算出 a[n],可以节省空间。

2.2、一维差分的局限性

我们在之前的标题中提到过,一维差分是有序的区间修改 + 单点查询,这里需要强调“有序性”,指的是先修改再查询,但实际上,这个“有序的”限定在题目中体现得非常的狭隘,凭啥给你老老实实的先改再查啊?如果查询和修改均发生了很多次,而且它们的顺序是随机的,此时 m 次修改复杂度为O(m),一次查询需要累加求前缀和,那么 k 次查询的复杂度就是O(m + k*n),未免有些慢了。

那么,对于区间修改 + 单点查询,一维差分就显得有些不够用。

其实,在大多数情况下,维护区间值的时候,我们更常用树状数组和线段树来解决。

三、树状数组

树状数组是一种常见的高级数据结构,用以高效率地查询和维护区间和,它体现的是原数组和其区间和的关系。

3.1、树状数组的实现

树状数组的实现不难理解,即 将数组用分治的思想处理成两部分,然后将处理完的部分继续分成两部分……这样一来,无论是修改还是查询,都能够以O(eq?%5Clog_%7B2%7Dn)的复杂度进行,以有8个元素的数组为例,将其画成树状,如图所示:

3cbc7cb795f741bd9915bdd4ada74951.jpeg

这幅图(出自算法竞赛p161,罗勇军著)就是树状数组的核心,每一个有数字的节点储存的就是树状数组的一个元素 tree[ ],同时,每一个节点 tree[ ] 的值就是其下直连的子节点的和,例如 tree[1] = eq?a_%7B1%7D,tree[2] = tree[1] + eq?a_%7B2%7D,tree[3] = eq?a_%7B3%7D,tree[4] = tree[2] + tree[3] + eq?a_%7B4%7D,…… ,tree[8] = tree[7] + tree[6] + tree[4] + eq?a_%7B8%7D

由树状数组的结构,不难看出其修改和查询是很高效的,因为树状数组的元素 tree[ ] 就是原数组 a[ ] 中的一段区间和,也称作区间和数组 tree[ ],两者是一个概念。计算前缀和的时候只需要查找对应的元素值 tree[ ],再加在一起就可以了。同时修改操作也变得非常简单,由于每个节点的值是它直连的子节点的和,在修改一个元素 a[ ] 时,只需要修改它和它上面的那些节点:父节点 即可。

具体实现方式:

注意到(反正我当时是没注意到的哈哈哈哈,这里当做结论给出),每次在查询和修改的时候,根据二进制的特征有

(1)查询前缀和的过程中,寻找对应区间和的 tree[ ] 值是每次去掉二进制编码最后的那个1,例如,sum(7) = tree[7] + tree[6] + tree[4],步骤如下:

① 7的二进制是111,去掉最后的1,为110,也就是十进制的6,即 tree[6]。

② 6的二进制是110,去掉最后的1,为100,也就是十进制的4,即 tree[4]。

③ 4的二进制是100,去掉最后的1就没有了。

(2)修改的过程中 查找每个元素的父节点是在二进制编码的最后的1上再加1,例如修改 tree[3] 时:

① 3的二进制是11,在最后的1上加1为100,也就是十进制的4,同步修改 tree[4]。

② 4的二进制是100,在最后的1上加1为1000,也就是十进制的8,同步修改 tree[8]。

③ 同理,继续修改 tree[16]、tree[32]……

到了最后,树状数组归结到一个关键问题——如何找到一个数二进制形式下的最后一个1。

结论:神奇的lowbit(x)

lowbit (x) = x & -x,功能是找到 x 的二进制形态下的最后一个1。原理利用了负数的补码表示,具体详见计算机科学导论,其实是我不太清楚了。如果有机会开博客另说吧 (。>∀<。)

3.2、树状数组的基本应用

3.2.1、单点修改 + 区间查询

单点修改和区间查询是树状数组的原始功能,因为修改的是数组中的元素 a[ ],所求的是树状数组的元素 tree[ ],也就是原数组的区间和,计算的核心是 lowbit 操作。

下面给出一道例题 洛谷P3374 【模板】树状数组1:


如题,已知一个数列,你需要进行下面两种操作:

将某一个数加上 x

  • 求出某区间每一个数的和

Input:

第一行包含两个正整数 n,m,分别表示该数列数字的个数和操作的总个数。

第二行包含 n 个用空格分隔的整数,其中第 i 个数字表示数列第 i 项的初始值。

接下来 m 行每行包含 3 个整数,表示一个操作,具体如下:

  • 1 x k 含义:将第 x 个数加上 k

  • 2 x y 含义:输出区间 [x,y] 内每个数的和

Output:

输出包含若干行整数,即为所有操作 2 的结果。


这道题就是最基础的树状数组题目,完美体现了单点修改和区间查询的特点,按照上文解析,给出代码如下:

#include<iostream>
#define lowbit(x) ((x) & -(x))    //定义lowbit操作
#define ll long long
using namespace std;

const int N = 1000010;
ll tree[N];

void update(ll x, ll d) {    //更新函数
	while (x < N) {
		tree[x] += d;    //该节点变化
		x += lowbit(x);    //寻找父节点
	}
}
ll sum(ll x) {    //求和函数
	int sum = 0;
	while (x > 0) {
		sum += tree[x];    //累加计算区间和的和
		x -= lowbit(x);    //寻找下一个节点
	}
	return sum;
}

int main() {
	ll n, m, I; cin >> n >> m;
	int d; ll p, q;
	for (int i = 1; i <= n; i++) { //初始化
		cin >> I;
		update(i, I);
	}
	for (int i = 1; i <= m; i++) {
		cin >> d >> p >> q;
		if (d == 1) {
			update(p, q);
		}
		else {
			cout << sum(q) - sum(p - 1) << endl;
		}
	}
	return 0;
}

很简单吧?下面开始进阶了。

3.2.2、区间修改 + 单点查询

试思考,能否把树状数组原始功能中的单点修改扩展为区间修改?其实,只需要一个巧妙的操作,即结合差分数组,就能实现高效的、不受“有序性”所限制的区间修改 + 单点查询,进而实现区间修改 + 区间查询。

注意到(这下是真的注意到了),树状数组的底部数组 a[ ] 和自身数组元素 tree[ ] 之间有着微妙的关系,tree[ ] 就是 a[ ] 的区间和,还记得我们是怎么实现区间修改的么?是差分数组。利用一维差分,可以实现数组在区间上的统一变化。

那么我们只需要结合好差分数组,将底部的数组视作差分数组 d[ ],此时区间和数组 tree[ ] 可以视为变形后的前缀和,以此计算出原数组 a[ ],那么对区域的修改就可以转化为单点修改,单点查询也就顺理成章地转换成了求区间和数组 tree[ ] 了。

还是一道模板题 洛谷P3368 【模板】树状数组2


如题,已知一个数列,你需要进行下面两种操作:

  • 将某区间每一个数加上 x;

  • 求出某一个数的值。

Input:

第一行包含两个整数 N、M,分别表示该数列数字的个数和操作的总个数。

第二行包含 N 个用空格分隔的整数,其中第 i 个数字表示数列第 i 项的初始值。

接下来 M 行每行包含 2 或 4个整数,表示一个操作,具体如下:

  • 1 x y k 含义:将区间 [x,y] 内每个数加上 k;
  • 2 x 含义:输出第 x 个数的值。

Output:

输出包含若干行整数,即为所有操作 2 的结果。


#include<iostream>
#define lowbit(x) ((x) & -(x))
#define ll long long
using namespace std;

const ll N = 100000000;
ll tree[N];

void update(ll x, ll d) {    //操作和3.2代码一致
	while (x < N) {
		tree[x] += d;
		x += lowbit(x);
	}
}
ll sum(ll x) {
	int sum = 0;
	while (x > 0) {
		sum += tree[x];
		x -= lowbit(x);
	}
	return sum;
}
int main() {
	ll n, m, I, preI = 0; cin >> n >> m;
	int d; ll p, q, k;
	for (int i = 1; i <= n; i++) {
		cin >> I;
		update(i, I - preI);    //这里要把原数组的差分数组导入
		preI = I;
	}
	for (int i = 1; i <= m; i++) {
		cin >> d;
		if (d == 1) {    //区间修改
			cin >> p >> q >> k;
			update(p, k);
			update(q + 1, -k);
		}
		else {
			cin >> p;
			cout << sum(p) << endl;    //单点查询
		}
	}
	return 0;
}

利用差分,可以把区间问题转化为单点问题,如果区间内每个元素都修改,复杂度为O(m),用差分优化为只记录两个端点时,复杂度降低到O(1),这就是差分数组的重要作用。

3.2.3、区间修改 + 区间查询

前面将简单的单点修改扩展为了区间修改,下面考虑把单点查询扩展到区间查询,将查询的一个原数组元素 a[n] 的值改成数组 a[ ] 在区间内的和。

在前一节的研究中我们可以发现 a[ ] 和 tree[ ] 两个数组之间的关系,即 tree[ ] 反映 a[ ] 中的区间和。但我们也发现,如果想对原数组 a[ ] 进行区间修改,需要构建它的差分数组 d[ ] 并把它视作树状数组的底部元素,那么 a[ ] 就自然成为了树状数组中的节点,树状数组已经满了,此时若还需要整出它的前缀和数组(区间和)的话,上哪去构建呢?

可以很自然地想到,再加一个树状数组。

如果(虽然这种想法可能性不太高)只是简单地使用两个树状数组,一个用于区间修改,一个用于区间查询,效果其实并不高,做一次区间修改的复杂度为O(\log_{2}n),用另一个树状数组处理区间和,复杂度为O(k\log_{2}n),做n次修改和查询,总复杂度来到了O(n^{2}\log_{2}n),太恐怖了。

因此,我们需要让这两个树状数组紧密结合,称为二阶树状数组,同时也是差分和树状数组的结合。具体结合方法我们放在题目里说。

下面给出一道例题(没错这篇博客都是取的标准模板题,因为我没咋练过别的

洛谷P3372 线段树1:


如题,已知一个数列,你需要进行下面两种操作:

  • 将某区间每一个数加上 k。
  • 求出某区间每一个数的和。

Input:

第一行包含两个整数 n,m,分别表示该数列数字的个数和操作的总个数。

第二行包含 n 个用空格分隔的整数,其中第 i 个数字表示数列第 i 项的初始值。

接下来 m 行每行包含 3 或 4 个整数,表示一个操作,具体如下:

  • 1 x y k:将区间 [x,y] 内每个数加上 k。
  • 2 x y:输出区间 [x,y] 内每个数的和。

Output:

输出包含若干行整数,即为所有操作 2 的结果。


这道题还是可以使用树状数组来解的,那么如何使用二阶树状数组呢?两个树状数组是怎样结合的呢?

既然我们已经知道了如何使用差分数组把单点修改扩展为区间修改,且已知 a[ ] 和 d[ ] 的关系(在树状数组中)。接下来要用 tree[ ] 所表示的原数组 a[ ] 进一步计算出前缀和,我们不妨从前缀和的表达式入手,开始推导:

①a[n] = d[0] + d[1] + … + d[n];

②a[1] + a[2] + a[3] + … + a[k]

 = d[1] + (d[1] + d[2]) + (d[1] + d[2] + d[3]) + … + (d[1] + d[2] + … + d[k])

 = k*d[1] + (k - 1)*d[2] + (k - 2)*d[3] + … + (k - (k - 1))*d[k]

 = k*(d[1] + d[2] + … + d[k]) - (0*d[1] + 1*d[2] + 2*d[3] + … + (k-1)*d[k])

 = k\sum_{i=1}^{k}d[i] - \sum_{i=1}^{k}(i-1)d[i]

这里的k代表的就是下标,在题目中要灵活使用。

由此,我们就发现,求 a[n] 的前缀和可以转化为求它的两个差分数组的前缀和的差,而求差分数组的前缀和又回到了求原数组 a[n] 上,多巧啊!那么我们只需要使用两个树状数组分别储存 d[i] 和 (i - 1)*d[i] 就可以了,最后再使用树状数组计算出它们的前缀和,再相减。

知道原理之后,解法代码如下:

#include<iostream>
#define ll long long
#define lowbit(x) (x & - (x))
using namespace std;

const int N = 100010;
ll tree1[N], tree2[N];    //两个树状数组

void update1(int x, ll d) {
	while (x < N) { tree1[x] += d; x += lowbit(x); }
}
void update2(int x, ll d) {
	while (x < N) { tree2[x] += d; x += lowbit(x); }
}
ll sum1(int x) {
	ll sum = 0;        //数据量大,一定要开long long
	while (x > 0) { sum += tree1[x]; x -= lowbit(x); }
	return sum;
}
ll sum2(int x) {
	ll sum = 0;
	while (x > 0) { sum += tree2[x]; x -= lowbit(x); }
	return sum;
}

int main() {
	int n, m; cin >> n >> m;
	ll old = 0, num;
	for (int i = 1; i <= n; i++) {
		cin >> num;
		update1(i, num - old);    //第一个差分
		update2(i, (i - 1) * (num - old));    //第二个差分
		old = num;
	}
	int a, x, y;
	ll k;
	for (int i = 1; i <= m; i++) {
		cin >> a;
		if (a == 1) {
			cin >> x >> y >> k;
			update1(x, k); update1(y + 1, -k);
			update2(x, (x - 1) * k); update2(y + 1, y * -k);    //y*-k是(y+1-1)*-k的变形
            //第二个差分数组存进去的时候是(i-1)*d[i],所以减的时候k要乘当前下标
		}
		else {
			cin >> x >> y;
			ll res = y * sum1(y) - sum2(y) - (x - 1) * sum1(x - 1) + sum2(x - 1);//套用公式
			cout << res << endl;
		}
	}
	return 0;
 }

四、总结

一维差分是算法,树状数组是数据结构,两者都可以用于对区间段的维护。

一维差分将区间问题变成了端点问题,属于从O(n)到O(1)的优化,但是一维差分有着较大的局限性,在面对随机且大量的数据修改和查询时,一维差分由于其查找的不便性显得有些不够用,此时便可以使用树状数组来解决问题。

树状数组将数据以分治的思想处理,将维护的过程变成了近乎二分查找,属于从O(n)到O(\log_{2}n) 的优化。其原始功能是处理原数组,给出 a[ ] 求它的区间和,为“单点修改 + 区间查询”。

而将其同差分结合之后,可以将差分数组 d[ ] 视作原数组放入树状数组处理,那么就将给出差分数组的区间和,也就能以此计算原数组 a[ ],从而通过改变端点来改变区间,实现单点修改到区间修改的扩展。

公式表明,原数组的前缀和可以转化为差分数组的两个前缀和的差,那么如果使用两个树状数组,就可以进一步求出原数组的前缀和,实现单点查询到区间查询的扩展。

实际上,单单只有对区间段的维护在题目中是不够的(往往会更难)。因此一维差分和树状数组最好作为一个维护区间的方法和手段,而关于它们的扩展应用,我们将在之后详述~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值