目录
例题及补充(求逆序对)
一、前缀和与差分数组
1.前缀和数组
我们经常会遇到求一段区间的连续和问题,直接暴力求解(从左端点一直累加到右端点)时间复杂度为o(n),貌似也不低...但是对于有m次询问的题,时间复杂度为O(mn)就显得有点捉襟见肘了,此时一个好的想法就是使用前缀和数组。
所谓前缀和数组,顾名思义就是维护一段序列的前缀和的数组,其实现也相当简单:
定义前缀和数组sum[N],全部初始化为0,输入第i个数据x时,只要将sum[i]=sum[i-1]+x,就能递推得到整个前缀和数组,但要注意i要从1开始。
得到了前缀和数组,我们再来看求一段区间的连续和问题,如果左端点是从1开始,那么直接返回sum[i]即是答案,如果左端点为L且1,此时利用容斥原理,只要减去sum[L-1]的部分即是答案,时间复杂度仅为O(1)!
2.差分数组
记录数组中每个元素与前一个元素的差,注意不是连续的差,仅仅是相邻位置的差:minus[i]=a[i]-a[i-1],此时我们注意到:,即差分数组的前缀和数组就是原数组。差分是一个很重要的思想,能让某些算法扩展出更多的功能。同样,差分思想与树状数组结合后,也会有一系列神奇的操作,具体看下文的介绍。
二、树状数组的使用
1.树状数组的建立:
树状数组建立的思想:
任意一个自然数x均可用二进制表示,即被唯一分解为···
,由此区间[1,x]可以进一步划分为O(logx)个小区间:
长度为:
,
长度为:
,
…… …… …… …… ……
长度为:
···
,
···
长度为:
···
,
···
将数x分解后,保存每个区间的和,这样在求区间1~x的和时,只需要将这logx个区间和相加即可得到。对于任意x,我们可以看出:分解出的区间长度 = x的二进制只保留最末尾的1的值,而要取出这个最末尾的1我们需要借助位运算,具体实现代码如下,原理可以自己手算摸索下:
//lowbit操作
int lowbit(int x) {
return x & (-x);
}
为什么叫树状数组呢?这个问题很关键,强烈建议读者弄明白:
我们以1~8为例:
0001:
:num[1]
0010:
:num[1]+num[2]
0011:
:num[3]
0100:
:num[1]+num[2]+num[3]+num[4]
0101:
:num[5]
0110:
:num[5]+num[6]
0111:
:num[7]
1000:
:num[1]+num[2]+num[3]+num[4]+num[5]+num[6]+num[7]+num[8]
直接观察似乎看不出什么,但是如果我们将其理解成下面的树形结构,我们将发现一系列奇妙的规律!
1.树的深度为logn;
2.第x个结点的父结点为x+lowbit(x);
3.第x个结点的子结点个数为lowbit(x);
我们关注结论2:第x个结点的父结点为x+lowbit(x),如4=0100(二进制),lowbit(4)=100,4+lowbit(4)=0100+100=1000=8,即4的父结点为8;7=0111,lowbit(7)=1,7+lowbit(7)=1000=8,即7的父结点也为8。那么为何x+lowbit(x)就一定是x的父结点呢?
在树状数组中,一个结点的祖先必须满足它求和的区间包含该结点,而父结点则是距离它最近的祖先。而对于任意结点i其求和区间为[ i-lowbit(i)+1, i ],假设y为x的父结点,则其就是使得y-lowbit(y)+1<=x恒成立的最小值。直接思考不易想到y的值,我们将y换一种形式表示:y=x+k(k>=1,k为整数),这样求出k的值也就相当于求得了y的值。那么原式变为x+k-lowbit(x+k)+1<=x,移项得k-lowbit(x+k)+1<=0。由于lowbit(x)为x最末尾的1,因此当0<k<lowbit(x)时,x+k将不会产生进位,最末尾的1一定在k上,所以lowbit(x+k)=lowbit(k),如x=0100,k取0001、0010、0011,对应的x+k为0101、0110、0111。所以原式变为k-lowbit(k)+1<=0,而k-lowbit(k)根据定义一定>=0,因此k-lowbit(k)+1恒>=1,这与要求恒<=0冲突,所以k一定要>=lowbit(x)。由于我们要求的k是满足条件的最小值,其实此时就可以大胆猜测k就等于lowbit(x),当k=lowbit(x)时,由于lowbit(x)为x最末尾的1,则x+lowbit(x)则必定发生一次进位,如上面4和7的例子,发生进位后,lowbit的值相比之前会发生左移,假设原lowbit(x)的值为
(m>=0,m为整数),则进位后lowbit(y)的值为
(n>=1,n为整数),所以原式改写为k-lowbit(x+k)+1=lowbit(x)-lowbit(x+k)+1等于
,令
,易证该函数单调递减,在m=0时取得最大值
(n>=1,
<=0), 因此当k=lowbit(x)时,x+k-lowbit(x+k)+1<=x恒成立,且k=lowbit(x)是使该式成立的最小值,所以x的父结点为x+k=x+lowbit(x)。
至于如何初始化树状数组,一个很直观且简单的方案就是先将数组全部置零,然后对每个位置调用单点修改函数更新数组的值(单点修改函数见下文),单点修改的时间复杂度为logn,一共n个位置,所以总时间复杂度为O(nlogn):
for(int i=1; i<=n; ++i){
cin >> x;
update(i, x);
}
不过对于有些题,这样建立树状数组可能慢了一点,所以我们可以更高效的初始化:从小到大依次扫描所有结点,并通过lowbit操作找到它的父结点,累加进去。这样整个操作的时间复杂度仅为O(n)(测试了几题后发现运行时间没有明显的减少,额,这个我也很难解释为什么...),于此同时带来的代价则是耗费了更多的空间,如果遇见卡内存的题就可能直接MLE了~
const int N = 100005;
long long num[N], sum[N];
int n;//数据规模
//建立树状数组;
void build(){
for(int i=1, x; i<=n; ++i){
sum[i] += num[i];
x = i + lowbit(i);
if(x <= n)
sum[x] += sum[i];
}
}
2.区间询问:
根据以上思想,我们用一个数组sum[i]来保存每个x对应区间的数之和,要计算num[1]到num[i]的和,只需要凑齐[1,i]这个区间,对于当前位置i,存储区间为[i-lowbit(i)+1,i], 那么它的上一个区间则以i-lowbit(i)结束,同时这也是其对应的x值,一直重复此操作,直到等于0为止(减去自身的lowbit值将得到连续的不重合的区间):
//求num[1]到num[x]的和
long long solve(int x) {
long long ans = 0;
for(int i=x; i>0; i-=lowbit(i))
ans += sum[i];
return ans;
}
3.单点修改:
如果修改了原数组num[i]一个位置的值,那么sum[i]数组中所有包含该值的位置都需要修改,即自身的所有父结点都需要修改,而x的父结点根据树状数组的特性可以知道等于x+lowbit(x)(加上自身的lowbit值将得到自身的父结点):
//单点修改,x位置+y
void update(int x, int y) {
for(int i=x; i<=n; i+=lowbit(i))
sum[i] += y;
}
4.与差分数组结合后实现区间修改:
我们都知道树状数组的一个劣势就是无法进行区间修改,但是利用前面提到过的差分数组,我们却可以实现这个nb的功能。
区间修改,单点查询:
举个栗子: 序列:2 3 4 5 6 7 9 差分数组:2 1 1 1 1 1 2
我们将原序列中的第2到第6个数+3 : 序列:2 6 7 8 9 10 9 差分数组:2 4 1 1 1 1 -1
此时我们发现差分数组中只有第2与第7两个位置的数据发生了改变。这是巧合吗?当然不是,其实只要稍微想想就能理解,一段区间(l,r)的数都增加了x,它们的差会改变吗?很显然不会,会改变的位置只有l与r+1。这样一个区间修改的问题就变为了单点修改的问题,只不过要修改两个点。同时我们知道差分数组的前缀和数组就是原数组,所以要实现区间修改,单点查询,我们只要将差分数组构造成树状数组,在进行区间修改时转变为单点修改就解决了!
这里再讲一下实际上用得更多的实现,其实我们可以不用构造原序列的差分数组,我们将原数组保留,然后建立一个全为0的数组,对其进行上述区间修改转单点修改操作(其实这个全为0的数组也是一个差分数组,只是它的原序列也全为0),这样做我们再查询前缀和,此时我们得到的其实就是原数组要增加的值,只要再与原数组的值相加就是最终答案。
区间修改,区间询问(要是弄不明白就直接上线段树吧):
我们假设上面单点修改(不构造差分数组)的树状数组为bit[i],原数组为num[i],再构造原数组的前缀和数组sum[i]。
由上述分析得知bit数组的前缀和就是原数组要增加的值,即:
现在要求原数组的前缀和,则总共要增加的值为:
这个式子我们可以做一个变形,我们考虑bit[1]~bit[x]每一个数在式子中被运算的次数:bit[1]出现了x次,bit[2]出现了x-1次···bit[x]出现了1次,所以上式可变形为: 。
我们目前只维护了bit[i]的前缀和,通过上式可知我们还要维护i*bit[i]的前缀和。
最终的答案就为:sum[x]+((x+1)*bit[i]的前缀和-i*bit[i]的前缀和)
5.二维树状数组:
从序列扩展到矩阵,一重循环改成二重循环即可:
//将(x,y)的值+z
void update(int x, int y, int z) {
for(int i=x; i<=n; i+=lowbit(i))
for(int j=y; j<=m; j+=lowbit(j))
sum[i][j]+=z;
}
//从(1,1)到(x,y)的和
long long solve(int x, int y) {
long long ans = 0;
for(int i=x; i>0; i-=lowbit(i))
for(int j=y; j>0; j-=lowbit(j))
ans += sum[i][j];
return ans;
}
区间修改的做法也能从一维拓展到二维,感兴趣的可以尝试着推导下。
例题及补充:
1.数星星
一道很经典的题目,同时也体现了树状数组的一个非常重要的用途:求逆序对!
先来看看这道题本身:粗略读题后的第一印象是这题应该是一个二维树状数组的问题,因为涉及到了两个方向——x轴与y轴。但是仔细读题,会发现y轴本身是有序的,呈一个升序排列!为充分利用这个有序性,我们可以沿y轴方向处理,这样对于题目“位于左下方”中“下方”的要求,实际上就已经满足且不需我们考虑了。于是,我们就只用考虑“左方”,即每颗星星之前有多少颗星星的x坐标小于等于该星星的x坐标。把横坐标看成一个序列,问题就转化为求每个数之前有多少小于等于它的数,这正是求逆序对的一个重要过程。
实际上我们已经有归并的方法来求得逆序对了,但是使用树状数组又快又实用,所以下面来看看如何用树状数组求逆序对:
按惯例举个栗子,假定降序为排序顺序,给出序列: 5 4 3 2 1
首先建立一个全为0的树状数组,然后我们依次扫描该序列,第一个数是5,我们就在树状数组第5个位置+1,同时计算树状数组1~5位置的前缀和,就得到该位置前面共有多少个数小于等于5(包括了它本身,减去1即可),答案累加这个值;第二个数是4,就在树状数组第4个位置+1,同时计算树状数组1~4位置的前缀和,就得到该位置前面共有多少个数小于等于4(同样包括了它本身,减去1即可),答案继续累加,以此类推直到扫描完整个序列,最终累加完成的答案就是逆序对数,本例很显然等于0。我们再来考虑排列顺序是升序又该如何操作:升序即要求每次求出某位置前有多少个数大于该位置的值,而上述过程我们算出的是小于等于该位置的数量,不过可以很快想到,利用容斥原理,用当前总共读入的数的个数减去小于等于该位置的数量,即可得到我们想要的答案。
2.数列操作
一个标准的模板题,这里之所以拿出来是因为这题卡了时间又卡了空间(但是loj上的这题一个都没卡,汗),所以不能用高效初始化,也不能用线段树,以及需要快速读入,其它就没什么了,权当检测下模板对不对吧。
顺便给一下快读的模板:
template<typename type>
void read(type &num){
int sign=1;
long long res=0;
char ch=getchar();
while (!isdigit(ch))
sign=ch=='-'? 0:1,ch=getchar();
while (isdigit(ch))
res=res*10+(ch^48),ch=getchar();
num=sign? res:-res;
}
3.A Tiny Problem with intergers
区间修改,单点查询。模板题,按上面讲的直接套。嗯......还是再次强调下有两种写法以及它们的区别:
构造差分数组:
其余的东西太占篇幅了,干脆不写了。。。
for(int i = 1; i <= n; ++i){
cin >> num[i];
update(i,num[i]-num[i-1]); //在输入的时候就构造差分数组
}
//输出时就不用加上原数组的值
cout<<solve(a)<<"\n";
不构造差分数组,这种写法更加常见也更好用:
同样占篇幅,不写,就是这么任性。。。
for(int i = 1; i <= n; ++i)
cin >> num[i]; //输入时未构造差分数组
//输出时要加上原数组的值
cout<<num[a]+solve(a)<<"\n";
4.A Simple Problem with Integers
区间修改,区间询问,具体解法参见上文。
5.打鼹鼠
二维数组模板题,直接套上面的模板就完事了,唯一要注意的是求从(a,b)到(c,d)的区间和的时候,不是solve(c,d)-solve(a-1,b-1),而是solve(c,d)-solve(c,b-1)-solve(a-1,d)+solve(a-1,b-1),自己画个图就知道了。
上面的例题没什么难题,主要是对应一些知识点,应该还算全面了,至于更多的有点难度的题,以后有机会再更新。