【算法笔记】树状数组/Binary Indexed Tree/Fenwick Tree

前言

树状数组,即树形存储的数组,又称Binary Indexed TreeFenwick 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 1n,m105,其中 m m m为操作总次数。

由于 n , m ≤ 1 0 5 n,m\le 10^5 n,m105,所以 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=Ai2k+1+Ai2k+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:=ilowbit(i)+1r:=iBi=j=lrAj

按照这样,就可以在 O ( log ⁡ n ) \mathcal O(\log n) O(logn)的时间复杂度内求 [ 0 , x ] [0,x] [0,x]中整数之和(prefixSum(x))或将 A x A_x Ax加上 k k kupdate(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 1BiN 1 ≤ i ≤ N 1\le i\le N 1iN
  • 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 1i<jN
→ 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 N105,Ai109

由于 N ≤ 1 0 5 N\le 10^5 N105,所以暴力的 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++N1=2N(N1)用树状数组维护每个数字的出现次数,则非逆序对的数量可以在遍历 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=AiAi1)。
此时,我们可以把“区间 [ 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+1k

此时, 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;
}

习题

总结

树状数组支持更新、求和两种操作。欢迎大家前来提问或补充~
求三连qwq


  1. 实际上所有情况都可以,不过其他的非特殊情况实现起来非常繁琐,有时还不如直接用线段树来得方便,因此这里忽略这种情况。 ↩︎

  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值