树状数组

树状数组是一种支持 \(O(\log n)\) 时间内进行 单点修改区间查询 的,代码量小的数据结构。

原理及实现

下为 洛谷 P3374 【模板】树状数组 1 题意简述:

已知一个 长度为 \(n\) 的数列 \(a\),你需要进行下面两种操作:

  • 将某一个数加上 \(x\)

  • 求出某区间每一个数的和。

保证 \(n \le 5 \times 10 ^ {5}\)​。

\(\operatorname{lowbit}()\)

定义 \(\operatorname{lowbit}(x)\) 表示二进制下 \(x\) 最低的 \(1\) 对应的数。

例如 \(\operatorname{lowbit}(20) = \operatorname{lowbit}(10100_{(2)}) = 100_{(2)} = 4\)

形式化地说,对于任意正整数 \(x\),总能将 \(x\) 表示成 \(s \times 2^{k + 1} + 2^k\),其中 \(2^k=\operatorname{lowbit}(x)\)

在应用中,由于计算机中负数用补码表示,即各位取反后加 \(1\),所以 \(\operatorname{lowbit}(x)\) 可以快速地用 x & -x 求出,下面是一个例子。

原码:\(1010 \cdots 1000 \cdots 0\)

反码:\(0101 \cdots 0111 \cdots 1\)

补码:\(0101 \cdots 1000 \cdots 0\)

显然有 lowbit(x) = x & -x

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

管辖区间

树状数组每个位置存储的是其向前 \(\operatorname{lowbit}\) 长度的区间和。形式化地说,设树状数组为 \(c\) ,那么 \(c_x = \sum_{i = x - \operatorname{lowbit}(x) + 1}^{x} a_i\)。我们称 \([x - \operatorname{lowbit}(x) + 1, x]\)\(c_x\) 的管辖区间。

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

树状数组

区间查询

先不考虑如何求出 \(c\),先来考虑如何用 \(c\) 求出区间和。

假设要求 \(\sum_{i = 1}^x a_i\),设 \(t = x - \operatorname{lowbit}(x) + 1\),易得 \(\sum_{i = 1}^x a_i = \sum_{i = 1}^t a_i + \sum_{i = t + 1}^x a_i = \sum_{i = 1}^t a_i + c_x\)

因此问题从求 \(\sum_{i = 1}^x a_i\) 变成了求 \(\sum_{i = 1}^{t} a_i\)。那么接下来对 \(t\) 进行类似的操作即可。

形式化地,我们可以写出查询 \(\sum_{i = 1}^x a_i\)​ 的过程:

  • \(c_x\) 开始往前跳,有 \(c_x = \sum_{i = x- \operatorname{lowbit}(x) + 1}^{x} a_i\)
  • \(x \gets x - \operatorname{lowbit}(x)\),如果 \(x = 0\) 说明已经跳到尽头了,终止循环;否则回到第一步。
  • 将跳到的 \(c\)​ 合并。

\(a_0 = 0\),类似前缀和,由此我们可以求出任意一个区间 \([l, r]\) 的区间和:

\[\sum_{i = l}^{r} a_i = \sum_{i = 1}^{r} a_i - \sum_{i = 1}^{l - 1} a_i \]

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

单点修改

假设要给 \(a_x\) 加上 \(k\),那么就找到并需要修改所有包含 \(a_x\)\(c\)

\(c_i\) 包含 \(a_x\),那么一定有:

  • \(x \le i\),这是显然的,因为每个 \(c\) 都不会包含后面的数。

  • \(\operatorname{lowbit}(x) \le \operatorname{lowbit}(i)\)​,当且仅当 \(x = i\) 时等号成立。

    观察 \(c_i\) 的管辖区间 \([i - \operatorname{lowbit}(i) + 1, i]\) 中每一个数的二进制表示,它们的 \(\operatorname{lowbit}\) 都比 \(i\) 更低。

    形式化证明:设 \(i = s \times 2^{k + 1} + 2^k\),其中 \(2^k = \operatorname{lowbit}(y)\)。那么 \(c_i\) 的管辖区间可以表示为 \([s \times 2^{k + 1} + 1, s \times 2^{k + 1} + 2^k]\)。这些数的 \(\operatorname{lowbit}\)\([1, 2^k]\) 中,得证。

  • \(\operatorname{lowbit}\) 相同的 \(c\) 不会包含同一个数,换言之所有 \(i\)\(\operatorname{lowbit}\) 都不同。由上一点的证明可知 \(\operatorname{lowbit}\) 不同的 \(c\) 管辖区间不会重叠。

由以上三点我们得知,可以按 \(\operatorname{lowbit}(i)\) 从小到大的顺序找出所有 \(i\)​。

\(x\) 是第一个 \(i\),记为 \(i_0\)。易证不存在 \(i > i_0\)\(\operatorname{lowbit}(i) < \operatorname{lowbit}(i_0)\)

在所有满足 \(i > i_0\)\(\operatorname{lowbit}(i) > \operatorname{lowbit}(i_0)\)\(i\) 中,\(i_0 + \operatorname{lowbit}(i_0)\)\(\operatorname{lowbit}\) 显然是最小的,记其为 \(t\) 。易证 \(\operatorname{lowbit}(t) > \operatorname{lowbit}(i_0)\),因此 \(t - \operatorname{lowbit}(t) < i_0\),所以 \(c_t\) 包含 \(i_0\),即 \(c_t\) 包含 \(i\),所以 \(t = i_0 + \operatorname{lowbit}(i_0)\) 是第二个 \(i\),即为 \(i_1\)

类似地,\(i_2 = i_1 + \operatorname{lowbit}(i_1)\)​,以此类推。

形式化地,我们可以写出给 \(a_x\) 加上 \(k\) 的过程:

  • 初始令 \(i = x\)
  • \(c_i \gets c_i + k\)
  • \(i \gets i + \operatorname{lowbit}(i)\),如果 \(i > n\) 说明已经跳到尽头了,终止循环;否则回到第二步。
void add(int x, int k)
{
    for (int i = x; i <= n; i += lowbit(i))
        c[i] += k;
}

初始化

可以设 \(a\) 初始全部为 \(0\),那么 \(c\) 也全部为 \(0\),然后进行 \(n\)​ 次单点修改操作:

for (int i = 1; i <= n; i++)
{
    int x;
    std::cin >> x;
    add(i, x);
}

当然,也有一种更优雅的,时间复杂度为 \(O(n)\) 的方式:

int pre[MAXN]; // a 数组的前缀和
for (int i = 1; i <= n; i++)
{
    pre[i] = pre[i - 1] + a[i];
    c[i] = pre[i] - pre[i - lowbit(i)];
}

完整代码

#include <iostream>

int n;
int c[500005];

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

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

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

int main()
{
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);
    std::cout.tie(nullptr);

    int m;

    std::cin >> n >> m;
    for (int i = 1; i <= n; i++)
    {
        int x;
        std::cin >> x;
        add(i, x);
    }

    for (int i = 1; i <= m; i++)
    {
        int op, x, y;
        std::cin >> op >> x >> y;
        if (op == 1)
            add(x, y);
        else
            std::cout << sum(y) - sum(x - 1) << "\n";
    }

    return 0;
}

复杂度分析

空间复杂度显然为 \(O(n)\)

时间复杂度:单点修改和区间查询的过程中,每一步都是跳一个 \(\operatorname{lowbit}\),而 \(n\) 最多有 \(\log n\) 个二进制位,因此时间复杂度为 \(O(\log n)\)

拓展

区间修改与单点查询

利用差分,树状数组还可以支持 区间修改单点查询 的操作。

易知这个问题可以转化为对原数列的差分数组进行单点修改和区间查询。

维护不是前缀和的信息

类似于单点加、求区间和,树状数组可以维护各种信息。

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

  • 结合律:\((x \circ y) \circ z = x \circ (y \circ z)\),其中 \(\circ\)​ 是一个二元运算符。

    区间查询 的本质是将目标区间拆成不超过 \(\log n\) 个区间并合并。不满足结合律的运算是不可以拆成几个部分再合并的。

  • 可差分:具有逆运算的运算,即已知 \(x \circ y\)\(x\) 可以求出 \(y\)

    对于不可差分信息,不存在直接修改 \(c\) 的方式。这是因为修改本身就相当于是把旧数从原区间「移除」,然后加入一个新数。「移除」时对区间信息的影响,相当于做「逆运算」,而不可差分信息不存在「逆运算」,所以无法直接修改 \(c\)​。

    (引用自 OI Wiki)

    事实上,通过一些修改,树状数组也可以维护不可差分信息,但复杂度劣于拥有同样功能的线段树,这里不作介绍。

posted @ 2024-02-18 21:00  lzy20091001  阅读(47)  评论(0)    收藏  举报