引言
我们来看这样两个问题:
1.在数组a[i]中,更新a[i]的值(单点更新)
2.在数组a[i]中,求a[i] + a[i + 1] + ... + a[i + k](区间求和)
朴素解析:
对于问题1来说,我们可以用一个简单的数组操作来实现,即
a[i] += b;
时间复杂度为O(1),很完美。
对于问题2来说,我们可以用一个for循环来实现,即
int ans = 0;
//求x~y的区间和
for(int i = x; i <= y; i ++) ans += a[i];
时间复杂度为O(n)。
如果对于n比较大的情况,或者对于多次操作单点更新,再多次求区间和的话,时间复杂度显然是比较高的。
有的小伙伴想到了用前缀和来维护,即
int b[N]; //b[N]为前缀和数组
for(int i = 1; i <= n; i ++) b[i] = b[i - 1] + a[i];
这样的话区间查询复杂度倒是O(1)了,使用b[y] - b[x - 1]即可计算x~y的区间和。
但是问题来了,如果我此时要对x这个点进行单点更新,那么我要把前缀和数组b[x]以及后面所有的点进行更新,时间复杂度为O(n),这就和前面暴力做法一样了。
树状数组引入
那么我们就想,能不能把这两个操作的复杂度折中一下呢?如果两个复杂度都是logn的话,效率的提升是巨大的。
我们能不能对前缀和维护的做法进行改进呢?对于前缀和来说,b[i]保存的是从b[1]~b[i]所有数的和。有没有一种可能,我们也许没必要保存前面所有数的和,只需要保存部分数的和,拼凑在一起就能表示所有数的和呢?
再深入思考一下,我们知道一般来说,logn是树的复杂度。而书的深度又与2的n次方有关,那么我们来试着研究一下数组的二进制下标,能不能发现一些规律呢?
1:000(1) 最后一个1转换为十进制为1
2:00(1)0 最后一个1转换为十进制为2
3:001(1) 最后一个1转换为十进制为1
4:0(1)00 最后一个1转换为十进制为4
...
我们有个大胆的想法:如果让以上的1、2、1、4作为管理元素个数的数组,会怎么样呢?
我们假设新数组c[i]为部分前缀和数组,按照上面的例子来说:
c[1] = a[1] 管理1个元素
c[2] = a[1] + a[2] 管理2个元素
c[3] = a[3] 管理1个元素
c[4] = a[1] + a[2] + a[3] + a[4] 管理4个元素
c[i]数组长这样:
回归我们在引言提出的问题
如果我们要更新a[1],那么我们就把后面所有管理着a[1]的c[i]更新一遍,即
c[1] += b, c[1 + 1] += b, c[2 + 2] += b, ... ,c[k + x] += b
//其中:x为k的二进制最后一个1代表的10进制的值
//比如k=2的时候,2转化为二进制是(1)0,那么x就是那个1表示的10进制的值,为2
如果要求a[1] + a[2] + a[3] + a[4],那我们直接求出c[4]即是答案,如果我们要求a[1] + a[2] + a[3],那么求c[2] + c[3]即是答案,即
a[1] + a[2] + a[3] = c[3] + c[3 - 1] + c[2 - 2];
a[1] + a[2] + ... + a[n] = c[n] + c[n - x] + ... + c[0]
//其中:x为n的二进制最后一个1代表的10进制的值
//比如n=6的时候,6转化为二进制是01(1)0,那么x就是那个1表示的10进制的值,为2
从上面两个例子中,我们惊奇的发现,只要每次能确定数组下标的二进制的最后一个1所代表的值,那我们就可以在logn的时间内完成单点更新以及区间查询了。
这个过程就像一个部分前缀和,每次不管是单点修改还是区间查询,都是只更改部分值就好了。而要确定每次要更新的点的下标,我们就要知道当前点下标的二进制最后一个1代表的10进制的值。
比如我要更新a[1],那么我要更新a[1], a[2], a[4], a[8] ... 而这些下标的确定,就是其二进制最后一个1代表的10进制累加起来的,也就是1 + 000(1) ,2 + 00(1)0,4 + 0(1)00 ...
树状数组核心操作lowbit
那么我们只剩下一个问题,就是在O(1)的时间内求出每个下标的二进制的最后一个1代表的10进制的值。如果不能在O(1)的时间内求出,那么所有之前的操作都没有太大的意义。
所以重点来了:
int lowbit(int x) {
return x & -x;
}
没错,只需要这么简单就可以求出上述的问题的答案。本次主要初步介绍树状数组,所以这样操作的原因这里暂时不加赘述。
有了这个lowbit操作,我们所有的操作都如鱼得水。
单点更新
void update(int idx, int k) {
//每次寻找下一个管理着当前点的树状数组
/*找当前点的二进制数的最后一个1转化为十进制的值,
每次加上这个值就可以找到下一个要更新的地方*/
for(int i = idx; i <= n; i += lowbit(i)) {
c[idx] += k;
}
}
查询区间和
//求区间1~idx的和
int query(int idx) {
int ans = 0;
for(int i = idx; i; i -= lowbit(i)) {
ans += c[i];
}
return ans;
}
//查询x~y的和:query(y) - query(x - 1)
总结
对于单点更新操作:我们只要每次更新i + lowbit(i)所表示的数组下标的值,就可以完成对整个数组的更新。
对于区间查询操作:我们每次只要求i - lowbit(i)所覆盖的那些部分前缀和数组,就可以拼凑出完整的前缀和。
我们循环时每次行进的距离都是2的整数次幂,所以这两个操作的复杂度均为O(logn)
当然,树状数组可以解决的问题远不止这两个,引入差分的思想可以让树状数组发挥其更多的光芒。后续请继续关注,如有不足之处请大家指出。