高级数据结构:树状数组(区间修改、逆序对问题)

目录

一、前缀和与差分数组

1.前缀和数组

2.差分数组

二、树状数组的使用

1.树状数组的建立

2.区间询问

3.单点修改

4.与差分数组结合后实现区间修改

5.二维树状数组

例题及补充(求逆序对)


一、前缀和与差分数组

1.前缀和数组

我们经常会遇到求一段区间的连续和问题,直接暴力求解(从左端点一直累加到右端点)时间复杂度为o(n),貌似也不低...但是对于有m次询问的题,时间复杂度为O(mn)就显得有点捉襟见肘了,此时一个好的想法就是使用前缀和数组。

所谓前缀和数组,顾名思义就是维护一段序列的前缀和的数组,其实现也相当简单:

定义前缀和数组sum[N],全部初始化为0,输入第i个数据x时,只要将sum[i]=sum[i-1]+x,就能递推得到整个前缀和数组,但要注意i要从1开始。

得到了前缀和数组,我们再来看求一段区间的连续和问题,如果左端点是从1开始,那么直接返回sum[i]即是答案,如果左端点为L且\neq1,此时利用容斥原理,只要减去sum[L-1]的部分即是答案,时间复杂度仅为O(1)!

2.差分数组

记录数组中每个元素与前一个元素的差,注意不是连续的差,仅仅是相邻位置的差:minus[i]=a[i]-a[i-1],此时我们注意到:a[i]=\sum_{j=1}^{i} minus[j],即差分数组的前缀和数组就是原数组。差分是一个很重要的思想,能让某些算法扩展出更多的功能。同样,差分思想与树状数组结合后,也会有一系列神奇的操作,具体看下文的介绍。

二、树状数组的使用

1.树状数组的建立:

树状数组建立的思想:

任意一个自然数x均可用二进制表示,即被唯一分解2^{xm}+2^{x(m-1)}+···+2^{x3}+2^{x2}+2^{x1},由此区间[1,x]可以进一步划分为O(logx)个小区间:

长度为2^{xm}[12^{xm}]

长度为2^{x(m-1) }[2^{xm}+12^{xm}+2^{x(m-1)}]

……  ……  ……  ……  ……

长度为2^{x2}[2^{xm}+2^{x(m-1)}+···+2^{x3}+12^{xm}+2^{x(m-1)}+···+2^{x3}+2^{x2}]

长度为2^{x1}[2^{xm}+2^{x(m-1)}+···+2^{x3}+2^{x2}+12^{xm}+2^{x(m-1)}+···+2^{x3}+2^{x2}+2^{x1}]

将数x分解后,保存每个区间的和,这样在求区间1~x的和时,只需要将这logx个区间和相加即可得到。对于任意x,我们可以看出:分解出的区间长度 = x的二进制只保留最末尾的1的值,而要取出这个最末尾的1我们需要借助位运算,具体实现代码如下,原理可以自己手算摸索下:

//lowbit操作
int lowbit(int x) {
    return x & (-x);
}

为什么叫树状数组呢?这个问题很关键,强烈建议读者弄明白:

我们以1~8为例:

00012^{0}     [1,2^{0}]:num[1]

0010:2^{1}     [1,2^{1}]:num[1]+num[2]     

00112^{0}     [1,2^{1}+2^{0}]:num[3]

0100:2^{2}     [1,2^{2}]:num[1]+num[2]+num[3]+num[4]

01012^{0}     [2^{2}+1,2^{2}+2^{0}]:num[5]

0110:2^{1}     [2^{2}+1,2^{2}+2^{1}]:num[5]+num[6]

01112^{0}     [2^{2}+2^{1}+1,2^{2}+2^{1}+2^{0}]:num[7]

1000:2^{3}     [1,2^{3}]: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取000100100011,对应的x+k为010101100111。所以原式变为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)的值为2^m(m>=0,m为整数),则进位后lowbit(y)的值为2^{m+n}(n>=1,n为整数),所以原式改写为k-lowbit(x+k)+1=lowbit(x)-lowbit(x+k)+1等于2^{m}-2^{m+n}+1<=0,令f(x)=2^{m}-2^{m+n}+1=(1-2^n)2^m+1,易证该函数单调递减,在m=0时取得最大值2-2^n(n>=1,2-2^n<=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数组的前缀和就是原数组要增加的值,即:      \sum_{i=1}^{x} bit[i]

现在要求原数组的前缀和,则总共要增加的值为:                 

                             bit[1]+(bit[1]+bit[2])+...+(bit[1]+bit[2]+...+bit[x])=\sum_{i=1}^{x} \sum_{j=1}^{i} bit[j] 

这个式子我们可以做一个变形,我们考虑bit[1]~bit[x]每一个数在式子中被运算的次数:bit[1]出现了x次,bit[2]出现了x-1次···bit[x]出现了1次,所以上式可变形为:  \sum_{i=1}^{x} \sum_{j=1}^{i} bit[j]=\sum_{i=1}^{x}(x-i+1)*bit[i]=(x+1)\sum_{i=1}^{x}bit[i]-\sum_{i=1}^{x}i*bit[i]    。

我们目前只维护了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),自己画个图就知道了。

上面的例题没什么难题,主要是对应一些知识点,应该还算全面了,至于更多的有点难度的题,以后有机会再更新。

  • 6
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值