[译]树状数组(Binary Indexed Trees)

算法2009-09-07 22:17:11阅读282评论2  字号: 订阅

声明:本文原文由boba5551撰写,由GeTiX翻译。本文仅供学习交流之用。
如需转载,请注明作者及出处。

Binary Indexed Trees,直译为“二进制索引树”。这是一种树形结构,专门用于快速的计算“前N项和”的问题。因为通常用数组实现,所以中文名就翻译成了“树状数组”。

导言
我们经常需要某些数据结构来使我们的算法更快。在这篇文章中,我们将讨论树状数组(Binary Indexed Trees)结构。根据
Peter M. Fenwick,这个结构原先用于数据压缩。现在它通常用于排序频率并维护累积频率表。

我们来定义下列问题:我们有n个盒子。可能的操作为

向盒子i添加石块 查询从盒子k到盒子l总的石块数

自然的解法带有对操作1为O(1)而对操作2为O(n)的时间复杂度。假设我们作m个操作。最坏的情况下(当所有操作都为2)就有时间复杂度O(n * m)。使用某些数据结构(例如RMQ)在最坏的情况下我们可以以O(m log n)的时间复杂度解决这个问题。另一个方法就是使用树状数组(Binary Indexed Tree)数据结构,也是带有O(m log n)的最坏时间复杂度——但树状数组的编写容易得多,并且比RMQ需要跟少的内存空间。

标记
  BIT - 树状数组(Binary Indexed Tree)
  MaxVal - 带有非零频率的最大的值
  f[i] - 索引i这个值的频率,i = 1 .. MaxVal
  c[i] - 索引i的累积频率(f[1] + f[2] + ... + f[i])
  tree[i] - 储存在BIT中索引i的频率和(后面将会描述索引是什么意思);有事我们会写为树频率(tree frequency)而不是储存在BIT中的频率和
  num¯ - 整数num的反码(整数的每一个二进制位都被反转:0 -> 1;1 -> 0)
注意:通常我们领f[0] = 0,c[0] = 0,tree[0] = 0,所以有时候我们就忽略索引0。

基本想法
每个整数都可以表示为某些2的幂次之和。同样的,累积频率也可以表示为一些子频率的集合之和。这里,每个集合包含不重叠频率的某些后缀数。

idxBIT的某个索引。ridx的二进制表示中最后一个1(从左向右)的位置。tree[idx]是从索引(idx - 2^r + 1)到索引idx(详见表1.1)的频率和。我们也说idx对从(idx - 2^r + 1)到idx的索引是管辖的(注意到管辖是我们的算法的关键,因为它是维护该树的方法)。

1 0 2 1 1 3 0 4 2 5 2 2 3 1 0 2
1 1 3 4 5 8 8 12 14 19 21 23 26 27 27 29
1 1 2 4 1 4 0 12 2 7 2 11 3 4 0 29

表 1.1


1 1..2 3 1..4 5 5..6 7 1..8 9 9..10 11 9..12 13 13..14 15 1..16

表 1.2 - 责任表


图 1.3 - 索引的责任树(条形显示了最高元素的频率累积范围)

图 1.4 - 带有树频率的树

 

假设我们要查找索引13的累积频率(对前13个元素)。在二进制表示中,13等于1101。因此,我们计算c[1101] = tree[1101] + tree[1100] + tree[1000](下面会详述)。

隔离最后一位数
注意:我们称其为“最后一位数”,而不是“最后一位非零的数”。

很多时候我们需要从二进制数中得到最后一位数,所以我们需要一个有效的方法来实现它。令num表示我们要隔离最后一位的那个整数。在二进制表示中num可以表示为像a1b,其中a表示最后一位数之前的二进制数而b表示最后一位数之后的零。
整数-num等于(a1b)¯ + 1 = a¯0b¯ + 1b全由零组成,所以全由一组成。最后我们有

-num = (a1b)¯ + 1 = a¯0b¯ + 1 = a¯0(0...0)¯ + 1 = a¯0(1...1) + 1 = a¯1(0...0) = a¯1b.

现在,我们可以很容易地隔离最后一位数了,只要对num-num使用按位运算符AND(在C++、Java它就是&):

           a1b
&      a¯1b
--------------------
= (0...0)1(0...0)

读取累积频率
如果我们想要读取某个整数idx的累积频率,我们将tree[idx]加到sum,从idx自身提取最后一位(我们也可以写为——移除最后一位、将最后一位改为零),只要idx大于零就重复这个过程。我们可以使用next函数(在C++中)

int read(int idx){ int sum =0; while (idx >0){ sum += tree[idx]; idx -= (idx & -idx); } return sum;}

例如对idx = 13;sum = 0:

1 13 = 1101 0 1 (2 ^0) 3
2 12 = 1100 2 4 (2 ^2) 14
3 8 = 1000 3 8 (2 ^3) 26
4 0 = 0 --- --- ---

图 1.5 - 箭头显示了我们用来得到和的从索引到零的路径(该图显示了索引为13的例子)

 

所以,我们的结果是26。这个函数的迭代次数是idx的二进制位数,它最多为log MaxVal

时间复杂度:O(log MaxVal)。
代码长度:不到十行。

改变某个位置的频率并更新树

其实就是更新所有管辖我们改变的值的索引的树频率。在读取某个位置的累积频率中,我们是移除最后一位并继续。在改变树中的某个频率val中,我们应该为val增加当前索引的值(开始的索引总是频率改变的那个),添加最后一位数到索引,当索引小于等于MaxVal时继续这一过程。C++的函数:

void update(int idx ,int val){ while (idx <= MaxVal){ tree[idx] += val; idx += (idx & -idx); }}

让我们看对于idx = 5的例子:

1 5 = 101 0 1 (2 ^0)
2 6 = 110 1 2 (2 ^1)
3 8 = 1000 3 8 (2 ^3)
4 16 = 10000 4 16 (2 ^4)
5 32 = 100000 --- ---


图 1.6 - 更新树(在方括号中的是更新前的树频率);箭头显示了我们从索引到MaxVal更新树的路径(该图显示了索引为5的例子)

使用上述算法或跟随显示在图1.6中的箭头我们可以更新BIT

时间复杂度:O(log MaxVal)。
代码长度:不到十行。

读取某个位置的确切频率
我们已经描述了我们如何能读取一个索引的累积频率。很明显我们无法只读取tree[idx]来得带索引idx的确切频率。一个方法是添加一个额外的数组,其中我们单独地存储每个值的频率。读取和存储都需要O(1);内存空间是线性的。有时节省内存是更重要的,所以我们将展示在不使用额外结构的情况下你如何能得到某个值的确切频率。

可能每个人都知道一个位置idx的确切频率可以通过调用read函数两次——f[idx] = read(idx) - read(idx - 1)——来得到,就是取得相邻的两个累积频率的差。这个过程总是工作在2 * O(log n)的时间。如果我们写一个新的函数,我们能得到快一点的算法,带更小的常数。

如果从两个索引到根的两条路径有相同的部分,我们可以计算和直到路径相遇,提取储存的和,我们就得到两个索引之间的频率和。很容易计算相邻索引之间的和,或者说读取给定索引的确切频率。

记给定索引为x,它的前趋为y。我们可以将y(以二进制)表示为a0b,其中b全由一组成。那么,x将为a1b¯(注意到全由零组成)。使用我们得到某个索引的sum的算法,让它为x,在第一次迭代中我们移除最后 一位,所以在第一次迭代之后x将成为a0b¯,将新的值记为z

y重复同一个过程。使用我们读取sum的函数我们可以从数字中(一次一个地)移除最后一位。几个步骤之后,我们的y将成为(但要注意,它为a0b)a0b¯,就和z一样。现在,我们可以写出我们的算法。注意唯一的例外当x等于0。C++函数:

int readSingle(int idx){ int sum = tree[idx]; // sum将减少
if (idx >0){ // 特殊情况
int z = idx - (idx & -idx); // 令z为第一 idx--; // idx不再重要,所以我们使用idx,而不是ywhile (idx != z){ // 在某次迭代idx(y)将成为z sum -= tree[idx]; // 改变从y到"同一路径"的树频率 idx -= (idx & -idx); }}return sum; }这是获得索引12的确切频率的例子:
首先,我们将计算z = 12 - (12 & -12) = 8,sum = 11
1 11 = 1011 0 1 (2 ^0) 9
2 10 = 1010 1 2 (2 ^1) 2
3 8 = 1000 --- --- ---


图 1.7 - 在BIT中读取某个索引的确切频率
(该图显示了索引12的例子)

让我们对使用read函数两次和上述写的算法来读取某个索引进行比较。注意到对每个奇数,算法将以常数时间O(1)运行,无需任何迭代。对大多数偶数idx,它将以c * O(log idx)运行,其中c严格地小于1,相比read(idx) - read(idx - 1),将以c1 * O(log idx)运行,其中c1总是大于1。

时间复杂度:c * O(log MaxVal),其中c小于1。
代码长度:不到十五行。

按一个固定的比例缩放整个树

有时我们想要按某个比例缩放我们的树。有了上述过程这就是十分容易的。如果我们想要按某一个固定的比例c缩放整个树,那么每个索引idx必须由-(c - 1) * readSingle(idx) / c更新(因为f[idx] - (c - 1) * f[idx] / c = f[idx] / c)。C++的简单函数:

void scale(int c){ for (int i =1 ; i <= MaxVal ; i++) update(-(c -1) * readSingle(i) / c , i); }

这也可以做的跟快。缩放是一个线性操作。每个树频率是某些频率的一个线性组合。如果我们按某个比例缩放每个频率,我们也就按这个频率缩放了树频率。不重写以上的时间复杂度为O(MaxVal * log MaxVal)的过程,我们可以达到时间复杂度O(MaxVal):

void scale(int c){ for (int i =1 ; i <= MaxVal ; i++) tree[i] = tree[i] / c; }时间复杂度:O(MaxVal)。
代码长度:只有几行。

按给定的累计频率查找索引
按给定的累积频率查找索引自然的、最简单的解决方法就是简单地通过所有索引迭代,计算累积频率,并检查它是否等于给定值。在累积频率为负时这是唯一的解决方法。然而,如果在我们的树中我们只有非负频率值(这意味着较大索引的累积频率更小)我们可以对二分查找作更改得到对数复杂度的算法。我们遍历所有位(从最高一位开始),得到索引,比较当前索引的累积频率和给定的值,并根据结果选取区间的低一半和高一半(就像二分查找)。C++函数:

// 如果树中存在多于一个有同一累积频率的索引,这个 // 过程将返回它们中的某一个(我们不知道是哪一个)// bitMask - 初始时,它是最大的一个 // bitMask存储了将要查找的区间int find(int cumFre){ int idx =0; // 这个变量是函数的返回值while ((bitMask !=0) && (idx < MaxVal)){ // 没有人喜欢溢出 :)int tIdx = idx + bitMask; // 我们算出区间的中点if (cumFre == tree[tIdx]) // 如果它们相等,我们就返回idxreturn tIdx; else if (cumFre > tree[tIdx]){ // 如果树频率“可以适合”到cumFre, // 就包含它 idx = tIdx; // 更新索引 cumFre -= tree[tIdx]; // 设置下一个循环的频率 } bitMask >>=1; // 当前区间减半 } if (cumFre !=0) // 可能给出的累积频率不存在return-1; elsereturn idx; }// 如果在树中存在多于一个有同一个累积频率的索引 // 这个过程会返回最大的一个int findG(int cumFre){ int idx =0; while ((bitMask !=0) && (idx < MaxVal)){ int tIdx = idx + bitMask; if (cumFre >= tree[tIdx]){ // 如果当前累积频率等于cumFre, // 我们仍然查找最高索引(如果存在) idx = tIdx; cumFre -= tree[tIdx]; } bitMask >>=1; } if (cumFre !=0) return-1; elsereturn idx; }累积频率为21的例子和函数find

tIdx为16;tree[16]大于21;bitMask减半并继续
tIdx为8;tree[8]小于21,所以我们结果应该包含前8个索引,记住idx因为我们确认它是结果的一部分;从tree[8]中减去cumFre (我们不必再次查找同一个累积频率——我们在树的剩下部分/另一部分查找其他累积频率);bitMask减半并继续
tIdx为12;tree[12]大于9(没必要覆盖区间1-8,在这个例子中,有更多的区间,因为只有区间1-16可以覆盖);bitMask减半并继续
tIdx为10;tree[10]小于9,所以我们应该更新值;bitMask减半并继续
tIdx为11;tree[11]等于2;返回索引(tIdx)
时间复杂度:O(log MaxVal)。
代码长度:不到二十行。

2D BIT
BIT可用为二维数据结果。假设你有一个带有点的平面(有非负的坐标)。你有三个问题:

在(x , y)设置点 从(x , y)移除点 在矩形(0 , 0), (x , y)计算点数 - 其中(0 , 0)为左下角,(x , y)为右上角,而边是平行于x轴和y轴。

如果m是查询的数目,max_x是最大的x坐标,而max_y是最大的y坐标,那么应该在O(m * log (max_x) * log (max_y))内被解决。在这种情况下,树中的每个元素将包含数组——(tree[max_x][max_y])。更新x坐标的索引和以前一样。例如,假设我们要设置/移除点(a, b)。我们将调用update(a , b , 1)/update(a , b , -1),其中update为:

void update(int x , int y , int val){ while (x <= max_x){ updatey(x , y , val); // 这个数组将更新tree[x] x += (x & -x); }}

函数updatey和函数update是“一样”的:

void updatey(int x , int y , int val){ while (y <= max_y){ tree[x][y] += val; y += (y & -y); }}

它可以写成一个函数/过程:

void update(int x , int y , int val){
int y1;
while (x <= max_x){
  y1 = y;
  while (y1 <= max_y){
   tree[x][y1] += val;
   y1 += (y1 & -y1);
  }
  x += (x & -x);
}
}


图 1.8 - BIT是数组的数组,所以这是一个二维BIT(大小16 x 8).
蓝色域为我们在更新索引(5 , 3)时要更新的域

其他函数的修改非常简单。另外,注意到BIT可以用为n维数据结构。

示例问题

SRM 310 - FloatingMedian问题2:
提出:
有一个n卡片的阵列。每个卡片倒放在桌面上。你有两个问题:
  1. T i j (反转从索引i到索引j的卡片,包括第i张和第j张卡——面朝下的卡将朝上;面朝上的卡将朝下)
  2. Q i (如果第i张卡面朝下回答0否则回答1)
解决:
解决问题(1和2)的方法有时间复杂度O(log n)。在数组f(长度n + 1)我们存储每个问题T(i, j)——我们设置f[i]++f[j + 1]--。对在ij之间(包括ij)每个卡k求和f[1] + f[2] + ... + f[k]将递增1,其他全部和前面的一样(看图2.0清楚一些),我们的结果将描述为和(和累积频率一样)模2。


图 2.0


使用BIT来存储(增加/减少)频率并读取累积频率。

结论

树状数组很容易编写。 对树状数组的每个查询需要常数或线性时间。 树状数组需要线性储存空间。 你可以用它作为n维数据结构。

参考文献
[1]
RMQ
[2] Binary Search
[3] Peter M. Fenwick

posted on 2011-05-10 13:19  cchun  阅读(254)  评论(0编辑  收藏  举报