树状数组

树状数组

引言

树状数组是一种十分有用的数据结构,同时它也是一种极其优美的数据结构,其代码简洁精炼。它经常可以作为线段树的简化版而使用,对于像区间求和这类问题,树状数组是游刃有余,但这并不代表可以完全取代线段树,因为有些问题必须借助线段树来完成,其实换句话说就是树状数组可以做的线段树一定可以,反之则不行。

树状数组思想

树状数组英文名Binary Indexed Tree,直译过来就是二进制索引树,事实上树状数组的精髓也就在于此,使用了二进制的思想来维护一个前缀和的数据结构。

然而或许大家还有一个疑问,树状数组名字里面既有树又有数组,那么它到底是数组还是树呢?事实上树状数组在物理存储上是一个数组的形式即是连续的,而在逻辑结构上是树形结构。

图示:

如图所示,图中的A数组是原始数组,C为树状数组(在物理存储空间中是连续的)。这样乍一看似乎和二进制的思想没有任何关系,之前说过树状数组的精髓在于二进制,别急,让我们把每个节点的值和二进制数结合看一下:

这里写图片描述

从图中可以看出,将下标i转为二进制数,将这个二进制数末尾0的个数设置为k,则树状数组中每一个元素C[i]存放了原数组中2^k个元素之和,即Cn = A(n – 2^k + 1) + A(n – 2^k + 2)+... + An

例:

i=4时,转为二进制数为100,末尾0的个数为2,则C[i]就是2^2=4个元素之和,即:

C[4] = A(4-2^2+1)+A(4-2^2+2)+A(4-2^2+3)+A(4-2^2+3) = A(1)+A(2)+A(3)+A(4)

现在知道了Cn怎么求得,然而还有一个问题就是2^k怎么计算,似乎没法一下子计算出来,常规做法是通过位运算的右移,循环判断最后一位是0还是1,从而统计末尾0的个数,一旦发现1后统计完毕,计数器保存的值就是k,但没必要这么复杂,我们可以看出末尾0的个数其实就是最后一个1所在的位置,例如100(4)末尾有2个0,而最后一个1所处的位置就是2(注意从0开始计数)。

因此现在就简单了,只要知道最后一个1在第几位就可以了,假如在第i位,则就有i个0,也就是k=i,则2^k=2^i,那么2^i怎么求解呢?

其实不知道大家发现没有,我们在将二进制转为十进制的时候不就是计算这个二进制数每一位上2的i次幂,i代表第几位。那么这里计算不一样的么,只不过是将一个二进制数除去最后一个1保留以外,其余全部变为0,例如:1010(10) –> 0010(2) 而这就等于2^k=2(k等于1)

很好,这下子变得更加简单了,也就是要计算2^k只要将对应的数最后一个1分离出来即可,有一个简单的分离末尾1的计算方法:x & -x,这也是树状数组的核心代码,下面是证明,相关证明用了不少位运算的知识,不太了解的请看这里

证明(摘抄别处的,懒得打了):

令num是我们要操作的整数。在二进制表示中,num可以记为a1b, a代表最后的1前面的二进制数码,由于a1b中的1代表的是从左向右的最后一个1, 因此b全为0,当然b也可以不存在。比如说13=1101,这里最后的1右边没有0,所以b不存在。

我们知道,对一个数取负等价于对该数的二进制表示取反加1。所以-num等于(a1b)^-+1 = a^-0b^-+1。由于b全是0,所以b^-全为1。最后,我们得到:

-num=(a1b)^-+1=a^- 0b^- +1=a^- 0(1…1)+1=a^-1(0…0)=a^-1b

现在,我们可以通过与操作(在C++,java中符号为&)将num中最后的1分离出来:

num & -num = a1b & a^-1b = (0…0)1(0…0)

以上就是树状数组的核心代码的证明,这段代码一般取名为lowbit函数。

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

树状数组操作

树状数组的操作主要有两种,一种是求和操作,另一种是更新操作。

求和操作

求和操作就是将数组中前n个元素相加,传统方式需要O(n)的时间复杂度,但如果将普通数组表示为树状数组,则求和操作时间复杂度就降至O(Logn)级别的,如果在面对多组查询的时候,这种优化可以节省大量的时间。

我们先看一下树状数组求和操作的代码,这代码异常简洁。

 int sum(int n) {
        int ans(0);
        while (n > 0) {
            ans += C[n];
            n -= lowbit(n);
        }
        return ans;
    }

除去函数定义,一共就五行代码,却硬生生将是将复杂度降低了一个量级,其实之所以可以这样做关键在于树状数组设计得好。

我们之前说过Cn = A(n – 2^k + 1) + A(n – 2^k + 2)+... + An,而上面的ans在加上C[n],之后,n去掉它末尾的那个1,变为n1,n1=n-2^k,则Cn1=A(n1 – 2^k1 + 1) + A(n1 – 2^k1 + 2)+... + An1 = A(n - 2^k - 2^k1 + 1)+A(n - 2^k – 2^k1 + 2)+... + A(n-2^k)不知大家看出来没有Cn1的末尾就是Cn最左边的前一位,所以这样就可以进行无缝连接,不断循环操作下去就可以求到A1+A2…..+An的结果了,是不是很神奇?

示例:

当n初始等于3的时候,下面为了方便起见,左边用二进制代替十进制

C(11)=A3

C(10)=A1+A2

Sum(3)=C(11)+C(10)=A3+(A2+A1)

更新操作

树状数组的更新操作与求和操作一样,也需要O(logn)的时间,虽然不及传统的O(1)的时间复杂度,但也是在可以接受的范围内,毕竟求和操作复杂度下降了,那么更新必然会有所提升。

同样先看代码:

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

一样的简洁,短短三行代码就实现了更新操作,想想线段树吧,简直不是一个量级的。

这边的相关证明其实与求和操作的一样,因为可以看出除了少了一个变量,减去操作变成加,也没什么改变了,所以这里就略去了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值