1 树状数组简介
树状数组,其实就是物理上存储是连续的,以数组的形式存储,逻辑上可以得到树形的父子关系。对于两个数组下标x,y(x < y),如果y=x + 2^k (k等于x的二进制表示中末尾0的个数),那么定义(y, x)为一组树上的父子关系,其中y为父结点,x为子结点。
如上图所示,其中A为普通数组,C为树状数组(C在物理空间上和A一样都是连续存储的,其实真正的数组A我们是不需要的,只要一个数组C就好)。树状数组的第4个元素C4的父结点为C8 (4的二进制表示为”100”,所以k=2,那么4 + 2^2 = 8),C6和C7同理。C2和C3的父结点为C4,同样也是可以用上面的关系得出的,那么从定义出发,奇数下标一定是叶子结点。
2 复杂度
对于普通数组,其修改的时间复杂度为O(1),而求数组中某一段的数值和的时间复杂度为O(n),因此对于n的值过大的情况,普通数组的时间复杂度我们是接受不了的。
树状数组是一个查询和修改复杂度都为log(n)的数据结构
3 结点的特点
(1)我们定义Ci的值为它的所有子结点的值 和 Ai 的总和,则当i为奇数时Ci一定为叶子结点,所以有Ci = Ai ( i为奇数 )
然后我们来看树状数组上的结点Ci具体表示什么,这时候就需要利用树的递归性质了。从图中可以得出:
C1 = A1
C2 = C1 + A2 = A1 + A2
C3 = A3
C4 = C2 + C3 + A4 = A1 + A2 + A3 + A4
C5 = A5
C6 = C5 + A6 = A5 + A6
C7 = A7
C8 = C4 + C6 + C7 + A8 = A1 + A2 + A3 + A4 + A5 + A6 + A7 + A8
建议直接看C8,因为它最具代表性。
我们从中可以发现,其实Ci还有一种更加普适的定义,它表示的其实是一段原数组A的连续区间和。根据定义,右区间是很明显的,一定是i,即Ci表示的区间的最后一个元素一定是Ai,那么接下来就是要求Ci表示的第一个元素是什么。从图上可以很容易的清楚,其实就是顺着Ci的最左儿子一直找直到找到叶子结点,那个叶子结点就是Ci表示区间的第一个元素。
更加具体的,如果i的二进制表示为 ABCDE1000,那么它最左边的儿子就是 ABCDE0100,这一步是通过结点父子关系的定义进行逆推得到,并且这条路径可以表示如下:
ABCDE1000 => ABCDE0100 => ABCDE0010 => ABCDE0001
这时候,ABCDE0001已经是叶子结点了,所以它就是Ci能够表示的第一个元素的下标,那么我们发现,如果用k来表示i的二进制末尾0的个数,Ci能够表示的A数组的区间的元素个数为2^k,又因为区间和的最后一个数一定是Ai,所以有如下公式:
Ci = sum{ A[j] | i - 2^k + 1 <= j <= i } =A[i-2^k+1]+A[i-2^k+2]+…+A[i]
(帮助理解:将j的两个端点相减+1 等于2^k)
4 求解2^k (k等于i的二进制表示中末尾0的个数,或从0开始,从右到左第一个1的位置)—lowbit(i)
下面的各种操作需要用到函数lowbit(i),这里先说明:
要求2^k(其中k为x的二进制表示末尾0的个数),那么最简单的实现办法就是通过位运算的右移,循环判断最后一位是0还是1,从而统计末尾0的个数,一旦发现1后统计完毕,计数器保存的值就是k,当然这样的做法总的复杂度为O( logn ),一个32位的整数最多可能进行31次判断(这里讨论整数的情况,所以符号位不算)。
这里介绍一种O(1)的方法计算2k的方法。
int lowbit(int i)
{
return i& (-i);
//or return i&(i^(i-1));
}
函数的解释如下:
在计算机中,数值都是用补码表示的,如:
x =1: 1 &-1>>(设位数为8)0000 0001 & 1111 1111 = 1,即lowbit(1)=1
x = 6:6 & -6 >> 0000 0110&1111 1010 = 2,即lowbit(6)=2;
为什么要以补码表示数值呢?
原因在于,使用补码,可以将符号位和数值域统一处理;同时,加法和减法也可以统一处理。
整数补码的表示分两种:
正数:正数的补码即其二进制表示;
例如一个8位二进制的整数+5,它的补码就是 00000101 (最高位是符号位,0表示”正”,1表示“负”)
负数:负数的补码即将其整数对应的二进制表示所有位取反(包括符号位)后+1;
例如一个8位二进制的整数-5,它的二进制表示是00000101,取反后为11111010,再+1就是11111011,这就是它的补码了。
下面的等式可以帮助理解补码在计算机中是如何工作的
+5 + (-5) = 00000101 + 11111011 = 1 00000000 (溢出了!!!) = 0
这里的加法没有将数值位和符号位分开,而是统一作为二进制位进行计算,由于表示的是8进制的整数,所以多出的那个最高位的1会直接舍去,使得结果变成了0,而实际的十进制计算结果也是0,正确。
补码复习完毕,那么再来看下面这个表达式的含义:
x & (-x) (其中 x >= 0)
首先进行&运算,我们需要将x和-x都转化成补码,然后再看&之后会发生什么,我们假设 x 的二进制表示的末尾是连续的 k 个 0,令x的二进制表示为 X0X1X2…Xn-2Xn-1, 则 {Xi = 0 | n-k <= i < n}, 这里的X0表示符号位。
x 的补码就是由三部分组成: (0)(X1X2…Xn-k-1)(k个0) 其中确定的是Xn-k-1为1,因为末尾是k个0,如果它为0,那就变成k+1个0了。X1至Xn-k-1 前的数是多少无所谓。
-x的补码也是由三部分组成: (1)(Y1Y2…Yn-k-1)(k个0) 其中确定的是Yn-k-1为1,其它的Xi和Yi相加为1,想想补码是怎么计算的就明白了。
那么 x & (-x) 也就显而易见了,由两部分组成 (1)(k个0),表示成十进制为 2^k 啦。
由于&的优先级低于-,所以代码可以这样写:
int lowbit(int x) {
return x & -x;
}
5 求和操作
明白了Ci的含义后,我们需要通过它来求sum{ A[j] | 1 <= j <= i },也就是之前提到的sum(i)函数。为了简化问题,用上面的函数lowbit(i)来表示2^k (k等于i的二进制表示中末尾0的个数)。那么:
sum(i) = sum{ A[j] | 1 <= j <= i }
= A[1] + A[2] + … + A[i]
= A[1] + A[2] + A[i-2^k] + A[i-2^k+1] + … + A[i]
= A[1] + A[2] + A[i-2^k] + C[i]
= sum(i - 2^k) + C[i]
= sum( i - lowbit(i) ) + C[i]
由于C[i]已知,所以sum(i)可以通过递归求解,递归出口为当i = 0时,返回0。sum(i)函数的函数主体只需要一行代码:
int sum(int i){
return i? C[i]+ sum( i- lowbit(i)):0;
}
展开就是这个意思
int sum(int i){
int s =0;
for(; i >0; i -= lowbit(i))
s += C[i];
return s;
}
观察 i - lowbit(i),其实就是将i的二进制表示的最后一个1及其右边的部分变为0。由于最多只有log(i)个1,所以求sum(n)的最坏时间复杂度为O(logn)。
6 更新操作
对于更新操作 add(int i,int v) //将第i元素增加V,A[i] += v。但是我们不能在原数组A上操作,而是要像求和操作一样,在树状数组C上进行操作。
那么其实就是求在Ai改变的时候会影响哪些Ci,看图一遍树形结构就一目了然了,Ai的改变只会影响Ci及其祖先结点,即A5的改变影响的是C5、C6、C8;而A1的改变影响的是C1、C2、C4、C8。
也就是每次add(i, v),我们只要更新Ci以及它的祖先结点:
void add(int i,int v){//将第i元素增加v
if(i <= n){
C[i]+= v;
add( i+ lowbit(i), v );
}
}
注:此处i+lowbit(i)表示第i个元素的直接父节点的下标,如i=6,则它的父元素为i+lowbit(i)=6+2=8
和求和操作类似,递归的时候常数开销比较大,所以一般写成迭代的形式更好。写成迭代形式的代码如下:
void add(int i,int v){
for(; i <= n; i += lowbit(i)){
C[i] += v;
}
}
7 区间求和
如求[x,y]的和:上文中的sum(x)求的是[1, x]的和,所以只需要求两次sum函数,然后相减得到,即sum(y) - sum(x-1)。
8 举个栗子
hdu 1166
敌兵布阵
Time Limit: 2000/1000 MS (Java/Others) Memory Limit: 65536/32768 K (Java/Others)
Total Submission(s): 71393 Accepted Submission(s): 29930
Problem Description
C国的死对头A国这段时间正在进行军事演习,所以C国间谍头子Derek和他手下Tidy又开始忙乎了。A国在海岸线沿直线布置了N个工兵营地,Derek和Tidy的任务就是要监视这些工兵营地的活动情况。由于采取了某种先进的监测手段,所以每个工兵营地的人数C国都掌握的一清二楚,每个工兵营地的人数都有可能发生变动,可能增加或减少若干人手,但这些都逃不过C国的监视。
中央情报局要研究敌人究竟演习什么战术,所以Tidy要随时向Derek汇报某一段连续的工兵营地一共有多少人,例如Derek问:“Tidy,马上汇报第3个营地到第10个营地共有多少人!”Tidy就要马上开始计算这一段的总人数并汇报。但敌兵营地的人数经常变动,而Derek每次询问的段都不一样,所以Tidy不得不每次都一个一个营地的去数,很快就精疲力尽了,Derek对Tidy的计算速度越来越不满:”你个死肥仔,算得这么慢,我炒你鱿鱼!”Tidy想:“你自己来算算看,这可真是一项累人的工作!我恨不得你炒我鱿鱼呢!”无奈之下,Tidy只好打电话向计算机专家Windbreaker求救,Windbreaker说:“死肥仔,叫你平时做多点acm题和看多点算法书,现在尝到苦果了吧!”Tidy说:”我知错了。。。”但Windbreaker已经挂掉电话了。Tidy很苦恼,这么算他真的会崩溃的,聪明的读者,你能写个程序帮他完成这项工作吗?不过如果你的程序效率不够高的话,Tidy还是会受到Derek的责骂的.
Input
第一行一个整数T,表示有T组数据。
每组数据第一行一个正整数N(N<=50000),表示敌人有N个工兵营地,接下来有N个正整数,第i个正整数ai代表第i个工兵营地里开始时有ai个人(1<=ai<=50)。
接下来每行有一条命令,命令有4种形式:
(1) Add i j,i和j为正整数,表示第i个营地增加j个人(j不超过30)
(2)Sub i j ,i和j为正整数,表示第i个营地减少j个人(j不超过30);
(3)Query i j ,i和j为正整数,i<=j,表示询问第i到第j个营地的总人数;
(4)End 表示结束,这条命令在每组数据最后出现;
每组数据最多有40000条命令
Output
对第i组数据,首先输出“Case i:”和回车,
对于每个Query询问,输出一个整数并回车,表示询问的段中的总人数,这个数保持在int以内。
Sample Input
1
10
1 2 3 4 5 6 7 8 9 10
Query 1 3
Add 3 6
Query 2 7
Sub 10 2
Add 6 3
Query 3 10
End
Sample Output
Case 1:
6
33
59
C++版代码:accepted
#include<iostream>
#include<cstdio>
#include<map>
#include<math.h>
#include<cstring>
#include<algorithm>
using namespace std;
int a[50005], n;
int Lowbit(int k) // 树状数组最精妙的设计精髓
{
return k & -k;
}
void Update(int i, int val) // 操作函数
{
//要把原数组的i个值加上val影响了它的上一级,所以要依次往上加
while(i <= n)
{
a[i] += val;
i += Lowbit(i);//每次加上Lowbit(i)代表改变这次的值就是能影响的到树状数组的值
}
}
int sum(int i)//这个函数求得是从1到i所有元素的和
{
int sum = 0;
while(i > 0)
{
sum += a[i];
i -= Lowbit(i);//每次往下减,自己可以带入树状数组的a[6]试试。应该就能明白
}
return sum;
}
int main()
{
int t, p = 1, i, a1, b, val;
scanf("%d",&t);
while(t--)
{
memset(a, 0, sizeof(a));
scanf("%d",&n);
for(i = 1; i <= n; i++)
{
scanf("%d",&val);
Update(i, val);//对于每次输入直接操作就可以。
}
char str[50];
printf("Case %d:\n",p++);
while(scanf("%s",str) != EOF)
{
if(strcmp(str,"End") == 0)
break;
scanf("%d%d",&a1,&b);
if(strcmp(str,"Add") == 0)
Update(a1,b);
else if(strcmp(str,"Sub") == 0)
Update(a1,-b);
else
{
printf("%d\n",sum(b) - sum(a1 - 1));//前边见后边,大的减小的因为每次算的都是从1到b从1到a1-1的和
}
}
}
return 0;
}
java版:
import java.util.Scanner;
public class Main {
static int array[];
static int n;
public static void main(String[] args) {
Scanner scanner=new Scanner(System.in);
int t=scanner.nextInt();
int caseCount=1;
while(t-->0){
n=scanner.nextInt();
array=new int [n+1];
for (int i = 1; i <=n ; i++) {
int val=scanner.nextInt();
add(i,val);
}
// 输入命令
System.out.println("Case "+caseCount+++":");
String str=scanner.nextLine();
String strs[]=str.split("\\s+");
if(strs[0].equals("End"))
continue;
while(!strs[0].equals("End")){
if(strs[0].equals("Query")){
System.out.println(sum(Integer.parseInt(strs[2]))-sum(Integer.parseInt(strs[1])-1));
}else if(strs[0].equals("Add"))
add(Integer.parseInt(strs[1]),Integer.parseInt(strs[2]));
else if(strs[0].equals("Sub"))
add(Integer.parseInt(strs[1]),-Integer.parseInt(strs[2]));
str=scanner.nextLine();
strs=str.split("\\s+");
}
}
}
/*
* 要把原数组的i个值加上val影响了它的上一级,所以要依次往上加
* */
private static void add(int i, int val) {
while(i<=n){
array[i]+=val;
// //每次加上Lowbit(i)代表改变这次的值就是能影响的到树状数组的值
i+=lowbit(i);
}
}
private static int lowbit(int i) {
return i&-i;
}
/*
* //这个函数求得是从1到i所有元素的和
* */
private static int sum(int i){
int sum=0;
while(i>0){
sum+=array[i];
// //每次往下减
i-=lowbit(i);
}
return sum;
}
}