【算法学习】高级数据结构 一、二、三维树状数组及其拓展应用

树状数组 Binary Indexed Tree, BIT(二叉索引树) ,是一种利用数的二进制特征进行检索的树状结构,一种奇妙而优雅、高效而简洁的数据结构。


1. 一维树状数组:单点修改和区间查询

设长度为 n n n数列 A = { a 1 , a 2 , . . . . , a n } A = \{a_1, a_2, ...., a_n\} A={a1,a2,....,an} ,对其进行下面的操作:

  • 单点修改 add(k,x) \text{add(k,x)} add(k,x) :把 a k a_k ak 加上 x x x
  • 求前缀和 sum(k) \text{sum(k)} sum(k) k ≤ n ,   s u m = a 1 + a 2 + . . . + a k k \le n,\ sum = a_1 + a_2 + ... + a_k kn, sum=a1+a2+...+ak
  • 动态连续区间和查询 rangeSum(i, j) \text{rangeSum(i,\ j)} rangeSum(i, j) 1 ≤ i , j ≤ n ,   a i + . . . + a j = s u m ( j ) − s u m ( i − 1 ) 1 \le i,j \le n,\ a_i + ... + a_j = sum(j) - sum(i - 1) 1i,jn, ai+...+aj=sum(j)sum(i1) 。这一操作就是前缀和的应用。

可能想出来的两种做法:

  • 普通数组:此时单点修改的复杂度为 O(1) \text{O(1)} O(1)区间求和的时间复杂度是 O(n) \text{O(n)} O(n)
  • 前缀和,区间求和的复杂度是 O(1) \text{O(1)} O(1) ,但是单点修改的复杂度上升为 O(n) \text{O(n)} O(n) ,因为它会影响后面的所有元素;

我们希望有一种折中的方法:单点修改要更新的区间不那么多,区间查询要组合的区间也不多,这样一来单点修改和区间查询的复杂度都小于 O(n) \text{O(n)} O(n) ,不那么慢。事实上,我们可以用一个数组 tree[] 维护若干个小区间:单点修改时,只修改包含这一元素的区间;区间求和时,通过对一些区间进行组合,得到目标区间,然后对所有用到的区间求和。显然,如果 tree[i] 维护的区间是 [i, i] ,就相当于普通数组,甚至于浪费了一倍的内存;如果 tree[i] 维护的区间是 [1, i] ,则等价于前缀和。

利用数的二进制特征类似线段树的思想——用一个大节点表示一些小节点的信息,查询时只需要查询一些大节点而不是更多的小节点,我们得到了一种新的数据结构——树状数组 Binary Indexed Tree ,又称二进制索引树。其工作原理如下图,最底层的没有画出来的是 A[] 中的八个数,上面的参差不齐的方块代表 A[] 的上级——tree[] 数组。显然可知,tree[2] 代表 A[1], A[2]tree[4] 代表 A[1], A[2], A[3], A[4]tree[6] 代表 A[5], A[6]tree[8] 则代表全部的八个数:

假设我们有一个 1 ~ 8 的区间,同时构建了上面的树状数组,如何执行单点修改和区间查询这两种操作呢?

  • 注意到 6 6 6 的二进制是 11 0 2 110_2 1102 ,如果我们要求前 6 6 6 项的和,可以组合 [ 6 , 4 ) = [ 11 0 2 , 10 0 2 ) ,   [ 4 , 0 ) = [ 10 0 2 , 00 0 2 ] [6, 4) = [110_2, 100_2),\ [4, 0) = [100_2, 000_2] [6,4)=[1102,1002), [4,0)=[1002,0002] 这两个区间,然后对它们的和求和。如何得到这两个区间呢?很简单,只要不断去掉二进制数最右边的一个1就可以了。
  • 如果我们要对原数组进行单点修改的话,则是反过来的。修改第 6 6 6 项的值,则要修改另外的一些区间 ( 10 0 2 , 11 0 2 ] ,   ( 00 0 2 , 100 0 2 ] (100_2, 110_2],\ (000_2, 1000_2] (1002,1102], (0002,10002] ——通过不断加上二进制数最右边的一个1,可以得到并操作这些区间。

这种连续跳跃的思想和倍增跳表类似,树状数组的这两个操作也因此效率很高,时间复杂度均为 O(log 2 n ) \text{O(log}_2n) O(log2n) ,而且代码非常简洁清晰。


2. 一维树状数组模板

//单点修改+区间查询(特例是单点查询)
//计算出x的二进制表示中从右往左第一个1代表的值
#define lowbit ((x) & -(x)) 
//更新数组tree[],ak=ak+d
void add(int k, int d) { //单点修改原数组的同时,维护了原数组的区间和信息
	while (k <= n) {  //注意更新到tree[n]为止
		tree[k] += d; //修改和ak有关的tree[]
		k += lowbit(k);
	}
}
//求和sum=a1+a2+...+ak
int sum(int k) {
	int sum = 0;
	while (k > 0) { //不对tree[0]进行操作
		sum += tree[k];
		k -= lowbit[k];
	}
	return sum;
}
int queryRange(int l, int r) { //区间查询(单点查询是区间查询的特例)
	return sum(r) - sum(l - 1);
}

add(), sum() 的复杂度都是 O(log 2 n ) \text{O(log}_2n) O(log2n) 。使用方法如下:

  • 初始化add() :先清空数组 tree[] ,然后读取 a 1 , a 2 , a 3 , . . . , a n a_1, a_2, a_3,...,a_n a1,a2,a3,...,an ,用 add() 逐一处理这 n n n 个数,得到 tree[] 数组。这一过程中,我们不需要显式定义 A[] 数组,它隐含在 tree[] 中。
  • 求和 sum() :计算 s u m = a 1 + a 2 + . . . + a k sum = a_1 + a_2 +... + a_k sum=a1+a2+...+ak ,基于数组 tree[] 求和。
  • 单点修改元素add() :即修改原始数组 A[] ,这一修改实际反映在树状数组 tree[] 上。

3. 模板代码说明

(1) lowbit操作

树状数组的核心是一个 lowbit() 操作,功能是找到 x 的二进制数的最后一个 1。我们将 lowbit(x) 定义为 x 的二进制表示中最右边的 1 所对应的值

在程序实现中,lowbit(x) = x & -x 。原理就是利用整数的补码表示, -x 实际上是 x 按位取反、末尾加一后的结果。如 x = 6 = 0000011 0 2 x = 6 = 00000110_2 x=6=000001102 − x = x 补 = 1111101 0 2 -x = x_补 = 11111010_2 x=x=111110102 ,那么 l o w b i t ( x ) = x   & − x = 0000001 0 2 = 2 lowbit(x) = x\ \& -x = 00000010_2 = 2 lowbit(x)=x &x=000000102=2 。再看 x = 38288 ,二者按位与之后前面的部分全部变 0lowbit 部分保持不变:

1 ~ 9 为例,其 lowbit() 操作结果如下:

x123456789
x 的二进制 1 2 1_2 12 1 0 2 10_2 102 1 1 2 11_2 112 10 0 2 100_2 1002 10 1 2 101_2 1012 11 0 2 110_2 1102 11 1 2 111_2 1112 100 0 2 1000_2 10002 100 1 2 1001_2 10012
lowbit(x) 1 1 1 2 2 2 1 1 1 4 4 4 1 1 1 2 2 2 1 1 1 8 8 8 1 1 1
tree[x] 数组tree[1] = a1tree[2] = a1+a2tree[3] = a3tree[4] = a1+a2+a3+a4tree[5] = a5tree[6] = a5+a6tree[7] = a7tree[8] = a1+a2+...+a8tree[9] = a9

根据计算结果可得到下图。这是一棵典型的BIT,有 15 15 15 个结点,编号为 1 ∼ 15 1\sim 15 115

仔细观察上图,可知这幅图有这些特性:

  • 灰黑色结点是BIT中的结点,它属于一个以它自身结尾的水平长条;
  • 每一层结点的 lowbit 值相同,或者说编号的 lowbit 值相同的结点,都在同一层;
  • lowbit(i) 值代表 tree[i] 管理的 A[] 元素个数,更直观来看是水平长条的长度;
  • lowbit 值越大,越靠近二进制索引树的树根;
  • 编号为 0 的点是虚拟结点,它不是树的一部分,其存在只是让算法好理解一些;
  • 对于结点 i ,如果它是左子结点,那么父结点的编号是 i + lowbit(i) ;如果它是右子节点,那么父结点的编号是 i - lowbit(i)

(2) tree数组

弄清楚树的结构后,我们使用 lowbit(x) 操作构建 tree[] 辅助数组,所有的运算都围绕着 tree[] 数组进行。其中 tree[] 的每个元素 tree[i] 都是 A A A 数组中从 i - lowbit(i) + 1i 的一段连续区间和: t r e e i = A i − l o w b i t ( i ) + 1 + A i − l o w b i t ( i ) + 2 + ⋯ + A i tree_i = A_{i - lowbit(i) + 1} + A_{i - lowbit(i) + 2} + \dots + A_i treei=Ailowbit(i)+1+Ailowbit(i)+2++Ai

所以,在BIT中每个灰黑色结点 i 都属于一个以它自身结尾的水平长条,如果结点的 lowbit(i) = 1 ,长条就是结点自己。最重要的结论:i 结尾的水平长条中的元素之和就是 tree[i] 。比如结点 12 12 12 的长条就是从 9 ∼ 12 9\sim 12 912 ,即 t r e e 12 = A 9 + A 10 + A 11 + A 12 tree_{12} =A_9 + A_{10}+A_{11}+A_{12} tree12=A9+A10+A11+A12 。同理, t r e e 6 = A 5 + A 6 tree_6=A_5+A_6 tree6=A5+A6


(3) 前缀和计算

有了 tree[] 数组,如何进行前缀和 S i S_i Si 的计算呢?我们只用顺着结点 i i i 往左走,边走边往上爬,不一定沿着树中的边爬,沿途中累加经过的所有 tree[i] 就可以了。这一过程中,经过的 tree[i] 对应的长条不重复不遗漏地包含了所有需要累加的元素。对应到代码中的计算,就是一个不断去掉二进制数 i i i 最右边的一个 1 1 1 的过程

也就是不断让 i -= lowbit(i) ,从而得到多个目标区间。由于利用了数的二进制特征,所以这些目标区间不会重叠。如下图,要求出 S 11 S_{11} S11 ,就要沿着结点 11 11 11 往左往上爬:

(4) 单点修改

如果修改了一个源数据 A i A_i Ai ,需要更新 tree[i] 数组中的哪些结点呢?我们从结点 i i i 开始往右走,边走边往上爬,同样不一定沿着树边爬。沿途中,修改所有结点对应的 tree[i] 即可。这一过程中,有且仅有这些结点对应的长条包含被修改的元素

对应到代码中,就是把二进制数 i i i 右边的一系列连续的 1 1 1 变成 0 0 0 ,再把这一系列 1 1 1 的前一位 0 0 0 变成 1 1 1 ,像一个进位过程,也就是不断 i += lowbit(i) 。如下图,要修改 A 3 A_3 A3 ,就要沿着结点 3 3 3 往右往上爬:

有了前缀和,就可以轻松实现区间和查询。这里不多赘述。

(5) 初始化操作

预处理的方法,就是输入数据到 A[] 、然后清空 tree[] ,接着执行 n n nadd 操作,总时间复杂度是 O ( n log ⁡ n ) O(n\log n) O(nlogn) ,但是它支持 O ( log ⁡ n ) O(\log n) O(logn) 的单点修改和 O ( log ⁡ n ) O(\log n) O(logn) 的前缀和查询。

(6) 线性建树

在上面的初始化操作中,我们可以使用 O ( n log ⁡ n ) O(n\log n) O(nlogn) 的时间建立一个树状数组,但是这还太慢。原因是:我们对每个 A[i] 都执行了一次 add 操作,每次都沿着结点 i 往右往上走到子树根结点,同时给路径上的每个结点都加上 A[i]

既然每个结点的值都是由所有与自己直接相连的儿子结点的值求和得到的,因此可以倒着计算贡献,即每次确定完儿子结点的值后,用儿子结点的值更新自己的父亲结点。实际代码如下:

void init() {
	for (int i = 1; i <= n; ++i) {
		tree[i] += a[i]; //每次都确定一个儿子结点的值
		int j = i + lowbit(i);
		if (j <= n) tree[j] += tree[i];
	}
}

4. 一维树状数组变形1:单点查询和区间修改

实际上,如果改变树状数组的语义(卖个关子先),则基于"单点修改"和"求前缀和"可以完成下面两种操作:

  • 单点查询:返回 A i A_i Ai 的值。在普通的一维树状数组中很简单:sum(i) - sum(i - 1)只是区间查询的特例。然而如果要在支持区间修改的一维树状数组中做到这一点,就会麻烦一点。
  • 区间修改:给区间 [ l , r ] [l, r] [l,r] 内每个数加上 k k k 。这种操作可能有很多次。在普通的一维树状数组中对区间进行多次单点修改,时间复杂度绝对会爆炸。如果想要支持这种操作的话,就必须进行一定程度的变形。

回想以前学习的知识:

  • 如果直接对原数组进行操作,单点查询为 O ( 1 ) O(1) O(1);单次对区间进行修改为 O ( n ) O(n) O(n) ,总的时间复杂度为 O ( m n ) O(mn) O(mn) m m m 为区间修改操作的次数 。
  • 改为使用一维差分的话(【算法学习】算法技巧之差分 一维、二维差分的实现与应用),单次区间修改能降低到 O ( 1 ) O(1) O(1) 常数时间复杂度。 然而此时单点查询 A[i] 需要从 A[0] 开始对差分数组 d 求前缀和……这又是一个单次复杂度为 O ( n ) O(n) O(n) 级别的操作。

普通的一维树状数组(支持单点修改和区间查询)是在操作原数组前缀和之间寻找性能的动态平衡,变形的一维树状数组(支持单点查询和区间修改)则是在操作原数组差分之间寻找性能的动态平衡。然而本质上来说,这两者都依赖于树状数组对许多个区间和信息的维护。

不过,树状数组维护的不就是区间和或者说前缀和信息吗?我们使用树状数组来维护这一差分数组的区间和信息,此时:

  • 初始化数组时,就是将原数组的差分依次 add 进入 tree[]
  • 单点查询:对树状数组"求前缀和" sum ,复杂度为 O ( log ⁡ n ) O(\log n) O(logn)
  • 区间修改:采用差分的方法,对原数组区间 [ l , r ] [l, r] [l,r] 加上 k k k ,等同于对差分数组的 D l D_l Dl 加上 k k k D r + 1 D_{r + 1} Dr+1 减去 k k k 。于是对树状数组进行两次"单点修改" add 操作即可。复杂度为 O ( log ⁡ n ) O(\log n) O(logn)
  • 单点修改:这里是区间修改的特例,如要对 A[x] 加上 k ,只需要执行 tree[x] += k, tree[x + 1] -= k 即可。

具体代码模板如下:

//单点查询+区间修改(特例是单点修改)
#define lowbit(x) ((x) & (-x))
const int maxn = 5e5 + 10;
int tree[maxn], n; 
void add(int i, int d) { //单点修改差分数组的同时,维护了差分数组的区间和信息
	while (i <= n) {
		tree[i] += d;
		i += lowbit(i);
	}
}
int sum(int i) {	//前缀和查询(单点查询) 
	int ret = 0;
	while (i) {
		ret += tree[i];
		i -= lowbit(i);
	}
	return ret;
}
void addRange(int l, int r, int v) { //区间修改(单点修改是区间修改的特例)
	add(l, v);   
	add(r + 1, -v);
}

int main() { 
	//...
	memset(tree, 0, sizeof(tree));
	int pre = 0, now; 
	for (int i = 1; i <= n; ++i) {
		scanf("%d", &now);
		add(i, now - pre);	//维护差分数组 
		pre = now;
	}
	//...
    return 0;
}  

当然,我们也可以用线段树,它完全支持上述所有操作,比较好理解,但是代码写起来比较复杂。


5. 一维树状数组变形2:区间查询和区间修改

维护原数组 A[] 的差分数组 d[] 的基础上,如果还想要实现区间查询,则需要下一点功夫。假设此时要对 A[] 的一个前缀 r 求和,即 ∑ i = 1 r A i \sum\limits^r_{i=1}A_i i=1rAi ,又由差分定义得 A i = ∑ j = 1 i d j A_i= \sum\limits^i_{j=1} d_j Ai=j=1idj ,进行如下推导:
∑ i = 1 r A i = ∑ i = 1 r ∑ j = 1 i d j = ∑ i = 1 r d i × ( r − i + 1 ) = ∑ i = 1 r d i × ( r + 1 ) − ∑ i = 1 r d i × i \begin{aligned} &\sum^r_{i=1}A_i\\ =&\sum^r_{i=1}\sum^i_{j=1} d_j\\ =&\sum^r_{i=1}d_i\times (r - i + 1)\\ =&\sum^r_{i=1}d_i\times(r + 1) - \sum^r_{i=1}d_i\times i \end{aligned} ===i=1rAii=1rj=1idji=1rdi×(ri+1)i=1rdi×(r+1)i=1rdi×i

由于区间查询或者区间和可以用两个前缀和相减得到,因此只需要使用两个树状数组分别维护 ∑ d i \sum d_i di ∑ d i × i \sum d_i\times i di×i,就能实现区间求和。模板代码如下:

//区间查询+单点查询+区间修改(特例是单点修改)
#define lowbit(x) ((x) & (-x))
const int maxn = 5e5 + 10;
int tree1[maxn], tree2[maxn], n; //t1维护\sum d_i, t2维护\sum d_i\times i
void add(int i, int d1) { //单点修改两个差分数组的同时,维护了两个差分数组的区间和信息
	int d2 = i * d1;
	while (i <= n) {
		tree1[i] += d1, tree2[i] += d2;
		i += lowbit(i);
	}
} 
int sum(int *tree, int i) { //指定树状数组进行前缀和查询(对tree1使用sum即为单点查询)  
	int ret = 0;
	while (i) {
		ret += tree[i];
		i -= lowbit(i);
	}
	return ret;
}
void addRange(int l, int r, int v) { //区间修改(单点修改是区间修改的特例)
	add(l, v);       
	add(r + 1, -v);
}
int queryRange(int l, int r) { //区间查询
	return (r + l) * sum(tree1, r) - sum(tree2, r) //前缀和sum[r]
	- (l * sum(tree1, l - 1) - sum(tree2, l - 1)); //前缀和sum[l-1] 
}

6. 二维树状数组

见这篇博客


7. 三维树状数组

见这篇博客


8. 线段树和树状数组的对比

区间数据结构,我们之前学过分块数组,也学过ST表,现在学了树状数组,还有线段树。不妨对后两者做一下对比:

  • 时间复杂度
    虽然都是 O ( n log ⁡ n ) O(n\log n) O(nlogn) ,但你会发现,树状数组的常数明显优于线段树,编程复杂度也远远小于线段树。

  • 空间复杂度
    树状数组吊打线段树,线段树要开 2 2 2 倍到 4 4 4 倍内存(推荐 4 4 4 倍),但是树状数组开一倍就够了。

  • 适用范围
    线段树之所以还能够存在,理由是因为它十分灵活,能适用于很多方面。树状数组能做到的,线段树都能做到;线段树能做到的,树状数组不一定能做到。不仅仅是区间、单点的查询和修改,还有懒标记等等,可以用于模拟、DP等。而且离散化之后也可以相对压缩空间,所以线段树的适用范围更加广泛。


9. 题目应用

HDU P1166 敌兵布阵
POJ 2182
洛谷 P1908 逆序对
洛谷 P3374 【模板】树状数组 1
洛谷 P3368 【模板】树状数组 2

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

memcpy0

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值