学习笔记:树状数组

树状数组

引入

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

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

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

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

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

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

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

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

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

原理

初步感受

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

一种做法是:$a_1 + a_2 + a_3 + a_4 + a_5 + a_6 + a_7$,需要求 $7$ 个数的和。

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

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

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

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

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

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

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

例如,从图中可以看出:

  • $c_2$ 管辖的是 $a[1 \ldots 2]$;
  • $c_4$ 管辖的是 $a[1 \ldots 4]$;
  • $c_6$ 管辖的是 $a[5 \ldots 6]$;
  • $c_8$ 管辖的是 $a[1 \ldots 8]$;
  • 剩下的 $c[x]$ 管辖的都是 $a[x]$ 自己(可以看做 $a[x \ldots x]$ 的长度为 $1$ 的小区间)。

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

举例:计算 $a[1 \ldots 7]$ 的和。

过程:从 $c_{7}$ 开始往前跳,发现 $c_{7}$ 只管辖 $a_{7}$ 这个元素;然后找 $c_{6}$,发现 $c_{6}$ 管辖的是 $a[5 \ldots 6]$,然后跳到 $c_{4}$,发现 $c_{4}$ 管辖的是 $a[1 \ldots 4]$ 这些元素,然后再试图跳到 $c_0$,但事实上 $c_0$ 不存在,不跳了。

我们刚刚找到的 $c$ 是 $c_7, c_6, c_4$,事实上这就是 $a[1 \ldots 7]$ 拆分出的三个小区间,合并得到答案是 $c_7 + c_6 + c_4$。

举例:计算 $a[4 \ldots 7]$ 的和。

我们还是从 $c_7$ 开始跳,跳到 $c_6$ 再跳到 $c_4$。此时我们发现它管理了 $a[1 \ldots 4]$ 的和,但是我们不想要 $a[1 \ldots 3]$ 这一部分,怎么办呢?很简单,减去 $a[1 \ldots 3]$ 的和就行了。

那不妨考虑最开始,就将查询 $a[4 \ldots 7]$ 的和转化为查询 $a[1 \ldots 7]$ 的和,以及查询 $a[1 \ldots 3]$ 的和,最终将两个结果作差。

管辖区间

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

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

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

举个例子,$c_{88}$ 管辖的是哪个区间?

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

因此,$c_{88}$ 代表 $a[81 \ldots 88]$ 的区间信息。

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

这里注意:$\boldsymbol{\operatorname{lowbit}}$ 指的不是最低位 1 所在的位数 $\boldsymbol{k}$,而是这个 1 和后面所有 0 组成的 $\boldsymbol{2^k}$。

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

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

区间查询

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

回顾查询 $a[4 \ldots 7]$ 的过程,我们是将它转化为两个子过程:查询 $a[1 \ldots 7]$ 和查询 $a[1 \ldots 3]$ 的和,最终作差。

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

事实上,将有关 $l \ldots r$ 的区间询问转化为 $1 \ldots r$ 和 $1 \ldots l - 1$ 的前缀询问再差分,在竞赛中是一个非常常用的技巧。

那前缀查询怎么做呢?回顾下查询 $a[1 \ldots 7]$ 的过程:

从 $c_{7}$ 往前跳,发现 $c_{7}$ 只管辖 $a_{7}$ 这个元素;然后找 $c_{6}$,发现 $c_{6}$ 管辖的是 $a[5 \ldots 6]$,然后跳到 $c_{4}$,发现 $c_{4}$ 管辖的是 $a[1 \ldots 4]$ 这些元素,然后再试图跳到 $c_0$,但事实上 $c_0$ 不存在,不跳了。

我们刚刚找到的 $c$ 是 $c_7, c_6, c_4$,事实上这就是 $a[1 \ldots 7]$ 拆分出的三个小区间,合并一下,答案是 $c_7 + c_6 + c_4$。

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

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

  • 从 $c[x]$ 开始往前跳,有 $c[x]$ 管辖 $a[x-\operatorname{lowbit}(x)+1 \ldots x]$;
  • 令 $x \gets x - \operatorname{lowbit}(x)$,如果 $x = 0$ 说明已经跳到尽头了,终止循环;否则回到第一步。
  • 将跳到的 $c$ 合并。

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

比如我们要维护的信息是和,直接令初始 $\mathrm{ans} = 0$,然后每跳到一个 $c[x]$ 就 $\mathrm{ans} \gets \mathrm{ans} + c[x]$,最终 $\mathrm{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']$。
  • 令 $x' \gets x' + \operatorname{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]$ 加上 $p - a[x]$。如果是将 $a[x]$ 乘上 $p$,就考虑转化为 $a[x]$ 加上 $a[x] \times p - a[x]$。

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

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

建树

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

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

比如给定序列 $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)$。

时间复杂度:

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

区间加区间和

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

考虑序列 $a$ 的差分数组 $d$,其中 $d[i] = a[i] - a[i - 1]$。由于差分数组的前缀和就是原数组,所以 $a_i=\sum_{j=1}^i d_j$。

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

$$ \begin{aligned} &\sum_{i=1}^{r} a_i\\=&\sum_{i=1}^r\sum_{j=1}^i d_j \end{aligned} $$

观察这个式子,不难发现每个 $d_j$ 总共被加了 $r - j + 1$ 次。接着推导:

$$ \begin{aligned} &\sum_{i=1}^r\sum_{j=1}^i d_j\\=&\sum_{i=1}^r d_i\times(r-i+1) \\=&\sum_{i=1}^r d_i\times (r+1)-\sum_{i=1}^r d_i\times i \end{aligned} $$

$\sum_{i=1}^r d_i$ 并不能推出 $\sum_{i=1}^r d_i \times i$ 的值,所以要用两个树状数组分别维护 $d_i$ 和 $d_i \times i$ 的和信息。

那么怎么做区间加呢?考虑给原数组 $a[l \ldots r]$ 区间加 $x$ 给 $d$ 带来的影响。

因为差分是 $d[i] = a[i] - a[i - 1]$,

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

那就不难想到维护方式了:对于维护 $d_i$ 的树状数组,对 $l$ 单点加 $v$,$r + 1$ 单点加 $-v$;对于维护 $d_i \times i$ 的树状数组,对 $l$ 单点加 $v \times l$,$r + 1$ 单点加 $-v \times (r + 1)$。

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

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

#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;
}

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

逆序对

这个笔者之前写过,这里给出代码,有需要的可以戳这里 $\longrightarrow$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 @ 2023-10-12 19:36  tsqtsqtsq  阅读(37)  评论(0)    收藏  举报  来源