树状数组-必知必会

本文深入探讨了树状数组(又称二叉索引树)这一数据结构,其核心功能在于快速更新数组元素和计算前缀和。文章详细阐述了树状数组的原理,包括如何通过二进制位运算优化更新和查询操作,以及其实现细节和性能分析。此外,还通过实例展示了树状数组如何解决区间查询和更新问题,证明了其时间复杂度为O(logn)。最后,文章通过代码示例解释了如何使用树状数组进行实际操作。
摘要由CSDN通过智能技术生成

更好的阅读体验

定义

首先参考维基百科的定义:

A Fenwick tree or binary indexed tree is a data structure that can efficiently update elements and calculate prefix sums in a table of numbers.

翻译过来就是:

Fenwick树,或者说二叉索引树是一种能高效对一个数组,更新并计算前缀和的数据结构。

所以根据定义来说,这个数据结构一定支持两种高效操作:

  • 更新操作,例如update(index, value),将数组中index位置的值改成value
  • 区间查询,例如preSum(index),查询数组中下标从 1 1 1index的前缀和。

朴素想法

我们单纯的考虑这个问题,有三种朴素做法

  1. 开数组维护上述结构,每次更新的时候直接更新值,每次查询的时候扫描整个数组。这样做的话,时间复杂度【更新 O ( 1 ) O(1) O(1),查询 O ( n ) O(n) O(n) 】,空间复杂度 O ( n ) O(n) O(n)
  2. 开数组维护前缀和。每次更新的时候更新每个后缀,查询的时候直接返回。这样做的话,时间复杂度【更新 O ( n ) O(n) O(n),查询 O ( 1 ) O(1) O(1) 】,空间复杂度 O ( n ) O(n) O(n)
  3. 普通的二叉树来维护。这样做的话,时间复杂度【更新 O ( l o g   n ) O(log\ n) O(log n) ,查询 O ( l o g   n ) O(log\ n) O(log n)】,空间复杂度 O ( n ) O(n) O(n)

在最终的过程中,我们可以根据读写量来灵活的选择,读少写多采用方案 1 1 1 ,读多写少采用方案 2 2 2 ,如果读写均衡采用方案 3 3 3

痛点

  1. 对于上述方案 1 1 1 2 2 2 ,两个操作性能并不均衡,在比赛中,并不知道哪个更多,哪个更少,无法预估。
  2. 上述方案 3 3 3 ,代码量大,而且常数不小。
  3. 是否存在一个数据结构,性能均衡,而且比较容易打出来呢?

应运而生

于是就有了树状数组这个结构。

让我们先忽略这个概念,先考虑一下线段树怎么实现的。

从上图我们可以看到,我们对于每个区间维护了一个属性,然后对于区间和这种操作,这个属性就是区间的值,然后我们统计区间和,只需要找到一个个最大的区间,然后将他们合并即可。

简单点说,线段树是通过将区间拆分然后重组来保证了性能。其中拆分后打lazy标记保证了每次更新复杂度是 O(n)的,重组保证了,每次求区间属性的时候复杂度是 O(n)

基于上述想法,我们可以换一种枚举节点/区间的方式来实现这个过程。

在这里插入图片描述

其中有一半的节点和源节点保持一致,长度为 1 1 1,四分之一的节点和二叉树的叶节点的父节点一致,长度为 2 2 2,以此类推。。

可能这样看不直观,我们换一个大点的图:

如图,不妨设原数组为 a a a,需要生成的数组为 t t t,一共有 n n n 个数字,下标从 1 1 1 开始 。我们按照上述的方式生成一个数组。

如何生成这个数组

简单点说就是如何统计 t i t_i ti

我们可以看到对于第 i i i 个位置的 t i t_i ti 管理了多长的区间呢?

我们来看几个例子:

  • t 1 t_1 t1 管理了长度为 1 1 1 的区间 [ 1 , 1 ] [1,1] [1,1] ,其中 1 1 1 的二进制表示为 00001 00001 00001
  • t 4 t_4 t4 管理了长度为 4 4 4 的区间 [ 1 , 4 ] [1,4] [1,4] ,其中 4 4 4 的二进制表示为 00100 00100 00100
  • t 14 t_{14} t14 管理了长度为 2 2 2 的区间 [ 13 , 14 ] [13,14] [13,14] ,其中 14 14 14 的二进制表示为 01110 01110 01110
  • t 16 t_{16} t16 管理了长度为 16 16 16 的区间 [ 1 , 16 ] [1,16] [1,16] ,其中 16 16 16 的二进制表示为 10000 10000 10000

目前看不出来啥规律是吧,那我们定义一个函数 lowbit(x)表示 x x x 在二进制表示下最低位对应的数字。

现在发现了吗?

是的, t x t_x tx 管理了 lowbit(x)长度,实际为 [ x − l o w b i t ( x ) + 1 , x ] [x-lowbit(x)+1, x] [xlowbit(x)+1,x] 这个区间。也即 t x = ∑ i = x − l o w b i t ( x ) + 1 x a i t_x = \sum_{i=x-lowbit(x)+1}^{x} a_i tx=i=xlowbit(x)+1xai

如何更新

更新一般有两种,updateadd,二者本质上并无不同,我们仅讨论add

还是老样子,我们先看一下如果需要更新 a 4 a_4 a4 会影响那些值呢?

在这里插入图片描述

如图,可以看到需要更新 t 4 , t 8 , t 16 t_4, t_8, t_{16} t4,t8,t16 这三个值。

在这里插入图片描述

同理,如果更新 t 3 t_3 t3 需要更新 t 3 , t 4 , t 8 , t 16 t_3,t_4,t_8,t_{16} t3,t4,t8,t16 这四个值。

从中我们可以简单的发现,如果更新 t 1 t_1 t1 需要同时更新 t 2 t_2 t2 ,如果更新 t 0 t_0 t0 同时需要更新 t 1 t_1 t1 那么也必然需要同时更新 t 2 t_2 t2 。这个很好理解,我们把上述的图从下往上看,显然改了底层,上层需要更新,上层的上层显然也需要更新。

基于此,我们只需要找到 t i t_i ti 对应的上层,然后递归这个过程就可以完成完整的更新逻辑了。

那么如何找到 t i t_i ti 对应的上层呢?

先说结论 t i t_i ti 的上层就是 t i + l o w b i t ( i ) t_{i+lowbit(i)} ti+lowbit(i)

证明

虽然这个结论很巧妙,但是证明起来确实让我懵了几秒。如果对证明过程不感兴趣可以略过此部分

还记得刚刚提到的吗? t i t_i ti 是处理了区间 [ i − l o w b i t ( i ) + 1 , i ] [i-lowbit(i)+1, i] [ilowbit(i)+1,i]

也就是我要找到一个最小的 j j j 满足 [ j − l o w b i t ( j ) + 1 , j ] [j-lowbit(j)+1, j] [jlowbit(j)+1,j] 能覆盖上述区间,也即,找到最小的 j j j 满足 j − l o w b i t ( j ) ≤ i − l o w b i t ( i ) j-lowbit(j) \le i-lowbit(i) jlowbit(j)ilowbit(i) 并且 i ≤ j i \le j ij ,二者不同时取等号。

所以通过上述两个式子能得到 l o w b i t ( j ) > l o w b i t ( i ) lowbit(j) \gt lowbit(i) lowbit(j)>lowbit(i)

也即原条件转换成了,找到最小的 j j j 满足 $lowbit(j) \gt lowbit(i) $ 且 i ≤ j i \le j ij

所以我们不妨将 i i i j j j 都在二进制表示下右移 l o w b i t ( i ) lowbit(i) lowbit(i) 位,原问题不变。

那么我们有新的 l o w b i t ( i ′ ) = 1 lowbit(i^{'}) = 1 lowbit(i)=1,那么显然有 j ′ = i ′ + 1 j^{'} = i^{'}+1 j=i+1 是最小的 j j j ,并且,因为 l o w b i t ( i ′ ) = 1 lowbit(i^{'}) = 1 lowbit(i)=1 ,所以 l o w b i t ( j ′ ) = l o w b i t ( i ′ + 1 ) lowbit(j^{'}) = lowbit(i^{'} + 1) lowbit(j)=lowbit(i+1) 显然至少为 2 2 2 ,原问题得证。

一个简单的示例

我们以上图中更新 a 3 a_3 a3 为例,假设更新操作为更新 a 3 = 5 a_3 = 5 a3=5 需要进行哪些操作。

在这里插入图片描述
(这里本来是个gif,但是csdn转存有点问题,所以导致成了图片,建议看原链接

从上图中我们可以看出:

  1. 我们先更新 t 3 t_3 t3
  2. 然后找 t 3 t_3 t3 的上级,也即 t 3 + l o w b i t ( 3 ) = t 4 t_{3+lowbit(3)} = t_{4} t3+lowbit(3)=t4 ,然后更新 t 4 t_4 t4
  3. 然后找 t 4 t_4 t4 的上级,也即 t 4 + l o w b i t ( 4 ) = t 8 t_{4+lowbit(4)} = t_8 t4+lowbit(4)=t8 ,然后更新 t 8 t_8 t8
  4. 依次类推
  5. 。。。

可以看出这是一个可以递归调用的过程。

我们简单看下代码:

void update(int index, int add) {
  while(index<n) {
    t[index]+=add;
    index += lowbit(index);
  }
}

和上述流程一致。

如何查询

以前缀和为例,我们看看如何求前缀和。

我们假设查询长度为 i i i 的前缀和,也即查询 [ 1 , i ] [1,i] [1,i] 的区间和。

首先,每个 t i t_i ti 管理的区间是 [ i − l o w b i t ( i ) + 1 , i ] [i-lowbit(i)+1, i] [ilowbit(i)+1,i] ,也即,它本身管理的是某个前缀和的后缀和,一个朴素自然的想法就是我们每次选择一个 t i t_i ti 统计这部分后缀和,然后就变成了一个求 [ 1 , i − l o w b i t ( i ) ] [1,i-lowbit(i)] [1,ilowbit(i)] 的区间和这个子问题,然后我们递归这个过程就可以求出前缀和。

还是老样子,我们来看个例子(以求 [ 1 , 11 ] [1,11] [1,11] 的区间和为例:

在这里插入图片描述

(这里本来是个gif,但是csdn转存有点问题,所以导致成了图片,建议看原链接

  1. 我们先统计 t 11 t_{11} t11 的贡献,然后发现 t 11 t_{11} t11 管理了区间 [ 11 , 11 ] [11,11] [11,11] ,所以问题转换为子问题:统计区间 [ 1 , 10 ] [1,10] [1,10] 的区间和。
  2. 然后统计 t 10 t_{10} t10 的贡献,它管理了区间 [ 9 , 10 ] [9,10] [9,10] ,所以问题转换为子问题:统计区间 [ 1 , 8 ] [1,8] [1,8] 的贡献。
  3. 以此类推。。

我们可以发现,这个也显然是一个可以递归的过程,来简单看下代码:

int getPrefix(int index) {
  int sum = 0;
  while(index) {
    sum+=t[index];
    index -= lowbit(index);
  }
}

然后这里还剩余一个额外的证明:如何证明这个复杂度是 O ( l o g n ) O(log n) O(logn) 的。

一个直观的想法是:将 i i i 在二进制下表示,我每次减少了lowbit(i),也即减少了一个二进制位。对于一个自然数来说,最多有多少个二进制表示下的 1 1 1 呢?显然至多只有 l o g 2 ( n ) log_2(n) log2(n) 个。所以复杂度可以保证。

至此,树状数组所有的东西就讲解完毕了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

zuhiul

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值