《0-1数据结构》数状数组

引言

 

我们来看这样两个问题:

1.在数组a[i]中,更新a[i]的值(单点更新)

2.在数组a[i]中,求a[i] + a[i + 1] + ... + a[i + k](区间求和)

朴素解析:

对于问题1来说,我们可以用一个简单的数组操作来实现,即

a[i] += b;

时间复杂度为O(1),很完美。

对于问题2来说,我们可以用一个for循环来实现,即

int ans = 0;
//求x~y的区间和
for(int i = x; i <= y; i ++) ans += a[i];

时间复杂度为O(n)。

如果对于n比较大的情况,或者对于多次操作单点更新,再多次求区间和的话,时间复杂度显然是比较高的。

有的小伙伴想到了用前缀和来维护,即

int b[N]; //b[N]为前缀和数组
for(int i = 1; i <= n; i ++) b[i] = b[i - 1] + a[i];

这样的话区间查询复杂度倒是O(1)了,使用b[y] - b[x - 1]即可计算x~y的区间和。

但是问题来了,如果我此时要对x这个点进行单点更新,那么我要把前缀和数组b[x]以及后面所有的点进行更新,时间复杂度为O(n),这就和前面暴力做法一样了。

树状数组引入

那么我们就想,能不能把这两个操作的复杂度折中一下呢?如果两个复杂度都是logn的话,效率的提升是巨大的。

我们能不能对前缀和维护的做法进行改进呢?对于前缀和来说,b[i]保存的是从b[1]~b[i]所有数的和。有没有一种可能,我们也许没必要保存前面所有数的和,只需要保存部分数的和,拼凑在一起就能表示所有数的和呢?

再深入思考一下,我们知道一般来说,logn是树的复杂度。而书的深度又与2的n次方有关,那么我们来试着研究一下数组的二进制下标,能不能发现一些规律呢?

1:000(1)        最后一个1转换为十进制为1

2:00(1)0        最后一个1转换为十进制为2

3:001(1)        最后一个1转换为十进制为1

4:0(1)00        最后一个1转换为十进制为4

...

我们有个大胆的想法:如果让以上的1、2、1、4作为管理元素个数的数组,会怎么样呢?

我们假设新数组c[i]为部分前缀和数组,按照上面的例子来说:

c[1] = a[1]        管理1个元素

c[2] = a[1] + a[2]        管理2个元素

c[3] = a[3]        管理1个元素

c[4] = a[1] + a[2] + a[3] + a[4]        管理4个元素

c[i]数组长这样:

回归我们在引言提出的问题

如果我们要更新a[1],那么我们就把后面所有管理着a[1]的c[i]更新一遍,即

c[1] += b, c[1 + 1] += b, c[2 + 2] += b, ... ,c[k + x] += b
//其中:x为k的二进制最后一个1代表的10进制的值
//比如k=2的时候,2转化为二进制是(1)0,那么x就是那个1表示的10进制的值,为2

如果要求a[1] + a[2] + a[3] + a[4],那我们直接求出c[4]即是答案,如果我们要求a[1] + a[2] + a[3],那么求c[2] + c[3]即是答案,即

a[1] + a[2] + a[3] = c[3] + c[3 - 1] + c[2 - 2];

a[1] + a[2] + ... + a[n] = c[n] + c[n - x] + ... + c[0]

//其中:x为n的二进制最后一个1代表的10进制的值
//比如n=6的时候,6转化为二进制是01(1)0,那么x就是那个1表示的10进制的值,为2

从上面两个例子中,我们惊奇的发现,只要每次能确定数组下标的二进制的最后一个1所代表的值,那我们就可以在logn的时间内完成单点更新以及区间查询了。

这个过程就像一个部分前缀和,每次不管是单点修改还是区间查询,都是只更改部分值就好了。而要确定每次要更新的点的下标,我们就要知道当前点下标的二进制最后一个1代表的10进制的值。

比如我要更新a[1],那么我要更新a[1], a[2], a[4], a[8] ...        而这些下标的确定,就是其二进制最后一个1代表的10进制累加起来的,也就是1 + 000(1) ,2 + 00(1)0,4 + 0(1)00  ...

树状数组核心操作lowbit

那么我们只剩下一个问题,就是在O(1)的时间内求出每个下标的二进制的最后一个1代表的10进制的值。如果不能在O(1)的时间内求出,那么所有之前的操作都没有太大的意义。

所以重点来了:

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

没错,只需要这么简单就可以求出上述的问题的答案。本次主要初步介绍树状数组,所以这样操作的原因这里暂时不加赘述。

有了这个lowbit操作,我们所有的操作都如鱼得水。

单点更新

void update(int idx, int k) {
    //每次寻找下一个管理着当前点的树状数组
    /*找当前点的二进制数的最后一个1转化为十进制的值,
    每次加上这个值就可以找到下一个要更新的地方*/
    for(int i = idx; i <= n; i += lowbit(i)) {
        c[idx] += k;
    }
}

查询区间和

//求区间1~idx的和
int query(int idx) {
    int ans = 0;
    for(int i = idx; i; i -= lowbit(i)) {
        ans += c[i];
    }
    return ans;
}

//查询x~y的和:query(y) - query(x - 1)

总结

对于单点更新操作:我们只要每次更新i + lowbit(i)所表示的数组下标的值,就可以完成对整个数组的更新。

对于区间查询操作:我们每次只要求i - lowbit(i)所覆盖的那些部分前缀和数组,就可以拼凑出完整的前缀和。

我们循环时每次行进的距离都是2的整数次幂,所以这两个操作的复杂度均为O(logn)

当然,树状数组可以解决的问题远不止这两个,引入差分的思想可以让树状数组发挥其更多的光芒。后续请继续关注,如有不足之处请大家指出。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值