数据结构:树状数组

树状数组是一种支持 单点修改区间查询 的,代码量小的数据结构。

什么是「单点修改」和「区间查询」?

假设有这样一道题:

已知一个数列 ,你需要进行下面两种操作:

  • 给定 ,将 自增
  • 给定 ,求解 的和。

其中第一种操作就是「单点修改」,第二种操作就是「区间查询」。

类似地,还有:「区间修改」、「单点查询」。它们分别的一个例子如下:

  • 区间修改:给定 ,将 中的每个数都分别自增
  • 单点查询:给定 ,求解 的值。

注意到,区间问题一般严格强于单点问题,因为对单点的操作相当于对一个长度为 的区间操作。

普通树状数组维护的信息及运算要满足 结合律可差分,如加法(和)、乘法(积)、异或等。

  • 结合律:,其中 是一个二元运算符。
  • 可差分:具有逆运算的运算,即已知 可以求出

需要注意的是:

  • 模意义下的乘法若要可差分,需保证每个数都存在逆元(模数为质数时一定存在);
  • 例如 这些信息不可差分,所以不能用普通树状数组处理,但是:

事实上,树状数组能解决的问题是线段树能解决的问题的子集:树状数组能做的,线段树一定能做;线段树能做的,树状数组不一定可以。然而,树状数组的代码要远比线段树短,时间效率常数也更小,因此仍有学习价值。

有时,在差分数组和辅助数组的帮助下,树状数组还可解决更强的 区间加单点值区间加区间和 问题。

树状数组

初步感受

先来举个例子:我们想知道 的前缀和,怎么做?

一种做法是:,需要求 个数的和。

但是如果已知三个数 的和, 的总和, 的总和(其实就是 自己)。你会怎么算?你一定会回答:,只需要求 个数的和。

这就是树状数组能快速求解信息的原因:我们总能将一段前缀 拆成 不多于 段区间,使得这 段区间的信息是 已知的

于是,我们只需合并这 段区间的信息,就可以得到答案。相比于原来直接合并 个信息,效率有了很大的提高。

不难发现信息必须满足结合律,否则就不能像上面这样合并了。

下面这张图展示了树状数组的工作原理:

最下面的八个方块代表原始数据数组 。上面参差不齐的方块(与最上面的八个方块是同一个数组)代表数组 的上级—— 数组。

数组就是用来储存原始数组 某段区间的和的,也就是说,这些区间的信息是已知的,我们的目标就是把查询前缀拆成这些小区间。

例如,从图中可以看出:

  • 管辖的是
  • 管辖的是
  • 管辖的是
  • 管辖的是
  • 剩下的 管辖的都是 自己(可以看做 的长度为 的小区间)。

不难发现, 管辖的一定是一段右边界是 的区间总信息。我们先不关心左边界,先来感受一下树状数组是如何查询的。

举例:计算 的和。

过程:从 开始往前跳,发现 只管辖 这个元素;然后找 ,发现 管辖的是 ,然后跳到 ,发现 管辖的是 这些元素,然后再试图跳到 ,但事实上 不存在,不跳了。

我们刚刚找到的 ,事实上这就是 拆分出的三个小区间,合并得到答案是

举例:计算 的和。

我们还是从 开始跳,跳到 再跳到 。此时我们发现它管理了 的和,但是我们不想要 这一部分,怎么办呢?很简单,减去 的和就行了。

那不妨考虑最开始,就将查询 的和转化为查询 的和,以及查询 的和,最终将两个结果作差。

管辖区间

那么问题来了, 管辖的区间到底往左延伸多少?也就是说,区间长度是多少?

树状数组中,规定 管辖的区间长度为 ,其中:

  • 设二进制最低位为第 位,则 恰好为 二进制表示中,最低位的 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 二进制表示中第一个 1x 最低位的 1

(...)[...] 中省略号的每一位分别相反,所以 x & -x = (...)10...00 & [...]10...00 = 10...00,得到的结果就是 lowbit

1
2
3
4
5
6
7
8
int lowbit(int x) {
// x 的二进制中,最低位的 1 以及后面所有 0 组成的数。
// lowbit(0b01011000) == 0b00001000
// ~~~~^~~~
// lowbit(0b01110010) == 0b00000010
// ~~~~~~^~
return x & -x;
}

区间查询

接下来我们来看树状数组具体的操作实现,先来看区间查询。

回顾查询 的过程,我们是将它转化为两个子过程:查询 和查询 的和,最终作差。

其实任何一个区间查询都可以这么做:查询 的和,就是 的和减去 的和,从而把区间问题转化为前缀问题,更方便处理。

事实上,将有关 的区间询问转化为 的前缀询问再差分,在竞赛中是一个非常常用的技巧。

那前缀查询怎么做呢?回顾下查询 的过程:

往前跳,发现 只管辖 这个元素;然后找 ,发现 管辖的是 ,然后跳到 ,发现 管辖的是 这些元素,然后再试图跳到 ,但事实上 不存在,不跳了。

我们刚刚找到的 ,事实上这就是 拆分出的三个小区间,合并一下,答案是

观察上面的过程,每次往前跳,一定是跳到现区间的左端点的左一位,作为新区间的右端点,这样才能将前缀不重不漏地拆分。比如现在 管的是 ,下一次就跳到 ,即访问

我们可以写出查询 的过程:

  • 开始往前跳,有 管辖
  • ,如果 说明已经跳到尽头了,终止循环;否则回到第一步。
  • 将跳到的 合并。

实现时,我们不一定要先把 都跳出来然后一起合并,可以边跳边合并。

比如我们要维护的信息是和,直接令初始 ,然后每跳到一个 ,最终 就是所有合并的结果。

1
2
3
4
5
6
7
8
int getsum(int x) {  // a[1]..a[x]的和
int ans = 0;
while (x > 0) {
ans = ans + c[x];
x = x - lowbit(x);
}
return ans;
}

树状数组与其树形态的性质

在讲解单点修改之前,先讲解树状数组的一些基本性质,以及其树形态来源,这有助于更好理解树状数组的单点修改。

我们约定:

  • 。即, 管辖范围的左端点。
  • 对于任意正整数 ,总能将 表示成 的形式,其中
  • 下面「 不交」指 的管辖范围和 的管辖范围不相交,即 不相交。「 包含于 」等表述同理。

性质 :对于 ,要么有 不交,要么有 包含于

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
2
3
4
5
6
void add(int x, int k) {
while (x <= n) { // 不能越界
c[x] = c[x] + k;
x = x + lowbit(x);
}
}

建树

也就是根据最开始给出的序列,将树状数组建出来( 全部预处理好)。

一般可以直接转化为 次单点修改,时间复杂度 (复杂度分析在后面)。

比如给定序列 要求建树,直接看作对 单点加 ,对 单点加 ,对 单点加 即可。

也有 的建树方法,见本博客后文 建树一节。

复杂度分析

空间复杂度显然

时间复杂度:

  • 对于区间查询操作:整个 的迭代过程,可看做将 二进制中的所有 ,从低位到高位逐渐改成 的过程,拆分出的区间数等于 二进制中 的数量(即 )。因此,单次查询时间复杂度是
  • 对于单点修改操作:跳父亲时,访问到的高度一直严格增加,且始终有 。由于点 的高度是 ,所以跳到的高度不会超过 ,所以访问到的 的数量是 级别。因此,单次单点修改复杂度是

区间加区间和

本小节需要了解前缀和和差分的部分知识。

该问题可以使用两个树状数组维护差分数组解决。

考虑序列 的差分数组 ,其中 。由于差分数组的前缀和就是原数组,所以

一样地,我们考虑将查询区间和通过差分转化为查询前缀和。那么考虑查询 的和,即 ,进行推导:

观察这个式子,不难发现每个 总共被加了 次。接着推导:

并不能推出 的值,所以要用两个树状数组分别维护 的和信息。

那么怎么做区间加呢?考虑给原数组 区间加 带来的影响。

因为差分是

  • 多了 不变,所以 的值多了
  • 不变而 多了 ,所以 的值少了
  • 对于不等于 且不等于 的任意 要么都没发生变化,要么都加了 还是 ,所以其它的 均不变。

那就不难想到维护方式了:对于维护 的树状数组,对 单点加 单点加 ;对于维护 的树状数组,对 单点加 单点加

而更弱的问题,「区间加求单点值」,只需用树状数组维护一个差分数组 。询问 的单点值,直接求 的和即可。

这里直接给出「区间加区间和」的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int t1[MAXN], t2[MAXN], n;

int lowbit(int x) { return x & (-x); }

void add(int k, int v) {
int v1 = k * v;
while (k <= n) {
t1[k] += v, t2[k] += v1;
// 注意不能写成 t2[k] += k * v,因为 k 的值已经不是原数组的下标了
k += lowbit(k);
}
}

int getsum(int *t, int k) {
int ret = 0;
while (k) {
ret += t[k];
k -= lowbit(k);
}
return ret;
}

void add1(int l, int r, int v) {
add(l, v), add(r + 1, -v); // 将区间加差分为两个前缀加
}

long long getsum1(int l, int r) {
return (r + 1ll) * getsum(t1, r) - 1ll * l * getsum(t1, l - 1) -
(getsum(t2, r) - getsum(t2, l - 1));
}

根据这个原理,应该可以实现「区间乘区间积」,「区间异或一个数,求区间异或值」等,只要满足维护的信息和区间操作是同种运算即可,感兴趣的读者可以自己尝试。

//待续