前缀和算法可以计算某一个区间的累记和,但是出现修改的时候,前缀和的效率便得不到保障。于是数状数组出现了。出现原因总结——需求从单纯的区间查询变为了单点修改 + 区间查询。
树状数组
本文不探讨树状数组的开发过程,这里先给出树状数组的结构:
树状数组的设计非常巧妙,它让下标为\(i\)(从1开始)的位置存储原数组的部分和。比如下标为2的位置,存储了前两个数据的和,而下标为4的位置存储了前四个数据的和。
但是也有些特殊的位置,比如6。你会发现,虽然它是偶数下标,但是它并没有存储前6个数据,而是只存储了5、6两个数据。下面给出树状数组的核心机制\(lowbit\)。
lowbit()
\(lowbit\)的定义是:一个二进制数的低位零的个数。
比如,2 的二进制是 10 ,那么 \(lowbit(2)\) = 1 。
而 6 的二进制是110,所以和2一样 \(lowbit(6)\) = 1。
于是我们可以给出树状树组下标为 \(i\) 的位置的定义:
TreeArr[i] = 0;
for(int j = 0; j <= lowbit(i) ; j++)TreeArr[i] +=data[i-j];
这里我直接写了C++
的代码,但是阅读应该没有困难。其中data
是原数组的数据,TreeArr
是我们构造的树状数组。
lowbit()
函数的实现
\(lowbit\)函数有两种实现方式,其中第一种是比较容易理解的:
int lowbit(int i){
return x - (x & (x - 1));
}
第二种是比较抽象的,但是我个人比较喜欢,因为它更加的简洁优美。
int lowbit(int i){
return i & -i;
}
这个有兴趣的朋友可以自己推导一下,不过也不会很复杂的。
update(int i,int data)
函数
然后我来看一下单点更新如何在树状数组这样奇怪的数据结构上实现。
首先这个操作传入两个参数,一个是在原数组的位置,另一个是修改后的数值。再来看一下树状数组的结构:
假设我给data[3]
加上一个1
,那么树状数组中受到影响的节点有3
、4
、8
,不难发现,我们可以从底部的3
出发,自下而上的找出所有被影响的点,而4
= 3 + lowbit(3)
、8
= 4 + lowbit(4)
,是不是非常的妙?前推和后推都回到了\(lowbit\)上,不然怎么数\(lowbit\)是树状数组的核心机制呢?
有了这个理论基础,我们就能轻松的写出\(update\)函数的代码了。
int update(int i ,int data){
while(i < n){ // n 是数组的长度
TreeArr[i] += data;
i += lowbit(i);
}
}
不难看出这是一个O(lgN)的更新操作。
Sum(int j)
函数
Sum(int j)
函数实现了原数组中前j
个数据的求和。
前面提到过,TreeArr[i]
包括了从i
开始的往前数lowbit(i)
个数据的和,那么在求前 j
个数据的和时,我们可以利用和更新中类似的方法,每次计算当前lowbit(j)
个数据的和,然后去到j - lowbit(j)
的位置继续求前面的值。代码如下,也是非常的简洁
int Sum(int j){
int rest = 0;
while(j > 0){
rest += TreeArr[j];
j -= lowbit(j);
}
return rest;
}
不难看出这是一个O(lgN)的求和。
应用:单点更新区间查询
P3374 【模板】树状数组 1
https://www.luogu.com.cn/problem/P3374
区间更新 + 单点查询
这时,我们的需求改变了,我们不再需要区间查询了,而只要单点查询,但是需要实现区间修改。这时我们可以使用到一个数学概念——差分。使用差分作为树状数组存储的内容,可以让树状树组从单点修改 + 区间查询变为区间修改 + 单点查询。
差分的定义
假设d[n]
是一个差分数组,那么:
非常好理解,如果我要修改全部的数据,比如把所有数据加 1,那么我们只需要在第一个位置加上一就好了,因为差分数组的性质,其他的位置值并不需要修改。
那么如果我们要进行单点的查询,比如 data[n]
(原数据),就需要计算前d
数组的前n
项和。这一就完美地完成了从单点修改 + 区间查询变为区间修改 + 单点查询的过程。
树状数组的不足
当我们的问题变成区间修改 + 区间查询时,树状树组便不能完成这个工作了(一维),我们需要更好的数据结构,下节课——线段树完美解决树状数组的问题。
PS:其实并不是,树状树组的内存是(N),而线段树需要(N<<2)也就是原数组四倍的内存空间。