利用树状数组解决几类问题
树状数组作为一种实现简单、应用较广的高级数据结构,在OI界的地位越来越重要,下面我来简单介绍一下树状数组和它的简单应用。
一、树状数组简介
树状数组(Binary Indexed Trees,简称BIT)是一种特殊的数据结构,这种数据结构的时空复杂度和线段树相似,但是它的系数要小得多。它可以方便地查询出一段区间中的数字之和。其查询和修改的时间复杂度均为O(lbN),并且是一个在线的数据结构,可以随时修改并查询。我接下来用一道题目来介绍树状数组的几个基本操作。
【引例】
假设有一列数{Ai}(1<=i<=n),支持如下两种操作:
1. 将Ak的值加D。(k,D是输入的数)
2. 输出(s,t都是输入的数,s<=t)
这道题目用线段树很容易可以解决,我就不多说了,那么如何用树状数组来解决呢?我们新增一个数组C[],其中C[i]=a[i-2k+1]+……+a[i](k为i在二进制形式下末尾0的个数)。根据定义我们可以得出一下这张表格:
i | 二进制 | K |
|
1 | (1)2 | 0 | c[1]=a[1] |
2 | (10) 2 | 1 | c[2]=a[1]+a[2]=c[1]+a[2] |
3 | (11) 2 | 0 | c[3]=a[3] |
4 | (100) 2 | 2 | c[4]=a[1]+a[2]+a[3]+a[4]=c[2]+c[3]+a[4] |
5 | (101) 2 | 0 | c[5]=a[5] |
6 | (110) 2 | 1 | c[6]=a[5]+a[6]=c[5]+a[6] |
…… |
在这里,我们会发现k值的求解会有一些难度,这就引出了树状数组的第一个操作:低位技术,也叫Lowbit(k)。
对于Lowbit这里我提三种不同的写法(这三种形式是等价的,读者有兴趣可以去证明一下):
1.Lowbit(k)=k and (k or (k-1))
2.Lowbit(k)=k and not (k-1)
3.Lowbit(x)=k and –k
然后我来分析引例的树状数组解法,为了可以更好地理解这种方法,读者可以根据以下这幅图来加以理解。
【操作2】修改操作:将Ak的值加D
可以从C[i]往根节点一路上溯,调整这条路上的所有C[]即可,这个操作的复杂度在最坏情况下就是树的高度即O(lbN)。
定理1:若A[k]所牵动的序列为C[p1],C[p2]……C[pm], 则p1=k,而(li为pi在二进制中末尾0的个数)。
例如A[1]……A[8]中,a[3] 添加x;
p1=k=3 p2=3+20=4
p3=4+22=8 p4=8+23=16>8
由此得出,c[3]、c[4]、c[8]亦应该添加x。
定理的证明如下:
【引 理】若A[k]所牵动的序列为C[p1],C[p2] …… C[pm], 且p1 <p2<…<pm,则有l1 <l2<…<lm(li为pi在二进制中末尾0的个数)。
证明:若存在某个i有li≥li+1,则,,,即: (1)
而由Li=Li+1、Pi< Pi+1可得 (2)
(1) (2)矛盾,可知l1 <l2 <……<lm
定理2:p1=k,而p i+1=pi+2li
证明:因为p1 <p2 < …… <pm 且C[p1],C[p2] …… C[pm]中包含a[k],因此p1=k。在p序列中,pi+1=pi+2li是pi后最小的一个满足li+1 >li的数(若出现Pi+x比pi+1更小,则x<2li ,与x在二进制中的位数小于li相矛盾)。Pi+1=pi+2li,li+1≥li+1。
由pi-2li+1≤K≤Pi可知,Pi+1-2li+1+1≤Pi+2li–2*2li+1=Pi– 2li+1≤K≤Pi≤P i+1 ,故Pi与pi+1之间的递推关系式为
P i+1=Pi+2li
【操作3】求数列的前n项和。只需找到n以前的所有最大子树,把其根节点的C加起来即可。不难发现,这些子树的数目是n在二进制时1的个数,或者说是把n展开成2的幂方和时的项数, 因此,求和操作的复杂度也是O(lbN)。
根据c[k]=a[k-2l+1]+… +a[k] (l为k在二进制数中末尾0的个数),我们从k1=k出发,按:ki+1=ki-2lki(lki为ki在二进制数中末尾0的个数)
递推k2,k3,…,km (km+1=0)。
由此得出
S=c[k1]+c[k2]+c[k3]+ … + c[km]
相信看了这两个操作的理论部分,读者应该有了一定的理解,下面给出这两种操作和其他一些重要操作的Pascal代码。
【操作1】低位技术:Lowbit(k)
Lowbit(k):即k在2进制数中从最低位开始连续0的位数的关于2的幂。
代码
Function Lowbit(k:Longint) :Longint;
Begin
Lowbit:=kand –k;
End;
【操作2】修改操作:Modify(k, D)
Modify(k, D):对数组 a[] 中的第k个元素加上D。为了维护C[] 数组,我就必须要把 C[] 中所有“管”着 a[k] 的 c[k] 全部加上D,这样才能随时以 O(lbN) 的复杂度进行 Sum(i) 的操作。而 "k:=k+ Lowbit(k)" 正是依次访问所有包含 a[k]的 c[k] 的过程。
修改a[k],我们需对c[k] , c[k+Lowbit(k)] , c[k+Lowbit(k)+Lowbit(k+Lowbit(k))]……进行修改。时间复杂度O(lbN) 。
代码
Procedure Modify(k,D:Longint);
Begin
Whilek<=N Do
Begin
C[k]:=C[k]+D;
k:=k+Lowbit(k);
End;
End;
【操作3】求和操作:Sum(N)
Sum(i): 求和正好反过来,每次 “i:=i-Lowbit(i)” 依次求 a[i] 之前的某一段和。因为 c[i] 有这样一个性质:Lowbit(i) 的值即为 c[i] “管”着 a[i] 中元素的个数,比如 i = (101100)2,那么 c[i] 就是从 a[i] 开始往前数(100) 2 = 4 个元素的和,也就是 c[i]= a[i] + a[i - 1] + a[i - 2] + a[i - 3]。那么每次减去 Lowbit(i)就是依次跳过当前 c[i] 所能管辖的范围,以便不重不漏地求出所有 a[i] 之前的元素之和。
Sum(i)=c[i]+c[i-Lowbit(i)]+c[i-Lowbit(i)-Lowbit(i-Lowbit(i))]……时间复杂度O(lbN)
代码
Function Sum(N:Longint):Longint;
Begin
Sum:=0;
While N>0Do
Begin
Sum:=C[N]+Sum;
N:=N-Lowbit(N);
End;
End;
和其他高级数据结构一样,树状数组也支持求第k小元素、删除以及插入操作(这些一直不被人熟知),下面我来重点介绍一下:
【操作4】删除操作:Delete(k)
我们设C[i]表示数i出现的次数,删除第k个数只需要Dec(C[A[k]])即可(如果A[k]很大,只需要离散化即可)。
代码
Procedure Delete(K:Longint);
Begin
Modify(A[k],-1);
End;
【操作5】插入操作:Insert(k)
类似地,我们设C[i]表示数i出现的次数,插入第k个数只需要Inc(C[A[k]])即可(如果A[k]很大,只需要离散化即可)。
代码
Procedure Insert(K:Longint);
Begin
Add(A[k],1);
End;
【操作6】取第K小的数:FindKth(K)
同操作4,我们设C[i]表示数i出现的次数。如果要求出最小值,也就是说我们需要找出一个最小的i,使得,这个i必定是最小值。接下来就是如何找出这个i的问题了。令,我们可以发现S具有单调性,于是我们可以通过二分查找来找出这个i.
代码
Function FindKth(K:Longint):Longint;
Var Left,Right,Mid,tmp:Longint;
Begin
Left:=1; Right:=MaxValue;
While Left<=RightDo
Begin
Mid:=(Left+Right)shr 1;
tmp:=Sum(mid);
If tmp<KThen Left:=Mid+1
else
Begin
FindKth:=Mid;
Right:=Mid-1;
End;
End;
End;
以上三个操作的时间复杂度分别为:O(lbN)、O(lbN)和O(lb2N)。其实用树状数组找第K小的数还有其他的做法,可以将时间复杂强度降为O(lbN),这里就不再详细介绍,又想去的读者可以参见相关资料。
二、利用树状数组解决几类问题
1.区间求和类问题
【例1】数列操作1
描述
给定一个初始值都为0的序列,动态地修改一些位置上的数字,加上一个数,减去一个数,或者乘上一个数,然后动态地提出问题,问题的形式是求出一段数字的和。
输入
输入数据第一行包含2个整数N,M(1≤N,M≤100000),表示序列的长度和操作的次数。
接下来M行,每行会出现如下的一种格式:
· Add ix ——将序列中第i个数加上x
· Sub ix ——将序列中第i个数减去x
· Mul ix ——将序列中第i个数乘上x
· Queryi j ——求出序列中第i个数到第j个数的和
输出
对于每一个Query操作输出一个数,表示序列中第i个数到第j个数的和。
分析:
这道题目我们很容易想到用朴素模拟来解决,但时间复杂度最坏为O(NM),超时是必然的。这时候我们就可以巧妙借助树状数组来解决这个问题。加、减与求和操作就如同上文介绍的一样简单,只与乘操作,我们可以先将这个数减至0,然后加上这个数乘以x。
时间复杂度为O(MlbN)
【例2】数列操作2
描述
给定一个初始值都为0的序列,动态地修改一段连续位置上的数字,加上一个数,减去一个数,然后动态地提出问题,问题的形式是求出一个位置的数字。
输入
输入数据第一行包含2个整数N,M(1≤N,M≤100000),表示序列的长度和操作的次数。
接下来M行,每行会出现如下的一种格式:
· Add ij x ——将序列中第i个数到第j个数加上x
· Queryi ——求出序列中第i个数
输出
对于每一个Query操作输出一个数,表示序列中第i个数。
分析:
我们把支持这种操作的树状数组称为树状数组的模式二,对于模式二,树状数组可以做到随时修改数组a[]中某个区间的值O(1),查询某个元素的值O(lbN)
在这种模式下,a[i]已经不再表示真实的值了,只不过是一个没有意义的、用来辅助的数组。这时我们真正需要的是另一个假想的数组b[],b[i] 才表示真实的元素值。但c[] 数组却始终是为a[]数组服务的,这一点大家要明确。此时Sum(i)虽然也是求a[i]之前的元素和,但它现在表示的是实际我要的值,也就是b[i]。
比如现在我要对图1中a[]数组中红色区域的值全部1。当然你可以用模式一的 Modify(i)对该区间内的每一个元素都修改一次,但如果这个区间很大,那么每次修改的复杂度就都是O(NlbN),m次修改就是O(MNlbN),这在M和N很大的时候仍是不满足要求的。这时模式二便派上了用场。我只要将该区域的第一个元素+1,最后一个元素的下一位置-1,对每个位置Sum(i)以后的值见图2:
相信大家已经看得很清楚了,数组b[]正是我们想要的结果。模式二难理解主要在于a[] 数组的意义。这时请不要再管a[i]表示什么,a[i]已经没有意义了,我们需要的是b[i]!但模式二同样存在一个缺陷,如果要对某个区间内的元素求和,复杂度就变成O(nlbn)了。所以要分清两种模式的优缺点,根据题目的条件选择合适的模式,灵活应变!
【例3】[SDOI2009]HH的项链
描述
HH有一串由各种漂亮的贝壳组成的项链。HH相信不同的贝壳会带来好运,所以每次散步完后,他都会随意取出一段贝壳,思考它们所表达的含义。HH不断地收集新的贝壳,因此,他的项链变得越来越长。有一天,他突然提出了一个问题:某一段贝壳中,包含了多少种不同的贝壳?这个问题很难回答……因为项链实在是太长了。于是,他只好求助睿智的你,来解决这个问题。
输入
第一行:一个整数N,表示项链的长度。
第二行:N个整数,表示依次表示项链中贝壳的编号(编号为0到1000000之间的整数)。
第三行:一个整数M,表示HH询问的个数。
接下来M行:每行两个整数,L和R(1≤ L ≤ R ≤ N),表示询问的区间。
输出
M行,每行一个整数,依次表示询问对应的答案。
分析:
本题数据规模较大,需要合理使用数据结构。题目中有两个元素:区间和颜色。如果直接使用线段树套平衡树,需要牵扯到“合并”操作,时间复杂度很高(比如当询问类似[2,N-1]的时候)。因此,需要做一些预处理工作使得询问更容易解决。
观察在某一区间内出现的某种颜色,假设有若干个同色点都在这个区间中,而该颜色只能计算一遍,因此我们需要找出一个有“代表性”的点,当然是第一个最有“代表性”。观察该点,以及它前后同色点的位置,有什么可以利用的规律?很显然,它的上一个同色点肯定在区间左侧,而同区间内的该颜色的其他点,它们的上一个同色点显然在区间内。这样,我们的工作便是找到询问区间[l,r]中这样的点x的个数:点x的上一个同色点在询问区间[l,r]左侧。
到这一步,我们有一个在线算法:建线段树,将询问分成O(lbN)个小区间分别处理。如果再套平衡树,需要二分查找才能求符合条件的点的“个数”。这样总的理论最坏复杂度为O(M(lbN)3),虽然实际情况会好一些,但还是难以符合要求。我们分析一下该算法复杂度高的原因:平衡树难以很好支持求“个数”的运算,需要二分来实现。那么求“个数”效率最高的是什么数据结构?当然是树状数组。
当然,线段树是无法再套树状数组的,那样空间复杂度会达到O(N2),所以我们要舍弃线段树,寻找新的算法。对于一堆“无序”的询问区间,如果没有线段树,便很难处理。因此我们考虑将区间按左端点排序,使其有序,然后从左到右扫描每个点。再考虑那些“上一个同色点在询问区间左侧”的点,我们的目的是快速求出该区间中这种点的个数。而这些点的上一个同色点因为在询问区间左侧,所以已经被扫描过,而区间内其他点却没有!这样,如果我们换个角度,改“上一个”为“下一个”,预处理出每个点i的下一个同色点的位置Next[i],并且在扫描过该点i后使树状数组C[Next[i]]=1。那么对于询问区间[l,r],答案便是Sumc[r]-Sumc[l-1]!这样,算法只有“加1”和“求和”两种操作。问题到此得到圆满解决, 最终时间复杂度为O(MlbN)。
2.求序列中第k大数
【例4】魔兽争霸
描述
小x正在销魂地玩魔兽他正控制着死亡骑士和N个食尸鬼(编号1~N)去打猎。死亡骑士有个魔法,叫做“死亡缠绕”,可以给食尸鬼补充HP。
战斗过程中敌人会对食尸鬼实施攻击,食尸鬼的HP会减少。小x希望随时知道自己部队的情况,即HP 值第k多的食尸鬼有多少HP,以便决定如何施放魔法。请同学们帮助他:)
小x向你发出3种信号:(下划线在输入数据中表现为空格)
· A i a 表示敌军向第i 个食尸鬼发出了攻击,并使第i 个食尸鬼损失了a 点HP,如果它的HP<=0,那么这个食尸鬼就死了(Undead也是要死的……)。敌军不会攻击一个已死的食尸鬼。
· C i a 表示死亡骑士向第i个食尸鬼放出了死亡缠绕,并使其增加了a点HP。HP值没有上限。死亡骑士不会向一个已死的食尸鬼发出死亡缠绕
· Q k 表示小x向你发出询问
输入
第一行,一个正整数N,以后N个整数 表示N个食尸鬼的初始HP 值
接着一个正整数M,以下M行 每行一个小x发出的信号
输出
对于小x的每个询问,输出HP 第k多的食尸鬼有多少HP,如果食尸鬼总数不足k个,输出-1。每个一行数。
最后一行输出一个数:战斗结束后剩余的食尸鬼
分析
这道题目描述十分清楚,关键就是选取好的数据结构来实现。
我们设C[i]表示HP等于i出现的次数,假设所有数不超过S。
· 对于操作A_i_a:我们只需要将C[hp[i]]减1,C[hp[i]-a]加1即可,同时将hp[i]减a。要特殊考虑hp[i]-a小于等于0的情况;
· 对于操作C_i_a:我们只需要将C[hp[i]]减1,C[hp[i]+a]加1即可,同时将hp[i]加a;
· 对于操作Q_k:我们需要找到一个数x使得{ΣC[i]=k|i<=x,C[x]<>0}即可。
我们可以发现前两个操作的时间复杂度均为O(1),而第三个操作的时间复杂度接近于O(S)。显然我们只要将操作3的时间复杂度降下来就可以了。注意到这道题目只需要求和,这时候树状数组就派上了巨大的用场。构建一个树状数组C[]:
· 对于操作A_i_a:我们只需要Add(hp[i],-1),Add(hp[i]-a,1)即可,同时将hp[i]减a。要特殊考虑hp[i]-a小于等于0的情况;
· 对于操作C_i_a:我们只需要Add(hp[i],-1),Add(hp[i]+a,1)即可,同时将hp[i]加a;
· 对于操作Q_k:我们可以使用二分查找来找到那个数x(实现方式和找最小值一样)。
至此,我们只需要将所有的数先离散化就可以了。
时间复杂度:O(MlbN)
3.“图腾”类问题的统计
【例5】逆序对
描述
对于一个包含N个非负整数的数组A[1..n],如果有i < j,且A[ i ]>A[j ],则称(A[ i] ,A[ j] )为数组A中的一个逆序对。例如,数组(3,1,4,5,2)的逆序对有(3,1),(3,2),(4,2),(5,2),共4个。给定一个数组,求该数组中包含多少个逆序对。
输入
输入数据第一行包含一个整数N,表示数组的长度;
第二行包含N个整数。
输出
输出数据只包含一个整数,表示逆序对的个数。
分析
如题,这道题目只要求输出逆序对的个数就可以了,传统的求逆序对的方法有好多,这里我只介绍用树状数组求逆序对。
设C[i](i如果会很大,只需要离散化即可)表示i出现的次数。我们顺序枚举每一个数A[i],显然以A[i]为结尾的逆序对的个数为,这一步我们可以利用树状数组优化到O(lbN),接下来inc(C[A[i]])。将所有的加起来就可以计算出答案了。
【例6】[TYVJP1432]楼兰图腾
描述
在完成了分配任务之后,西部314来到了楼兰古城的西部。相传很久以前这片土地上(比楼兰古城还早)生活着两个部落,一个部落崇拜尖刀(‘V’),一个部落崇拜铁锹(‘∧’),他们分别用V和∧的形状来代表各自部落的图腾。
西部314在楼兰古城的下面发现了一幅巨大的壁画,壁画上被标记出了N个点,经测量发现这N个点的水平位置和竖直位置是两两不同的。西部314认为这幅壁画所包含的信息与这N个点的相对位置有关,因此不妨设坐标分别为(1,y1),(2,y2),…,(n,yn),其中y1~yn是1到N的一个排列。
如图,图中的y1=5,y2=1,y3=2,y4=4,y5=3.
西部314打算研究这幅壁画中包含着多少个图腾,其中V图腾的定义如下(注意:图腾的形式只和这三个纵坐标的相对大小排列顺序有关)1<=i<j<k<=N且yi>yj,yj<yk;
而崇拜∧的部落的图腾被定义为1<=i<j<k<=N且yi<yj,yj>yk;
西部314想知道,这N个点中两个部落图腾的数目。因此,你需要编写一个程序来求出V的个数和∧的个数。
输入
第一行一个数N;
第二行是N个数,分别代表y1,y2……yn
输出
两个数,中间用空格隔开,依次为V的个数和∧的个数
分析
我们可以发现,如果能统计出V图腾的个数,那么∧图腾的个数也可以利用类似的方法统计出来,这里我介绍两种方法来统计V图腾的个数。
方法一:
我们可以发现V图腾的前半部分具有逆序对的性质,因此我们可以利用类似的方法统计V图腾的个数。设f[1,i]表示数i出现的次数,f[2,i]表示以数i为结尾的“\”图腾的个数,f[3,i]表示以数i结尾的V图腾的个数。接下来我们顺序枚举Y[i],然后就是f[1..3]的转移:
最后的答案就是,用树状数组优化这个方程就可以将时间复杂度降为O(NlbN)。
方法二:
我们可以从另一个角度来考虑。我们仔细观察一下图形,V图腾要求两边的点的高度大于中间的点的高度。也就是说定下了中间的点之后,我们只需要分别统计这个点左边和右边比他高的点的个数就可以了(分别设为LS和RS)。根据乘法原理,以这个点为中间点的V图腾的个数就是LS*RS。显然我们的答案就是。至于计算RS和LS的过程中,我们只需要用树状数组来优化就可以了。
在实际运行效果中,虽然两种方法的时间复杂度都是O(NlbN),但方法一花的时间远超过方法二。这就告诉我们,在平时做题目的时候,要多多注意分析各种算法的时空复杂度,尽量使算法完美。
【例7】K阶逆序对(原创题)
描述
Jim是一位数列爱好者,他对于各种数列都有一定的研究。同时他对和数列有关的数也特别感兴趣,最近他开始研究逆序对。
先给出逆序对的定义:对于一个包含N个正整数的数组A[1..N],如果有i<j,且A[i]>A[j],则称<A[i],A[j]>为数组A中的一个逆序对。例如,数组{3,1,4,5,2}的逆序对有<3,1>,<3,2>,<4,2>,<5,2>,共4个。
同许多牛人一样,Jim很快解决了求一个给定序列的逆序对个数的问题。但Jim并不满足这一点,他开始研究逆序对的推广形式:K阶逆序对。下面给出K阶逆序对的定义:对于一个包含N个正整数的数组A[1..N],如果存在一个序列B[1..K],有1<=B[1]<B[2]<…<B[K]<=N,且A[B[1]]>A[B[2]]>…>A[B[K]],则称<A[B[1]],A[B[2]],…,A[B[K]]>为数组A中的一个K阶逆序对。
Jim想知道对于一个给定的序列A[1..N],到底存在多少个K阶逆序对?Jim在研究了K=2,3,4,…,的情况之后,发现这个问题十分复杂。于是他找到了OIT,希望你能帮助他解决这个问题。由于Jim不喜欢看到高精度数,你只需要告诉他这个数对10000007取模之后的结果。
输入
输入文件名为kpair.in,共N+1行。
第1行包含2个整数N和K,表示序列的长度和逆序对的阶数。
接下来N行,每行一个整数,表示Tim给出的序列。第i+1行表示序列的第i个数。
输出
输出文件名为kpair.out。共1行,包含一个整数,表示K阶逆序对的个数。你只需要输出这个数对1000007取模之后的结果。
分析
有了上面楼兰图腾的铺垫,相信读者能很快想出这道题目的解法。
我们可以发现,这个K阶逆序对是由一个个相互连接的逆序对组成的,我们可以考虑从递推的方向上解决这个问题。
设表示以数j为结尾的长度为i的逆序对的个数(我们这里先假定序列A里面的数不超过N)。类似地:
答案就是。
时间复杂度为O(NKlbN)
三、小结
1、树状数组有两种模式的应用:
模式一:对数组a[]的某个元素作修改O(lbN),查询某个区间内所有元素的和O(lbN)
模式二:随时修改数组a[]中某个区间的值O(1),查询某个元素的值O(lbN)
2、树状数组具有程序简单、代码短、不易出错、易推广到多维。
以二维为例,用c[k1][k2]表示c[k1-(2^t1)+1][k2-(2^t2)+1]+ ... + c[k1][k2]的总和。
3、树状数组相比线段树的优势:空间复杂度略低,编程复杂度低,容易扩展到多维情况。劣势:由于对其进行的运算有限制,树状数组的应用范围并不广泛。
总的来说,树状数组始终只是一种数据结构,而数据结构的出现就是为了辅助解题用的。也就是说,我们在学习树状数组的过程中要注意它的应用方面。比如说优化一些动规方程,优化贪心的时间复杂度等等。
其实树状数组还有其他更加高级的应用,像多维树状数组。由于篇幅的限制,这里不能一一介绍下面提供另外几道树状数组的题目,以便读者可以练习:
8.ZJOI2003 密码机