树状数组
什么是树状数组?
它其实本质上和数组一样是一种数据结构,区别在于,它是树状的!(好像是废话)。
但是正是因为它是树状,所以它能高效的支持单点修改和区间查询!(并不是说它不能支持区间修改和单点查询。后面通过一些辅助数组和差分数组也可以实现)
树状数组具体能干什么?
如果你想知道一个数组1-7的前缀和,你会怎么做?
1 | for(int i=1;i<=7;i++){ |
朴实无华的循环。时间复杂度O(n)
你也许会想,这还能玩出朵花来吗?当然,请相信代码世界有着无穷的创造力。
现在如果我告诉你,我知道a[1-4],a[5-6],a[7]呢?
那么当然就是这三个数的相加了。这就是树状数组的关键
我们总能将一段长n的区间拆成不多于logn段区间
接下来我将附上一张原理图,让你直观了解什么是树状。
由图可知,减少时间复杂度的关键就在于c数组,那么问题来了
c[x]管辖的区间到底是什么?(lowbit引入预告)
树状数组中我们规定,c[x]管辖的区间是2的k次方(k是数的二进制表示中,最低位的1所在的位置)。
那么2的k次方就是x的二进制表示中最低位的1以及后面所有0组成的数字。
例如c88管辖哪个区间呢?它的二进制是01011000.那么它最低位的1以及后面的0组成的是1000,所以管辖八个元素。代表a[81-88]。对于1000,我们叫他,lowbit(88)。
代码如何实现lowbit(x)
这个就涉及到位运算知识了,补码反码的运算。这里不说原理了(犯懒)。
1 | int lowbit(int x){ |
就是这么的朴实无华)
如何通过lowbit(x)实现区间查询
前面我们提到,树状数组可以实现区间查询有了lowbit之后,我们就可以轻松获得c[x]对应的a[]有几个,从而丝滑的实现区间查询了。
回顾上面提到的a[4–7],
c7–lowbit(7)=1
c6–lowbit(6)=2
c4–lowbit(4)=4
令x不断地减去lowbit(x)直到等于0,就说明我们要找的数都找完了。
代码实现如下
1 | int getsum(int x) { // a[1]..a[x]的和 |
如何通过lowbit(x)实现单点修改?
如果是a数组,那么单点修改不过是一个等于号的赋值操作。但是由于我们是通过c数组来计算以达到logn实现区间查询的目的,那么我们现在应该思考的问题是
如何通过修改c数组来单点修改a数组
不妨联想一下我们如何实现Sn->an
$$
Sn-Sn-1=an
$$
如果想要an +1,那我们只需要Sn +1.如果想要a(n-1) +1,那我们只要S(n-1)+1
(很简单的道理,1,2,3,4,5这个数组,我想要4变成6,那么S5 会变成17,S4会变成12,S3 是6不变。S4-S3=a4=6)
Cn其实也是另一种形式的Sn,只是他不是从1到n,但是他也是有规律的,这个规律就是lowbit(x)。
c[x]管理的区间是 [x - lowbit(x) + 1, x]
我们通过例子不难看出,本质上就是,谁包含我,我加上一个数,包含我的它也要加上一个数。
我们只需要通过lowbit向上搜寻,我们要加上的单点在哪个节点上即可。
以c[6]为例
c[6]
管理[5, 6]
(lowbit(6)=2)c[8(6+2)]
管理[1, 8]
(lowbit(8)=8)c[16(8+8)]
管理[1, 16]
(因为16 - 16 + 1 = 1
)
(还可以往上延申
话不多说,献上代码!
1 | void add(int x, int k) { |
来练练手叭(恭喜你已经学会了单点修改和区间查询)

这是一道模板题,只要你理解了原理(不理解好像也没问题)就能丝滑的写出。#130. 树状数组 1 :单点修改,区间查询 - 题目 - LibreOJ,附上题目链接,快去试试吧!
1 |
|
如何实现区间修改,单点查询?
前面提到过,树状数组在辅助数组的帮助下可以实现区间修改。那么在我不提示的情况下,你不妨先思考几分钟,让子弹飞一会。
让我来猜一猜你现在是不是已经有了一个朴素的想法。
1 | for(int i=l;i<=r;i++){ |
但是这样,时间复杂度不是又上来了吗?那你何必费这么大功夫折腾树状数组(狗狗问号脸)
如果你现在一头雾水,毫无灵感这很正常,是因为你还没有遇见它–差分数组)
什么是差分数组呢?我们这里为什么会想到构造它?
不妨回顾一下我们前面我们是如何降低时间复杂度的。
其实就是构造一个关联的数组。那么除了树状拆分来得到c数组,我们其实还可以通过逆向求和的方式得到d数组也就是差分数组。
什么叫逆向求和?你可以通过an累加得到Sn,自然也可以通过Sn递减得到an.
Sn是an的求和数组,那么an也可以成为另一个人的求和数组。
$$
an-a(n-1)=dn
$$
这就是差分数组。其实听起来很简单吧。(事实上也很简单)
我们利用add方法来构造它
1 | for (int i = 1; i <= n; i++) |
难的是,我们如何利用它把区间修改 转化成单点修改。
假设a数组 2 2 4 5 8 10
那么d数组 2 0 2 1 3 2
我现在想把a[2-3]加2,那么
a数组 2 4 6 5 8 10
d数组 2 2 2 -1 3 2
你动手算了一遍就会发现一个规律,其实我们区间修改[l-r]时,对于d数组来说,变的只有d[l]和d[r+1]。
这并不是什么神奇规律,这是差分数组的必然。a[l]作为第一个增大的元素,d数组只能依靠d[l]增长来让总和等于a[l]。而d[l]增大后x,意味着a[l]往后的数其实都会增大x。(就像a2增大2,那s2后面的数也都会增大2).
问题来了,我们是要实现一个区间,而不是从一个数往后都增大。那其实也很简单。
在右界减去一个x不久好了?那么从r往后的数都会减去一个x。自然就实现了区间修改。
总结!
差分数组是一种常见的处理区间修改的技术:
- 定义差分数组d,其中d[i] = a[i] - a[i-1](a[0]=0)
- 区间[l,r]加x的操作可以转化为:
- d[l] += x
- d[r+1] -= x
也许你会疑惑怎么就总结了,单点查询不是还没讲吗?有了d数组的情况下,我们如何求a?
这不就是朴实无华的 已知an求Sn吗。求个前缀和就好啦。
$$
a[i] = getsum(i) = d[1] + d[2] + … + d[i]
$$
也许你会疑惑,这时间复杂度不是又上来了吗?
看到求这个前缀和你有没有觉得熟悉呢,朋友。这不就是我们一开始引入树状数组要解决的问题吗?也就是前缀和查询功能。
那么再根据d数组构建一个属于它的树状数组tree,问题就迎刃而解了!
话不多说,奉上题目代码
[题目链接](#131. 树状数组 2 :区间修改,单点查询 - 题目 - LibreOJ)
AC代码
1 |
|
最后关卡,如何实现区间修改,区间查询?
恭喜你已经到了一维数组这一关的boss关卡,但我想说,这一关只和数学推导有关了。
区间修改我们无疑问的仍然使用差分数组,那么如何用差分数组实现区间查询呢?
原始数组a
的前缀和可以表示为:
$$
sum[1..x] = Σ(i=1 to x) a[i]
$$
使用差分数组d
(其中d[i] = a[i] - a[i-1]
,a[0]=0
):
$$
sum[1..x] = Σ(i=1 to x) Σ(j=1 to i) d[j]
= Σ(j=1 to x) (x - j + 1) * d[j]
= (x+1) * Σ(j=1 to x) d[j] - Σ(j=1 to x) j * d[j]
$$
然后丝滑的
$$
sum[l..r] = sum[1..r] - sum[1..l-1]
$$
我们用两个树状数组维护sum[1-x]最后的两项结果即可
$$
(x+1) * Σ(j=1 to x) d[j] - Σ(j=1 to x) j * d[j]
$$
这正是我们使用两个树状数组维护的内容:
tree1
维护Σd[j]
tree2
维护Σj * d[j]
附上AC代码
1 |
|
是的,到这里,区间修改,单点修改,区间查询,单点查询已经玩不起花了。
但是树状数组还没有结束!
因为这只是一维数组。树状数组还有plus版本,也就是二维数组。
欲知后事如何,请听下回分解~