数据结构学习笔记 - 树状数组

基础知识

转载请注明出处:bestsort.cn

我们先回想一下二叉树(什么你说你不知道什么是二叉树?

 也就是类似这张图的一种数据结构,我们不难看出,最底层是按照 2 的 n 次方不断增加的。

那如果我们把第一层设为 [1,N] ,然后开始不断往下下放,什么你问我 N 一定会被分成不同的 2

的 x(x∈Z)的 m 个数之和? 

这个问题问的好,要不咱仔细想想计算机为什么是 2 进制的

然后我们变一下形(其实不变也可以只不过这样好理解)如图

接着讲,我们于是就可以不断在 2 的 x 次方内下放,最后我们可以把 [1,X] 分成 O(log x)个小区间,这些小区间的共同特点是:若区间结尾为 R ,则区间长度就等于 R 的“二进制分解”下最小的 2 的次幂,即 lowbit(R)

int lowbit(int x){
 return x&(-x);
}

例如:x = 7 = 2^2 + 2^1 + 2^0,区间 [1,7] 可以分成 [1,4] [5,6] [7,7] 三个小区间

什么?你还是不理解?你连lowbit()也不知道?

lowbit 就是找出整数在二进制表示下所有等于 1 的位。

它实现的操作是类似于 x&(-x) 的操作

对于一个数的负数就等于对这个数取反+1

也就是说补码和原码必然相反,所以原码有0的部位补码全是1,补码再+1之后由于进位那么最末尾的1和原码最右边的1一定是同一个位置(当遇到第一个1的时候补码此位为0,由于前面会进一位,所以此位会变为1)

所以我们只需要进行 x&(-x) 就可以取出最低位的 1 了

然后我们再来看两张具体的图


 

 注意:其中灰色的节点已经被上层覆盖,并没有实际意义

记住了吧

然后我们再来看看二进制转换后的这个图

 

 是不是好理解一些了,如果还是有些想不通,可以自己模拟一下

(101 + 1 -> 110 + 10 -> 1000 + 1000 -> 10000)

常用操作

单点更新:

void update(int x,int z){//x指位置,z指改变的值
    while(x<=n){
        a[x]+=z;
        x+=lowbit(x);
    }
    /*
    for(int i=x;i<=n;i+=lowbit(x)){//这个会更快
        a[i]+=z;
    }    
    */
}

​

区间查询:

int getsum(int x){
    int ans=0;
    while(x){
        ans+=a[x];
        x-=lowbit(x);
    }
    return ans;
}

什么你问我怎么区间查询?

可以利用前缀和的思路,用 getsum(y) - getsum(x-1) 求出来

接下来我们尝试理解一些进阶的思路

求逆序对:

首先我们对输入的 a 数组进行离散化

什么你问我什么是离散化?

有时候我们输入一个数组,它不一定是连续的,有可能跨度非常大,但是我们用树状数组求逆序对,就需要我们把它变成一个个连续的点(可以随便排列)所以我们要怎么来求离散化后的数组呢

我们在加一个数组 b 存下 a 数组,然后我们排序 b 数组,再用 lower_bound() 找到 b 数组中对应 a 数组的位置,这样我们就实现了离散化

什么你问我 lower_bound() 怎么用?

lower_bound(ForwardIter first, ForwardIter last,const _Tp& val)算法返回一个非递减序列[first, last)中的第一个大于等于值val的位置。

upper_bound(ForwardIter first, ForwardIter last, const _Tp& val)算法返回一个非递减序列[first, last)中的第一个大于值val的位置。

然后我们模拟一次

a = [3,4,5,2147483647,1] = b

b = [1,3,4,5,2147483647]

在 for 循环里,3 -> 2 4->3 5 ->4 2147483647 -> 5 1 -> 1

然后 a 数组就变成了 a = [2,3,4,5,1]

for(int i=1;i<=n;i++){
    cin>>a[i];
	b[i]=a[i];
}
sort(b+1,b+1+n);
for(int i=1;i<=n;i++) a[i]=lower_bound(b+1,b+1+n,a[i])-b;

解决完离散化的问题,我们就来思考怎么求逆序对呢

我们还用刚才的那个例子

a = [3,4,5,2147483647,1]

b = [2,3,4,5,1]

注意:我们以下的操作都要遵循树状数组的运算规则,即 lowbit() 运算

这里有点难理解,不如尝试自己画图理解一下

我们按顺序插入 b 数组中的值,插入每个值我们都往它“上面的值”进行 +1 的操作,这样我们就可以知道如果从这个点开始往下下放会有几个值(也就时此时这个点前面有几个值已经在 a 数组中了),之后求它对应的逆序对数时需要用到,并且在插入一个值后我们直接就进行求逆序对的操作,那么问题来了,我们怎么求逆序对呢

我们已经知道,getsum(int x) 函数可以求 [1,x] 的和,那如果我们加到其中一个 x’ 时,我们加上的正是它本身在当前树状数组里向下下放会有多少个值(因为我们之前有 +1 的操作),这时我们可以用代换的思路,我们既然求出它的顺序时 getsum(x) 那么它的逆序对就是 i - getsum(x) 我们用 ans 加上它的和就可以了

这里因为树状数组用的是 lowbit() 操作,所以我们不能直接用正常的思路来理解 a 数组的值,要联系上树状数组的存储规则,如果还是不理解我们用一下刚才的样例

b = [2,3,4,5,1]

2 3 4 5 1
1 2 3 4 5
Case 1 // 将 2 插入到 a 数组中
0 1 0 1 0
Case 2 // 将 3 插入到 a 数组中
0 1 1 2 0
Case 3 // 将 4 插入到 a 数组中
0 1 1 3 0
Case 4 // 将 5 插入到 a 数组中
0 1 1 3 1
Case 5 // 将 1 插入到 a 数组中
1 2 1 4 1

怎么样是不是更好理解一些了,我们存进来的值还要往上 +1 就是要知道之后插入被加过的值时,它前面已经有几个值了,只不过用的是树状数组的存储规律比较超出常理,

#include<iostream>
#include<cmath>
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;

int n;
int a[100100];
int b[100100];

int lowbit(int x){
	return x&(-x);
}

void update(int x){
	while(x<=n){
		a[x]++;
		x+=lowbit(x);
	}
}

int getsum(int x){
	int cnt=0;
	while(x){
		cnt+=a[x];
		x-=lowbit(x);
	}
	return cnt;
}

int main(){
	cin>>n;
	int z;
	for(int i=1;i<=n;i++){
		cin>>a[i];
		b[i]=a[i];
	}
	sort(b+1,b+1+n);
	for(int i=1;i<=n;i++) a[i]=lower_bound(b+1,b+1+n,a[i])-b;
	for(int i=1;i<=n;i++){
		b[i]=a[i];
		a[i]=0;
	}
	long long ans=0;
	for(int i=1;i<=n;i++){
		update(b[i]);
		ans+=i-getsum(b[i]);
	}
	cout<<ans<<endl;
	return 0;
}

这就是完整的求一个 n 序列的逆序对问题,很显然我把单点修改变成了 +1 操作

求区间最大值:

其实只要我们理解了刚才逆序对的思路,就比较好理解这个了

刚才说 a 数组存的是 i 对应的前面有几个值是正序的,那我们可以改变一下它存的值,变成存储前几个值最大的值

void update(int x,int v){
    while(x<=n){
        a[x]=max(a[x],v);
        x+=lowbit(x);
    }
}
int query(int x){
    int ans=0;
    while(x){
        ans=max(ans,a[x]);
        x-=lowbit(x);
    }
    return ans;
}

区间修改:

直接去学一下线段树

那么我们怎么用树状数组实现区间更新呢

我们可以考虑一下利用前缀和差分来实现,假如我们要修改 [L,R] 加上 x ,这时我们可以在 a[L]+=x,a[R+1]-=x;

然后当单点查询时,我们把这个下放就可以了

什么你想知道怎么区间查询?

区间修改后怎么实现区间查询:

该部分内容转自胡小兔的OI博

我们刚才说了,区间修改就是实现前缀和差分,而之前单点修改后区间查询也是用的前缀和差分的思路,那么这就很有意思了,我们面对的是一个前缀和套前缀和的东西

位置p的前缀和是\sum_{i=1}^{p}a[i]=\sum_{i=1}^{p}\sum_{j=1}^{i}d[j]

在等式最右侧的式子\sum_{i=1}^{p}\sum_{j=1}^{i}d[j]中,d[1]被用了p次,d[2]被用了p-1次……那么我们可以写出:位置p的前缀和 =\sum_{i=1}^{p}\sum_{j=1}^{i}d[j]=\sum_{i=1}^{p}d[i]*(p-i+1)=(p+1)*\sum_{i=1}^{p}d[i]-\sum_{i=1}^{p}d[i]*i

那么我们可以维护两个数组的前缀和:
一个数组是 sum1[i]=d[i]
另一个数组是 sum2[i]=d[i]*i

查询
位置p的前缀和即:数组中p的前缀和 - sum2数组中p的前缀和。

区间[l, r]的和即:位置r的前缀和 - 位置l的前缀和。

修改
对于sum1数组的修改同问题2中对d数组的修改。

对于sum2数组的修改也类似,我们给 sum2[l] 加上 l * x,给 sum2[r + 1] 减去 (r + 1) * x。

void add(int p, int x){
    for(int i=p;i<=n;i+=lowbit(i)){
    	sum1[i]+=x;
		sum2[i]+=x*p;
	}
}

void range_add(int l,int r,int x){
    add(l,x);
	add(r+1,-x);
}

int ask(int p){
    int res=0;
    for(int i=p;i;i-=lowbit(i))
        res+=(p+1)*sum1[i]-sum2[i];
    return res;
}

int range_ask(int l, int r){
    return ask(r)-ask(l-1);
}

二维树状数组

声明:大部分摘抄的他人的博客,只供学习使用

这个说实话我目前也没用过,只是听说有这么个东西,这就是你抄别人博客的理由,害,读书人的事儿能叫抄吗。顾名思义,它可以用到类似于矩阵的操作

其实就是在一维树状数组的一个点处又建了一个一维树状数组

在一维树状数组中,a[x](树状数组中的那个“数组”)记录的是右端点为x、长度为lowbit(x)的区间的区间和。
那么在二维树状数组中,可以类似地定义a[x][y]记录的是右下角为(x, y),高为lowbit(x), 宽为 lowbit(y)的区间的区间和。

单点修改+区间查询

void add(int x, int y, int z){ //将点(x, y)加上z
    int memo_y = y;
    while(x <= n){
        y = memo_y;
        while(y <= n)
            tree[x][y] += z, y += y & -y;
        x += x & -x;
    }
}

void ask(int x, int y){//求左上角为(1,1)右下角为(x,y) 的矩阵和
    int res = 0, memo_y = y;
    while(x){
        y = memo_y;
        while(y)
            res += tree[x][y], y -= y & -y;
        x -= x & -x;
    }
}

区间修改 + 单点查询

我们对于一维数组进行差分,是为了使差分数组前缀和等于原数组对应位置的元素。

那么如何对二维数组进行差分呢?可以针对二维前缀和的求法来设计方案。

二维前缀和:

sum[i][j]=sum[i-1][j]+sum[i][j-1]-sum[i-1][j-1]+a[i][j]

那么我们可以令差分数组d[i][j]表示a[i][j]与 a[i-1][j]+a[i][j-1]-a[i-1][j-1]的差。

例如下面这个矩阵

 1  4  8
 6  7  2
 3  9  5

对应的差分数组就是

 1  3  4
 5 -2 -9
-3  5  1

当我们想要将一个矩阵加上x时,怎么做呢?
下面是给最中间的3*3矩阵加上x时,差分数组的变化:

0  0  0  0  0
0 +x  0  0 -x
0  0  0  0  0
0  0  0  0  0
0 -x  0  0 +x

这样给修改差分,造成的效果就是:

0  0  0  0  0
0  x  x  x  0
0  x  x  x  0
0  x  x  x  0
0  0  0  0  0

void add(int x, int y, int z){ 
    int memo_y = y;
    while(x <= n){
        y = memo_y;
        while(y <= n)
            tree[x][y] += z, y += y & -y;
        x += x & -x;
    }
}
void range_add(int xa, int ya, int xb, int yb, int z){
    add(xa, ya, z);
    add(xa, yb + 1, -z);
    add(xb + 1, ya, -z);
    add(xb + 1, yb + 1, z);
}
void ask(int x, int y){
    int res = 0, memo_y = y;
    while(x){
        y = memo_y;
        while(y)
            res += tree[x][y], y -= y & -y;
        x -= x & -x;
    }
}

区间修改 + 区间查询

类比之前一维数组的区间修改区间查询,下面这个式子表示的是点(x, y)的二维前缀和:

\sum_{i=1}^{x}\sum_{j=1}^{y}\sum_{k=1}^{i}\sum_{h=1}^{j}d[h][k]

(d[h][k]为点(h, k)对应的“二维差分”(同上题))

这个式子炒鸡复杂(O(n^4) 复杂度!),但利用树状数组,我们可以把它优化到O(\log_2 n)

首先,类比一维数组,统计一下每个d[h][k]出现过多少次。d[1][1]出现了x*y次,d[1][2]出现了x*(y-1)次……d[h][k]出现了(x-h+1)*(y-k+1) 次。

那么这个式子就可以写成:

\sum_{i=1}^{x}\sum_{j=1}^{y}d[i][j]*(x+1-i)*(y+1-j)

把这个式子展开,就得到:

(x+1)*(y+1)*\sum_{i=1}^{x}\sum_{j=1}^{y}d[i][j]-(y+1)*\sum_{i=1}^{x}\sum_{j=1}^{y}d[i][j]*i-(x+1)*\sum_{i=1}^{x}\sum_{j=1}^{y}d[i][j]*j+\sum_{i=1}^{x}\sum_{j=1}^{y}d[i][j]*i*j

那么我们要开四个树状数组,分别维护:

d[i][j],d[i][j]*i,d[i][j]*j,d[i][j]*i*j

#include <cstdio>
#include <cmath>
#include <cstring>
#include <algorithm>
#include <iostream>
using namespace std;
typedef long long ll;
ll read(){
    char c; bool op = 0;
    while((c = getchar()) < '0' || c > '9')
        if(c == '-') op = 1;
    ll res = c - '0';
    while((c = getchar()) >= '0' && c <= '9')
        res = res * 10 + c - '0';
    return op ? -res : res;
}
const int N = 205;
ll n, m, Q;
ll t1[N][N], t2[N][N], t3[N][N], t4[N][N];
void add(ll x, ll y, ll z){
    for(int X = x; X <= n; X += X & -X)
        for(int Y = y; Y <= m; Y += Y & -Y){
            t1[X][Y] += z;
            t2[X][Y] += z * x;
            t3[X][Y] += z * y;
            t4[X][Y] += z * x * y;
        }
}
void range_add(ll xa, ll ya, ll xb, ll yb, ll z){ //(xa, ya) 到 (xb, yb) 的矩形
    add(xa, ya, z);
    add(xa, yb + 1, -z);
    add(xb + 1, ya, -z);
    add(xb + 1, yb + 1, z);
}
ll ask(ll x, ll y){
    ll res = 0;
    for(int i = x; i; i -= i & -i)
        for(int j = y; j; j -= j & -j)
            res += (x + 1) * (y + 1) * t1[i][j]
                - (y + 1) * t2[i][j]
                - (x + 1) * t3[i][j]
                + t4[i][j];
    return res;
}
ll range_ask(ll xa, ll ya, ll xb, ll yb){
    return ask(xb, yb) - ask(xb, ya - 1) - ask(xa - 1, yb) + ask(xa - 1, ya - 1);
}
int main(){
    n = read(), m = read(), Q = read();
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            ll z = read();
            range_add(i, j, i, j, z);
        }
    }
    while(Q--){
        ll ya = read(), xa = read(), yb = read(), xb = read(), z = read(), a = read();
        if(range_ask(xa, ya, xb, yb) < z * (xb - xa + 1) * (yb - ya + 1))
            range_add(xa, ya, xb, yb, a);
    }
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++)
            printf("%lld ", range_ask(i, j, i, j));
        putchar('\n');
    }
    return 0;
}

再次声明,本博客仅作学习使用

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值