树状数组
原文链接:树状数组
1. 引言:问题的提出
在算法竞赛中,我们频繁处理序列上的区间操作。一个最基础也最核心的问题是动态区间和:给定一个序列,需要支持两种操作:
- 单点修改 (Point Update): 修改序列中某个元素的值。
- 区间查询 (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) 有一个非常高效的位运算实现:
这个技巧利用了计算机中负数以补码(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)\) 。
我们来看几个例子(假设数组下标从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]\)
- 首先加上 \(c[7]\) , 它管辖区间 \((6, 7]\) , 即 \(a[7]\) 。
- 接下来需要求 \(\sum_{i=1}^{6} a[i]\) 。我们从 \(x = 7\) 跳转到 \(x' = 7 - \text{lowbit}(7) = 7 - 1 = 6\) 。
- 加上 \(c[6]\) ,它管辖区间 \((4, 6]\) ,即 \(a[5] + a[6]\) 。
- 接下来需要求 \(\sum_{i=1}^{4} a[i]\) 。我们从 \(x = 6\) 跳转到 \(x' = 6 - \mathrm{lowbit}(6) = 6 - 2 = 4\) 。
- 加上 \(c[4]\) ,它管辖区间 \((0, 4]\) ,即 \(a[1] + a[2] + a[3] + a[4]\) 。
- 接下来需要求 \(\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\) 。
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\) 都需要被更新。
我们发现,这些需要更新的 \(j\) 恰好构成了从 \(x\) 开始,不断加上自己的 lowbit 向上“爬”的路径。以修改 \(a[3]\) 为例:
- \(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}\) - \(c[4]\) 管辖 \((0, 4]\) ,包含 \(a[3]\) ,需要更新。 \(c[4] \gets c[4] + v\) 。
- 从 \(x = 4\) 跳转到 \(x' = 4 + \mathrm{lowbit}(4) = 4 + 4 = 8\) 。
- \(c[8]\) 管辖 \((0, 8]\) ,包含 \(a[3]\) ,需要更新。 \(c[8] \gets c[8] + v\) 。
- …以此类推,直到索引超出数组范围 \(N\) 。
这个过程的通用逻辑是:
要给 \(a[x]\) 增加 \(v\) ,我们从 \(x\) 开始,给 \(c[x]\) 增加 \(v\) ,然后令 \(x = x + \mathrm{lowbit}(x)\) ,重复此过程直到 \(x > N_{\circ}\)
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\) 次操作。操作有两种:
- 将某一个数 \(a[x]\) 加上 \(k\) 。
- 输出区间 \([x, y]\) 内每一个数的和。
分析:
这正是树状数组解决的经典问题。操作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\) )。
差分数组有两条重要性质:
-
原值与前缀和: \(a[x] = \sum_{i=1}^{x} d[i]\) 。可以看出, 查询 \(a[x]\) 的值, 等价于查询差分数组 \(d\) 的前缀和。
-
区间修改与单点修改:对原数组 \(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\) 次操作。操作有两种:
- 将区间 \([x, y]\) 内每个数加上 \(k\) 。
- 查询位置 \(z\) 的数的值。
\(N,M\leq 5\cdot 10^{5}\)
分析:
这正是上述差分思想的模板应用。
- 初始化:给定初始数组 \(a\) ,我们需要构建差分数组 \(d\) 的树状数组。
\(d[i] = a[i] - a[i - 1]\)
我们可以遍历一遍原数组,对每个i从1到n,计算出d[i],然后对树状数组执行add(i, d[i])。
- 操作1 \(1xyk\) : 执行 \(\operatorname{add}(x, k)\) 和 \(\operatorname{add}(y + 1, -k)\) 。
- 操作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]\) 。
这个双重求和的式子不便于直接用树状数组计算。我们需要对它进行变形。考虑每个 \(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 a[i]\) ,需要维护两个量:
- \(\sum d[j]\) :差分数组 \(d\) 的前缀和。
- \(\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])\) 。
现在我们分析两种操作:
- 区间修改 [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, 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\) 次操作。
- 将区间 \([x, y]\) 内每个数加上 \(k\) 。
- 查询区间 \([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]\) 内所有元素的和。
操作实现:
·修改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]\)
变换求和顺序,考虑每个 \(d[p][q]\) 的贡献。它被加了 \((x - p + 1)(y - q + 1)\) 次。
展开后得到:
这需要我们维护四个二维树状数组,分别管理 \(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\) 的棋盘上,有两种操作:
- L x1 y1 x2 y2 c: 将 (x1, y1) 到 (x2, y2) 的矩形区域内每个格子的数加上 c。
- 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] 范围内的数出现了多少次。
算法流程:
- 如果 \(a[i]\) 的值域很大(例如达到 \(10^9\) ),但数量不多(例如 \(10^5\) ),需要先对数值进行离散化,将它们映射到 \([1, N]\) 的一个紧凑区间内。
- 初始化一个值域大小的、全为0的树状数组。
- 从 \(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}\) ,可以这样操作:
- 在DFS序的 dfn[x] 位置加 v。
- 当查询节点 y 的值时,其值等于从根到 y 路径上所有节点在 DFS 序上对应位置的值之和。这个和等于在 dfn 序列上 [1, dfn[y]] 区间的前缀和。
综合起来,对路径 u-v 加值 v(设 \(l = lca(u, v)\) , \(p = \text{parent}(l)\) ),可以转化为四次单点修改:
- add(dfn[u], v)
- add(dfn[v], v)
- add(dfn[l], -2 * v) (或者如原方法,在l处减v,在p处再减v)
采用在 lca 和 parent(lca) 处修改的经典方法,可以更清晰地表达为:
- add(dfn[u], v)
- add(dfn[v], v)
- add(dfn[l], -v)
- 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\) 。
- long long 溢出:在求和问题中,即使单个元素值不大,累加和也可能超过 int 范围。树状数组 c 和相关变量应使用 long long。
- 区间查询的边界:查询区间 \([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]\) 的修改。 - 值域与坐标域:在逆序对等问题中,要分清树状数组是建立在原数组下标上,还是建立在数值范围上。后者通常需要离散化。
- 初始化:如果用 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;
}
题目推荐
| # | Problem | Platform | 典型套路 | 难度* | Why / 主要考点 |
| 1 | 61E Enemy is Weak | Codeforces | 统计出现次数→逆序三元组 | ★★★ | 双遍扫描 + 两棵 BIT 统计 「左边比我大」「右边比我小」,经典“三元逆序对”模型。(codeforces.com) |
| 2 | 1741E Sending a Sequence Over the Network | Codeforces | 前缀可行性检查+BIT二分 | ★★☆ | 倒序扫数组,用 BIT 标记“可到达”区间并用 find_kth 快速跳转。(codeforces.com) |
| 3 | 296C Greg and Array | Codeforces | RU + RQ (双BIT) | ★★☆ | “操作次数”与“数组元素”都做差分;两次区间加、两棵 BIT合并得到最终值。(codeforces.com) |
| 4 | 1354D Multiset | Codeforces | 频率表 + BIT上二分 | ★★☆ | 维护出现次数,支持插入/第k小删除;练习 find_kth。 (Codeforces.com, codeforces.com) |
| 5 | P3374 【模板】树状数组1 | 洛谷 | PU + RQ | ★★☆ | 最基础模板;单点加、区间求和。(luogu.com.cn) |
| 6 | P3368 【模板】树状数组2 | 洛谷 | RU + PQ | ★★☆ | 差分 + BIT,实现区间加、单点查。(luogu.com.cn) |
| 7 | P3372 【模板】线段树1 (BIT解法) | 洛谷 | RU + RQ | ★★☆ | 用“双BIT”替代线段树;验证公式 prefix = (x+1)-S1-S2。(luogu.com.cn) |
| 8 | P1908逆序对 | 洛谷 | 值域 BIT +离散化 | ★★☆ | 从后向前扫 + 统计 <a[i]个数,O(n log n)逆序对。(luogu.com.cn) |
| 9 | practice2 B-Fenwick Tree | AtCoder(ALPC) | PU + RQ | ★★☆ | 官方“练习册”原题;熟悉 fw.add / fw-sum 接口。(atcoder.jp) |
| 10 | ABC253 G Swap Many | AtCoder | RU + RQ | ★★★ | 区间大量 swap → 数学化为区间加;双BIT解是官方推荐。(atcoder.jp) |
| 11 | ABC389 F Rated Range | AtCoder | RU + PQ | ★★☆ | Editorial 用 Fenwick + 差分 + “いもす法”,练习离线区间加。 (atcoder.jp) |
| 12 | ABC174 F Range Set Sum | AtCoder | 颜色出现位置 + BIT | ★★★ | 先按颜色上次出现位置 offline,把“区间不同色计数”压成经典「点加+区间和」。(img.atcoder.jp) |

浙公网安备 33010602011771号