树状数组

树状数组英文名称为Binary Index Tree,直译过来就是二进制索引树,BIT。

树状数组非常巧妙,一般人想不出来,不过很多算法都不是一般人能想出来的。

1. 引入

1.1 问题

树状数组解决的问题:

一个数组arr[],更新数组中的某个值arr[i],询问数组中某个区间(x,y)的和。看似很平常的一个问题,我们很容易写出  更新值时间复杂度为O(1),询问时间复杂度为O(N)的算法。  而树状数组可以实现两个时间复杂度均为O(logn)。而且代码量非常简单。非常漂亮。


1.2 原始解决办法

原始的朴素的解决办法是,更新i位置的值,就直接为arr[i]赋值,询问区间(x,y)和时,我们循环,从arr[x]加到arr[y],求出这个和。

1.3 改进

原始方法很简单,复杂度也很低,O(N)。如果想要提高效率,应该怎么办?
很明显,在现有的数据结构的基础上,是没有办法再提高效率了。必须改进数据结构,甚至增加空间复杂度,空间换时间很多时候都能行的通的。
极端一点的办法是,我把所有可能问到的区间的和先求出来,存放到sum[][]的二维数组里面,询问的时候,我O(1)的时间就可以给出答案。
但是这样一来,更新某个位置的值时,就需要改大量的sum[][]表中的值,时间复杂度应该远大于O(N)了。

1.4 继续改进

1.3的改进看似失败了,但是并不是一点好处都没有,至少给我们提供了一条道路,要想提高1.2方法中的效率,我们应该改进数据结构,存储区间和是唯一的可以提高效率的途径。只是1.3的改进有点极端,存储了全部的区间和,1.2是没有存储一点点区间和,两者效率都不算好。所以,我们容易的想到,存储部分的区间和。存储哪些区间呢???
存储的这些区间,应该能够方便的求出其它区间和的值。
现在问题变的更清晰了,就是找一些区间,把它们的和存储下来。但是关键是找哪些区间????

1.5 初次尝试

1.4末尾提到的问题,比较难。不过,应该想到利用树去组织吧。把arr[]数组中的每个节点当做叶子。然后arr[0]arr[1]结合,形成上一层一个点x,arr[2]arr[3]结合,也形成一个点y,把后面的每两个点都结合,都形成上一层的点。然后x,y再形成第三层的节点。最后形成一个树根。如下图:

这个结构的确可以满足我们的要求,满足我们的时间复杂度的要求,但是,它不叫树状数组,它应该叫做  线段树。 线段树是统计的利器。但是它比树状数组难。学习线段树,请看我后面的博客。
树状数组的结构图如下图:

和我们前面那个图有一点类似吧。只是结合的位置不同了。这个结构中区间和的保存如下:

C[1]=A[1];

C[2]=A[1]+A[2];

C[3]=A[3];

C[4]=A[1]+A[2]+A[3]+A[4];

C[5]=A[5];C[6]=A[5]+A[6];

C[7]=A[7];

C[8]= A[1]+A[2]+A[3]+A[4]+A[5]+A[6]+A[7]+A[8];

为什么要这样存??这个问题有难度,我也没有搞清楚。但是这样存的结果是可以解决下面的两个基本问题,
1. 我修改了数组A中的某个位置的值后,可以很快速的找到C数组中需要修改的值。比如,修改了A[3]的值后,可以立即得到需要修   改C[3]  C[4] C[8]的值;
2. 询问区间和时,我可以快速组合出来。根据前面的分析,我们不可能存储每个区间的和。所以在询问某个区间的时候,我们如果没有存储过这个区间的和,那么我必须能够快速的找到这个区间可以有哪些区间和组合出来。比如求区间[1,7]的和Sum(7)=C[7]+C[6]+C[4];
只要能解决好上面的这两个问题的算法,就都可以搞定1.1中的问题。也就都是非常好的算法。你也可以想想别的结构,满足上面的这两点就行了。

2.  树状数组 

2.1 二进制表示

其实中文翻译成了树状数组虽然也比较形象,但是没有能表达出这个算法最本质的思想。我们用二进制索引树这个名字继续讨论。把上面的一些式子写成二进制形式
Sum(7)=Sum(111)=C[111]+C[110]+C[100]
Sum(6)=Sum(110)=C[110]+C[100]
Sum(5)=Sum(101)=C[101]+C[100];
Sum(4)=Sum(100)=C[100];
Sum(3)=Sum(11)=C[11]+C[10];
写一个稍微大一点的数字,可能更好
Sum(13)=Sum(1101)=C(1101) + C(1100) + C(1000)
上面的图中,只画了8,没有画到13。你可以往后接着画的,画的方法就是,比如要话第i个,那么把i写成二进制,从右往左数,看看最后面的1是从右往左的第几位,那么i就应该是在树的第几层(最下面是第一层)。比如i=7的时候,二进制是111,那么最后的1是右面第一个,那么它就在第一层。同理,1,3,5,9……都应该在第一层(最下层)。看看i=4,二进制是100,那么4就应该在第三层。
很多地方给出了数组C的公式C[i]=A[i-2^x+1]+…+A[i]其中x为i的二进制中的从右往左数有连续“0”的个数。比如C[7]=C[111]最右边没有0,所以C[7]=A[7-2^0+1]+…+A[7]=A[7]。这个公式我觉得不好,不如上一段中用层的方式理解。
下面只要接着求上面的两个问题就行了,就是第一节最后提到的两个问题。要想解决这两个问题,我们必须了解一下二进制索引的一些规律。

2.2 小规律

由2.1中列的几个式子,可以总结出:求Sum(x)就是把x二进制表示中,先加上C[x],然后剔除最右边的1,继续加下一个,直到最后变成0。(这句话我写的不好,不过,根据2.1中的几个式子,可以总结出这个东西来的)。如何剔除最右边的1?有一个很简单的式子  x&(-x),这就是最右边的1的数字。比如:6&(-6)的结果就是2,所以加完C[6]后,下一个加的是C[4]。这个式子的理论计算如下:把x写成二进制形式:a1b,其中a代表最后的一个1前面的数字,而b的值都为0。比如二进制序列110101000,那么a=11010,b=000;-x=(a1b)¯ + 1 = a¯0b¯ + 1。(m)¯表示把m各位取反。由于b各位都是0,所以b取反后都变成了1,所以a¯0b¯ + 1=a¯1b。最终:
-num = (a1b)¯ + 1 = a¯0b¯ + 1 = a¯0(0...0)¯ + 1 = a¯0(1...1) + 1 = a¯1(0...0) = a¯1b.
最后加上位的与运算:
           a1b
&      a¯1b
--------------------
= (0...0)1(0...0)

这样就求出了最后面的一个1的数值。
代码写出来就是:
int getSum(int idx){
	int sum = 0;
	while (idx > 0){
		sum += tree[idx];
		idx -= (idx & -idx);
	}
	return sum;
}
这段代码就可以求出来1到idx的和。

还是那两个问题,一个是求和是哪几项相加,这个问题刚刚已经解决了。另一个是更新一个数值后,哪几项需要更新?
比如修改了A[5](A[101]),那么需要修改的是A[6](A[110]),A[8](A[1000]),A[16](A[10000])……可以看出来,需要更新的数字,就是在最好的一个1的位置不断加1。
最后的位置为1的数还是x&(-x)。那么下一个需要修改的就是  x+=x&(-x);就是修改C[x]了。
代码如下:
void update(int idx ,int val){
	while (idx <= MaxVal){
		tree[idx] += val;
		idx += (idx & -idx);
	}
}
这就解决了前面提到的两个基本问题,解决了这两个基本问题,整个算法就结束了。完整代码如下,是hdu1166敌兵布阵的代码:
#include<stdio.h>
#include<string.h>

const int maxlen=50000+10;
int C[maxlen];
int arr[maxlen];
int lowbit(int i){
    return i&(-i);
}

void change(int i,int value,int n){
    //n为元素总个数
    while(i<=n){
        C[i]=C[i]+value;
        i+=lowbit(i);
    }
}

int mySum(int i){
    int ressum = 0;
    while(i>0){
        ressum+=C[i];
        i-=lowbit(i);
    }
    return ressum;
}

int main(){

    //freopen("in.txt","r",stdin);
    int T;
    int caseT;
    scanf("%d",&T);
    int n,i,a,b;
    char str[10];
    for(caseT=1;caseT<=T;caseT++){
        scanf("%d",&n);

        for(i=1;i<=n;i++){
            scanf("%d",&arr[i]);
        }

        memset(C,0,sizeof(C));
    
        for(i=1;i<=n;i++){
            change(i,arr[i],n);
        }
        printf("Case %d:\n",caseT);
        while(scanf("%s",str)){
            if(str[0]=='E'){
                break;
            }
            scanf("%d %d",&a,&b);
            if(str[0]=='Q'){
                printf("%d\n",mySum(b)-mySum(a-1));
            }
            else if(str[0]=='A'){
                change(a,+b,n);
            }
            else if(str[0]=='S'){
                change(a,-b,n);
            }
            
        }
    }
    
}

3 小结

3.1 算法的过程

要想快速求出和,必须保存一些区间的和,保存的这些区间必须满足两个要求:
1. 询问某个区间时,能够快速求出这个区间可以由哪些区间组合。树状数组的求法是:idx -= idx&(-idx)。这个式子求出来的idx就是下一个需要加入的值。比如C[13],下一个值就是C[12],继续下一个就是C[8],再往下就是C[0]了。所以Sum(13)就是区间C[13]  C[12] C[8]的组合。
2. 修改了A的某个数值后,能够快速求出C中哪些数值需要修改。树状数组的求法是: idx+=idx&(-idx)。 这个式子求出来的idx就是下一个需要修改的C数组的下标。比如修改A[5],那么idx就是5,先修改C[5],然后下一个就是C[6],接着下一个就是C[8],一直进行下去。循环结束的位置是问题的规模。

3.2 利用算法的思路:

(1)基本问题
基本的问题就是更新某个值,询问某个区间。
(2)难度提高一点的问题
不是直接问了,比如有一类问题是求逆序对的个数,这个通过转变还是上述基本问题。先简化问题:输入n个数,这n个数不重复,大小在1到 n之间,求这n个数的逆序对个数。转变的方法就是
首先初始化A和C数组都是0,每输入一个数字X,就把A[x]变为1,然后更新C。然后求出Sum(x),这个Sum的意思就是输入X之前已经输入几个比X小的数字了,那么X后面输入的比X小的数的个数就是  pos-Sum(x),pos的意思是X是第几个输入的。数X造成的逆序对的个数就是pos-Sum(x),求出每次输入的值后造成的逆序对的个数,相加就是总的个数了。
(3)颠倒问题
与基本问题想法,编程了更新某个区间,询问某个位置。这个问题其实是线段树最基本的问题。不过通过变换,树状数组也可以解答。具体过程就不写了。
(4)规模扩大
树状数组可以扩展到二维,思路还是一样的。学会了一维的树状数组,再去看二维的,就很简单了。

问题不管怎么变换,其实最终利用到的树状数组的性质还是不会变的:就是哪个区间保存了哪些值的和。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值