前言
树状数组,即树形存储的数组,又称Binary Indexed Tree或Fenwick Tree。
抛开它树形的存储结构,这种神奇的数据结构的应用看起来与「 树」没什么关系:
有一个序列 A = ( A 1 , A 2 , … , A N ) A=(A_1,A_2,\dots,A_N) A=(A1,A2,…,AN),在不超过 O ( log N ) \mathcal O(\log N) O(logN)的时间复杂度内完成下列操作:
→ \to~ → 求 [ L , R ] [L,R] [L,R]区间内所有数之和。
→ \to~ → 指定一个元素 A x A_x Ax,将其加上 k k k。
如果想要使求和操作尽可能快,很容易想到前缀和,这样求和操作只要 O ( 1 ) \mathcal O(1) O(1)的时间,但更新操作的时间复杂度就升至 O ( N ) \mathcal O(N) O(N),无法满足题目要求;反之,若直接暴力维护 A A A中所有元素的值,则虽然更新操作只需要 O ( 1 ) \mathcal O(1) O(1),但求和操作的时间又变成了 O ( N ) \mathcal O(N) O(N),还是满足不了要求。那有没有一种算法,综合了两种方式的优势,达到题目时间要求呢?
肯定有,那就是今天说的——树状数组。
基本算法
洛谷 P3374【模板】树状数组 1
同“前言”中的部分, 1 ≤ n , m ≤ 1 0 5 1\le n,m\le 10^5 1≤n,m≤105,其中 m m m为操作总次数。
由于
n
,
m
≤
1
0
5
n,m\le 10^5
n,m≤105,所以
O
(
n
m
)
\mathcal O(nm)
O(nm)的暴力解法肯定行不通,需要使用
O
(
M
log
N
)
\mathcal O(M\log N)
O(MlogN)的树状数组。其存储结构大致上是这样的:
是不是已经有些明白了?这里我们我们把
B
B
B当作树状数组的内部存储,则据图可知:
- B 1 = A 1 B_1=A_1 B1=A1
- B 2 = A 1 + A 2 B_2=A_1+A_2 B2=A1+A2
- B 3 = A 3 B_3=A_3 B3=A3
- B 4 = A 1 + A 2 + A 3 + A 4 B_4=A_1+A_2+A_3+A_4 B4=A1+A2+A3+A4
- B 5 = A 5 B_5=A_5 B5=A5
- B 6 = A 5 + A 6 B_6=A_5+A_6 B6=A5+A6
- B 7 = A 7 B_7=A_7 B7=A7
- B 8 = A 1 + A 2 + A 3 + A 4 + A 5 + A 6 + A 7 + A 8 B_8=A_1+A_2+A_3+A_4+A_5+A_6+A_7+A_8 B8=A1+A2+A3+A4+A5+A6+A7+A8
- …
可以看出,
B
i
=
A
i
−
2
k
+
1
+
A
i
−
2
k
+
2
+
⋯
+
A
i
B_i=A_{i-2^k+1}+A_{i-2^k+2}+\dots+A_i
Bi=Ai−2k+1+Ai−2k+2+⋯+Ai,其中
k
k
k为
i
i
i在二进制下末尾
0
0
0的的个数。换句话说,
2
k
2^k
2k就是
i
i
i在二进制中的的lowbit
。
关于
lowbit
函数
→ \to~ →lowbit
的定义:
- l o w b i t ( 0 ) \mathrm{lowbit}(0) lowbit(0) 无意义。
- 对于任意 x > 0 x > 0 x>0, l o w b i t ( x ) = 2 k \mathrm{lowbit}(x)=2^k lowbit(x)=2k,其中 k k k为 x x x在二进制下末尾 0 0 0的的个数。
→ \to~ → 举例: l o w b i t ( 10010 ) = 10 \mathrm{lowbit}(10010)=10 lowbit(10010)=10; l o w b i t ( 10000 ) = 10000 \mathrm{lowbit}(10000)=10000 lowbit(10000)=10000
→ \to~ → C/C++实现:inline int lowbit(int x) { return x & -x; }
或宏定义形式:
#define lowbit(x) (x) & -(x)
根据lowbit
函数,上述公式可以写作:
l
:
=
i
−
l
o
w
b
i
t
(
i
)
+
1
r
:
=
i
B
i
=
∑
j
=
l
r
A
j
l:=i-\mathrm{lowbit}(i)+1\\ r:=i\\ B_i=\sum_{j=l}^{r} A_j
l:=i−lowbit(i)+1r:=iBi=j=l∑rAj
按照这样,就可以在
O
(
log
n
)
\mathcal O(\log n)
O(logn)的时间复杂度内求
[
0
,
x
]
[0,x]
[0,x]中整数之和(prefixSum(x)
)或将
A
x
A_x
Ax加上
k
k
k(update(x, k)
)。
对于
[
L
,
R
]
[L,R]
[L,R]的区间之和,可以按照segmentSum(l, r) = prefixSum(r) - prefixSum(l - 1)
的方式进行计算。详见代码:
#include <cstdio>
using namespace std;
template <typename value_type>
class fenwick_tree {
private:
const int n;
value_type* a;
inline int lowbit(int x) { return x & -x; }
public:
inline fenwick_tree(int m): n(m) {
a = new value_type[n + 1];
for(int i=0; i<=n; i++)
a[i] = 0;
}
inline ~fenwick_tree() { delete[] a; }
inline value_type prefixSum(int i) {
value_type res = 0;
for(; i; i-=lowbit(i)) res += a[i];
return res;
}
inline value_type segmentSum(int l, int r) {
return prefixSum(r) - prefixSum(l - 1);
}
inline void update(int i, const value_type& d) {
for(; i<=n; i+=lowbit(i))
a[i] += d;
}
};
int main()
{
int n, m;
scanf("%d%d", &n, &m);
fenwick_tree<int> bit(n);
for(int i=1; i<=n; i++)
{
int a;
scanf("%d", &a);
bit.update(i, a);
}
while(m--)
{
int op, x, y;
scanf("%d%d%d", &op, &x, &y);
if(op == 1) bit.update(x, y);
else printf("%d\n", bit.segmentSum(x, y));
}
return 0;
}
在洛谷 P3374上提交耗时仅 572 m s 572\mathrm{ms} 572ms,同题用同样为 O ( log N ) \mathcal O(\log N) O(logN)的线段树耗时约 2.5 s 2.5\mathrm{s} 2.5s,可见相比于线段树算法,使用树状数组不仅代码量小、容易实现,还有运行速度快等等优势。
基础算法到此为止,下面来看一些经典的扩展应用。
扩展应用
知识补充:离散化
对于序列
A
=
(
A
1
,
A
2
,
…
,
A
N
)
A=(A_1,A_2,\dots,A_N)
A=(A1,A2,…,AN),我们将
A
A
A中的每个数都按原先的映射到一个
[
1
,
N
]
[1,N]
[1,N]的范围内,这个过程被称为离散化。一般来说,离散化时同样的元素映射到同样的值,不同的元素映射到不同的值,且满足原先的大小关系。换句话说,令原先的序列为
(
A
1
,
…
,
A
N
)
(A_1,\dots,A_N)
(A1,…,AN),离散化后的序列为
(
B
1
,
…
,
B
N
)
(B_1,\dots,B_N)
(B1,…,BN),则满足如下条件:
- 1 ≤ B i ≤ N 1\le B_i\le N 1≤Bi≤N( 1 ≤ i ≤ N 1\le i\le N 1≤i≤N)
- 若 i ≠ j i\ne j i=j,且 A i < A j A_i<A_j Ai<Aj,则 B i < B j B_i<B_j Bi<Bj。
- 若 i ≠ j i\ne j i=j,且 A i = A j A_i=A_j Ai=Aj,则 B i = B j B_i=B_j Bi=Bj。
- 若 i ≠ j i\ne j i=j,且 A i > A j A_i>A_j Ai>Aj,则 B i > B j B_i>B_j Bi>Bj。
1. 求逆序对
逆序对问题是经典的序列问题。众所周知,这类问题可以用归并排序在
O
(
N
log
N
)
\mathcal O(N\log N)
O(NlogN)的时间内解决。不过,既然今天讲的是树状数组,那就不能用归并排序,就主要来说一说树状数组的解法。
洛谷 P1908 逆序对
给定一个整数序列 A = ( A 1 , A 2 , … , A N ) A=(A_1,A_2,\dots,A_N) A=(A1,A2,…,AN),求其中逆序对的个数。
一个整数对 ( i , j ) (i,j) (i,j)被称为“逆序对”,当且仅当如下条件满足:
→ 1 ≤ i < j ≤ N \to1\le i<j\le N →1≤i<j≤N
→ A i > A j \to A_i>A_j →Ai>Aj
数据范围: N ≤ 1 0 5 , A i ≤ 1 0 9 N\le 10^5,A_i\le 10^9 N≤105,Ai≤109。
由于
N
≤
1
0
5
N\le 10^5
N≤105,所以暴力的
O
(
N
2
)
\mathcal O(N^2)
O(N2)做法肯定不行。
我们考虑使用树状数组,先将数组离散化成
[
1
,
N
]
[1,N]
[1,N]之间的数,记离散化后的序列为
B
=
(
B
1
,
…
,
B
N
)
B=(B_1,\dots,B_N)
B=(B1,…,BN)。我们按
(
(
(总数对数量
)
−
(
)-(
)−(非逆序对数量
)
=
(
)=(
)=(逆序对数量
)
)
)的公式计算,其中总数对的数量为
1
+
2
+
⋯
+
N
−
1
=
N
(
N
−
1
)
2
1+2+\dots+N-1=\frac {N(N-1)}2
1+2+⋯+N−1=2N(N−1)。用树状数组维护每个数字的出现次数,则非逆序对的数量可以在遍历
B
B
B中的每个元素时动态计算。详见代码。
#include <cstdio>
#include <algorithm>
#define maxn 500005
using namespace std;
inline int read() {
static char c;
while((c = getchar()) < '0' && c > '9');
int res = c ^ 48;
while((c = getchar()) >= '0' && c <= '9')
res = (res << 3) + (res << 1) + (c ^ 48);
return res;
}
template <typename value_type>
class fenwick_tree {
private:
const int n;
value_type* a;
inline int lowbit(int x) { return x & -x; }
public:
inline fenwick_tree(int m): n(m) {
a = new value_type[n + 1];
for(int i=0; i<=n; i++)
a[i] = 0;
}
inline ~fenwick_tree() { delete[] a; }
inline value_type prefixSum(int i) {
value_type res = 0;
for(++i; i; i-=lowbit(i)) res += a[i];
return res;
}
inline void update(int i, const value_type& d) {
for(++i; i<=n; i+=lowbit(i))
a[i] += d;
}
};
int a[maxn], rk[maxn];
int main()
{
int n = read();
for(int i=0; i<n; i++)
a[rk[i] = i] = read();
stable_sort(rk, rk + n, [&](int x, int y) -> bool {
return a[x] < a[y];
}); // 因为可能会有重复的数字,所以必须使用稳定的stable_sort排序,用sort只有40分
fenwick_tree<int> bit(n); // 初始化树状数组,离散化之后大小为n就行
long long ans = n * (n - 1LL) >> 1LL; // 总数对个数
for(int i=0; i<n; i++)
{
ans -= bit.prefixSum(rk[i]); // 减去非逆序数对,即prefixSum(b[i])
bit.update(rk[i], 1); // 动态更新当前元素出现次数
}
printf("%lld\n", ans);
return 0;
}
习题:CF1676H2 Maximum Crossings (Hard Version)
2. 区间更新
用过线段树的都知道,树状数组最大的缺点就是无法直接实现区间更新。不过,在一些特殊情况1下,我们可以通过差分+离散化来间接实现区间的更新。先来看洛谷上的模板:
P3368【模板】树状数组 2
已知一个长为 N N N的数列,你需要进行下面两种操作:
1 x y k
:将 [ x , y ] [x,y] [x,y]区间内的每个数都加上 k k k。2 x
:求第 x x x个数的值。
运用差分的思想,用树状数组维护
A
A
A的差分数组
B
B
B(
B
i
=
A
i
−
A
i
−
1
B_i=A_i-A_{i-1}
Bi=Ai−Ai−1)。
此时,我们可以把“区间
[
x
,
y
]
[x,y]
[x,y]中所有元素都加上
k
k
k”看作:
- B x : = B x + k B_x:=B_x+k Bx:=Bx+k
- B y + 1 : = B y + 1 − k B_{y+1}:=B_{y+1}-k By+1:=By+1−k
此时,
A
x
=
B
1
+
⋯
+
B
x
A_x=B_1+\dots+B_x
Ax=B1+⋯+Bx,正好是树状数组的前缀和操作。
于是,我们在
O
(
N
log
N
)
\mathcal O(N\log N)
O(NlogN)的时间复杂度内解决了这个问题。
C++实现如下(此实现方式与前面讲的略有不同):
#include <cstdio>
#define maxn 500005
using namespace std;
template <typename value_type>
class fenwick_tree {
private:
const int n;
value_type* a;
inline int lowbit(int x) { return x & -x; }
public:
inline fenwick_tree(int m): n(m) {
a = new value_type[n + 1];
for(int i=0; i<=n; i++)
a[i] = 0;
}
inline ~fenwick_tree() { delete[] a; }
inline value_type prefixSum(int i) {
value_type res = 0;
for(; i; i-=lowbit(i)) res += a[i];
return res;
}
inline void update(int i, const value_type& d) {
for(; i<=n; i+=lowbit(i))
a[i] += d;
}
};
int a[maxn];
int main()
{
int n, m;
scanf("%d%d", &n, &m);
for(int i=1; i<=n; i++)
scanf("%d", a + i);
fenwick_tree<int> bit(n);
while(m--)
{
int op;
scanf("%d", &op);
if(op == 1)
{
int l, r, k;
scanf("%d%d%d", &l, &r, &k);
bit.update(l, k);
if(r < n) bit.update(r + 1, -k);
}
else
{
int x;
scanf("%d", &x);
printf("%d\n", a[x] + bit.prefixSum(x));
}
}
return 0;
}
习题
- 洛谷 P1168 中位数(求第 k k k大问题)
- 洛谷 P1637 三元上升子序列
- 洛谷 P6477 [NOI Online #2 提高组] 子序列问题
- 洛谷 P8253 [NOI Online 2022 提高组] 如何正确地排序
- AtCoder Beginner Contest 256 F - Cumulative Cumulative Cumulative Sum
- P3372【模板】线段树 1(没错,就是线段树模板,不妨考虑一下「 超级树状数组」?)
总结
树状数组支持更新、求和两种操作。欢迎大家前来提问或补充~
求三连qwq
实际上所有情况都可以,不过其他的非特殊情况实现起来非常繁琐,有时还不如直接用线段树来得方便,因此这里忽略这种情况。 ↩︎