学习笔记:树状数组

树状数组

引入

树状数组是一种支持 单点修改区间查询 的,代码量小的数据结构。树状数组和线段树具有相似的功能,但他俩毕竟还有一些区别:树状数组能有的操作,线段树一定有;线段树有的操作,树状数组不一定有。但是树状数组的代码要比线段树短,思维更清晰,速度也更快,在解决一些单点修改的问题时,树状数组是不二之选。那么,什么是单点修改和区间查询呢?

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

  • 给定 x,y,将 a[x] 自增 y
  • 给定 l,r,求解 a[lr] 的和。

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

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

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

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

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

  • 结合律:(xy)z=x(yz),其中 是一个二元运算符。
  • 可差分:具有逆运算的运算,即已知 xyx 可以求出 y

原理

初步感受

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

一种做法是:a1+a2+a3+a4+a5+a6+a7,需要求 7 个数的和。

那如果我告诉你三个数 ABCA=a[14] 的和,B=a[56] 的总和,C=a[77] 的总和(其实就是 a[7] 自己)。你会怎么算?你一定会回答:A+B+C,只需要求 3 个数的和。

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

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

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

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

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

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

例如,从图中可以看出:

  • c2 管辖的是 a[12]
  • c4 管辖的是 a[14]
  • c6 管辖的是 a[56]
  • c8 管辖的是 a[18]
  • 剩下的 c[x] 管辖的都是 a[x] 自己(可以看做 a[xx] 的长度为 1 的小区间)。

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

举例:计算 a[17] 的和。

过程:从 c7 开始往前跳,发现 c7 只管辖 a7 这个元素;然后找 c6,发现 c6 管辖的是 a[56],然后跳到 c4,发现 c4 管辖的是 a[14] 这些元素,然后再试图跳到 c0,但事实上 c0 不存在,不跳了。

我们刚刚找到的 cc7,c6,c4,事实上这就是 a[17] 拆分出的三个小区间,合并得到答案是 c7+c6+c4

举例:计算 a[47] 的和。

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

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

管辖区间

那么问题来了,c[x](x1) 管辖的区间到底往左延伸多少?也就是说,区间长度是多少?

树状数组中,规定 c[x] 管辖的区间长度为 2k,其中:

  • 设二进制最低位为第 0 位,则 k 恰好为 x 二进制表示中,最低位的 1 所在的二进制位数;
  • 2kc[x] 的管辖区间长度)恰好为 x 二进制表示中,最低位的 1 以及后面所有 0 组成的数。

举个例子,c88 管辖的是哪个区间?

因为 88(10)=01011000(2),其二进制最低位的 1 以及后面的 0 组成的二进制是 1000,即 8,所以 c88 管辖 8a 数组中的元素。

因此,c88 代表 a[8188] 的区间信息。

我们记 x 二进制最低位 1 以及后面的 0 组成的数为 lowbit(x),那么 c[x] 管辖的区间就是 [xlowbit(x)+1,x]

这里注意:lowbit 指的不是最低位 1 所在的位数 k,而是这个 1 和后面所有 0 组成的 2k

怎么计算 lowbit?根据位运算知识,可以得到 lowbit(x) = x & -x

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

区间查询

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

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

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

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

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

c7 往前跳,发现 c7 只管辖 a7 这个元素;然后找 c6,发现 c6 管辖的是 a[56],然后跳到 c4,发现 c4 管辖的是 a[14] 这些元素,然后再试图跳到 c0,但事实上 c0 不存在,不跳了。

我们刚刚找到的 cc7,c6,c4,事实上这就是 a[17] 拆分出的三个小区间,合并一下,答案是 c7+c6+c4

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

我们可以写出查询 a[1x] 的过程:

  • c[x] 开始往前跳,有 c[x] 管辖 a[xlowbit(x)+1x]
  • xxlowbit(x),如果 x=0 说明已经跳到尽头了,终止循环;否则回到第一步。
  • 将跳到的 c 合并。

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

比如我们要维护的信息是和,直接令初始 ans=0,然后每跳到一个 c[x]ansans+c[x],最终 ans 就是所有合并的结果。

int query(int x){
    int res = 0;
    for(int i = x ; i >= 1 ; i -= lowbit(i))
        res += c[i];
    return res;
}

单点修改

现在来考虑如何单点修改 a[x]

我们的目标是快速正确地维护 c 数组。为保证效率,我们只需遍历并修改管辖了 a[x] 的所有 c[y],因为其他的 c 显然没有发生变化。

管辖 a[x]c[y] 一定包含 c[x](根据性质 1),所以 y 在树状数组树形态上是 x 的祖先。因此我们从 x 开始不断跳父亲,直到跳得超过了原数组长度为止。

n 表示 a 的大小,不难写出单点修改 a[x] 的过程:

  • 初始令 x=x
  • 修改 c[x]
  • xx+lowbit(x),如果 x>n 说明已经跳到尽头了,终止循环;否则回到第二步。

区间信息和单点修改的种类,共同决定 c[x] 的修改方式。下面给几个例子:

  • c[x] 维护区间和,修改种类是将 a[x] 加上 p,则修改方式则是将所有 c[x] 也加上 p
  • c[x] 维护区间积,修改种类是将 a[x] 乘上 p,则修改方式则是将所有 c[x] 也乘上 p

然而,单点修改的自由性使得修改的种类和维护的信息不一定是同种运算,比如,若 c[x] 维护区间和,修改种类是将 a[x] 赋值为 p,可以考虑转化为将 a[x] 加上 pa[x]。如果是将 a[x] 乘上 p,就考虑转化为 a[x] 加上 a[x]×pa[x]

下面以维护区间和,单点加为例给出实现。

void update(int x, int k){
    for(int i = x ; i <= n ; i += lowbit(i))
        c[i] += k;
}

建树

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

一般可以直接转化为 n 次单点修改,时间复杂度 O(nlogn)

比如给定序列 a=(5,1,4) 要求建树,直接看作对 a[1] 单点加 5,对 a[2] 单点加 1,对 a[3] 单点加 4 即可。

代码

这里给出板子题的代码:

#include <iostream>
#define int long long
#define MAXN 500005
using namespace std;
int n, m, op, x, y, t;
int c[MAXN];
int read(){
    int t = 1, x = 0;char ch = getchar();
    while(!isdigit(ch)){if(ch == '-')t = -1;ch = getchar();}
    while(isdigit(ch)){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
    return x * t;
}
void write(int x){
    if(x < 0){putchar('-');x = -x;}
    if(x >= 10)write(x / 10);
    putchar(x % 10 + '0');
}
int lowbit(int x){return x & -x;}
void update(int x, int k){
    for(int i = x ; i <= n ; i += lowbit(i))c[i] += k;
}
int query(int x){
    int ret = 0;
    for(int i = x ; i >= 1 ; i -= lowbit(i))ret += c[i];
    return ret;
}
signed main(){
    n = read();m = read();
    for(int i = 1 ; i <= n ; i ++)
        t = read(),update(i, t);
    for(int i = 1 ; i <= m ; i ++){
        op = read();x = read();y = read();
        if(op == 1)update(x, y);
        else write(query(y) - query(x - 1)),putchar('\n');
    }
    return 0;
}

复杂度分析

空间复杂度显然 O(n)

时间复杂度:

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

区间加区间和

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

考虑序列 a 的差分数组 d,其中 d[i]=a[i]a[i1]。由于差分数组的前缀和就是原数组,所以 ai=j=1idj

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

i=1rai=i=1rj=1idj

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

i=1rj=1idj=i=1rdi×(ri+1)=i=1rdi×(r+1)i=1rdi×i

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

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

因为差分是 d[i]=a[i]a[i1]

  • a[l] 多了 va[l1] 不变,所以 d[l] 的值多了 v
  • a[r+1] 不变而 a[r] 多了 v,所以 d[r+1] 的值少了 v
  • 对于不等于 l 且不等于 r+1 的任意 ia[i]a[i1] 要么都没发生变化,要么都加了 va[i]+v(a[i1]+v) 还是 a[i]a[i1],所以其它的 d[i] 均不变。

那就不难想到维护方式了:对于维护 di 的树状数组,对 l 单点加 vr+1 单点加 v;对于维护 di×i 的树状数组,对 l 单点加 v×lr+1 单点加 v×(r+1)

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

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

#include <iostream>
#define int long long
#define MAXN 500005
using namespace std;
int n, m, op, x, y, k;
int a[MAXN], c[MAXN];
int read(){
    int t = 1, x = 0;char ch = getchar();
    while(!isdigit(ch)){if(ch == '-')t = -1;ch = getchar();}
    while(isdigit(ch)){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
    return x * t;
}
void write(int x){
    if(x < 0){putchar('-');x = -x;}
    if(x >= 10)write(x / 10);
    putchar(x % 10 ^ 48);
}
int lowbit(int x){return x & -x;}
void update(int x, int k){
    for(int i = x ; i <= n ; i += lowbit(i))c[i] += k;
}
int query(int x){
    int ret = 0;
    for(int i = x ; i >= 1 ; i -= lowbit(i))ret += c[i];
    return ret;
}
signed main(){
    n = read();m = read();
    for(int i = 1 ; i <= n ; i ++)a[i] = read();
    for(int i = 1 ; i <= m ; i ++){
        op = read();
        if(op == 1){
            x = read();y = read();k = read();
            update(x, k);update(y + 1, -k);
        }else{
            x = read();write(a[x] + query(x));putchar('\n');
        }
    }
    return 0;
}

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

逆序对

这个笔者之前写过,这里给出代码,有需要的可以戳这里 link

#include <iostream>
#include <algorithm>
#define int long long
#define MAXN 500005
using namespace std;
int n, ans;
struct node{
    int a, b;
    bool friend operator<(node a, node b){
        if(a.a == b.a)return a.b > b.b;
        else return a.a > b.a;
    }
}a[MAXN];
int c[MAXN];
int read(){
    int t = 1, x = 0;char ch = getchar();
    while(!isdigit(ch)){if(ch == '-')t = -1;ch = getchar();}
    while(isdigit(ch)){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
    return x * t;
}
int lowbit(int x){return x & -x;}
void update(int x, int k){
    for(int i = x ; i <= n ; i += lowbit(i))
        c[i] += k;
}
int query(int x){
    int res = 0;
    for(int i = x ; i >= 1 ; i -= lowbit(i))
        res += c[i];
    return res;
}
signed main(){
    n = read();
    for(int i = 1 ; i <= n ; i ++)a[i].a = read();
    for(int i = 1 ; i <= n ; i ++)a[i].b = i;
    sort(a + 1, a + n + 1);
    for(int i = 1 ; i <= n ; i ++)
        update(a[i].b, 1),ans += query(a[i].b - 1);
    cout << ans << endl;return 0;
}
posted @   tsqtsqtsq  阅读(8)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
· AI 智能体引爆开源社区「GitHub 热点速览」
点击右上角即可分享
微信分享提示