快速入门树状数组~

树状数组和下面的线段树可是亲兄弟了,但他俩毕竟还有一些区别:
树状数组能有的操作,线段树一定有;
线段树有的操作,树状数组不一定有。

这么看来选择线段树不就 「得天下了」 ?

事实上,树状数组的代码要比线段树短得多,思维也更清晰,在解决一些单点修改的问题时,树状数组是不二之选。


1.什么是树状数组

顾名思义树状数组就是模拟树形结构根据一定的规律建造的数组,那么为什么不直接建树呢?因为了解线段树的都知道线段树的模板代码量是比较大的,如果我们能用模拟的方法解决问题,效率会高很多。

2.树状数组可以解决什么问题

我们来想一下这个问题

这里通过一个简单的题目展开介绍,先输入一个长度为n的arr数组,然后我们有如下两种操作:

  1. 输入一个数m,输出数组中下标1~m的前缀和
  2. 对某个指定下标的数进行值的修改

多次执行上述两种操作


一般方法:

我们可以新建一个前缀和数组sum[n],来记录arr[0]-arr[n]的和,当然这个操作很简单,

这样的话,区间查询操作就变成了sum[R] - sum[L-1]了,表示的是L到R区间内的元素和,看起来是变成了O(1)。

但是这个时候如果我们需要更新某个下标对应的值,其后面所有的前缀和也需要发生改变。

我们发现,单点更新时间复杂度又变成了O(n)!!!

看来,鱼和熊掌不可兼得,不能将两者都维持到一个很低的复杂度上。那么,我想知道有没有一种方法,可以将整体的时间复杂度维持到一个比线性更快的水平上呢?

当然,答案就是线段树~咳咳,走错片场了不好意思。答案当然是先考虑树状数组啦

线段树基础入门传送门

3.树状数组介绍

还是以解决上面的区间和问题为例

我们先看一下这个图

在这里插入图片描述
相信大家都不陌生,这是一个二叉树结构图,如果我们把arr数组存到这个树最下面这一层的节点上,并且上面的父节点的值都是下面两个子节点值的和,是不是就可以解决这类区间问题了呢?

是的没错,但是这样的树形结构,叫做线段树。

而下面的树状数组和上图类似,但是省去了一些节点,所以在效率上会有所提高

在这里插入图片描述

黑色数组代表原来的数组(下面用A[i]代替),红色结构代表我们的树状数组(下面用C[i]代替),令每个位置存的是下面子节点的值的和,则有

原数组 A[i] , 树状数组C[i]

  • C1 = A1
  • C2 = A1 + A2
  • C3 = A3
  • C4 = A1 + A2 + A3 + A4
  • C5 = A5
  • C6 = A5 + A6
  • C7 = A7
  • C8 = A1 + A2 + A3 + A4 + A5 + A6 + A7 + A8

乍一看会很懵,这到底是按什么规律存的呢?

其实不难发现,在树状数组中所有奇数下标对应的都是原数组下标的值,而偶数下标对应的是和,但是偶数下标对应的和又有什么规律?

前人总结出了这个规律C[i]=A[i-2^k+1]+A[i-2^k+2]+......A[i];(k为i的二进制中从最低位到高位连续零的长度)

例如i=8时,k=3;

有了这个我们就知道 sum[7] = C[7] + C[6] + C[4];

那么又回到那个规律中,求区间和的问题转变成了求 2^k

这个问题前人的智慧又显现出来了!

他说 2^k = i&(-i),这个有一个专门的称呼,叫做lowbit

4. lowbit

我们可以先做一个测试来验证lowbit到底对不对,根据上面所说的

假设 i=8,那么根据规律k=3 , 2^3=8,我用程序运行一下

int lowbit(int x)
{
    return x&(-x);
}
int main()
{
    int i;
    while(cin>>i&&i)
    {
        int k = lowbit(i);
        cout<<k<<endl;
    }
    return 0;
}

运行结果是

8
8

可以发现,我们测始其他的答案也是对的,那lowbit又是什么原理呢?

这里利用的负数的存储特性,负数是以补码存储的,对于整数运算 x&(-x)有

  • 当x为0时,即 0 & 0,结果为0;

  • 当x为奇数时,最后一个比特位为1,取反加1没有进位,故x和-x除最后一位外前面的位正好相反,按位与结果为0。结果为1。

  • 当x为偶数,且为2的m次方时,x的二进制表示中只有一位是1(从右往左的第m+1位),其右边有m位0,故x取反加1后,从右到左第有m个0,第m+1位及其左边全是1。这样,x& (-x) 得到的就是x。

  • 当x为偶数,却不为2的m次方的形式时,可以写作x= y * (2^k)。其中,y的最低位为1。实际上就是把x用一个奇数左移k位来表示。这时,x的二进制表示最右边有k个0,从右往左第k+1位为1。当对x取反时,最右边的k位0变成1,第k+1位变为0;再加1,最右边的k位就又变成了0,第k+1位因为进位的关系变成了1。左边的位因为没有进位,正好和x原来对应的位上的值相反。二者按位与,得到:第k+1位上为1,左边右边都为0。结果为2的k次方。

总结一下:x&(-x),当x为0时结果为0;x为奇数时,结果为1;x为偶数时,结果为x中2的最大次方的因子。

分----------------------------------------------------割

现在我们知道了lowbit 的原理,我们再看一下这个公式

C[i]=A[i-2^k+1]+A[i-2^k+2]+......A[i]; 我们知道k为i的二进制中从最低位到高位连续零的长度,而小于i的节点的值肯定要小于i,那么它们的k绝对要小于i的k,而最长的就是k,因为它的二进制表示的数只能允许它右移k位,右移k位之后它就是叶子节点了,就只表示一个单一的A[]数组的值了,同时也是C[]树状数组的值。

所以得出结论 ,k 表示的 为i 这个节点表示的树的深度。

有了这点知识为基础,那么我们就可以知道,我们要修改某个元素的值,就会修改C[]的值,以及它的所有祖先节点的值。

下一步就是建立树状数组了~

5.建立树状数组

关于建树的过程,我们再来深入探讨一下,看一下下面的代码

   void updata(int i,int k){    //在i位置加上k
       while(i <= n){  //n表示原数组的最大下标
          c[i] += k;
          i += lowbit(i);
      }
  }

这个是建立树状数组的主要代码,对它稍作修改添加主要代码

void updata(int i,int k){    //在i位置加上k
    while(i <= n){
        c[i] += k;
    cout<<i<<' '<<c[i]<<endl;
    i += lowbit(i); 
    }
}
int main(){
    int t;
    n = 8;
    memset(a,0,sizeof(a));
    memset(c,0,sizeof(c));
    updata(1,222);
cout<<"****************************************"<<endl;
    updata(3,111);
    return 0;
}

运行结果

1 222
2 222
4 222
8 222
****************************************
3 111
4 333
8 333

结合树状数组结构图来看一下
在这里插入图片描述

发现建树的规律了没有,前面是再建树过程中访问到的树的下标,后边是修改后的值。
每次更新一个树节点的值,都会一直往后更新直到最后。

6.单点更新和区间查询

我们还用c[i]表示树状数组,a[i]表示原数组

先初始化原数组和树状数组,然后读入更新数据 ,

int m,n;
int lowbit(int x)
{
    return x&(-x);  
}
int getsum(int x)  //返回下标1-x元素的和
{
    int sum=0;
    for(int i=x;i>0;i-=lowbit(i))
    {
        sum += c[i];
    }
    return sum;
}

void add(int x,int y) //x表示值的下标,y是修改后的值
{
 
    for(int i=x;i<=n;i+=lowbit(i))
    {
        c[i] += y;  //更新树状数组
    }
}
int main()
{
    memset(a,0,sizeof(a));
    memset(c,0,sizeof(c));
    cin >> n;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i];  //读入原数组
        add(i,a[i]);  //更新树状数组
    }
    return 0;
}

这个模板我们就写好了,可以解决简单的区间求和,单点更新等操作,但是有时候我们需要用到区间更新,这时我们也可以首先考虑树状数组,如果实在不行,在使用线段树。

趁热打铁,先看一道非常简单的模板题 敌兵布阵

7.区间更新和单点查询

这里的单点查询是指查询下标从1-x的区间和,并不是真正意义上的查询单个元素的值。

如果题目是让你把x-y区间内的所有值全部加上k或者减去k,然后查询操作是问某个点的值,这种时候该怎么做呢?

如果使用上面的代码,也是可以完成的,但是这样的复杂度会非常高,这个时候,我们就不能再使用原数据的进行建树了,而是利用差分值.

差分数组

众所周知,差分数组一般都被用来快速处理区间加减的操作,因为当原数组某个区间内的值变了,区间内的差值是不变的,只有D[x]D[y+1]的值发生改变

利用一个新的数组记录原数组每一项和前一项的差值
d[i] 表示差分数组,

原数组是a[i]

那么

在这里插入图片描述

在这里插入图片描述
所以我们可以通过求d[i]的前缀和查询树中某个点的值

我们就利用这个性质,建立树状数组

#include <bits/stdc++.h>
using namespace std;
int n,m;
int a[50005],c[50005]; //对应原数组和树状数组

int lowbit(int x){
    return x&(-x);
}
void updata(int i,int k){    //在i位置加上k
    while(i <= n){
        c[i] += k;
    i += lowbit(i); 
    }
}
int getsum(int i){        //求A[1 - i]的和
    int res = 0;
    while(i > 0){
        res += c[i];
        i -= lowbit(i);
    }
    return res;
}
int main(){
    
    memset(a,0,sizeof(a));
    memset(c,0,sizeof(c));
    cin>>n;
    //这里已经把a[0]、c[0]初始化为0了
    for(int i=1;i<=n;i++)
    {
        cin>>a[i];
        updata(i,a[i]-a[i-1]);  //直接利用差分值建树
    }
    int x,y,k;
    cin>>x>>y>>k;  
    //将区间[x,y]内的值增加或减少k
    updata(x,k);
    updata(y+1,-k);
    cout<<getsum(3)<<endl; //单点查询
    return 0;
}

8.区间更新和区间查询

这是最常用的部分,也是用线段树写着最麻烦的部分——但是现在我们有了树状数组!

我们先来看一下下面的公式

原数组:a[i] , 差分数组:d[i]

位置p的前缀和为
在这里插入图片描述
在等式的最右侧里可以发现d[1]被用了p次,d[2]被用了p-1次,所以可以把推导出

在这里插入图片描述
这就可以看出了,我们求区间和的话只需要维护两个数组的前缀和,分别维护d[i]d[i]*i
假设sum1[i]=d[i],而sum2[i]=d[i]*i

实现代码


void add(ll p, ll x){
    for(int i = p; i <= n; i += i & -i)
        sum1[i] += x, sum2[i] += x * p;
}
void range_add(ll l, ll r, ll x){
    add(l, x), add(r + 1, -x);
}
ll ask(ll p){
    ll res = 0;
    for(int i = p; i; i -= i & -i)
        res += (p + 1) * sum1[i] - sum2[i];
    return res;
}
ll range_ask(ll l, ll r){
    return ask(r) - ask(l - 1);
}

参考文章

树状数组
OI wiki树状数组

附上线段树入门基础

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值