数据结构:树状数组
树状数组是一种支持 单点修改 和 区间查询 的,代码量小的数据结构。
什么是「单点修改」和「区间查询」?
假设有这样一道题:
已知一个数列
,你需要进行下面两种操作:
- 给定
,将 自增 。 - 给定
,求解 的和。 其中第一种操作就是「单点修改」,第二种操作就是「区间查询」。
类似地,还有:「区间修改」、「单点查询」。它们分别的一个例子如下:
- 区间修改:给定
,将 中的每个数都分别自增 ; - 单点查询:给定
,求解 的值。 注意到,区间问题一般严格强于单点问题,因为对单点的操作相当于对一个长度为
的区间操作。
普通树状数组维护的信息及运算要满足 结合律 且 可差分,如加法(和)、乘法(积)、异或等。
- 结合律:
,其中 是一个二元运算符。 - 可差分:具有逆运算的运算,即已知
和 可以求出 。
需要注意的是:
- 模意义下的乘法若要可差分,需保证每个数都存在逆元(模数为质数时一定存在);
- 例如
, 这些信息不可差分,所以不能用普通树状数组处理,但是: - 使用两个树状数组可以用于处理区间最值,见 Efficient Range Minimum Queries using Binary Indexed Trees。
- 本页面也会介绍一种支持不可差分信息查询的,
时间复杂度的拓展树状数组。
事实上,树状数组能解决的问题是线段树能解决的问题的子集:树状数组能做的,线段树一定能做;线段树能做的,树状数组不一定可以。然而,树状数组的代码要远比线段树短,时间效率常数也更小,因此仍有学习价值。
有时,在差分数组和辅助数组的帮助下,树状数组还可解决更强的 区间加单点值 和 区间加区间和 问题。
树状数组
初步感受
先来举个例子:我们想知道
一种做法是:
但是如果已知三个数
这就是树状数组能快速求解信息的原因:我们总能将一段前缀
于是,我们只需合并这
不难发现信息必须满足结合律,否则就不能像上面这样合并了。
下面这张图展示了树状数组的工作原理:
最下面的八个方块代表原始数据数组
例如,从图中可以看出:
管辖的是 ; 管辖的是 ; 管辖的是 ; 管辖的是 ; - 剩下的
管辖的都是 自己(可以看做 的长度为 的小区间)。
不难发现,
举例:计算
过程:从
我们刚刚找到的
举例:计算
我们还是从
那不妨考虑最开始,就将查询
管辖区间
那么问题来了,
树状数组中,规定
- 设二进制最低位为第
位,则 恰好为 二进制表示中,最低位的 1
所在的二进制位数; ( 的管辖区间长度)恰好为 二进制表示中,最低位的 1
以及后面所有0
组成的数。
举个例子,
因为 1
以及后面的 0
组成的二进制是 1000
,即
因此,
我们记 1
以及后面的 0
组成的数为
这里注意:1
所在的位数 1
和后面所有 0
组成的
怎么计算 lowbit
?根据位运算知识,可以得到 lowbit(x) = x & -x
。
lowbit 的原理
将
x
的二进制所有位全部取反,再加 1,就可以得到-x
的二进制编码。例如,的二进制编码是 110
,全部取反后得到001
,加1
得到010
。设原先
x
的二进制编码是(...)10...00
,全部取反后得到[...]01...11
,加1
后得到[...]10...00
,也就是-x
的二进制编码了。这里x
二进制表示中第一个1
是x
最低位的1
。
(...)
和[...]
中省略号的每一位分别相反,所以x & -x = (...)10...00 & [...]10...00 = 10...00
,得到的结果就是lowbit
。
1 | int lowbit(int x) { |
区间查询
接下来我们来看树状数组具体的操作实现,先来看区间查询。
回顾查询
其实任何一个区间查询都可以这么做:查询
事实上,将有关
那前缀查询怎么做呢?回顾下查询
从
往前跳,发现 只管辖 这个元素;然后找 ,发现 管辖的是 ,然后跳到 ,发现 管辖的是 这些元素,然后再试图跳到 ,但事实上 不存在,不跳了。 我们刚刚找到的
是 ,事实上这就是 拆分出的三个小区间,合并一下,答案是 。
观察上面的过程,每次往前跳,一定是跳到现区间的左端点的左一位,作为新区间的右端点,这样才能将前缀不重不漏地拆分。比如现在
我们可以写出查询
- 从
开始往前跳,有 管辖 ; - 令
,如果 说明已经跳到尽头了,终止循环;否则回到第一步。 - 将跳到的
合并。
实现时,我们不一定要先把
比如我们要维护的信息是和,直接令初始
1 | int getsum(int x) { // a[1]..a[x]的和 |
树状数组与其树形态的性质
在讲解单点修改之前,先讲解树状数组的一些基本性质,以及其树形态来源,这有助于更好理解树状数组的单点修改。
我们约定:
。即, 是 管辖范围的左端点。 - 对于任意正整数
,总能将 表示成 的形式,其中 。 - 下面「
和 不交」指 的管辖范围和 的管辖范围不相交,即 和 不相交。「 包含于 」等表述同理。
性质
Proof:
假设
和 相交,即 和 相交,则一定有 。 将
表示为 ,则 。所以, 可以表示为 ,其中 。 不难发现
。又因为 , 所以
,即 。 所以,如果
和 相交,那么 的管辖范围一定完全包含于 。
性质
Proof:
设
, ,则 , 。 不难发现
,所以 ,即 。 所以,
真包含于 。
性质
Proof:
设
,则 ,其中 。 不难发现
。又因为 , 因此
,即 。 所以,
和 不交。
有了这三条性质的铺垫,我们接下来看树状数组的树形态(请忽略
事实上,树状数组的树形态是
注意,在考虑树状数组的树形态时,我们不考虑树状数组大小的影响,即我们认为这是一棵无限大的树,方便分析。实际实现时,我们只需用到
这棵树天然满足了很多美好性质,下面列举若干(设
。 大于任何一个 的后代,小于任何一个 的祖先。 - 点
的 严格小于 的 。
证明:设
, ,则 ,不难发现 ,证毕。
- 点
的高度是 ,即 二进制最低位 1
的位数。
高度的定义:
点
的高度 满足:如果 ,则 ,否则 ,其中 代表 的所有儿子(此时 至少存在一个儿子 )。 也就是说,一个点的高度恰好比它最高的那个儿子再高
。如果一个点没有儿子,它的高度是 。 这里引出高度这一概念,是为后面解释复杂度更方便。
真包含于 (性质 )。 真包含于 ,其中 是 的任一祖先(在上一条性质上归纳)。 真包含 ,其中 是 的任一后代(上面那条性质 , 颠倒)。 - 对于任意
,若 不是 的祖先,则 和 不交。
证明:
和 的祖先中,一定存在一个点 使得 ,根据性质 得 不相交于 ,而 包含 ,因此 不交于 。
- 对于任意
,如果 不在 的子树上,则 和 不交(上面那条性质 , 颠倒)。 - 对于任意
,当且仅当 是 的祖先, 真包含于 (上面几条性质的总结)。这就是树状数组单点修改的核心原理。 - 设
,则其儿子数量为 ,编号分别为 。 - 举例:假设
, 的二进制编号为 ...1000
,则有三个儿子,二进制编号分别为 ...0111
、...0110
、...0100
。
- 举例:假设
证明:
在一个数
的基础上减去 , 二进制第 位会反转,而更低的位保持不变。 考虑
的儿子 ,有 ,即 且 。设 。 考虑
, 的第 位及后方均为 ,所以 的第 位变为 ,后面仍为 ,满足 。 考虑
,则 , 的第 位变为 ,不满足 。 考虑
,则 , 的第 位是 ,所以 ,不满足 。
- 举例:假设
, 的二进制编号为 ...1000
,则有三个儿子,二进制编号分别为 ...0111
、...0110
、...0100
。 c[...0100]
表示a[...0001 ~ ...0100]
。c[...0110]
表示a[...0101 ~ ...0110]
。c[...0111]
表示a[...0111 ~ ...0111]
。- 不难发现上面是三个管辖区间的并集恰好是
a[...0001 ~ ...0111]
,即。
证明:
的儿子总能表示成 ,不难发现, 越小, 越大,代表的区间越靠右。我们设 ,则 分别构成 从左到右的儿子。 不难发现
,所以 。 考虑相邻的两个儿子
和 。前者管辖区间的右端点是 ,后者管辖区间的左端点是 ,恰好相接。 考虑最左面的儿子
,其管辖左边界 恰为 。 考虑最右面的儿子
,其管辖右边界就是 。 因此,这些儿子的管辖区间可以恰好拼成
。
单点修改
现在来考虑如何单点修改
我们的目标是快速正确地维护
管辖
设
- 初始令
。 - 修改
。 - 令
,如果 说明已经跳到尽头了,终止循环;否则回到第二步。
区间信息和单点修改的种类,共同决定
- 若
维护区间和,修改种类是将 加上 ,则修改方式则是将所有 也加上 。 - 若
维护区间积,修改种类是将 乘上 ,则修改方式则是将所有 也乘上 。
然而,单点修改的自由性使得修改的种类和维护的信息不一定是同种运算,比如,若
下面以维护区间和,单点加为例给出实现。
1 | void add(int x, int k) { |
建树
也就是根据最开始给出的序列,将树状数组建出来(
一般可以直接转化为
比如给定序列
也有
复杂度分析
空间复杂度显然
时间复杂度:
- 对于区间查询操作:整个
的迭代过程,可看做将 二进制中的所有 ,从低位到高位逐渐改成 的过程,拆分出的区间数等于 二进制中 的数量(即 )。因此,单次查询时间复杂度是 ; - 对于单点修改操作:跳父亲时,访问到的高度一直严格增加,且始终有
。由于点 的高度是 ,所以跳到的高度不会超过 ,所以访问到的 的数量是 级别。因此,单次单点修改复杂度是 。
区间加区间和
本小节需要了解前缀和和差分的部分知识。
该问题可以使用两个树状数组维护差分数组解决。
考虑序列
一样地,我们考虑将查询区间和通过差分转化为查询前缀和。那么考虑查询
观察这个式子,不难发现每个
那么怎么做区间加呢?考虑给原数组
因为差分是
多了 而 不变,所以 的值多了 。 不变而 多了 ,所以 的值少了 。 - 对于不等于
且不等于 的任意 , 和 要么都没发生变化,要么都加了 , 还是 ,所以其它的 均不变。
那就不难想到维护方式了:对于维护
而更弱的问题,「区间加求单点值」,只需用树状数组维护一个差分数组
这里直接给出「区间加区间和」的代码:
1 | int t1[MAXN], t2[MAXN], n; |
根据这个原理,应该可以实现「区间乘区间积」,「区间异或一个数,求区间异或值」等,只要满足维护的信息和区间操作是同种运算即可,感兴趣的读者可以自己尝试。
//待续