数据结构_树状数组 详解

😀 Powerd By HeartFireY | -Binary Indexed Tree- |

文章目录

一、简介/前导

1.前导

我们来关注这样一个问题:要求对一个数组实现单点修改、区间求和。

我们不难得知:在朴素算法下,单点修改的时间复杂度数据结构_树状数组 详解_树状数组_03,区间求和的时间复杂度数据结构_树状数组 详解_数据结构_04

如果使用前缀和进行优化,区间求和的时间复杂度降为数据结构_树状数组 详解_树状数组_03,而单点修改的时间复杂度却变为数据结构_树状数组 详解_数据结构_04

显然,在强数据下,这两种算法是容易超出所给时间范围的。

如果我们使用一个数组来维护每一段区间的和,对所有需要用到的区间进行求和,但如果要查询或修改的区间跨度大,则会引起大量的区间更新、合并操作。显然这也是不明智的选择。

因此,我们想要寻找一种折中的方案,使得区间求和和单点修改的时间复杂度都不会太大。那么我们需要考虑引起时间开销的原因:

对于单点修改,在前缀和下需要对区间进行更新操作;对于区间求和,在朴素算法下需要对区间内的子区间进行合并操作。

那么我们能否找到这样一种结构:单点修改时引发的区间更新数量不会太多、区间查询时需要进行合并的对象不会太多,那么这就需要引出我们本篇文章的主要对象:树状数组(BIT)。

2.简介

树状数组(Binary Indexed Tree)又称二叉搜索树,简称BIT。

树状数组是这样一种数据结构:在数据结构_树状数组 详解_数据结构_07时间复杂度下,支持单点修改、区间查询的一种储存结构。

对于树状数组,我们很容易联系到线段树:线段树支持单点修改、区间修改、区间查询。同时在数据结构_树状数组 详解_数据结构_08标记的辅助下,能够实现很优秀的区间修改算法。如下是一棵线段树的结构:

数据结构_树状数组 详解_树结构_09

但是在许多场景下,我们并不要进行区间修改,仅仅是进行简单的单点修改和区间查询,因此我们可以引入树状数组。与线段树相比,树状数组的代码更为简洁,在解决一类问题(单点修改)时具有突出的优势。如下是一个树状数组的结构示意图:

数据结构_树状数组 详解_点修改_10

我们通过这张图对树状数组的结构进行分析,并介绍具体的原理。

这里可能会引发一个疑问:上图实际上并不是一棵二叉树?

我们通过一个动画来解释这个问题:

数据结构_树状数组 详解_树结构_11

二、树状数组 详解

1.区间查询 详解

首先,我们给出一个长度为数据结构_树状数组 详解_算法_12的数组(为了计算方便,我们约定下标从数据结构_树状数组 详解_数据结构_13开始)。其树状数组结构如下图所示:

数据结构_树状数组 详解_树结构_14

现在我们复现一下一开始提出的问题,单点修改,区间求和。

如果我们要求前数据结构_树状数组 详解_算法_12项的和,那么我们需要查询的区间是数据结构_树状数组 详解_树状数组_16

如果我们要求前7项的和,那么我们需要查询的区间是数据结构_树状数组 详解_算法_17;

数据结构_树状数组 详解_点修改_18

查询的区间是如何得到的?区间右端点不断地取最低为数据结构_树状数组 详解_数据结构_13的位并改为数据结构_树状数组 详解_点修改_20,作为左端点,直到没有数据结构_树状数组 详解_数据结构_13(即为数据结构_树状数组 详解_点修改_20)时结束。

我们再学习状态压缩的时候曾经使用过这样一个函数数据结构_树状数组 详解_树状数组_23,它的作用是返回数据结构_树状数组 详解_算法_24二进制表示中最低位为数据结构_树状数组 详解_点修改_20的数。

#define

这么做的依据是什么?我们回到结构图中,很显然可以看出:
数据结构_树状数组 详解_数据结构_26 管理的对象是 数据结构_树状数组 详解_数据结构_27&数据结构_树状数组 详解_算法_28
数据结构_树状数组 详解_树状数组_29 管理的对象是 数据结构_树状数组 详解_数据结构_27&数据结构_树状数组 详解_算法_28&数据结构_树状数组 详解_树结构_32&数据结构_树状数组 详解_点修改_33
数据结构_树状数组 详解_树结构_34 管理的对象是 数据结构_树状数组 详解_树状数组_35&数据结构_树状数组 详解_点修改_36
数据结构_树状数组 详解_树状数组_37 则管理全部 数据结构_树状数组 详解_算法_12

如果我们要求前数据结构_树状数组 详解_算法_39项的和,我们从数据结构_树状数组 详解_树结构_40开始,数据结构_树状数组 详解_树结构_40只管理一个点数据结构_树状数组 详解_点修改_42;继续向前找会找到数据结构_树状数组 详解_树结构_34,而数据结构_树状数组 详解_树结构_34管理 数据结构_树状数组 详解_树状数组_35&数据结构_树状数组 详解_点修改_36;那么我们跳过被管理的元素继续向前找会找到数据结构_树状数组 详解_树状数组_29,管理的是 数据结构_树状数组 详解_数据结构_27&数据结构_树状数组 详解_算法_28&数据结构_树状数组 详解_树结构_32&数据结构_树状数组 详解_点修改_33。那么显然,查询操作结束。不难发现:查询的过程被不停的压缩优化。

那么我们只需要通过数据结构_树状数组 详解_树结构_52维护区间数据结构_树状数组 详解_树状数组_53,这样在查询前合并的区间数是小于数据结构_树状数组 详解_树状数组_54的。我们将数据结构_树状数组 详解_点修改_55数组和数据结构_树状数组 详解_树结构_56数组的对应关系用结构图进行说明:

数据结构_树状数组 详解_数据结构_57

2.单点修改 详解

解决了区间查询的问题,我们来解决单点修改的问题。如何维护树状数组数据结构_树状数组 详解_树结构_56呢?

假如我们要对数据结构_树状数组 详解_树结构_59进行修改,那么我们从数据结构_树状数组 详解_算法_60出发,沿着数据结构_树状数组 详解_树状数组_61的路径进行修改。这条路径是怎样得到的?

数据结构_树状数组 详解_树结构_62

我们将下标二进制化,再来观察路径:数据结构_树状数组 详解_数据结构_63,不难发现:更新路径上相邻结点数据结构_树状数组 详解_树结构_52数据结构_树状数组 详解_算法_65 刚好相差一个数据结构_树状数组 详解_树状数组_66,那么更新的过程就很简单了,循环取数据结构_树状数组 详解_树状数组_66更新数据结构_树状数组 详解_树状数组_68直到数据结构_树状数组 详解_树状数组_69 (数据结构_树状数组 详解_算法_70为数组长度)。

这样,我们就得到了一种可以在数据结构_树状数组 详解_数据结构_07时间下支持单点修改和区间查询的数据结构。

3.两种操作的具体实现

经过上述分析,我们可以对两种操作进行实现:

#define
#define
int a[MAXN], len;
int tree[MAXN];
//树状数组初始化(O(n)建树)
inline void init(){
memset(tree, 0, sizeof(tree));
for(int i = 1, tmp = 0; i <= len; i++){
tree[i] = a[i];
tmp = i + lowbit(i);
if(tmp <= n) tree[tmp] += tree[i];
}
}
//单点修改-更新路径
inline void update(int i, int x){
for(int pos = i; pos <= len; pos += lowbit(pos)) tree[pos] += x;
}
//求前n项和
inline int getsum(int i){
int ans = 0;
for(int pos = i; pos; pos -= lowbit(pos)) ans += tree[pos];
return ans;
}
//区间查询
inline int query(int l ,int r){
return getsum(b) - getsum(a - 1);
}

不难发现,几种操作都十分简单(相比于线段树),因此树状数组在解决单纯的“单点修改+区间查询”问题时是非常优秀的数据结构。

三、树状数组 总结

1.树状数组 - 区间加 区间求和

在上述的操作中,我们对于区间求和的过程是基于朴素求和得到的,如果仅仅针对区间加和区间求和这一种应用方式,我们可以通过前缀和与差分对这一过程继续进行优化:

若维护序列 数据结构_树状数组 详解_点修改_55 的差分数组 数据结构_树状数组 详解_树结构_73,此时我们对 数据结构_树状数组 详解_点修改_55 的一个前缀 数据结构_树状数组 详解_树结构_75 求和,即 数据结构_树状数组 详解_树状数组_76,由差分数组定义得 数据结构_树状数组 详解_算法_77

进行推导

数据结构_树状数组 详解_树结构_78

区间和可以用两个前缀和相减得到,因此只需要用两个树状数组分别维护 数据结构_树状数组 详解_算法_79数据结构_树状数组 详解_数据结构_80,就能实现区间求和。

#define
#define
int tree1[MAXN], tree2[MAXN], a[MAXN], len;
//建树,这里不用0(n)建树
inline void init(){
for(int i = 1; i <= len; i++) update(i, a[i]), update(i + 1 -x);
}
//对两个树状数组进行更新
inline void add(int i, int x){
int x1 = i * x;
for(int pos = i; pos <= len; pos += lowbit(pos)) tree1[pos] += x, tree2[pos] += x1;
}
//将区间加差分为两个前缀和
inline void update(int l, int r, int x){
add(l, x), add(r + 1, -x);
}
//对指定的树状数组求前n项和
inline int getsum(int *tree, int i){
int sum = 0;
for(int pos = i; pos; pos -= lowbit(pos)) ans += tree[i];
return sum;
}
//区间和查询
inline int query(int l, int r){
return (r + 1) * getsum(t1, r) - l * getsum(t1, l - 1) - (getsum(t2, r) - getsum(t2, l - 1));
}

2.树状数组 - 数据结构_树状数组 详解_数据结构_07查询第数据结构_树状数组 详解_点修改_82小/大元素

在此以第数据结构_树状数组 详解_点修改_82小的元素为例。若求第数据结构_树状数组 详解_点修改_82大则可以通过简单计算进行转化。

我们需要再次借用线段树中的思想。我们在可持久化线段树中,在求区间第数据结构_树状数组 详解_点修改_82小时,将所有数字看成一个可重集合,即定义数组 数据结构_树状数组 详解_点修改_55 表示值为 数据结构_树状数组 详解_点修改_87 的元素在整个序列重出现了 数据结构_树状数组 详解_树结构_88 次。找第 数据结构_树状数组 详解_点修改_82 大就是找到最小的 数据结构_树状数组 详解_算法_24 恰好满足 数据结构_树状数组 详解_树结构_91

因此可以想到算法:如果已经找到 数据结构_树状数组 详解_算法_24 满足 数据结构_树状数组 详解_树状数组_93,考虑能不能让 数据结构_树状数组 详解_算法_24 继续增加,使其仍然满足这个条件。找到最大的 数据结构_树状数组 详解_算法_24 后,数据结构_树状数组 详解_树结构_96 就是所要的值。
在树状数组中,节点是根据 2 的幂划分的,每次可以扩大 2 的幂的长度。令 数据结构_树状数组 详解_树结构_97 表示当前的 数据结构_树状数组 详解_算法_24 所代表的前缀和,有如下算法找到最大的 数据结构_树状数组 详解_算法_24

  1. 求出数据结构_树状数组 详解_树状数组_100
  2. 计算数据结构_树状数组 详解_数据结构_101
  3. 如果数据结构_树状数组 详解_点修改_102,则此时扩展成功,将数据结构_树状数组 详解_树结构_103累加到数据结构_树状数组 详解_点修改_104上;否则扩展失败,对数据结构_树状数组 详解_点修改_104
  4. 数据结构_树状数组 详解_数据结构_106减 1,回到步骤 2,直至数据结构_树状数组 详解_数据结构_106
int kth(int i){
int cnt = 0, ret = 0;
for (int i = log2(len); ~i; --i){ // i与上文depth含义相同
ret += 1 << i; // 尝试扩展
if (ret >= len || cnt + tree[ret] >= i) ret -= 1 << i; //扩展失败
else cnt += tree[ret]; // 扩展成功后 要更新之前求和的值
}
return ret + 1;
}

3.时间戳优化

时间戳优化有什么用?

我们经常碰到多组输入的题目,按照一般操作,每次输入新数据后我们会数据结构_树状数组 详解_点修改_108暴力清空数组。但如果每次输入新数据时,都暴力清空树状数组,就可能会造成超时。

因此使用 数据结构_树状数组 详解_树结构_109 标记,存储当前节点上次使用时间(即最近一次是被第几组数据使用)。每次操作时判断这个位置 数据结构_树状数组 详解_树结构_109

#define
#define
int tag[MAXN], t[MAXN], Tag, len;
void reset() { ++Tag; }
void update(int k, int v){
while (k <= len){
if (tag[k] != Tag) t[k] = 0;
t[k] += v, tag[k] = Tag;
k += lowbit(k);
}
}
int getsum(int k){
int ret = 0;
while (k){
if (tag[k] == Tag) ret += t[k];
k -= lowbit(k);
}
return ret;
}
t[k] = 0;
t[k] += v, tag[k] = Tag;
k += lowbit(k);
}
}
int getsum(int k){
int ret = 0;
while (k){
if (tag[k] == Tag) ret += t[k];
k -= lowbit(k);
}
return ret;
}