区间信息维护与查询【树状数组 】 - 原理1 一维树状数组
【原理1】 一维树状数组
有一个包含n 个数的数列2, 7, 1, 12, 5, 9 …,请计算前i 个数的和值,即前缀和sum[i ]=a [1]+a [2]+…+a [i ](i =1, 2, …, n)。该怎么计算呢?一个一个加起来怎么样?
sum = 0;
for(int k = 1; k <= i ; k ++){
sum += a[k];
}
若用这种办法,则计算前n 个数的和值需要O (n )时间。而且若对a [i ]进行修改,则对sum[i ], sum[i +1], …, sum[n ]都需要修改,在最坏的情况下需要O (n )时间。
当n 特别大时效率很低。
树状数组可以高效地计算数列的前缀和,其查询前缀和与点更新(修改)操作都可以在O (logn )时间内完成,那么树状数组是怎么巧妙实现这些的呢?
① 树状数组的由来
树状数组引入了分级管理制度且设置了一个管理小组,管理小组中的每个成员都管理一个或多个连续的元素。例如,在数列中有9个元素,分别用a [1], a [2], …, a [9]存储,还设置了一个管理小组c[]。
管理小组的每个成员都存储其所有子节点的和。
- c [1]:存储a [1]的值。
- c [2]:存储c [1]、a [2]的和值,相当于存储a [1]、a [2]的和值。
- c [3]:存储a [3]的值。
- c [4]:存储c [2]、c [3]、a [4]的和值,相当于存储a [1]、a[2]、a [3]、a [4]的和值。
- c [5]:存储a [5]的值。
- c [6]:存储c [5]、a [6]的和值,相当于存储a [5]、a [6]的和值。
- c [7]:存储a [7]的值。
- • c [8]:存储c [4]、c [6]、c [7]、a [8]的和值,相当于存储a[1]~a [8]的和值。
- c [9]:存储a [9]的值。
从上图可以看出,这个管理数组c []是树状的,因此叫作树状数组。怎么利用树状数组求前缀和及点更新呢?
[1] 查询前缀和
若想知道sum[7],则只需c [7]加上左侧所有子树的根即可,即sum[7]=c [4]+c [6]+c [7]。
- sum[4]:左侧没有子树,直接找c [4]即可,sum[4]=c [4]。
- sum[5]:左侧有一颗子树,其根为c [4],sum[5]=c [4]+c[5]。
- sum[9]:左侧有一棵子树,其根为c [8],sum[9]=c [8]+c[9]。
[2] 点更新
点更新指修改一个元素的值,例如对a [5]加上一个数y ,则需要更新该元素的所有祖先节点,即c [5]、c [6]、c [8],令这些节点都加上y 即可,对其他节点都不需要修改。
为什么只修改其祖先节点呢?因为当前节点只和祖先有关系,和其他节点没有关系。
- c [5]:存储a [5]的值,修改a [5]加上y ,因此c [5]也要加上y 。
- c [6]:存储c [5]、a [6]的和值(a [5]、a [6]),a [5]加上y ,c [6]也要加上y 。
- c [8]:存储c [4]、c [6]、c [7]、a [8]的和值(~a [1]a[8]),a [5]加上y ,c [8]也要加上y 。
那么这个管理数组(树状数组)是怎么得来的呢?
② 树状数组的实现
树状数组,又叫作二进制索引树(Binary Indexed Trees),通过二进制分解划分区间。那么c [i ]存储的是哪些值?
[1] 区间长度
若i 的二进制表示末尾有k 个连续的0,则c [i ]存储的区间长度为2^k ,从a [i ]向前数2^k 个元素,即c [i ]=a [i -2^k +1]+a [i-2^k +2]+…+a [i ]。
例如:i =6,6的二进制表示为110,末尾有1个0,即c [6]存储的值区间长度为2(2^1 ),存储的是a [5]、a [6]的和值,即c [6]=a[5]+a [6]。
i =5,5的二进制表示为101,末尾有0个0,即c [5]存储的值区间长度为1(2^0 ),它存储的是a [5]的值,即c [5]=a [5]。
怎么得到这个区间的长度呢?若i 的二进制表示末尾有k 个连续的0,则c [i ]存储的值区间长度为2^k ,换句话说,区间长度就是i 的二进制表示下最低位的1及它后面的0构成的数值。例如i =20,其二进制表示为10100,末尾有两个0,区间长度为2^2 (4),其实就是10100最低位的1及其后面的0构成的数值100(该数为二进制,其十进制为4)。
怎么得到100呢?可以先把10100取反,得到01011,然后加1得到01100,此时,最低位的1仍然为1,而该位前面的其他位与原值相反,因此与原值10100进行与运算即可。
- 取反运算(~):1变成0,0变成1。
- 与运算(&):两位都是1,则为1,否则为0。
在计算机中二进制数采用的是补码表示,-i 的补码正好是i 取反加1,因此(-i )&i 就是区间的长度。若将c [i ]存储的值区间长度用lowbit(i )表示,则lowbit(i )=(-i )&i 。
算法代码:
int lowbit(int i){
return (-i) & i;
}
[2] 前驱和后继
直接前驱:c [i ]的直接前驱为c [i -lowbit(i )],即c [i ]左侧紧邻的子树的根。
直接后继:c [i ]的直接后继为c [i +lowbit(i )],即c [i ]的父节点。
前驱:c [i ]的直接前驱、其直接前驱的直接前驱等,即c [i ]左侧所有子树的根。
后继:c [i ]的直接后继,其直接后继的直接后继等,即c [i ]的所有祖先。
c [7]的直接前驱为c [6],c [6]的直接前驱为c [4],c [4]没有直接前驱;c [7]的前驱为c [6]、c [4]。
c [5]的直接后继为c [6],c [6]的直接后继为c [8],c [8]没有直接后继;c [5]的后继为c [6]、c [8]。
[3] 查询前缀和
前i 个元素的前缀和sum[i ]等于c [i ]加上c [i ]的前驱,sum[7]等于c [7]加上c [7]的前驱,c [7]的前驱为c [6]、c [4],因此sum[7]=c [7]+c [6]+c [4]。
算法代码:
int sum(){ //求 前缀和a[1] .. a[i]
int s = 0;
for( ; i > 0 ; i -= lowbit(i)){ //直接前驱 i -= lowbit(i)
s += c[i];
}
return s;
}
[4] 点更新
若对a [i ]进行修改,令a [i ]加上一个数z ,则只需更新c [i ]及其后继(祖先),即令这些节点都加上z 即可,不需要修改其他节点。修改a [5],另其加上2,则只需c [5]+2,对c [5]的后继分别加上2,即c [6]+2、c [8]+2。
算法代码:
void add(int i , int z){ //a[i] 加上z
for(; i <= n ; i += lowbit(i)){ //直接后继,即父节点 i += lowbit(i)
c[i] += z;
}
}
注意:树状数组的下标从1开始,不可以从0开始,因为lowbit(0)=0时会出现死循环。
[5] 查询区间和
若求区间和值a [i ]+a [i +1]+…+a [j ],则求解前j 个元素的和值减去前i -1个元素的和值即可,即sum[j ]-sum[i -1]。
算法代码:
int sum(int i , int j){ // 求区间和a[i] .. a[j]
return sum(j) - sum(i - 1);
}
③ 算法分析
树状数组是通过二进制分解划分区间的。树状数组的性能与n 的二进制位数有关,n 的二进制位数为 ⌊ logn ⌋ + 1 ,⌊ x ⌋ 表示向下取整,即取小于或等于x 的最大整数。⌊ log5 ⌋ =2,5的二进制位数为3位;⌊ log8 ⌋ =3,8的二进制位数为4。
如何求解树状数组的高度呢?树状数组底层的叶子是c [1],因此从开始一直找其后继(祖先)直到树根,就是树状数组的高度。
c [1]-c [2^1 ]-c [2^2 ]-c [2^3 ]-…-c [n ],每次都是2倍增长,假设n =2^x,则x =logn ,因此树高h =O (logn )。更新时,从叶子更新到树根,执行的次数不超过树的高度,因此更新的时间复杂度为O (logn )。
查询前缀和时,需要不停地查找前驱,那么前驱最多有多少个呢?
n 的二进制数有k = ⌊ logn ⌋ + 1 位,在最多的情况下,每一位都是1,则n =“111…1”可以被表示为n =2^(k -1) +2^(k -2) +…+2^1 +2^0 。7=“111”=2^2 +2^1 +2^0 ,c [7]的前驱为c [7-2^0 ]、c[7-2^0 -2^1 ]、c[7-2^0 -2^1 -2^2 ],最后一个为c [0],表示不存在,因此c [7]的前驱为c [6]、c [4]。前驱的个数与n 的二进制数的位数有关,不超过O(logn ),
因此查询前缀和的时间复杂度为O (logn ),即树状数组修改和查询的时间复杂度均为O (logn )。