树状数组

原文链接:树状数组

1. 引言:问题的提出

在算法竞赛中,我们频繁处理序列上的区间操作。一个最基础也最核心的问题是动态区间和:给定一个序列,需要支持两种操作:

  1. 单点修改 (Point Update): 修改序列中某个元素的值。
  2. 区间查询 (Range Query): 查询序列中任意一个区间的元素之和。

我们来分析此问题的朴素解法及其复杂度。设序列长度为 \(N\) ,操作次数为 \(M\)

  • 朴素数组

  • 单点修改:直接访问数组下标修改,时间复杂度 \(O(1)\)

  • 区间查询:遍历区间 \([L, R]\) 内所有元素并求和,时间复杂度 \(O(N)\)

  • 总时间复杂度:最坏情况下为 \(O(M \cdot N)\)

  • 前缀和数组

\(S[i] = \sum_{k=1}^{i} a[k]\)

  • 区间查询:查询区间 \([L, R]\) 的和,可通过 \(S[R] - S[L - 1]\) 计算,时间复杂度 \(O(1)\)
  • 单点修改:若修改 \(a[i]\) 的值为 \(a'[i]\) ,差值为 \(\Delta = a'[i] - a[i]\) 。那么从 \(S[i]\)\(S[N]\) 的所有前缀和都将受到影响,需要全部更新。时间复杂度 \(O(N)\)
  • 总时间复杂度:最坏情况下仍为 \(O(M \cdot N)\)

两种朴素方法均在一种操作上达到最优,却在另一种操作上表现糟糕。在 \(N\)\(M\) 同阶(例如 \(10^{5}\) )的情况下, \(O(M \cdot N)\) 的复杂度无法接受。这促使我们去寻找一种更平衡的数据结构,能够在两种操作上都取得对数级别的时间复杂度。线段树是其中一个经典的解决方案,而本文的主角——树状数组(Binary Indexed Tree, BIT),又称芬威克树(Fenwick Tree),是另一个代码实现更为简洁、常数更小、思想更精妙的解决方案。

树状数组能够在 \(O(\log N)\) 的时间内完成单点修改和前缀和查询,进而通过两次前缀和查询实现 \(O(\log N)\) 的区间和查询。

2. 核心思想:二进制分解与lowbit

树状数组的精髓在于它对前缀和的巧妙分解与重组。它并非像前缀和数组那样,让 \(S[i]\) 存储 \([1,i]\) 的完整和,而是让每个节点仅“管理”原数组的一小段区间。

2.1 区间的管辖

我们将构建一个辅助数组,记为 \(c\)\(c[x]\) 存储的是原数组 \(a\) 在某个特定区间的和。这个特定区间是哪一段呢?答案由 \(x\) 的二进制表示决定。

定义一个函数 lowbit(x),它返回 \(x\) 在二进制表示下,最低位的 1 以及它后面的所有 0 构成的数。例如:

\(x = 6\) \((0110_{2})\) ,最低位的1在第二位(从右往左,从1开始数),值为 \(2^{1} = 2\) 。所以lowbit(6) \(= 2\) \((0010_{2})\)

\(x = 12\) \((1100_{2})\) ,最低位的1在第三位,值为 \(2^{2} = 4\) 。所以lowbit(12)=4 \((0100_{2})\)

\(x = 7\) \((0111_{2})\) ,最低位的1在第一位,值为 \(2^{0} = 1\) 。所以lowbit(7) \(= 1\) \((0001_{2})\)

在计算机中,lowbit(x) 有一个非常高效的位运算实现:

\[\operatorname {l o w b i t} (x) = x \& (- x) \]

这个技巧利用了计算机中负数以补码(Two's Complement)形式存储的特性。对于一个正数 \(x\) ,其相反数 -x 的补码表示等价于 \(-x + 1\) (按位取反后加一)。因此, \(x \& (-x)\) 实际上就是 \(x \& (-x + 1)\) ,该运算的结果恰好分离出 \(x\) 的二进制表示中最低位的 1。

现在,我们规定:树状数组节点 \(c[x]\) 存储原数组 \(a\) 在区间 \((x - \mathrm{lowbit}(x), x]\) 内所有元素的和。这个区间的长度恰好是 \(\mathrm{lowbit}(x)\)

\[c [ x ] = \sum_ {i = x - \operatorname {l o w b i t} (x) + 1} ^ {x} a [ i ] \]

我们来看几个例子(假设数组下标从1开始):

\(\cdot c[1]\) : lowbit(1) = 1, 区间 \((0,1]\) , 存 \(a[1]\)
\(\cdot c[2]\) : lowbit(2) = 2, 区间 \((0,2],\)\(a[1] + a[2]\)
\(c[3]\) : lowbit(3) = 1, 区间 (2,3], 存 a[3]。
\(\cdot c[4]\) : lowbit(4) = 4, 区间 (0,4], 存 \(a[1] + a[2] + a[3] + a[4]\)
\(c[5]\) : lowbit(5) = 1, 区间 (4, 5], 存 a[5]。
\(\cdot c[6]\) : lowbit(6) = 2, 区间 (4,6], 存 \(a[5] + a[6]\)
\(\cdot c[7]\) : lowbit(7) = 1, 区间 (6,7), 存 a[7]。
\(\cdot c[8]\) : lowbit(8) = 8, 区间 \((0,8]\) , 存 \(a[1] + \dots + a[8]\)

这种管辖关系形成了一种隐含的树形结构。 \(c[x]\) 的“父节点”是 \(c[x + \mathrm{lowbit}(x)]\) (只要 \(x + \mathrm{lowbit}(x)\leq N)\) 。例如, \(c[1]\)\(c[2]\) 的信息汇总到 \(c[2]\)\(1 + \mathrm{lowbit}(1) = 2\) ), \(c[3]\)\(c[2]\) 的信息汇总到 \(c[4]\)\(2 + \mathrm{lowbit}(2) = 4,3 + \mathrm{lowbit}(3) = 4\) )。

2.2核心操作的推导

基于上述管辖规则,我们可以推导出两种核心操作的逻辑。

2.2.1 查询前缀和 query(x)

目标:计算 \(\sum_{i=1}^{x} a[i]\)

根据 \(c\) 数组的定义,我们可以将任意前缀和 \(\sum_{i=1}^{x} a[i]\) 分解为若干个不相交区间的和,而这些区间恰好对应某些 \(c[j]\)

以查询 query(7) 为例,即求 \(\sum_{i=1}^{7} a[i]\)

  1. 首先加上 \(c[7]\) , 它管辖区间 \((6, 7]\) , 即 \(a[7]\)
  2. 接下来需要求 \(\sum_{i=1}^{6} a[i]\) 。我们从 \(x = 7\) 跳转到 \(x' = 7 - \text{lowbit}(7) = 7 - 1 = 6\)
  3. 加上 \(c[6]\) ,它管辖区间 \((4, 6]\) ,即 \(a[5] + a[6]\)
  4. 接下来需要求 \(\sum_{i=1}^{4} a[i]\) 。我们从 \(x = 6\) 跳转到 \(x' = 6 - \mathrm{lowbit}(6) = 6 - 2 = 4\)
  5. 加上 \(c[4]\) ,它管辖区间 \((0, 4]\) ,即 \(a[1] + a[2] + a[3] + a[4]\)
  6. 接下来需要求 \(\sum_{i=1}^{0} a[i]\) 。我们从 \(x = 4\) 跳转到 \(x' = 4 - \text{lowbit}(4) = 4 - 4 = 0\) 。跳转到0,循环结束。

最终,query(7)的结果是 \(c[7] + c[6] + c[4]\)

\(c[7]\rightarrow a[7]\)

\(c[6]\rightarrow a[5] + a[6]\)

\(c[4]\rightarrow a[1] + a[2] + a[3] + a[4]\)

它们的和正好是 \(\sum_{i=1}^{7} a[i]\)

这个过程的通用逻辑是:

要求前缀和 query(x),我们从 \(x\) 开始,累加 \(c[x]\) ,然后令 \(x = x - \mathrm{lowbit}(x)\) ,重复此过程直到 \(x = 0\)

\[\operatorname {q u e r y} (x) = \sum_ {i = x, i > 0, i \leftarrow i - \operatorname {l o w b i t} (i)} c [ i ] \]

2.2.2 单点修改 add(x, v)

目标:将 \(a[x]\) 的值增加 \(\mathcal{V}\)

\(a[x]\) 发生变化时,我们需要更新所有包含了 \(a[x]\)\(c[j]\) 。哪些 \(c[j]\) 会包含 \(a[x]\) 呢?

根据 \(c[j]\) 的定义,它管辖区间 \((j - \mathrm{lowbit}(j), j]\) 。如果 \(a[x]\)\(c[j]\) 包含,则必须满足:

\[j - \operatorname {l o w b i t} (j) < x \leq j \]

所有满足此条件的 \(j\) 都需要被更新。

我们发现,这些需要更新的 \(j\) 恰好构成了从 \(x\) 开始,不断加上自己的 lowbit 向上“爬”的路径。以修改 \(a[3]\) 为例:

  1. \(c[3]\) 管辖 \((2,3]\) ,包含 \(a[3]\) ,需要更新。 \(c[3] \gets c[3] + v\)
    2.下一个包含 \(a[3]\) 的更大的区间是哪个?我们从 \(x = 3\) 跳转到 \(x^{\prime} = 3 + \mathrm{lowbit}(3) = 3 + 1 = 4_{\circ}\)
  2. \(c[4]\) 管辖 \((0, 4]\) ,包含 \(a[3]\) ,需要更新。 \(c[4] \gets c[4] + v\)
  3. \(x = 4\) 跳转到 \(x' = 4 + \mathrm{lowbit}(4) = 4 + 4 = 8\)
  4. \(c[8]\) 管辖 \((0, 8]\) ,包含 \(a[3]\) ,需要更新。 \(c[8] \gets c[8] + v\)
  5. …以此类推,直到索引超出数组范围 \(N\)

这个过程的通用逻辑是:

要给 \(a[x]\) 增加 \(v\) ,我们从 \(x\) 开始,给 \(c[x]\) 增加 \(v\) ,然后令 \(x = x + \mathrm{lowbit}(x)\) ,重复此过程直到 \(x > N_{\circ}\)

\[\operatorname {a d d} (x, v): \text {f o r} i \leftarrow x \text {t o} N \text {s t e p} i \leftarrow i + \text {l o w b i t} (i), \text {d o} c [ i ] \leftarrow c [ i ] + v \]

3. 底层原理

3.1原理证明

3.1.1 query 操作的正确性

query(x) 的过程是将 \(x\) 的二进制表示从低位到高位逐个剥离。任何一个正整数 \(x\) 都可以唯一地表示为其二进制下各位权值之和。例如 \(x = 7(0111_{2}) = 4 + 2 + 1\) 。query(7) 的计算过程是 \(c[7] + c[6] + c[4]\)

\(\cdot x = 7\) \((0111_{2})\) 。query(7)加上 \(c[7]\)\(c[7]\) 覆盖区间 \((7 - \mathrm{lowbit}(7),7] = (6,7]\) 。长度为1。

  • \(x \gets 7 - \text{lowbit}(7) = 6\) (01102)。query(7) 加上 \(c[6]\)\(c[6]\) 覆盖区间

\((6 - \mathrm{lowbit}(6), 6] = (4, 6]\) 。长度为2。

  • \(x \gets 6 - \text{lowbit}(6) = 4\) (01002)。query(7) 加上 \(c[4]\)\(c[4]\) 覆盖区间

\((4 - \mathrm{lowbit}(4), 4] = (0, 4]\) 。长度为 \(4\)

  • \(x \gets 4 - \mathrm{lowbit}(4) = 0\) 结束。

所加和的区间为 \((6,7] \cup (4,6] \cup (0,4]\) , 它们的并集恰好是 \([1,7]\) (假设从 1 开始)。这个过程实际上是将区间 \([1, x]\) 完美地分割成了 \(\log x\) 个小块, 每一块都由一个 \(c\) 节点表示。 \(x = \text{lowbit}(x)\) 的操作在二进制层面相当于将最右边的 1 变为 0 , 因此每次迭代都会处理一个二进制位, 保证了不重不漏。

3.1.2 add 操作的正确性

add(x, v) 的过程是更新所有“祖先”节点。我们需要证明,对于任意 \(y \geq x\) ,query(y) 的结果能正确反映 \(a[x]\) 的变化,当且仅当 \(y\) 的 query 路径经过了 \(x\) 的 add 路径上的某个节点。

add(x, v) 更新的节点序列是 \(x_0 = x, x_1 = x_0 + \mathrm{lowbit}(x_0), x_2 = x_1 + \mathrm{lowbit}(x_1), \ldots\)

query(y) 访问的节点序列是 \(y_0 = y, y_1 = y_0 - \mathrm{lowbit}(y_0), y_2 = y_1 - \mathrm{lowbit}(y_1), \ldots\)

我们需要证明:若 \(y \geq x\) ,则 \(\exists k, j\) 使得 \(x_{k} = y_{j}\)

这个证明略显复杂, 但直观上可以这样理解: \(x_{k}\) 序列是 \(x\) 不断向高位进位形成的, 而 \(y_{j}\) 序列是 \(y\) 不断剥离低位形成的。如果 \(y \geq x\) , 那么 \(y\) 的二进制表示必然在某个高位上与 \(x\) 的祖先链重合。

例如,修改 \(a[5]\) ,add(5)路径是 \(5\to 6\to 8\to 16\dots\)

查询 query(7),路径是 \(7 \rightarrow 6 \rightarrow 4 \rightarrow 0\) 。路径在节点6相遇。

查询 query(12), 路径是 \(12 \rightarrow 8 \rightarrow 0\) 。路径在节点 8 相遇。

因此,对 \(a[x]\) 的修改能够被正确地传递到所有需要它的前缀和查询中。

3.2复杂度分析

  • 时间复杂度:

lowbit(x) 是 \(O(1)\) 的位运算。

add(x, v) 操作中,循环 \(x += \text{lowbit}(x)\) 。该操作在二进制层面上的效果是:将 \(x\) 最低位的 1“进位”,这会使得 \(x\) 末尾的 0 的数量增加。由于 \(x\) 不超过 \(N\) ,其二进制长度有限,这个“爬升”过程最多执行 \(O(\log N)\) 次。

query(x) 操作中,循环 \(x \gets \text{lowbit}(x)\) 。该操作在二进制层面上的效果是:每次都将 \(x\) 最低位的 1 变为 0 。由于一个数二进制表示中 1 的个数是有限的,这个过程最多执行 \(O(\log N)\) 次(等于 \(x\) 中 1 的个数)。

因此,单点修改和前缀和查询的时间复杂度均为 \(O(\log N)\)

- 空间复杂度:

需要一个辅助数组 \(c\) 来存储树状数组的节点, 大小与原序列相同, 故空间复杂度为 \(O(N)\)

3.3 C++ 实现

我们通常将树状数组封装起来。注意,为了 lowbit 的简洁性和正确性,我们的数组下标通常从 1 开始。如果题目输入是 0-indexed,需要手动映射到 1-indexed。

#include<bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 500005; // 数组大小,根据题目调整
ll c[N]; // 树状数组,c[x] 存储 (x-lowbit(x), x] 区间的和
int n; // 原数组的大小

// lowbit(x) 返回 x 的二进制表示中最低位的 1 所代表的值
int lowbit(int x) {
    return x & -x;
}

// 在位置 x 增加值 v
void add(int x, ll v) {
    for (; x <= n; x += lowbit(x)) {
        c[x] += v;
    }
}

// 查询前缀和 [1, x]
ll query(int x) {
    ll sum = 0;
    for (; x; x -= lowbit(x)) {
        sum += c[x];
    }
    return sum;
}

// 查询区间和 [l, r]
ll range_query(int l, int r) {
    retur nquery(r) - query(l - 1);
}

// 示例main函数
int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    // ... 具体问题逻辑 ...
    return 0;
}

代码解释:

  • c[N]:核心数组。使用 long long 防止求和溢出。
  • lowbit(x) : 核心函数, 实现 \(O(1)\)
  • \(\operatorname{add}\left( {\mathrm{x},\mathrm{v}}\right) :\)\(x\) 开始,沿着“父链” \(x + =\) lowbit(x) 向上更新,直到超出范围 \(n\)
  • query(x) : 从 \(x\) 开始, 沿着 \(x\) - \(=\) lowbit(x) 向前累加, 直到 \(x\) 归零。
  • range_query(l, r) : 利用前缀和思想,区间 \([l, r]\) 的和等于前缀 \([1, r]\) 的和减去前缀 \([1, l-1]\) 的和。

4. 基础应用

4.1 单点修改,区间查询

这是树状数组最直接的应用。

例题:洛谷 P3374 【模板】树状数组 1

题意概括:

给定一个长度为 \(N\) 的序列, 进行 \(M\) 次操作。操作有两种:

  1. 将某一个数 \(a[x]\) 加上 \(k\)
  2. 输出区间 \([x, y]\) 内每一个数的和。

\[N, M \leq 5 \cdot 1 0 ^ {5} 。 \]

分析:

这正是树状数组解决的经典问题。操作1是单点修改,操作2是区间查询。我们可以使用树状数组维护序列。

• 初始化: 题目给定了初始序列。我们可以看作是一个空序列, 然后对每个初始值 \(a[i]\) 执行一次 \(\operatorname{add}(i, a[i])\)

  • 操作1 \(1 \times k\) : 执行 \(\operatorname{add}\left( {x,k}\right)\)
  • 操作2 2xy:执行 query(y) - query(x-1) 并输出。

代码实现:

#include<bits/stdc++.h>
using namespace std;
using ll = long long;

const int MAXN = 500005;
ll c[MAXN];
int n, m;

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

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

ll query(int x) {
    ll sum = 0;
    for (; x; x -= lowbit(x)) {
        sum += c[x];
    }
    return sum;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);

    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {
        ll val;
        cin >> val;
        add(i, val); // 逐个加入初始值
    }

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

    return 0;
}

复杂度:

初始化: \(N\) 次add操作,总时间 \(O(N\log N)\)

  • \(M\) 次操作:每次操作 \(O(\log N)\)
  • 总时间复杂度: \(O\left(N \log N + M \log N\right)\)
    ·空间复杂度: \(O(N)\)

4.2 区间修改, 单点查询

问题转换:现在我们需要支持区间 \([1, r]\) 内所有元素统一增加一个值 \(v\) ,并能查询某一点 \(a[x]\) 的当前值。

直接用树状数组似乎无法高效处理区间修改,因为一次修改会影响区间内所有点,逐个调用 add 会退化到 \(O(N \log N)\)

这里的关键技巧是差分(Differencing)。

我们维护原数组 \(a\) 的差分数组 \(d\) ,定义 \(d[i] = a[i] - a[i - 1]\) (规定 \(a[0] = 0\) )。

差分数组有两条重要性质:

  1. 原值与前缀和: \(a[x] = \sum_{i=1}^{x} d[i]\) 。可以看出, 查询 \(a[x]\) 的值, 等价于查询差分数组 \(d\) 的前缀和。

  2. 区间修改与单点修改:对原数组 \(a\) 的区间 \([l, r]\) 统一加上 \(v\) ,只会影响到差分数组 \(d\) 的两个位置:

  • \(d[l]\) 的变化: \(a[l]\) 增加了 \(v\) , 而 \(a[l - 1]\) 不变, 所以新的

\(d^{\prime}[l] = (a[l] + v) - a[l - 1] = (a[l] - a[l - 1]) + v = d[l] + v_{\circ}\)\(d[l]\) 增加了 \(\mathcal{V}_{\circ}\)

  • \(d[r + 1]\) 的变化: \(a[r]\) 增加了 \(v\) , 而 \(a[r + 1]\) 不变, 所以新的

\(d^{\prime}[r + 1] = a[r + 1] - (a[r] + v) = (a[r + 1] - a[r]) - v = d[r + 1] - v.\)\(d[r + 1]\) 减少了 \(v_{\circ}\) (需注意 \(r + 1\leq n\) 的边界)

  • 对于 \(l < i \leq r\) 的其他位置 \(i\) , \(a[i]\)\(a[i - 1]\) 都增加了 \(v\) , 其差值 \(d[i]\) 不变。

通过差分, 我们将问题 \(\star \star\) 「区间修改, 单点查询」转化为了「单点修改, 前缀和查询」**。后者正是树状数组的专长。

我们可以用树状数组来维护这个差分数组 \(d\)

  • 区间修改 \(\lbrack 1,r\rbrack\)\(v :\) 对应于 \(\operatorname{add}\left( {l,v}\right)\)\(\operatorname{add}\left( {r + 1, - v}\right)\) 在差分树状数组上执行。

  • 单点查询 \(\mathrm{a}\left\lbrack \mathrm{x}\right\rbrack\) : 对应于 query(x) 在差分树状数组上执行。

例题:洛谷 P3368 【模板】树状数组 2

题意概括:

给定一个长度为 \(N\) 的序列,初始值都为0(题目给的是初始序列,但可以转化为差分)。进行 \(M\) 次操作。操作有两种:

  1. 将区间 \([x, y]\) 内每个数加上 \(k\)
  2. 查询位置 \(z\) 的数的值。

\(N,M\leq 5\cdot 10^{5}\)

分析:

这正是上述差分思想的模板应用。

  1. 初始化:给定初始数组 \(a\) ,我们需要构建差分数组 \(d\) 的树状数组。

\(d[i] = a[i] - a[i - 1]\)

我们可以遍历一遍原数组,对每个i从1到n,计算出d[i],然后对树状数组执行add(i, d[i])。

  1. 操作1 \(1xyk\) : 执行 \(\operatorname{add}(x, k)\)\(\operatorname{add}(y + 1, -k)\)
  2. 操作2 2z:执行 query(z) 并输出结果。

代码实现:

#include<bits/stdc++.h>
using namespace std;
using ll = long long;

const int MAXN = 500005;
ll c[MAXN];
int n, m;

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

// 这里的 add 和 query 操作的是差分数组
void add(int x, ll v) {
    for (; x <= n; x += lowbit(x)) {
        c[x] += v;
    }
}

ll query(int x) {
    ll sum = 0;
    for (; x; x -= lowbit(x)) {
        sum += c[x];
    }
    return sum;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);

    cin >> n >> m;
    ll prev_a = 0;
    for (int i = 1; i <= n; ++i) {
        ll cur_a;
        cin >> cur_a;
        // 构建差分数组的树状数组
        // d[i] = a[i] - a[i-1]
        add(i, cur_a - prev_a);
        prev_a = cur_a;
    }

    for (int i = 0; i < m; ++i) {
        int op;
        cin >> op;
        if (op == 1) {
            int x, y;
            ll k;
            cin >> x >> y >> k;
            add(x, k);
            if (y + 1 <= n) {
                add(y + 1, -k);
            }
        } else {
            int x;
            cin >> x;
            cout << query(x) << "\n";
        }
    }

    return 0;
}

复杂度:

初始化: \(O(N\log N)\)

  • \(M\) 次操作:区间修改是两次 add,单点查询是一次 query。每次操作复杂度均为 \(O(\log N)\)
    总时间复杂度: \(O(N\log N + M\log N)\)
    ·空间复杂度: \(O(N)\)

5. 区间修改,区间查询

这是树状数组应用的顶峰,也是面试和竞赛中的一个难点和重点。问题是同时支持对区间 \([1, r]\) 增加一个值 \(v\) ,并查询区间 \([1, r]\) 的和。

如果我们继续沿用上一节的差分思想,用一个树状数组维护差分数组 \(d[i] = a[i] - a[i - 1]\) ,那么区间修改变成了两次单点修改。但如何查询区间和?

区间 \([l, r]\) 的和是 \(\sum_{i = l}^{r} a[i]\) 。这可以通过两个前缀和相减得到: \(\sum_{i = 1}^{r} a[i] - \sum_{i = 1}^{l - 1} a[i]\) 。我们现在聚焦于如何计算前缀和 \(\sum_{i = 1}^{x} a[i]\)

\[\sum_ {i = 1} ^ {x} a [ i ] = \sum_ {i = 1} ^ {x} \sum_ {j = 1} ^ {i} d [ j ] \]

这个双重求和的式子不便于直接用树状数组计算。我们需要对它进行变形。考虑每个 \(d[j]\) 对总和的贡献。 \(d[j]\)\(a[i]\) 的计算中出现,当且仅当 \(j \leq i\) 。在 \(\sum_{i=1}^{x} a[i]\) 中, \(d[j]\) 会被

\(a[j], a[j + 1], \ldots, a[x]\) 所包含,共出现了 \((x - j + 1)\) 次。

因此,我们可以变换求和的顺序:

\[\sum_ {i = 1} ^ {x} \sum_ {j = 1} ^ {i} d [ j ] = \sum_ {j = 1} ^ {x} (x - j + 1) \cdot d [ j ] \]

将这个式子展开:

\[\begin{array}{l} \sum_ {j = 1} ^ {x} (x - j + 1) \cdot d [ j ] = \sum_ {j = 1} ^ {x} (x + 1) \cdot d [ j ] - \sum_ {j = 1} ^ {x} j \cdot d [ j ] \\ = (x + 1) \sum_ {j = 1} ^ {x} d [ j ] - \sum_ {j = 1} ^ {x} (j \cdot d [ j ]) \\ \end{array} \]

这个公式是解决问题的关键。我们发现,要计算前缀和 \(\sum a[i]\) ,需要维护两个量:

  1. \(\sum d[j]\) :差分数组 \(d\) 的前缀和。
  2. \(\sum (j\cdot d[j])\) :另一个数组 \(d^{\prime}[j] = j\cdot d[j]\) 的前缀和。

这启发我们使用两个树状数组:

  • 第一个树状数组 c1,维护差分数组 \(d\) 。c1 上的 query(x) 得到 \(\sum_{j=1}^{x} d[j]\)
  • 第二个树状数组 c2,维护数组 \(j \cdot d[j]\) 。c2 上的 query(x) 得到 \(\sum_{j=1}^{x} (j \cdot d[j])\)

现在我们分析两种操作:

  1. 区间修改 [l, r] 加 v

这对应于差分数组的两次单点修改: \(d[l]\) 增加 \(\mathcal{V}\)\(d[r + 1]\) 减少 \(\upsilon_{\circ}\)

  • 对 c1 : 执行 add(l, v) 和 add(r+1, -v)。

  • 对 c2:

  • \(d[l]\) 变化了 \(v\) ,所以 \(l \cdot d[l]\) 变化了 \(l \cdot v\) 。执行 \(\text{add}(l, l^*v)\)

  • \(d[r + 1]\) 变化了 \(-v\) ,所以 \((r + 1) \cdot d[r + 1]\) 变化了 \(-(r + 1) \cdot v\) 。执行 \(\mathrm{add}(r + 1, -(r + 1)^*v)\) 。(这里的乘法可能导致 long long 溢出,需要注意类型)

  1. 区间查询 [1, r] 和

利用 range_sum[l, r] = prefix_sum[r] - prefix_sum[l-1]。我们先定义一个函数 prefix_sum(x) 用于计算 \(\sum_{i=1}^{x} a[i]\)

根据推导的公式

prefix_sum(x) = (x+1) * query1(x) - query2(x)
其中 query1 是在 c1 上的查询,query2 是在 c2 上的查询。那么,区间和就是 prefix_sum(r) - prefix_sum(1-1)。

例题:洛谷P3372【模板】线段树1

(虽然题目是线段树模板,但它也是区间修改、区间查询的模板,可以用树状数组解决)

题意概括:

给定一个长度为 \(N\) 的序列,进行 \(M\) 次操作。

  1. 将区间 \([x, y]\) 内每个数加上 \(k\)
  2. 查询区间 \([x, y]\) 的和。

\(N,M\leq 10^{5}\)

分析:

完全符合上述模型。使用两个树状数组维护差分信息即可。

代码实现:

#include<bits/stdc++.h>
using namespace std;
using ll = long long;

const int MAXN = 100005;
ll c1[MAXN], c2[MAXN]; // c1 维护 d[i], c2 维护 i*d[i]
int n, m;

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

// 在指定树状数组 c 的 x 位置增加 v
void add(ll c[], int x, ll v) {
    for (; x <= n; x += lowbit(x)) {
        c[x] += v;
    }
}

// 在指定树状数组 c 上查询前缀和
ll query(ll c[], int x) {
    ll sum = 0;
    for (; x; x -= lowbit(x)) {
        sum += c[x];
    }
    return sum;
}

// 区间 [l, r] 统一增加 v
void range_add(int l, int r, ll v) {
    add(c1, l, v);
    add(c1, r + 1, -v);
    add(c2, l, l * v);
    add(c2, r + 1, -(r + 1) * v);
}

// 计算原数组的前缀和 [1, x]
ll prefix_sum(int x) {
    ll s1 = query(c1, x);
    ll s2 = query(c2, x);
    return (ll)(x + 1) * s1 - s2;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);

    cin >> n >> m;
    ll prev_a = 0;
    for (int i = 1; i <= n; ++i) {
        ll cur_a;
        cin >> cur_a;
        // 初始化差分数组 d[i] = a[i] - a[i-1]
        ll d = cur_a - prev_a;
        add(c1, i, d);
        add(c2, i, i * d);
        prev_a = cur_a;
    }

    for (int i = 0; i < m; ++i) {
        int op, l, r;
        cin >> op >> l >> r;
        if (op == 1) {
            ll k;
            cin >> k;
            range_add(l, r, k);
        } else {
            cout << prefix_sum(r) - prefix_sum(l - 1) << "\n";
        }
    }

    return 0;
}

复杂度:

初始化: \(O(N\log N)\)
·range_add:4次add操作,复杂度 \(O(\log N)\)

  • prefix_sum : 2 次 query 操作,复杂度 \(O(\log N)\)
  • 区间查询:2次 prefix_sum,复杂度 \(O(\log N)\)
    ·总时间复杂度: \(O(N\log N + M\log N)\)
    ·空间复杂度: \(O(N)\)

6. 高维树状数组

树状数组的思想可以自然地推广到二维乃至更高维度。这里以二维树状数组为例。

6.1二维树状数组:单点修改,矩阵查询

问题:维护一个 \(N \times M\) 的矩阵,支持单点修改(x,y)的值,并查询子矩阵(x1,y1)到(x2,y2)的和。

类比一维情况, \(c[x]\) 维护的是一个长度为lowbit(x)的区间。在二维中, \(c[x][y]\) 维护的是一个大小为lowbit(x)*lowbit(y)的矩形区域的和。具体地, \(c[x][y]\) 存储的是矩阵A在区域 \((x - \mathrm{lowbit}(x),y - \mathrm{lowbit}(y)]\)\((x,y]\) 内所有元素的和。

\[c [ x ] [ y ] = \sum_ {i = x - \operatorname {l o w b i t} (x) + 1} ^ {x} \sum_ {j = y - \operatorname {l o w b i t} (y) + 1} ^ {y} A [ i ] [ j ] \]

操作实现:

·修改add(x,y,v):将 \(A[x][y]\) 增加 \(\mathcal{V}\) 。需要更新所有包含点 \((x,y)\)\(c[i][j]\) 。这形成了二维的"父链”。

void add(int x, int y, ll v) {
    for (int i = x; i <= n; i += lowbit(i)) {
        for (int j = y; j <= m; j += lowbit(j)) {
            c[i][j] += v;
        }
    }
}
  • 查询 query(x, y):查询左上角为 (1, 1),右下角为 \((x, y)\) 的矩阵和。
ll query(int x, int y) {
    ll sum = 0;
    for (int i = x; i; i -= lowbit(i)) {
        for (int j = y; j; j -= lowbit(j)) {
            sum += c[i][j];
        }
    }
    return sum;
}
  • 任意矩阵查询:查询(x1,y1)到(x2,y2)的和,利用二维前缀和的容斥原理:sum = query(x2, y2) - query(x1-1, y2) - query(x2, y1-1) + query(x1-1, y1-1)。

复杂度:

  • 单点修改: \(O(\log N \cdot \log M)\)
  • 前缀矩阵查询: \(O(\log N \cdot \log M)\)
    ·空间复杂度: \(O(N\cdot M)\)

6.2二维树状数组:矩阵修改,矩阵查询

类似于一维的情况,我们可以通过二维差分将「矩阵修改,矩阵查询」问题转化为在二维差分矩阵上的操作。

定义二维差分矩阵 \(d[i][j] = A[i][j] - A[i - 1][j] - A[i][j - 1] + A[i - 1][j - 1]\)

\(\cdot A[x][y] = \sum_{i=1}^{x}\sum_{j=1}^{y}d[i][j].\) 查询单点值 \(A[x][y]\) 变成了查询二维差分矩阵的前缀和。

  • \(A\) 的一个子矩阵 \((x1, y1)\)\((x2, y2)\) 统一加 \(v\) ,只会影响 \(d\) 矩阵的四个点:

\(\mathrm{d}[x1][y1] + = \mathrm{v}\)
\(\mathrm{d}[x1][y2 + 1] = v\)
\(\mathrm{d}[x2 + 1][y1] = -v\)
\(\mathrm{d}[x2 + 1][y2 + 1] + = v\)

查询矩阵(1,1)到 \((\mathbf{x},\mathbf{y})\) 的和 \(\sum_{i = 1}^{x}\sum_{j = 1}^{y}A[i][j]\)

\[\sum_ {i = 1} ^ {x} \sum_ {j = 1} ^ {y} A [ i ] [ j ] = \sum_ {i = 1} ^ {x} \sum_ {j = 1} ^ {y} \sum_ {p = 1} ^ {i} \sum_ {q = 1} ^ {j} d [ p ] [ q ] \]

变换求和顺序,考虑每个 \(d[p][q]\) 的贡献。它被加了 \((x - p + 1)(y - q + 1)\) 次。

\[\sum_ {p = 1} ^ {x} \sum_ {q = 1} ^ {y} (x - p + 1) (y - q + 1) d [ p ] [ q ] \]

展开后得到:

\[(x + 1) (y + 1) \sum \sum d [ p ] [ q ] - (y + 1) \sum \sum p \cdot d [ p ] [ q ] - (x + 1) \sum \sum q \cdot d [ p ] [ q ] + \sum \sum \]

这需要我们维护四个二维树状数组,分别管理 \(d[p][q],p\cdot d[p][q],q\cdot d[p][q]\)\(p\cdot q\cdot d[p][q]\) 每次矩阵修改,需要对这四个树状数组各进行4次单点修改。每次矩阵查询,需要对这四个树状数组各进行4次查询,然后组合。复杂度为 \(O(\log N\cdot \log M)\)

例题:洛谷P4514上帝造题的七分钟

题意概括:

在一个 \(N\times M\) 的棋盘上,有两种操作:

  1. L x1 y1 x2 y2 c: 将 (x1, y1) 到 (x2, y2) 的矩形区域内每个格子的数加上 c。
  2. k x1 y1 x2 y2:查询(x1,y1)到(x2,y2)的矩形区域内所有数的和。

分析:

这是二维区间修改、区间查询的模板题,直接套用上述的四元二维树状数组模型即可。代码实现较为繁琐,但逻辑是清晰的。

#include <bits/stdc++.h>

using namespace std;
using ll = long long;

const int MX = 2050;

int n, m;
int t1[MX][MX]; // t1 使用 int 节省空间
ll t2[MX][MX], t3[MX][MX], t4[MX][MX];

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

// 模板化 add 和 sum 函数以适应 int 和 ll 两种类型
template<typename T>
void add(T t[MX][MX], int x, int y, T v) {
    for (int i = x; i <= n; i += lb(i)) {
        for (int j = y; j <= m; j += lb(j)) {
            t[i][j] += v;
        }
    }
}

template<typename T>
T sum(T t[MX][MX], int x, int y) {
    T res = 0;
    for (int i = x; i > 0; i -= lb(i)) {
        for (int j = y; j > 0; j -= lb(j)) {
            res += t[i][j];
        }
    }
    return res;
}

// v 即 delta,本身在 int 范围内
void upd(int x, int y, int v) {
    add(t1, x, y, v);
    add(t2, x, y, (ll)v * x);
    add(t3, x, y, (ll)v * y);
    add(t4, x, y, (ll)v * x * y);
}

// 计算前缀和,内部计算过程需要 long long 避免溢出
ll ask(int x, int y) {
    if (x <= 0 || y <= 0) return0;
    ll res = 0;
    res += (ll)(x + 1) * (y + 1) * sum(t1, x, y);
    res -= (ll)(y + 1) * sum(t2, x, y);
    res -= (ll)(x + 1) * sum(t3, x, y);
    res += sum(t4, x, y);
    return res;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);

    char op;
    cin >> op >> n >> m;

    while (cin >> op) {
        int a, b, c, d;
        if (op == 'L') {
            int v;
            cin >> a >> b >> c >> d >> v;
            upd(a, b, v);
            upd(c + 1, b, -v);
            upd(a, d + 1, -v);
            upd(c + 1, d + 1, v);
        } else {
            cin >> a >> b >> c >> d;
            ll res = ask(c, d) - ask(a - 1, d) - ask(c, b - 1) + ask(a - 1, b - 1);
            cout << res << "\n";
        }
    }

    return 0;
}

7. 高级技巧

7.1 逆序对问题

问题:给定一个排列或序列,求其中逆序对的数量。逆序对是指满足 \(i < j\)\(a[i] > a[j]\) 的数对 \((i, j)\)

思想:

从左到右遍历序列 \(a\) 。当我们处理到第 \(i\) 个元素 \(a[i]\) 时,我们想知道在它前面(即 \(j < i\) )有多少个元素 \(a[j]\) 满足 \(a[j] > a[i]\) 。将这些数量累加起来就是总的逆序对数。

这个问题可以转化为:当我们处理 \(a[i]\) 时,查询集合 \(\{a[1],\dots ,a[i - 1]\}\) 中大于 \(a[i]\) 的元素个数。这可以用树状数组高效解决。我们建立一个树状数组,其下标域代表值的范围。

c[v] 记录的是值为 v 的数出现了多少次。

query(v) 则返回的是值在 [1, v] 范围内的数出现了多少次。

算法流程:

  1. 如果 \(a[i]\) 的值域很大(例如达到 \(10^9\) ),但数量不多(例如 \(10^5\) ),需要先对数值进行离散化,将它们映射到 \([1, N]\) 的一个紧凑区间内。
  2. 初始化一个值域大小的、全为0的树状数组。
  3. \(i = 1\)\(N\) 遍历原序列 \(a\) :

a. 查询在 \(a\left\lbrack i\right\rbrack\) 之前已经插入的元素总数,即 \(i - 1\)
b. 查询值小于等于 \(a\left\lbrack i\right\rbrack\) 的元素个数,即 query(a[i])。
c. 那么, 值大于 \(a[i]\) 的元素个数就是 (i-1) - query(a[i])。将这个数累加到总答案 ans 。
d. 将 \(a\left\lbrack i\right\rbrack\) 的出现信息加入树状数组:add(a[i], 1)。

例题:洛谷 P1908 逆序对

题意概括:求一个序列的逆序对数量。 \(N\leq 5\cdot 10^{5},a[i]\leq 10^{9}\)

代码实现:

#include<bits/stdc++.h>
using namespace std;
using ll = long long;

const int MAXN = 500005;
ll c[MAXN];
int n;
int a[MAXN], b[MAXN]; // a 是原数组,b 是离散化用的辅助数组

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

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

ll query(int x) {
    ll sum = 0;
    for (; x; x -= lowbit(x)) sum += c[x];
    return sum;
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);

    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        b[i] = a[i];
    }

    // 离散化
    sort(b + 1, b + 1 + n);
    int m = unique(b + 1, b + 1 + n) - (b + 1);
    for (int i = 1; i <= n; ++i) {
        a[i] = lower_bound(b + 1, b + 1 + m, a[i]) - b;
    }

    ll ans = 0;
    // 从后往前遍历,计算每个数前面有多少比它大的数
    // 等价于从后往前遍历,计算每个数后面有多少比它小的数
    // 这样代码更简洁
    for (int i = n; i >= 1; --i) {
        ans += query(a[i] - 1); // 查询已加入的、值比 a[i] 小的个数
        add(a[i], 1);           // 将 a[i] 加入
    }
    
    // 另一种写法:从前往后遍历
    // for (int i = 1; i <= n; ++i) {
    //     ans += (i - 1) - query(a[i]); // 总数 - 小于等于a[i]的数
    //     add(a[i], 1);
    // }

    cout << ans << "\n";

    return 0;
}

复杂度:

  • 离散化:排序 \(O(N \log N)\)
  • 主循环: \(N\) 次 add 和 query, 总共 \(O(N \log N)\)
  • 总时间复杂度: \(O(N \log N)\)
  • 空间复杂度: \(O(N)\)

7.2 在树状数组上二分

问题:给定一个序列,其中只有非负数。求最小的 \(k\) ,使得 \(\sum_{i=1}^{k} a[i] \geq S\)

朴素解法:

二分答案 \(k \in [1, N]\) 。对于每个 mid,用树状数组计算 query(mid) 并与 S 比较。

一次 check 是 \(O(\log N)\) , 总时间复杂度为 \(O\left(\log^{2} N\right)\)

\(O(\log N)\) 解法:

我们可以利用树状数组的二进制结构来加速这个二分过程。这类似于在二叉搜索树上查找。

我们从高位到低位确定 \(k\) 的二进制位。

假设 \(N\) 的二进制长度为 \(L\) (即 \(2^{L - 1} \leq N < 2^L\) )。我们从大到小遍历 \(p = 2^{L - 1}, 2^{L - 2}, \ldots, 2^0\)

维护一个当前位置 pos 和当前已累加的和 sum。

在每一步,我们尝试将pos增加p(即尝试将k的这一位置为1)。

如果 \(\mathrm{pos} + \mathrm{p} < = \mathrm{N}\)\(c[\mathrm{pos} + p]\) (它代表了区间 \((\mathrm{pos}, \mathrm{pos} + p]\) 的和) 与当前 sum 相加后仍小于目标值 \(S\) , 说明真正的 \(k\) 一定大于 \(\mathrm{pos} + \mathrm{p}\) 。于是我们采纳这一步: \(\mathrm{sum} + = c[\mathrm{pos} + p]\) , \(\mathrm{pos} + = p\)

否则,我们不移动pos,继续尝试更小的p。

循环结束后,pos 是满足 \(\sum_{i=1}^{\mathrm{pos}} a[i] < S\) 的最大位置。因此,所求的答案是 \(\mathrm{pos} + 1\)

代码实现:

// 在维护非负数序列的树状数组上,查找第一个位置k使得前缀和>=S

int find_kth(ll S) {
    int pos = 0;
    ll current_sum = 0;
    // 动态计算不大于n的最大的2的幂次作为起点
    int p = 1;
    while ((p << 1) <= n) {
        p <<= 1;
    }

    for (; p > 0; p >>= 1) {
        if (pos + p <= n && current_sum + c[pos + p] < S) {
            current_sum += c[pos + p];
            pos += p;
        }
    }
    return pos + 1;
}

解释:

这个过程的正确性依赖于 \(c[x]\) 的管辖范围。当pos累加p(一个2的幂)时,pos的二进制表示中没有比p更高的位,pos+p的lowbit就是p。 \(c[\mathrm{pos} + p]\) 恰好覆盖了新增加的区间。整个过程就像在路径压缩的字典树上行走,每一步都决定一个二进制位。

应用场景:

在一些需要查询第k大/小元素的问题中,如果我们对值域建立树状数组,c[v] 表示 v 的出现次数,那么 find_kth(k) 就可以快速找到第k小的元素。这在某些场景下可以替代平衡树或 pb_ds。

7.3 树上问题与DFS序

树状数组是一维数据结构,但可以通过巧妙的序列化技术处理树上问题。最常用的技术是DFS序。

对一棵树进行深度优先遍历,记录每个节点 u 第一次被访问到的时间戳 dfn[u] 和遍历完其子树后回溯的时间戳 out[u](或直接记录其子树大小 sz[u])。

子树修改/查询:

一个节点 u 的整个子树(包括它自己)在DFS序中恰好对应一个连续的区间:[dfn[u], dfn[u] + sz[u] - 1]。
因此,对 u 的子树进行操作,就转化为了对DFS序序列上一个区间的操作。

  • 子树求和: query(dfn[u] + sz[u] - 1) - query(dfn[u] - 1)
  • 子树统一加值:转化为差分散组上的 add(dfn[u], v) 和 add(dfn[u] + sz[u], -v)。

路径修改/查询:

路径问题更复杂,通常需要结合LCA(最近公共祖先)。

对路径 u-v 的修改,可以拆解为 u-lca(u,v) 和 v-lca(u,v) 两条链。

在DFS序上,对从根到节点 \(x\) 的路径加值 \(\mathsf{v}\) ,可以这样操作:

  1. 在DFS序的 dfn[x] 位置加 v。
  2. 当查询节点 y 的值时,其值等于从根到 y 路径上所有节点在 DFS 序上对应位置的值之和。这个和等于在 dfn 序列上 [1, dfn[y]] 区间的前缀和。

综合起来,对路径 u-v 加值 v(设 \(l = lca(u, v)\)\(p = \text{parent}(l)\) ),可以转化为四次单点修改:

  1. add(dfn[u], v)
  2. add(dfn[v], v)
  3. add(dfn[l], -2 * v) (或者如原方法,在l处减v,在p处再减v)

采用在 lca 和 parent(lca) 处修改的经典方法,可以更清晰地表达为:

  1. add(dfn[u], v)
  2. add(dfn[v], v)
  3. add(dfn[l], -v)
  4. if \((\mathsf{p}! = 0)\) {add(dfn[p], -v);} // lca不是树根时,其父节点p也需修改

这里的 \(p! = 0\) 假设根节点的父节点为0。

查询点 \(x\) 的值就是对差分数组求前缀和 query(dfn[x])。查询子树 \(x\) 的和,则是在维护原值的树状数组上(通过差分构建)查询区间 [dfn[x], dfn[x] + sz[x] - 1] 的和。这些操作的组合可以解决复杂的树上问题。

8.对比与总结

8.1与线段树的比较

树状数组和线段树是解决动态区间问题的两大主力。它们各有优劣。

特性树状数组 (Fenwick Tree)线段树 (Segment Tree)
实现复杂度极低。核心代码仅几行,不易出错。较高。需要递归建树、查询、修改, 代码量大,细节多。
常数因子非常小。循环实现,cache友好。较大。递归调用开销,内存访问不连续。
功能有限。 主要支持满足结合律和可逆性的操作 (如加减法)。强大。支持任意满足结合律的操作(如max, min, gcd)。 通过懒惰标记支持复杂的区间更新。
解决问题单点改/区查,区改/单查,区改/区查 (和),逆序对等。几乎所有树状数组能解决的问题, 以及区间最值、历史最值等。
空间\( O\left( {N\text{)}}\right) \)\( O\left( {4N}\right) \)
时间复杂度\( O\left( {\log N}\right) \)\( O\left( {\log N}\right) \)

选择策略:

  • 如果问题只涉及区间和、单点修改,或者可以通过差分转化为这类问题,优先使用树状数组。它的代码简洁、常数小,是比赛中的利器。
  • 如果问题涉及区间最值(RMQ)、区间GCD、或者一些不具备可逆性的复杂操作,必须使用线段树。
  • 可以把树状数组看作一个特化的、轻量级的线段树。

8.2常见错误与注意事项

1.下标从1开始:lowbit的实现依赖于正整数的二进制表示。树状数组的所有操作都应在1-based索引上进行。若题目是0-based,需手动 \(+1\)

  1. long long 溢出:在求和问题中,即使单个元素值不大,累加和也可能超过 int 范围。树状数组 c 和相关变量应使用 long long。
  2. 区间查询的边界:查询区间 \([l, r]\) 的和是 query(r) - query(1-1),1-1 容易错写成 I。
    4.差分数组的边界:在区间修改 \([1,r]\) 时,差分数组的修改是d[l]和 \(\mathrm{d}[\mathrm{r} + 1]\) 。务必检查 \(r + 1\) 是否会超出数组范围n。如果 \(r = n\) ,则不需要 \(\mathrm{d}[n + 1]\) 的修改。
  3. 值域与坐标域:在逆序对等问题中,要分清树状数组是建立在原数组下标上,还是建立在数值范围上。后者通常需要离散化。
  4. 初始化:如果用 add 逐个添加初始值,复杂度是 \(O(N \log N)\) 。对于某些场景,存在 \(O(N)\) 的建树方法: \(c[i]\) 可以由其“子节点”的和来计算, \(c[i] = \sum_{j=i-\text{lowbit}(i)+1}^{i} a[j]\) 。但这通常不是瓶颈,for 循环 add 更简单通用。

8.3 结语

树状数组以其极致的简洁和高效,在算法竞赛中占据了一席之地,其核心的二进制分解思想更是一种优雅的算法范式,能够启发我们从不同角度看待求和与分解。
附录例题选讲

CF1354D Multiset


#include <bits/stdc++.h>

using namespace std;

const int MX = 1000005;

int n, q;
int bit[MX];

// 单点更新
void upd(int p, int v) {
    for (; p <= n; p += p & -p) {
        bit[p] += v;
    }
}

// 在树状数组上二分,O(log n) 查找第 k 小的元素
int f(int k) {
    int p = 0;
    // 从 log(n) 级别的大步长开始尝试
    for (int i = 20; i >= 0; --i) {
        int nxt = p + (1 << i);
        // 如果跳到 nxt 后,元素总数仍小于 k,说明第 k 小的元素在更后面
        // 于是安全地跳过去,并减去已经跳过的元素数量
        if (nxt <= n && bit[nxt] < k) {
            p = nxt;
            k -= bit[p];
        }
    }
    return p + 1;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);

    cin >> n >> q;
    int cnt = 0;
    
    // 初始化
    for (int i = 0; i < n; ++i) {
        int x;
        cin >> x;
        upd(x, 1);
    }
    cnt += n;
    
    // 处理查询
    for (int i = 0; i < q; ++i) {
        int k;
        cin >> k;
        if (k > 0) {
            upd(k, 1);
            cnt++;
        } else {
            // 找到第 |k| 小的元素并移除
            upd(f(-k), -1);
            cnt--;
        }
    }
    
    if (cnt == 0) {
        cout << 0 << "\n";
    } else {
        // 如果集合非空,输出任意一个元素,这里选择输出第 1 小的
        cout << f(1) << "\n";
    }
    
    return 0;
}

CF61E Enemy is weak

#include <bits/stdc++.h>

using namespace std;
using ll = long long;

const int MX = 1000005;

int n;
int a[MX];
int b[MX]; // 用于离散化的辅助数组
ll bit[MX];
ll l[MX], r[MX]; // l[i]记录左边比a[i]大的, r[i]记录右边比a[i]小的

// 树状数组标准模板
int lb(int x) {
    return x & -x;
}

void upd(int p, int v) {
    for (; p <= n; p += lb(p)) {
        bit[p] += v;
    }
}

ll qry(int p) {
    ll res = 0;
    for (; p > 0; p -= lb(p)) {
        res += bit[p];
    }
    return res;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);

    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        b[i] = a[i];
    }

    // 离散化
    sort(b + 1, b + n + 1);
    for (int i = 1; i <= n; ++i) {
        a[i] = lower_bound(b + 1, b + n + 1, a[i]) - b;
    }
    
    // 从左到右遍历,计算 l[i]
    for (int i = 1; i <= n; ++i) {
        l[i] = qry(n) - qry(a[i]); // 总数 - 小于等于a[i]的数 = 大于a[i]的数
        upd(a[i], 1);
    }
    
    // 清空树状数组
    memset(bit, 0, sizeof(bit));
    
    // 从右到左遍历,计算 r[i]
    for (int i = n; i >= 1; --i) {
        r[i] = qry(a[i] - 1); // 小于a[i]的数的数量
        upd(a[i], 1);
    }
    
    ll ans = 0;
    for (int i = 1; i <= n; ++i) {
        ans += l[i] * r[i];
    }
    
    cout << ans << "\n";

    return 0;
}

P1471 方差(混进来一个线段树)

//seg
#include <bits/stdc++.h>

using namespace std;
using ll = long long;

const int MX = 100005;

struct N {
    double s1, s2, lz;
} t[MX * 4];

int n, m;
double a[MX];

void up(int p) {
    t[p].s1 = t[p * 2].s1 + t[p * 2 + 1].s1;
    t[p].s2 = t[p * 2].s2 + t[p * 2 + 1].s2;
}

// 下推懒标记
void down(int p, int l, int r) {
    if (t[p].lz == 0) return;

    double k = t[p].lz;
    int lc = p * 2, rc = p * 2 + 1;
    int mid = (l + r) / 2;
    int lenL = mid - l + 1;
    int lenR = r - mid;

    // 更新左儿子
    t[lc].lz += k;
    t[lc].s2 += 2 * k * t[lc].s1 + lenL * k * k;
    t[lc].s1 += lenL * k;

    // 更新右儿子
    t[rc].lz += k;
    t[rc].s2 += 2 * k * t[rc].s1 + lenR * k * k;
    t[rc].s1 += lenR * k;

    t[p].lz = 0;
}

void build(int p, int l, int r) {
    t[p].lz = 0;
    if (l == r) {
        t[p].s1 = a[l];
        t[p].s2 = a[l] * a[l];
        return;
    }
    int mid = (l + r) / 2;
    build(p * 2, l, mid);
    build(p * 2 + 1, mid + 1, r);
    up(p);
}

// 区间修改
void upd(int p, int l, int r, int ql, int qr, double k) {
    if (ql <= l && r <= qr) {
        t[p].lz += k;
        t[p].s2 += 2 * k * t[p].s1 + (double)(r - l + 1) * k * k;
        t[p].s1 += (double)(r - l + 1) * k;
        return;
    }

    down(p, l, r);
    int mid = (l + r) / 2;
    if (ql <= mid) upd(p * 2, l, mid, ql, qr, k);
    if (qr > mid) upd(p * 2 + 1, mid + 1, r, ql, qr, k);
    up(p);
}

// 区间查询
N qry(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) {
        return t[p];
    }
    down(p, l, r);
    int mid = (l + r) / 2;
    if (qr <= mid) returnqry(p * 2, l, mid, ql, qr);
    if (ql > mid) returnqry(p * 2 + 1, mid + 1, r, ql, qr);
    
    // 区间横跨两个儿子
    N resL = qry(p * 2, l, mid, ql, qr);
    N resR = qry(p * 2 + 1, mid + 1, r, ql, qr);
    N res;
    res.s1 = resL.s1 + resR.s1;
    res.s2 = resL.s2 + resR.s2;
    return res;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);

    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
    }

    build(1, 1, n);

    cout << fixed << setprecision(4);

    for (int i = 0; i < m; ++i) {
        int op, x, y;
        cin >> op >> x >> y;
        if (op == 1) {
            double k;
            cin >> k;
            upd(1, 1, n, x, y, k);
        } else {
            N res = qry(1, 1, n, x, y);
            double len = y - x + 1;
            double avg = res.s1 / len;
            if (op == 2) {
                cout << avg << "\n";
            } else {
                double var = res.s2 / len - avg * avg;
                cout << var << "\n";
            }
        }
    }

    return 0;
}

P3431 [POI 2005] AUT-The Bus

#include <bits/stdc++.h>

using namespace std;
using ll = long long;

const int MX = 100005;

struct P {
    int x, y, p;
} pts[MX];

int ys[MX];      // 存储 y 坐标用于离散化
ll tree[MX * 4]; // 线段树
int d_y;         // 唯一 y 坐标的数量

// 线段树:点更新(取较大值)
void upd(int p, int l, int r, int idx, ll val) {
    if (l == r) {
        tree[p] = max(tree[p], val);
        return;
    }
    int mid = l + (r - l) / 2;
    if (idx <= mid) {
        upd(p * 2, l, mid, idx, val);
    } else {
        upd(p * 2 + 1, mid + 1, r, idx, val);
    }
    tree[p] = max(tree[p * 2], tree[p * 2 + 1]);
}

// 线段树:区间查询最大值
ll qry(int p, int l, int r, int ql, int qr) {
    if (ql > r || qr < l || ql > qr) return 0;
    if (ql <= l && r <= qr) {
        return tree[p];
    }
    int mid = l + (r - l) / 2;
    ll res = 0;
    if (ql <= mid) {
        res = max(res, qry(p * 2, l, mid, ql, qr));
    }
    if (qr > mid) {
        res = max(res, qry(p * 2 + 1, mid + 1, r, ql, qr));
    }
    return res;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);

    int n, m, k;
    cin >> n >> m >> k;

    for (int i = 0; i < k; ++i) {
        cin >> pts[i].x >> pts[i].y >> pts[i].p;
        ys[i] = pts[i].y;
    }

    // 离散化 y 坐标
    sort(ys, ys + k);
    d_y = unique(ys, ys + k) - ys;

    // 按 x, y 坐标排序站点
    sort(pts, pts + k, [](const P& a, const P& b) {
        if (a.x != b.x) return a.x < b.x;
        return a.y < b.y;
    });

    // 修正后的DP过程,不再需要批处理
    for (int i = 0; i < k; ++i) {
        int y_comp = lower_bound(ys, ys + d_y, pts[i].y) - ys + 1;
        
        // 查询所有y坐标小于等于当前点的最大dp值
        ll prev_max = qry(1, 1, d_y, 1, y_comp);
        
        // 计算当前点的dp值并更新到线段树
        ll dp_val = prev_max + pts[i].p;
        upd(1, 1, d_y, y_comp, dp_val);
    }

    cout << qry(1, 1, d_y, 1, d_y) << "\n";

    return 0;
}

题目推荐

#ProblemPlatform典型套路难度*Why / 主要考点
161E Enemy is WeakCodeforces统计出现次数→逆序三元组★★★双遍扫描 + 两棵 BIT 统计 「左边比我大」「右边比我小」,经典“三元逆序对”模型。(codeforces.com)
21741E Sending a Sequence Over the NetworkCodeforces前缀可行性检查+BIT二分★★☆倒序扫数组,用 BIT 标记“可到达”区间并用 find_kth 快速跳转。(codeforces.com)
3296C Greg and ArrayCodeforcesRU + RQ (双BIT)★★☆“操作次数”与“数组元素”都做差分;两次区间加、两棵 BIT合并得到最终值。(codeforces.com)
41354D MultisetCodeforces频率表 + BIT上二分★★☆维护出现次数,支持插入/第k小删除;练习 find_kth。 (Codeforces.com, codeforces.com)
5P3374 【模板】树状数组1洛谷PU + RQ★★☆最基础模板;单点加、区间求和。(luogu.com.cn)
6P3368 【模板】树状数组2洛谷RU + PQ★★☆差分 + BIT,实现区间加、单点查。(luogu.com.cn)
7P3372 【模板】线段树1 (BIT解法)洛谷RU + RQ★★☆用“双BIT”替代线段树;验证公式 prefix = (x+1)-S1-S2。(luogu.com.cn)
8P1908逆序对洛谷值域 BIT +离散化★★☆从后向前扫 + 统计 <a[i]个数,O(n log n)逆序对。(luogu.com.cn)
9practice2 B-Fenwick TreeAtCoder(ALPC)PU + RQ★★☆官方“练习册”原题;熟悉 fw.add / fw-sum 接口。(atcoder.jp)
10ABC253 G Swap ManyAtCoderRU + RQ★★★区间大量 swap → 数学化为区间加;双BIT解是官方推荐。(atcoder.jp)
11ABC389 F Rated RangeAtCoderRU + PQ★★☆Editorial 用 Fenwick + 差分 + “いもす法”,练习离线区间加。 (atcoder.jp)
12ABC174 F Range Set SumAtCoder颜色出现位置 + BIT★★★先按颜色上次出现位置 offline,把“区间不同色计数”压成经典「点加+区间和」。(img.atcoder.jp)
posted @ 2025-10-30 19:33  xihegudi  阅读(6)  评论(0)    收藏  举报