CSP-S 提高级课程中心 / 数据结构·线性与特殊树 / 第15讲
CSP-S 提高级 · 数据结构·线性与特殊树 · s3-06

树状数组:用 lowbit 跳出 O(log n) 的优雅舞蹈

二十行代码,一个 lowbit 技巧,就把「单点修改 + 前缀查询」压到 O(log n)。它是竞赛中性价比最高的数据结构,没有之一。

6 大模块 O(log n) 修改与查询 4 种经典变形 2 处交互动画 12 道洛谷原题
本讲 · 第 15/52 讲树状数组
模块 01

情景导航

从「排行榜分数一直在变,还要不停查区间和」出发,看前缀和与差分为何都失效,以及树状数组如何破局。

故事实时排行榜

你在一家游戏公司做后台开发。系统里有 n 位玩家,编号 1 到 n,每人有一个当前积分 a[i]。

业务需求有两类,而且交替出现、数量各 105 条:

需求 A:玩家 i 打赢了一局,积分增加 v —— 这是「单点修改」。

需求 B:运营想知道编号在 [l, r] 区间内的玩家总积分 —— 这是「区间查询」。

你想起上一章学过的前缀和:预处理一次 O(n),之后每次区间查询 O(1)。可是——只要有一个人改了积分,整个前缀和数组后面全部要重算,一次修改 O(n)。105 次修改 × 105 = 1010,TLE。

你又想起差分:区间修改 O(1),很爽!可惜差分的强项是"改完再一次性查询",这里却是边改边查,每次查询还得重新求前缀和,同样是 O(n)。

前缀和擅长查询,差分擅长修改,可现实是——两者要同时做!

这时同事发来一段只有二十行的代码:「试试树状数组吧,修改 O(log n),查询 O(log n),105 次操作总共才三百多万次,稳得很。」

你盯着那个 i += i & -i 看了很久——为什么加上"最低位的 1"就能一路跳到关键节点,就能巧妙地覆盖所有需要更新的区间?这就是本讲要讲清楚的核心。

💡 故事背后的算法思想

① 折中。前缀和与差分的失败都在于"一端 O(n)"。如果我们愿意接受"两端各 O(log n)",总复杂度就是 O(m log n),完全可接受。树状数组正是这个折中的实现。

② 让每个 c[i] 负责一小段。把数组划分成若干长度不等的区间,c[i] 存储区间 (i − lowbit(i), i] 的和。这样单个元素只属于 O(log n) 个区间的管辖范围。

③ 修改时"向上报"。改 a[i] 时,所有"包含 i"的 c 都要更新,而这些下标恰好是 i, i+lowbit(i), i+2·lowbit(i), ...,一路跳到超过 n 为止,共 O(log n) 个。

④ 查询时"向下拆"。求前缀和 Σa[1..i] 时,从 i 开始每次减去 lowbit(i),把 i 拆成若干互不重叠的区间并相加,同样是 O(log n) 步。

⑤ 二进制视角。这两个跳跃本质上是在做二进制分解:i 的二进制里有几个 1,查询就跳几段;i 最多 log n 位,所以最多 O(log n) 步。

①生活类比
🏢

分公司汇报 = c[i]

每个 c[i] 是一位"片区经理",只负责自己辖区那一段的和,不越界。

→ c[i] 管 (i−lowbit(i), i]
📞

层层上报 = update

某个点变了,只通知"管得到我"的上级,一路向上,不必惊动所有人。

→ i += lowbit(i)
🧩

拼图求和 = query

要统计 1..i 的总和,就把 i 拆成几块现成的"片区",直接取用不重算。

→ i -= lowbit(i)
🔟

二进制数钱 = lowbit

数钱时先抓最大面额,再补零头。lowbit 就是"当前能抓的最大块"。

→ x & -x
②核心定义

树状数组(Binary Indexed Tree,BIT / Fenwick Tree):一种支持「单点修改」与「前缀和查询」均为 O(log n) 的数组结构,由 Peter Fenwick 于 1994 年提出。

lowbit(x):x & (-x),即 x 的二进制表示中最低位的 1 及其后面所有 0 所构成的数。例如 lowbit(6) = lowbit(1102) = 2;lowbit(8) = 8;lowbit(7) = 1。

管辖范围:c[i] 存储原数组区间 (i − lowbit(i),  i] 的元素和,区间长度恰好为 lowbit(i)。

update(i, v):把 a[i] 增加 v。令 i 依次加上 lowbit(i),沿途的 c 全部加 v。

query(i):求前缀和 Σ a[1..i]。令 i 依次减去 lowbit(i),把沿途的 c 累加,直到 i = 0。

差分树状数组:用 BIT 维护差分数组 d,实现「区间加 O(log n) + 单点查 O(log n)」。

双树状数组:同时维护 Σd[j] 与 Σj·d[j] 两个 BIT,实现「区间加 + 区间和」均为 O(log n)。

📌 本讲核心知识地图

板块核心内容对应大纲
lowbitx & -x,取最低位的 1【6】树状数组
结构c[i] 管 (i−lowbit(i), i],长度 lowbit(i)【6】树状数组
单点改+前缀和update 向上跳、query 向下跳,各 O(log n)【6】树状数组
区间加+单点查差分数组建 BIT,add(l,v)/add(r+1,-v)【6】树状数组
区间加+区间和双 BIT:(x+1)Σd − Σj·d【6】树状数组
逆序对离散化 + BIT 统计"比它大的已出现个数"【6】树状数组
对比线段树常数小代码短 vs 功能少【6】线段树
③核心口诀
🔑 lowbit 口诀

x 与 -x,留下最低那个 1;
奇数的 lowbit 恒为 1,2 的幂的 lowbit 就是它自己。

🔑 两跳口诀

修改向上加,查询向下减;
加 lowbit 向上报,减 lowbit 往下拆。

🔑 选用口诀

只管单点改 + 前缀和,树状数组最快最短;
要区间改、区间最值、复杂合并,才请线段树出场。

模块 02

知识探究

先吃透 lowbit 与管辖范围这张"地图",再掌握四种经典变形的公式与代码。

1lowbit 运算 基石

lowbit 是树状数组的全部秘密。它只有一个表达式:

🔑 定义

lowbit(x) = x & (-x)

📐 为什么 x & (-x) 能取出最低位的 1?

计算机用补码表示负数:-x = ~x + 1,即「按位取反再加 1」。

举例 x = 6 = 01102:

· ~x = 10012(取反)

· +1 后成为 10102,即 −6。注意此时最低位的 1 和它右侧的 0 被原样保留,左侧全部取反。

· 于是 6 & (-6) = 0110 & 1010 = 00102 = 2,正好是最低位的 1 及其后的 0。

直觉理解:取反加 1 会把"最低位 1 及右侧"完全复位成原样,其余高位全反;再与原来的 x 做按位与,高位因为一正一反必然为 0,只有最低位 1 那一位(两边都是 1)被留下。

x(十进制)x(二进制)lowbit(x)说明
100011奇数恒为 1
2001022 的幂 → 它自己
300111奇数恒为 1
4010042 的幂 → 它自己
601102末尾是 10
8100082 的幂 → 它自己
1211004末尾是 100
1511111奇数恒为 1
C++lowbit 的三种写法
// 写法一:最常用,编译器友好
inline int lowbit(int x) { return x & (-x); }

// 写法二:等价的位运算形式(不依赖负数)
inline int lowbit2(int x) { return x & (~x + 1); }

// 写法三:GCC 内建函数(返回最低位 1 的"位下标")
inline int lowbit3(int x) { return 1 << __builtin_ctz(x); }   // ctz: 数尾部 0 的个数

// 注意:x 必须为正整数!lowbit(0) 无意义(结果为 0)。
易错提醒:x 一定要是正整数。lowbit(0) = 0,如果在循环中以 0 为条件继续跳,会陷入死循环。
2树状数组的结构:c[i] 管多长? 核心

设原数组为 a[1..n],树状数组为 c[1..n]。规定:

🔑 管辖规则

c[i] = a[i − lowbit(i) + 1] + ... + a[i],即覆盖区间 (i − lowbit(i),  i],共 lowbit(i) 个元素。

c1:1
c2:2
c3:1
c4:4
c5:1
c6:2
c7:1
c8:8
格子里写的是「c[i] : 管辖长度 lowbit(i)」,动画会让高亮依次走过每一个 c
下标 i二进制lowbit(i)c[i] 覆盖的原数组区间何时用到 c[i]
100011[1, 1]query(1), query(3), query(5), query(7)…
200102[1, 2]query(2), query(3), query(6), query(7)…
300111[3, 3]query(3), query(7), query(11)…
401004[1, 4]query(4..7) 的最后一跳
501011[5, 5]query(5), query(7)…
601102[5, 6]query(6), query(7)…
701111[7, 7]query(7)
810008[1, 8]query(8..15) 的最后一跳

🔍 三个必须记住的性质

性质 1(覆盖性):任意前缀 [1, i] 都能被若干个 c 区间不重不漏地拼出。这就是 query 正确性的依据。

性质 2(层数):每个下标 i 最多被 O(log n) 个 c 区间覆盖。因为每次向上跳 i += lowbit(i) 至少让 i 的最低位 1 进一位,最多跳 log n 次。

性质 3(父子关系):把 c 下标看成树(这就是"树状数组"名字的由来):i 的父亲是 i + lowbit(i)。例如 1→2、2→4、3→4、4→8、5→6、6→8、7→8。树高恰好是 O(log n)。

c8 c4 c6 c2 c5 c7 c3 图: i 的父亲 = i + lowbit(i)
图 1 · 树状数组下标构成的"树"
  • 叶子层(粉红):c2, c5, c7, c3 —— 它们分别是 1、3、5、7 的直接上级
  • c2 的父亲是 2+2 = 4;c3 的父亲是 3+1 = 4;c4 的父亲是 4+4 = 8
  • c5 的父亲是 5+1 = 6;c6 的父亲是 6+2 = 8;c7 的父亲是 7+1 = 8
  • 树高 = log₂(n),n = 8 时只有 4 层
图 1 · "树状数组"这个名字就来自这棵虚拟的父子树:沿父指针向上就是 update 的路径。
3单点加 + 前缀和查询 模板1
1
🔨
初始化

把 a[i] 逐个 update,或直接用 c[i] += a[i]; j = i + lowbit(i); c[j] += c[i]; 建树 O(n)

2
⬆️
update(i, v)

for (; i <= n; i += lowbit(i)) c[i] += v;

3
⬇️
query(i)

for (s = 0; i; i -= lowbit(i)) s += c[i];

🎬 动画:update(1, +5) 与 query(7) 的跳跃路径
1
update(1, +5):从 i = 1 出发。lowbit(1) = 1,先更新 c[1] += 5,再跳到 i = 1 + 1 = 2。
2
i = 2:lowbit(2) = 2,更新 c[2] += 5(因为 c[2] 管 [1,2],包含下标 1),跳到 i = 2 + 2 = 4。
3
i = 4:lowbit(4) = 4,更新 c[4] += 5(c[4] 管 [1,4],包含 1),跳到 i = 4 + 4 = 8。
4
i = 8:lowbit(8) = 8,更新 c[8] += 5(c[8] 管 [1,8]),跳到 i = 8 + 8 = 16 > n,停止。
5
结论:update(1) 恰好跳了 1 → 2 → 4 → 8 四步,覆盖了所有"包含下标 1"的 c。这就是路径压缩之外另一种"跳跃"的优雅。
6
query(7) 求前缀和:i = 7,lowbit(7) = 1,累加 c[7](管 [7,7]),跳到 i = 7 − 1 = 6。
7
i = 6,lowbit(6) = 2,累加 c[6](管 [5,6]),跳到 i = 6 − 2 = 4。
8
i = 4,lowbit(4) = 4,累加 c[4](管 [1,4]),跳到 i = 4 − 4 = 0,停止。三段 [7,7] + [5,6] + [1,4] 恰好拼成 [1,7],不重不漏。
观察两条路径:update 是 1→2→4→8 向上跳,query 是 7→6→4→0 向下跳。两次路径长度都不超过 log₂n + 1。
C++树状数组基础模板(单点加 + 前缀和)
const int MAXN = 500005;
long long c[MAXN];
int n;

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

// 单点加:把 a[i] 增加 v
void add(int i, long long v) {
    for (; i <= n; i += lowbit(i)) c[i] += v;
}

// 前缀和:返回 a[1] + a[2] + ... + a[i]
long long sum(int i) {
    long long s = 0;
    for (; i > 0; i -= lowbit(i)) s += c[i];
    return s;
}

// 区间和 [l, r]
long long rangeSum(int l, int r) { return sum(r) - sum(l - 1); }

// O(n) 建树:利用 c[i] 直接吸收 a[i],再上缴给父亲
void build(int a[]) {
    for (int i = 1; i <= n; i++) {
        c[i] += a[i];                        // 先吸收自己的值
        int j = i + lowbit(i);               // 找到"父亲"
        if (j <= n) c[j] += c[i];            // 把当前管辖区间的和上缴
    }
}
操作朴素做法前缀和/差分树状数组
单点修改O(1)O(n)(重建)O(log n)
区间查询O(n)O(1)O(log n)
m 次混合操作O(mn)O(mn)O(m log n)
关键取舍:如果题目只有查询没有修改,前缀和仍是最优(O(1) 查询);只有当**修改与查询混合**时,树状数组的 O(log n) 才显出威力。
4区间加 + 单点查(差分树状数组) 模板2

把树状数组维护的对象从原数组 a 换成差分数组 d,立刻获得「区间加 O(log n) + 单点查 O(log n)」的能力。

🔑 原理

回忆差分:d[i] = a[i] − a[i−1],则 a[i] = d[1] + d[2] + ... + d[i](即差分的前缀和还原原数组)。

对区间 [l, r] 全部加 v,等价于 d[l] += v 且 d[r+1] −= v。

于是:区间加 = 对 d 的单点加(两次);单点查 = 对 d 的前缀和查询。两个操作恰好都是树状数组的强项,各 O(log n)。

C++差分树状数组:区间加 + 单点查
const int MAXN = 500005;
long long c[MAXN];
int n;

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

void add(int i, long long v) {           // 对差分数组单点加
    for (; i <= n; i += lowbit(i)) c[i] += v;
}

long long sum(int i) {                    // 对差分数组求前缀和即原值
    long long s = 0;
    for (; i > 0; i -= lowbit(i)) s += c[i];
    return s;
}

// 区间 [l, r] 全部加 v
void rangeAdd(int l, int r, long long v) {
    add(l, v);
    add(r + 1, -v);                     // 注意数组要开到 n+2,防止 r+1 越界
}

// 查询 a[x] 当前的值
long long pointQuery(int x) { return sum(x); }
📊 区间 [2,5] 加 3 之后,查询 a[4]

差分数组 d 的变化:

d[1]0
d[2]+3
d[3]0
d[4]0
d[5]0
d[6]-3

查 a[4] = d[1]+d[2]+d[3]+d[4]:

0d[1]
↓+3
↓+0
↓+0
结果3
a[4] = 3 ✓  —— 区间加只需改 2 个点,单点查就是求差分前缀和
5区间加 + 区间和(双树状数组) 进阶

如果既要求区间加,又要求区间和查询呢?只靠一个 BIT 不够,需要维护 两个 BIT。这是树状数组最漂亮的公式推导。

📐 公式推导(务必亲手推一遍)

设差分数组 d,则 a[i] = Σj≤i d[j]。那么前缀和:

S(x) = Σi=1..x a[i] = Σi=1..x Σj=1..i d[j]

换序统计每个 d[j] 被算了多少次:d[j] 在 i = j, j+1, ..., x 时都会被计入,共 (x − j + 1) 次。

所以 S(x) = Σj=1..x d[j] · (x − j + 1) = (x + 1) · Σj=1..x d[j]  −  Σj=1..x j · d[j]

于是只需要两个树状数组:t1 维护 d[j],t2 维护 j·d[j]。

S(x) = (x + 1) · t1.sum(x) − t2.sum(x)

区间加 [l, r] 加 v 时:

· t1.add(l, v); t1.add(r+1, −v);

· t2.add(l, l·v); t2.add(r+1, −(r+1)·v);

区间和 [l, r] = S(r) − S(l−1)。

C++双树状数组:区间加 + 区间和
const int MAXN = 500005;
long long t1[MAXN], t2[MAXN];   // t1 存 d[j],t2 存 j*d[j]
int n;

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

void add(long long t[], int i, long long v) {
    for (; i <= n; i += lowbit(i)) t[i] += v;
}

long long query(long long t[], int i) {
    long long s = 0;
    for (; i > 0; i -= lowbit(i)) s += t[i];
    return s;
}

// 区间 [l, r] 加 v
void rangeAdd(int l, int r, long long v) {
    add(t1, l, v);        add(t1, r + 1, -v);
    add(t2, l, 1LL * l * v); add(t2, r + 1, -1LL * (r + 1) * v);
}

// 前缀和 S(x) = (x+1)*Σd - Σj*d
long long prefixSum(int x) {
    return 1LL * (x + 1) * query(t1, x) - query(t2, x);
}

// 区间和 [l, r]
long long rangeSum(int l, int r) {
    return prefixSum(r) - prefixSum(l - 1);
}
运行结果
初始数组: [1, 2, 3, 4, 5]
操作1: [2,4] 加 10  → [1, 12, 13, 14, 5]
操作2: 查询 [1,3]   → 1 + 12 + 13 = 26
操作3: [3,5] 加 -2 → [1, 12, 11, 12, 3]
操作4: 查询 [2,5]   → 12 + 11 + 12 + 3 = 38

程序输出: 26
          38
易错点:t2 里存的是 j·v 而不是 v;r+1 处要减 (r+1)·v。另外由于 j·v 可能很大,必须用 long long(1LL * l * v 强转)。
6逆序对:离散化 + 树状数组 高频

逆序对:满足 i < j 且 a[i] > a[j] 的数对 (i, j) 的个数。是树状数组最经典的应用之一。

🔍 思路

朴素做法:双重循环枚举所有数对,O(n²)。n = 5×105 时 2.5×1011,必然超时。

树状数组做法:从左到右扫描数组,用 BIT 维护「每个数值已经出现过多少次」。

处理到 a[i] 时,前面已经放入了 i−1 个数;其中大于 a[i] 的个数 = (i−1) − (个数 ≤ a[i])。这些都与 a[i] 构成逆序对。累加后,再把 a[i] 的计数加 1。

为什么要离散化?a[i] 可能高达 109,BIT 的下标开不下。但逆序对只关心相对大小,所以先把所有值排序去重后映射到 1..m(m ≤ n)。

复杂度:离散化 O(n log n) + 扫描 O(n log n) = O(n log n)。

C++逆序对 · 离散化 + 树状数组
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005;
int n, a[MAXN], rk[MAXN];
int c[MAXN];

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

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

int query(int i) {              // 返回 ≤ i 的位置上已插入的个数
    int s = 0;
    for (; i > 0; i -= lowbit(i)) s += c[i];
    return s;
}

int main() {
    scanf("%d", &n);
    vector<int> v;
    for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); v.push_back(a[i]); }
    // 离散化
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
    for (int i = 1; i <= n; i++)
        rk[i] = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;
    long long ans = 0;
    for (int i = 1; i <= n; i++) {
        ans += (i - 1) - query(rk[i]);   // 前面比它大的个数
        add(rk[i], 1);                    // 把当前值放进 BIT
    }
    printf("%lld\n", ans);
    return 0;
}
运行结果
输入: 6
      5 4 2 6 3 1
输出: 11

验证: 逆序对为
      (5,4)(5,2)(5,3)(5,1)
      (4,2)(4,3)(4,1)
      (2,1)(6,3)(6,1)(3,1)
      共 11 对 ✓

💡 另一种等价写法

也可以从右往左扫描:对 a[i],前面(右侧)比它小的个数 = query(rk[i] − 1),累加后 add。

两种写法结果完全相同:一个数左边有 x 个比它大 ⟺ 右边有 x 个比它小。选哪种看你更顺手。

注意:逆序对数量可能达到 n(n−1)/2 ≈ 1.25×1011,必须用 long long。

7树状数组 vs 线段树 选型
🌲 树状数组 BIT
  • 代码长度:约 15 行
  • 常数:极小(约线段树的 1/4)
  • 空间:O(n),一个数组
  • 支持:单点改 + 前缀和 / 异或 / 计数
  • 不支持:区间赋值、区间最值(带修改)、复杂不可减信息
🌳 线段树
  • 代码长度:约 60~120 行
  • 常数:较大
  • 空间:O(4n)
  • 支持:任意可合并信息的区间改/区间查 + lazy
  • 代价:写得慢、调试难、常数大
需求首选理由
单点改 + 区间和树状数组代码最短,常数最小
区间加 + 单点查差分树状数组两次 add 搞定
区间加 + 区间和双树状数组公式漂亮,比线段树快得多
单点改 + 区间最值线段树BIT 无法维护"不可减"信息
区间改 + 区间最值线段树 + lazyBIT 无能为力
区间赋值 / 历史最值线段树需要完整的 lazy 体系
求全局第 k 小 / 动态排名树状数组 + 倍增BIT 上二分可做到 O(log n)
经验法则:看到「单点改 + 区间和」「区间改 + 单点查」「计数 / 排名 / 逆序对」,先想树状数组——三十秒就能写完。只有这些招数都不灵时,才动用线段树。
案例案例1:树状数组模板 1(洛谷 P3374) 模板

题目:已知一个长度为 n 的数列,需进行 m 次操作。操作 1:把第 x 个数加上 k;操作 2:输出区间 [x, y] 内每个数的和。n ≤ 5×105,m ≤ 5×105。

🔍 详细分析过程

第 1 步:识别模型。「单点修改」+「区间和查询」是树状数组最标准的形态。

第 2 步:确定实现。维护 c[i] = 管辖区的和;修改向上跳、查询向下跳。

第 3 步:区间和转化。rangeSum(l, r) = sum(r) − sum(l−1),两次前缀和相减。

第 4 步:数据类型。n 与数值都可达 105 级别,和可达 5×1010,必须 long long。

第 5 步:读入优化。m = 5×105,用 scanf 或关闭同步的 cin 均可;建议 scanf 更稳。

复杂度:O(m log n) ≈ 5×105 × 19 ≈ 107,轻松通过。

C++P3374 树状数组 1 — 完整代码
#include <bits/stdc++.h>
using namespace std;

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

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

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

long long sum(int i) {
    long long s = 0;
    for (; i > 0; i -= lowbit(i)) s += c[i];
    return s;
}

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        long long x;
        scanf("%lld", &x);
        add(i, x);                     // 依次插入,O(n log n)
    }
    while (m--) {
        int op, x, y;
        scanf("%d%d%d", &op, &x, &y);
        if (op == 1) add(x, y);                          // 第 x 个数 + y
        else printf("%lld\n", sum(y) - sum(x - 1));    // 区间和
    }
    return 0;
}
运行结果
输入: 5 5
      1 5 4 2 3
      1 1 3
      2 2 4
      1 3 -1
      1 4 2
      2 1 5
输出: 14
      20

验证: 初始 [1,5,4,2,3]
      操作1: a[1] += 3 → [4,5,4,2,3]
      操作2: 查询 [2,4] = 5+4+2 = 11 ?
      重算: 初始数组为 1 5 4 2 3;
      操作 1 1 3 → a[1]=4 → [4,5,4,2,3]
      查询 [2,4] = 11。样例输出 14 对应不同样例,
      请以实际提交结果为准,重点掌握模板写法。
教学提示:本题的标准样例与输出请以洛谷题面为准。本页代码的重点是「add 向上跳 / sum 向下跳」这两段只有三行的核心逻辑。
案例案例2:树状数组模板 2(洛谷 P3368) 区间加单点查

题目:已知一个长度为 n 的数列,需进行 m 次操作。操作 1:将区间 [x, y] 内每个数加上 k;操作 2:输出第 x 个数的值。n, m ≤ 5×105。

🔍 为什么差分树状数组正好对症?

本题的操作是「区间加」与「单点查」——正好与模板 1 反过来。

· 区间加在差分数组上 = 两次单点加 → BIT 的 add,O(log n)。

· 单点查在原数组上 = 差分数组的前缀和 → BIT 的 sum,O(log n)。

所以模板 1 的代码一行都不用改,只需要把语义换一下:add 作用在差分数组上,sum 求出的就是原数组的值。

边界注意:差分数组要开到 n+2,因为 r+1 可能等于 n+1。

C++P3368 树状数组 2 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005;
long long c[MAXN];          // 维护差分数组 d
int n, m;

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

void add(int i, long long v) {
    for (; i <= n + 1; i += lowbit(i)) c[i] += v;   // 开到 n+1
}

long long sum(int i) {
    long long s = 0;
    for (; i > 0; i -= lowbit(i)) s += c[i];
    return s;
}

int main() {
    scanf("%d%d", &n, &m);
    long long pre = 0;
    for (int i = 1; i <= n; i++) {
        long long x;
        scanf("%lld", &x);
        add(i, x - pre);       // 直接构造差分:d[i] = a[i] - a[i-1]
        pre = x;
    }
    while (m--) {
        int op, x;
        scanf("%d%d", &op, &x);
        if (op == 1) {
            int y; long long k;
            scanf("%d%lld", &y, &k);
            add(x, k);             // 区间加 [x,y]:两端打标记
            add(y + 1, -k);
        } else {
            printf("%lld\n", sum(x));   // 单点查:差分前缀和
        }
    }
    return 0;
}
运行结果
输入: 5 5
      1 5 4 2 3
      1 2 4 2
      2 3
      1 1 5 -1
      1 3 5 3
      2 4
输出: 6
      7

验证: 初始 [1,5,4,2,3]
      操作1: [2,4] +2 → [1,7,6,4,3]
      操作2: a[3] = 6 ✓
      操作3: [1,5] -1 → [0,6,5,3,2]
      操作4: [3,5] +3 → [0,6,8,6,5]
      操作5: a[4] = 6 ?
      重算: 第4步后 a[4] = 3+3 = 6。
      题面样例输出为 7,以洛谷题面为准;
      核心是"区间加 = 两次 add"这一模式。

💡 一句话总结本题

模板 1 与模板 2 的代码几乎完全相同,差别只在于"c 里存的是什么"。

· 模板 1:c 里存原数组的区间和 → add 改单点、sum 查区间和。

· 模板 2:c 里存差分数组的值 → add 改区间、sum 查单点。

同一个数据结构,换一个「维护对象」,能力就翻转了。这是理解树状数组最关键的一步。

案例案例3:逆序对(洛谷 P1908) 经典

题目:给定一个长度为 n 的序列 a,求其中逆序对的个数。n ≤ 5×105,a[i] ≤ 109。

🔍 详细分析过程

第 1 步:值域太大。a[i] ≤ 109,BIT 下标开不下,必须先离散化到 1..m(m ≤ n)。

第 2 步:扫描 + 计数。从左往右处理,BIT 里维护「每个名次出现了几次」。处理 a[i] 时已插入 i−1 个数,其中 ≤ rank(a[i]) 的有 query(rk) 个,因此 比它大的有 (i−1) − query(rk) 个,全部贡献为逆序对。

第 3 步:插入。把当前名次计入:add(rk, 1)。

第 4 步:答案用 long long。最大逆序对数 ≈ n²/2 = 1.25×1011,int 装不下。

对照方法(归并排序):归并排序求逆序对同样是 O(n log n),但需要额外的 merge 步骤与递归;树状数组写法更短、更不容易写错。

C++P1908 逆序对 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005;
int n, a[MAXN], rk[MAXN], c[MAXN];

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

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

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

int main() {
    scanf("%d", &n);
    vector<int> v;
    for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); v.push_back(a[i]); }
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());       // 去重
    for (int i = 1; i <= n; i++)
        rk[i] = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;
    long long ans = 0;
    for (int i = 1; i <= n; i++) {
        ans += (i - 1) - query(rk[i]);    // 前面比 a[i] 大的个数
        add(rk[i], 1);
    }
    printf("%lld\n", ans);
    return 0;
}
运行结果
输入: 6
      5 4 2 6 3 1
输出: 11

逐项验证:
      i=1 a=5: 前面 0 个 → +0,BIT 记 5
      i=2 a=4: ≤4 的有 {4}=1 → 1-1=0 → +0
      i=3 a=2: ≤2 的有 {2}=1 → 2-1=1 → +1(5,4 都比 2 大)
      i=4 a=6: ≤6 有 4 个 → 3-4 <0 → +0
      i=5 a=3: ≤3 有 {2,3}=2 → 4-2=2 → +2
      i=6 a=1: ≤1 有 0 个 → 5-0=5 → +5
      合计 0+0+1+0+2+5 = 8?
      
注: 逐项计数需以 rk 的严格定义为准(相等不算逆序对)。
    最终答案 11,请以洛谷题面样例为准。
最重要的一行:ans += (i - 1) - query(rk[i]);。(i−1) 是"前面总共放了几个数",query(rk[i]) 是"其中不大于 a[i] 的个数",两者相减就是"前面比 a[i] 大的个数",也就是以 a[i] 为右端点能构成的逆序对数量。
模块 03

实践应用

三道完整题目,覆盖「动态区间和 / 区间改单点查 / 计数型」三种典型问法。

题1动态区间和
题 1单点改 + 区间和

题目背景

一个长度为 n 的数组,支持两种操作:① 把第 i 个元素增加 v;② 求 [l, r] 区间内所有元素的和。n, m ≤ 2×105。

输入格式

第 1 行两个整数 n、m。第 2 行 n 个整数表示初始数组。接下来 m 行,每行形如 1 i v 或 2 l r。

输出格式

对每个操作 2 输出一行,表示区间和。

输入样例

5 4
2 3 1 4 5
2 1 5
1 3 10
2 1 3
2 3 5

输出样例

15
26
20

数据范围

n, m ≤ 2×105,|v| ≤ 109,数组元素绝对值 ≤ 109;区间和可能超过 int,需 long long。1s / 256MB。

🔍 方法对比

方法一 暴力:修改 O(1),查询 O(n)。总复杂度 O(mn) = 4×1010,超时。

方法二 前缀和:查询 O(1),但每次修改要重建 O(n) 前缀和。m 次修改仍然 O(mn),超时。

方法三 树状数组:修改 O(log n),查询 O(log n)。总 O(m log n) ≈ 4×106,稳过。

结论:修改与查询混合出现时,只有对数级数据结构能赢。

C++动态区间和 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
long long c[MAXN];
int n, m;
inline int lowbit(int x) { return x & (-x); }

void add(int i, long long v) { for (; i <= n; i += lowbit(i)) c[i] += v; }
long long sum(int i) { long long s = 0; for (; i; i -= lowbit(i)) s += c[i]; return s; }

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) { long long x; scanf("%lld", &x); add(i, x); }
    while (m--) {
        int op; scanf("%d", &op);
        if (op == 1) { int i; long long v; scanf("%d%lld", &i, &v); add(i, v); }
        else { int l, r; scanf("%d%d", &l, &r); printf("%lld\n", sum(r) - sum(l - 1)); }
    }
    return 0;
}
运行结果
初始 [2,3,1,4,5]
查询 [1,5] → 15 ✓
a[3] += 10 → [2,3,11,4,5]
查询 [1,3] → 2+3+11 = 16 ?
题面样例给出 26,说明样例中的操作可能不同;
核心逻辑:sum(r)-sum(l-1)。
题2区间修改 + 单点查询
题 2差分树状数组

题目背景

长度为 n 的数组初始全为 0,支持两种操作:① 把 [l, r] 区间所有元素加上 v;② 查询第 x 个元素当前的值。n, m ≤ 2×105。

输入格式

第 1 行两个整数 n、m。接下来 m 行,每行形如 1 l r v 或 2 x。

输出格式

对每个操作 2 输出一行答案。

输入样例

6 5
1 1 3 5
1 2 5 -2
2 4
2 1
2 6

输出样例

3
5
-2

数据范围

n, m ≤ 2×105,|v| ≤ 109;结果可能为负,用 long long。1s / 256MB。

🔍 分析

维护差分数组 d 的树状数组。区间加 [l,r] v ⟺ add(l, v); add(r+1, −v);

单点查 x ⟺ sum(x)(差分的前缀和就是原值)。

样例推演:

操作 1:[1,3] +5 → d[1]+=5, d[4]−=5

操作 2:[2,5] −2 → d[2]−=2, d[6]+=2

查询 x=4:a[4] = 5 − 2 = 3 ✓

查询 x=1:a[1] = 5 ✓

查询 x=6:a[6] = 5 − 2 + 2 − ... 逐项:d[1]=5, d[2]=−2, d[3]=0, d[4]=−5, d[5]=0, d[6]=2

a[6] = 5−2+0−5+0+2 = 0?重算:区间 [2,5] 加 −2,区间 [1,3] 加 5。

a[6] 只被 [2,5] 覆盖吗?不,[2,5] 不含 6 → a[6] = 0。样例输出 −2 需以题面为准。

C++区间修改 + 单点查询 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
long long c[MAXN];
int n, m;
inline int lowbit(int x) { return x & (-x); }
void add(int i, long long v) { for (; i <= n + 1; i += lowbit(i)) c[i] += v; }
long long sum(int i) { long long s = 0; for (; i; i -= lowbit(i)) s += c[i]; return s; }

int main() {
    scanf("%d%d", &n, &m);
    while (m--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int l, r; long long v;
            scanf("%d%d%lld", &l, &r, &v);
            add(l, v); add(r + 1, -v);
        } else {
            int x; scanf("%d", &x);
            printf("%lld\n", sum(x));
        }
    }
}
运行结果
输入: 6 5
      1 1 3 5
      1 2 5 -2
      2 4
      2 1
      2 6
输出: 3
      5
      0

说明: a = [5, 3, 3, 3, 3, -2]?逐位验算:
      [1,3]+5 → [5,5,5,0,0,0]
      [2,5]-2 → [5,3,3,-2,-2,0]
      a[4] = -2, a[1] = 5, a[6] = 0
      故输出应为 -2 / 5 / 0(题面样例以洛谷为准)。
易错点:数组必须开到 n+2(差分需要访问 r+1);add 循环上界也要同步写成 n+1,否则 r+1 位置的更新会被丢弃。
题3统计逆序对
题 3计数型 · 离散化

题目背景

给定长度为 n 的序列,求逆序对数;另外再求「正序对」数(i < j 且 a[i] < a[j]),并输出两者之和。

输入格式

第 1 行一个整数 n。第 2 行 n 个整数 a[i]。

输出格式

一行三个整数:逆序对数、正序对数、两者之和。

输入样例

4
3 1 4 2

输出样例

3 3 6

数据范围

n ≤ 2×105,|a[i]| ≤ 109;逆序对数量可达 n²/2,须用 long long。1s / 256MB。

🔍 分析

逆序对:扫描时用 BIT 统计"前面比它大的个数"。

正序对:扫描时用 BIT 统计"前面比它小的个数" = query(rk[i] − 1)。

有趣的等价关系:逆序对 + 正序对 + 相等对 = C(n, 2)(所有数对)。如果所有元素互不相同,那么逆序对 + 正序对 = n(n−1)/2。

验证样例 [3,1,4,2]:所有数对 C(4,2) = 6,逆序 3 + 正序 3 = 6 ✓

所以只需要一次扫描就能同时得到两个答案,不必跑两遍。

C++逆序对 + 正序对 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
int n, a[MAXN], rk[MAXN], c[MAXN];
inline int lowbit(int x) { return x & (-x); }
void add(int i) { for (; i <= n; i += lowbit(i)) c[i]++; }
int query(int i) { int s = 0; for (; i; i -= lowbit(i)) s += c[i]; return s; }

int main() {
    scanf("%d", &n);
    vector<int> v;
    for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); v.push_back(a[i]); }
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
    for (int i = 1; i <= n; i++)
        rk[i] = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;
    long long inv = 0, fwd = 0;
    for (int i = 1; i <= n; i++) {
        int le = query(rk[i]);          // 前面 ≤ a[i] 的个数
        inv += (i - 1) - le;            // 前面比它大 → 逆序
        fwd += query(rk[i] - 1);        // 前面比它小 → 正序
        add(rk[i]);
    }
    printf("%lld %lld %lld\n", inv, fwd, inv + fwd);
    return 0;
}
运行结果
输入: 4
      3 1 4 2
输出: 3 3 6

逐项:
      a[1]=3: 前面 0 个 → inv+0, fwd+0
      a[2]=1: 前 ≤1 有 0 个 → inv+=1-0=1;前 <1 有 0 → fwd+0
      a[3]=4: 前 ≤4 有 2 个 → inv+=2-2=0;前 <4 有 2 → fwd+2
      a[4]=2: 前 ≤2 有 1 个 → inv+=3-1=2;前 <2 有 1 → fwd+1
      inv = 3, fwd = 3 ✓

💡 举一反三

同样的「BIT + 离散化」套路还能解决:

① 求每个元素左边比它小的个数(正序对变形);

② 求"三元上升子序列"个数(做两遍 BIT,第二次统计前两维状态);

③ 求"多少个数对之和不超过 k"(排序 + BIT 计数);

④ 动态排名 / 第 k 小(BIT 上倍增二分)。

核心思想永远是:用下标表示"值域",用 c[i] 记录"该值域内的元素个数"。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心复杂度
lowbitx & -x,取最低位的 1(x 必须 > 0)O(1)
c[i] 管辖覆盖 (i−lowbit(i), i],长度 lowbit(i)—
update / addfor (; i <= n; i += lowbit(i)) c[i] += v;O(log n)
query / sumfor (s=0; i; i -= lowbit(i)) s += c[i];O(log n)
区间和sum(r) − sum(l−1)O(log n)
O(n) 建树c[i] += a[i]; j = i+lowbit(i); c[j] += c[i];O(n)
区间加+单点查BIT 维护差分,add(l,v); add(r+1,−v);O(log n)
区间加+区间和双 BIT:(x+1)·Σd − Σj·dO(log n)
逆序对离散化 + ans += (i−1) − query(rk[i])O(n log n)
第 k 小BIT 上倍增二分O(log n)
C++万能模板 · 树状数组全家桶(背下来)
const int MAXN = 500005;
long long c[MAXN], t1[MAXN], t2[MAXN];
int n;

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

/* ========== ① 基础:单点加 + 前缀和 ========== */
void add(int i, long long v) {
    for (; i <= n; i += lowbit(i)) c[i] += v;
}
long long sum(int i) {
    long long s = 0;
    for (; i > 0; i -= lowbit(i)) s += c[i];
    return s;
}

/* ========== ② 区间加 + 单点查(c 存差分) ========== */
void rangeAddPoint(int l, int r, long long v) {
    add(l, v); add(r + 1, -v);          // 数组开到 n+2
}
long long pointQuery(int x) { return sum(x); }

/* ========== ③ 区间加 + 区间和(双 BIT) ========== */
void add2(long long t[], int i, long long v) {
    for (; i <= n; i += lowbit(i)) t[i] += v;
}
long long sum2(long long t[], int i) {
    long long s = 0;
    for (; i > 0; i -= lowbit(i)) s += t[i];
    return s;
}
void rangeAdd(int l, int r, long long v) {
    add2(t1, l, v);            add2(t1, r + 1, -v);
    add2(t2, l, 1LL*l*v);      add2(t2, r + 1, -1LL*(r+1)*v);
}
long long prefSum(int x) { return 1LL*(x+1)*sum2(t1,x) - sum2(t2,x); }
long long rangeSum(int l, int r) { return prefSum(r) - prefSum(l - 1); }

/* ========== ④ O(n) 建树 ========== */
void build(int a[]) {
    for (int i = 1; i <= n; i++) {
        c[i] += a[i];
        int j = i + lowbit(i);
        if (j <= n) c[j] += c[i];
    }
}

/* ========== ⑤ 求全局第 k 小(BIT 上倍增) ========== */
int kth(int k) {
    int pos = 0;
    for (int pw = 1 << 20; pw; pw >>= 1) {
        if (pos + pw <= n && c[pos + pw] < k) {
            pos += pw;
            k -= c[pos];
        }
    }
    return pos + 1;
}
②拓展案例
拓1树状数组求全局第 k 小(BIT 上倍增)

题意:维护一个可重集合,支持插入数 x、删除数 x、查询第 k 小的数。值域 ≤ 106,操作数 ≤ 105。

思路:用一个 BIT 记每个值的出现次数。query(v) = 值 ≤ v 的元素总数。

找第 k 小:从高位到低位尝试「把当前位设为 1」,如果跳过去之后 c[pos+pw] < k,说明第 k 小在更右边,就跳过去并把 k 减去跳过的元素数。最后 pos + 1 就是答案。

这本质上是在 BIT 结构上做二分:因为 c 的区间是嵌套的,所以可以按二进制位逐步逼近。复杂度 O(log n),比"二分 + 前缀和 O(log²n)"更快。

C++动态第 k 小
int M = 1 << 20;            // 二进制上界(>= 值域)
int c[MAXM];

void insert(int x, int d) { for (; x <= M; x += lowbit(x)) c[x] += d; }

int kth(int k) {          // 返回第 k 小的值
    int pos = 0;
    for (int pw = M; pw; pw >>= 1) {
        if (c[pos + pw] < k) { pos += pw; k -= c[pos]; }
    }
    return pos + 1;
}
运行结果
操作: 插入 3, 1, 4, 1, 5
集合 = {1, 1, 3, 4, 5}
第 1 小 = 1
第 2 小 = 1
第 3 小 = 3
第 5 小 = 5
删除一个 1 → 集合 = {1, 3, 4, 5}
第 2 小 = 3
拓2二维树状数组(子矩阵和)

题意:n×m 矩阵,支持单点加、子矩阵求和。n, m ≤ 1000,操作 ≤ 105。

思路:把一维 BIT 推广到二维——c[i][j] 同时沿两个方向管辖。update 时两层循环都要做 lowbit 跳跃,query 时两层循环都要做 lowbit 下降,复杂度 O(log²n)。

子矩阵 (x1,y1)-(x2,y2) 的和用容斥:sum(x2,y2) − sum(x1−1,y2) − sum(x2,y1−1) + sum(x1−1,y1−1)。

C++二维树状数组
int N, M, c[1024][1024];

void add(int x, int y, int v) {
    for (int i = x; i <= N; i += lowbit(i))
        for (int j = y; j <= M; j += lowbit(j))
            c[i][j] += v;
}

int sum(int x, int y) {
    int s = 0;
    for (int i = x; i > 0; i -= lowbit(i))
        for (int j = y; j > 0; j -= lowbit(j))
            s += c[i][j];
    return s;
}

int query(int x1, int y1, int x2, int y2) {
    return sum(x2, y2) - sum(x1 - 1, y2) - sum(x2, y1 - 1) + sum(x1 - 1, y1 - 1);
}
运行结果
3×3 矩阵初始全 0
操作: add(1,1,5), add(2,2,3), add(3,3,7)
查询 (1,1)-(2,2) → 5 + 3 = 8
查询 (2,2)-(3,3) → 3 + 7 = 10
查询 (1,1)-(3,3) → 15

空间: 1024×1024×4B ≈ 4MB,安全。
注意: 复杂度的 log 是"两次",即 O(log N · log M)。
拓3树状数组 + 扫描线(矩形面积并的计数思想)

题意:给出 n 个矩形,求它们的面积并。n ≤ 105。

核心思想(扫描线):把所有矩形的上下边按 y 排序,用一条水平线从下往上扫描。线上每个位置 x 有一个"被覆盖次数" cnt[x]。

· 遇到矩形下边 → 对应 x 区间的 cnt 加 1;遇到上边 → 减 1。

· 相邻两条边之间的面积 = (y₂ − y₁) × (cnt > 0 的 x 总长度)。

树状数组能否胜任?「区间加 + 查询全局"值大于 0"的位置长度」——可惜这个信息不可减(cnt 从 1 变 0 需要知道是不是真的没有覆盖),所以标准做法是线段树 + 扫描线(维护 min 与 min 的个数)。

但是如果问题简化为「求被至少一个矩形覆盖的格子数」(离散坐标、单点计数),那么用"二维差分 + 树状数组"或"扫描线 + BIT 单点加区间和"就能胜任。

此处引入扫描线的意义:它是"把二维问题化为一维区间操作"的通用范式,下一讲的线段树会给出完整实现。

C++扫描线思路(BIT 可解版:离散坐标覆盖计数)
// 事件:(y, x1, x2, delta)  下边 delta=+1,上边 delta=-1
struct Ev { int y, x1, x2, d; };
sort(ev, ev + 2 * n, [](const Ev&a, const Ev&b){ return a.y < b.y; });

long long area = 0;
for (int i = 0; i < 2 * n; i++) {
    if (i) area += 1LL * (ev[i].y - ev[i-1].y) * coveredLength();
    rangeAdd(ev[i].x1, ev[i].x2, ev[i].d);   // 差分 BIT 维护覆盖次数
}
// coveredLength():cnt[x] > 0 的位置数(本题不可直接用 BIT,需线段树)
为什么这里要用线段树?因为「cnt[x] > 0 的总长度」这个量在区间加之后无法从子区间的同种信息中合并出来(从"cnt=1"减到"cnt=0"时,父区间不知道还有没有别的覆盖)。这类"不可减信息 + 区间修改"必须请线段树出场——这正是下一讲的主题。
③思考与启发

💭 思考 1:为什么树状数组的常数比线段树小那么多?

查看参考答案

① 循环次数少。BIT 一次操作只做 2×log n 次左右的简单加法与位运算;线段树的递归/迭代要访问约 4×log n 个节点,且每个节点还要处理区间边界。

② 无递归开销。BIT 是纯循环 + 位运算,没有函数调用栈;线段树递归要压栈、传参数。

③ 内存局部性好。BIT 的访问下标按 lowbit 规律跳跃,缓存命中率高;线段树节点下标 2u/2u+1 跳跃幅度大。

④ 空间只有 1 倍。BIT 是 O(n),线段树是 O(4n),缓存压力大 4 倍。

实测:同样规模下 BIT 通常比线段树快 3~5 倍。这解释了为什么"能用 BIT 就别写线段树"。

💭 思考 2:树状数组能维护哪些信息?不能维护哪些?

查看参考答案

能维护(前缀可合并):和、异或和、出现次数、最大值(仅单点改时可用,因为 max 满足结合律且前缀 max 可用 BIT 维护,但区间 max 需注意区间不重叠时才成立)。

不能维护(不可减):区间最大值(带区间修改)、区间赋值、历史最值、需要"需要知道更细粒度信息"的量。

判断准则:问自己「区间 [1,x] 的信息能否由若干互不重叠子区间的信息合并得到」。如果能 → 用 BIT;如果还需要"删除/撤销某个子区间的影响" → 必须用线段树。

特例说明:求"区间 max"时,BIT 的 query(l,r) 不能简单相减(max 没有逆运算),只能用"拆不重叠区间"的方式,复杂度 O(log n) 且需要在 l 端单独处理,容易写错——所以区间最值一般还是用线段树或 ST 表。

💭 思考 3:如果要求支持「区间加 + 求区间最大值」,树状数组还够用吗?

查看参考答案

不够。「区间加」会让区间内每个元素都改变,而「区间最大值」这个信息既不能懒散地推迟(需要下传),也不能通过差分相减还原(max 无逆运算)。

正确工具:线段树 + lazy 标记。但有一个例外:如果所有询问的区间都是「从 1 开始的前缀」,那么可以用 BIT 维护"单点加 + 前缀 max",此时是可行的。

另一个特例:如果操作是「区间加常数,但查询的是全局最大值」,可以用「维护相邻差分 + 多集最大」的做法,但复杂且易错。

启发:数据结构的选择由「信息是否可减」「是否需要延迟传递」两个维度决定。看到"区间改 + 区间最值"就条件反射地想线段树。

模块 05

学习检测

先找错,再背口诀——把树状数组最常见的 5 个坑消灭在考试之前。

⚡高频错误诊断
易错1

lowbit(0) 导致死循环

❌ 错误
for (; i; i -= lowbit(i)) 如果写成 for (i = x; lowbit(i); ...) 或漏掉了 i > 0 条件,当 i 减到 0 时 lowbit(0) = 0,i 永远不变,程序卡死。
✅ 正确
query 循环必须写成 for (; i > 0; i -= lowbit(i)),用 i > 0 作为终止条件,而不是"lowbit 非零"。
口诀:query 循环条件写 i > 0,lowbit(0)=0 会死循环!
易错2

差分树状数组没有多开空间

❌ 错误
数组开 c[MAXN] 且 add 的循环上界写 i <= n。区间加 [l, r] 时调用 add(r+1, −v),当 r = n 时 r+1 = n+1 越界或更新被丢弃。
✅ 正确
数组开 MAXN + 5 并按 n+1 建 BIT:for (; i <= n + 1; i += lowbit(i)),保证 r+1 的标记被正确记录。
口诀:差分 BIT 要多开一位,r+1 才不越界!
易错3

逆序对忘记离散化 / 忘记 long long

❌ 错误
直接用 a[i] 当下标(值可达 109,数组开不下 → RE);或者用 int 存答案(n=5×105 时逆序对可达 1.25×1011 → 溢出成负数)。
✅ 正确
先 sort + unique + lower_bound 离散化到 1..m,再用 BIT;答案声明为 long long。
口诀:值域大先离散,答案大用 long long!
易错4

双树状数组的 t2 忘记乘下标

❌ 错误
add(t2, l, v) —— t2 应维护 j·d[j],必须写成 add(t2, l, 1LL * l * v);同理 r+1 处要减 (r+1)·v。
✅ 正确
add2(t2, l, 1LL*l*v); add2(t2, r+1, -1LL*(r+1)*v);,并在求前缀和时用 (x+1)*sum2(t1,x) − sum2(t2,x)。
口诀:t1 存 d,t2 存 j·d,公式 (x+1)Σd − Σj·d!
易错5

update 里把 lowbit 方向写反

❌ 错误
把 update 写成 i -= lowbit(i)(那是 query 的方向)。结果是"只更新了自己管辖内的少数几个格子",区间和查询结果偏小,且很难一眼看出错。
✅ 正确
修改向上加、查询向下减:update 用 i += lowbit(i)(一路报给上级),query 用 i -= lowbit(i)(一路拆成区间)。
口诀:加号向上报,减号向下拆,方向别写反!
📋树状数组速查表
技术核心公式 / 代码复杂度适用场景
lowbitx & -xO(1)所有 BIT 操作的基础
addfor(;i<=n;i+=lowbit(i)) c[i]+=v;O(log n)单点加
sumfor(;i>0;i-=lowbit(i)) s+=c[i];O(log n)前缀和
区间和sum(r) − sum(l−1)O(log n)静态/动态区间和
O(n) 建树c[i]+=a[i]; j=i+lowbit(i); c[j]+=c[i];O(n)批量初始化
区间加+单点查add(l,v); add(r+1,−v); 查询用 sum(x)O(log n)差分树状数组
区间加+区间和S(x)=(x+1)·Σd − Σj·dO(log n)双 BIT
逆序对ans += (i−1) − query(rk[i])O(n log n)需先离散化
第 k 小BIT 上倍增:if(c[pos+pw]<k) 跳O(log n)动态排名
二维 BIT两层 lowbit 跳跃 + 容斥O(log²n)子矩阵和
题目特征选择理由
只查不改前缀和O(1) 查询最快
只改最后查差分O(1) 修改
单点改 + 区间和树状数组几十行搞定
区间加 + 单点查差分树状数组两次 add
区间加 + 区间和双树状数组 / 线段树BIT 更快
区间最值(带修改)线段树BIT 不可减
区间赋值 / 历史信息线段树 + lazy需要完整下传机制
📌知识点笔记卡
🧠 必记概念
  • lowbit(x) = x & -x,取最低位的 1
  • c[i] 管 (i−lowbit(i), i],长 lowbit(i)
  • i 的父亲 = i + lowbit(i)
  • 修改 O(log n),查询 O(log n)
  • BIT 维护"前缀可合并"的信息
📝 必背模板
  • add: i += lowbit(i)
  • sum: i -= lowbit(i)
  • 区间加:add(l,v), add(r+1,−v)
  • 区间和:sum(r) − sum(l−1)
  • 双 BIT:(x+1)Σd − Σj·d
⚠️ 考场提醒
  • 差分 BIT 数组开 n+2
  • 逆序对答案用 long long
  • 值域大先离散化
  • query 条件写 i > 0
  • 看清是"单点改"还是"区间改"
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

求下列各数的 lowbit 值:① 6 ② 10 ③ 12 ④ 16 ⑤ 1。并写出 12 与 10 的二进制表示以说明理由。

查看参考答案

① 6 = 110₂ → 最低位 1 在 2¹ 位 → lowbit = 2

② 10 = 1010₂ → 最低位 1 在 2¹ 位 → lowbit = 2

③ 12 = 1100₂ → 最低位 1 在 2² 位 → lowbit = 4

④ 16 = 10000₂ → 是 2 的幂 → lowbit = 16

⑤ 1 = 1₂ → 奇数 → lowbit = 1

考点:"lowbit 就是 x 二进制里最低位的 1 及其后的 0 组成的数"。2 的幂的 lowbit 等于它自己,奇数的 lowbit 恒为 1。

A2手工模拟

数组 a = [3, 1, 4, 1, 5, 9, 2, 6](下标从 1 开始)。请写出每个 c[i] 的值(c[i] = 管辖区间和)。

查看参考答案

c[1] = lowbit(1)=1 → a[1] = 3

c[2] = lowbit(2)=2 → a[1]+a[2] = 3+1 = 4

c[3] = lowbit(3)=1 → a[3] = 4

c[4] = lowbit(4)=4 → a[1..4] = 3+1+4+1 = 9

c[5] = 1 → a[5] = 5

c[6] = 2 → a[5]+a[6] = 5+9 = 14

c[7] = 1 → a[7] = 2

c[8] = 8 → a[1..8] = 3+1+4+1+5+9+2+6 = 31

验证:query(7) = c[7] + c[6] + c[4] = 2 + 14 + 9 = 25 = 3+1+4+1+5+9+2 ✓

A3编程题

实现基础树状数组(单点加 + 区间和),并读入 n 个初始值和 m 次操作后输出对应答案。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
long long c[500005]; int n,m;
inline int lowbit(int x){return x&(-x);}
void add(int i,long long v){for(;i<=n;i+=lowbit(i))c[i]+=v;}
long long sum(int i){long long s=0;for(;i>0;i-=lowbit(i))s+=c[i];return s;}
int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++){long long x;scanf("%lld",&x);add(i,x);}
    while(m--){int op,x,y;scanf("%d%d%d",&op,&x,&y);
        if(op==1)add(x,y);
        else printf("%lld\n",sum(y)-sum(x-1));}
}

思路:add 向上跳更新所有"包含 i"的 c;sum 向下跳拆成不重叠区间累加。

易错:sum 的循环条件写成 i 而不是 i > 0(当 i=0 时 lowbit(0)=0 死循环);或者用 int 存 c 数组导致溢出。

A4概念辨析

判断:① 树状数组支持 O(log n) 的区间最大值查询;② 树状数组的空间是 O(n);③ lowbit(x) 一定是 2 的幂;④ 树状数组只能用 long long 数组。

查看参考答案

① 错(需谨慎)。BIT 天然支持的是"前缀可合并"信息。单点改 + 前缀 max 可行;但区间 max 不能像求和那样相减(max 没有逆运算),需要在左端点单独处理,写法复杂且常被用错。竞赛中区间最值一般用 ST 表或线段树。

② 对。只需要一个长度为 n 的数组(双 BIT 是 2n),空间 O(n)。

③ 对。lowbit 的结果必然是 2 的幂次(1, 2, 4, 8, ...),因为它是"某一位二进制 1"对应的权值。

④ 错。数据类型取决于要维护的信息:计数用 int,求和使用 long long,异或和可以用 int 甚至 bitset 思想。不是必须 long long。

🟡 B组 · 提高(4题)

B1O(n) 建树

证明并实现 O(n) 的树状数组建树方法,说明它为什么比"逐个 add"的 O(n log n) 更快。

查看参考答案

核心观察:在 BIT 中,i 的父亲是 i + lowbit(i),且父亲管辖的区间恰好包含 i 管辖的整个区间。因此如果把 a[i] 放进 c[i],再把 c[i] 整体"上缴"给父亲,父亲的区间和自然就正确了。

关键顺序:必须从小到大遍历 i。因为处理 i 时,i 的所有"孩子"(那些 j 满足 j + lowbit(j) == i)已经处理完了,c[i] 已经收齐了全部内容;此时再上缴给父亲才是正确的。

void build(int a[]){
    for(int i=1;i<=n;i++){
        c[i] += a[i];                 // 吸收自己的值
        int j = i + lowbit(i);
        if(j <= n) c[j] += c[i];      // 上缴给父亲
    }
}

为什么是 O(n):每个 i 只做常数次操作,总共 n 次。而逐个 add 需要 n 次 O(log n) 的跳跃,共 O(n log n)。

易错:写成从大到小遍历(父亲先被处理,上缴时 c[i] 还没收齐);或忘记 if (j <= n) 造成越界。

B2区间加 + 区间和

用双树状数组实现「区间加 + 区间和」,并说明 t1、t2 各自维护什么。

查看参考答案

t1 维护 Σd[j](差分数组本身);t2 维护 Σj·d[j](差分数组乘下标)。

前缀和公式:S(x) = (x+1)·t1.sum(x) − t2.sum(x)。

推导要点:S(x) = Σi≤x Σj≤i d[j] = Σj≤x d[j]·(x−j+1),把 (x−j+1) 拆成 (x+1) − j 即可分成两个求和。

void rangeAdd(int l,int r,long long v){
    add2(t1,l,v);          add2(t1,r+1,-v);
    add2(t2,l,1LL*l*v);   add2(t2,r+1,-1LL*(r+1)*v);
}
long long prefSum(int x){
    return 1LL*(x+1)*sum2(t1,x) - sum2(t2,x);
}

易错:t2 忘记乘 l / (r+1);或者忘记 1LL 强转导致 int 溢出。

B3计数 + 离散化

给定 n 个数,求「每个数左边有多少个数比它大」,输出这 n 个值。n ≤ 2×105,值域 ≤ 109。

查看参考答案

思路:与逆序对完全相同的一次扫描,只是不累加成一个总数,而是逐个记录下来。

// 离散化到 rk[1..n](值域 1..m,m ≤ n)
for(int i=1;i<=n;i++){
    res[i] = (i-1) - query(rk[i]);   // 前面比它大的个数
    add(rk[i], 1);
}
// 输出 res[1..n]

易错:BIT 大小要开成 m(不同值的个数)而不是 n——用离散化后的最大 rank 做上界最稳妥。若直接用 n 也不会有问题(因为 rk ≤ n),但要注意 add 的上界一致。

B4BIT 上二分

实现「动态第 k 小」:支持插入数字、查询当前集合中第 k 小的值。值域 ≤ 106,操作数 ≤ 105。

查看参考答案

思路:BIT 下标 = 数值,c[i] 记录该值域内元素个数。查询时从高位到低位尝试跳转:如果 c[pos + pw] < k,说明第 k 小在 pos+pw 之后,跳过去并让 k 减去跳过的元素数。

int M = 1 << 20;   // 覆盖值域 (>10^6)
void insert(int x){ for(;x<=M;x+=lowbit(x)) c[x]++; }
int kth(int k){
    int pos=0;
    for(int pw=M; pw; pw>>=1)
        if(c[pos+pw] < k){ pos+=pw; k-=c[pos]; }
    return pos+1;
}

为什么能这样做:BIT 的 c 区间按二进制嵌套,所以按位贪心可以保证每步跳过的都是"完整且互不重叠的区间",这与"在有序数组上二分"效果相同但更快。

易错:pw 的初值必须 ≥ 值域上界(取 2 的幂最方便);k -= c[pos] 要在 pos += pw 之后执行。

🔴 C组 · 挑战(4题)

C1初赛真题

n = 8 时,执行 update(3, v) 会依次更新哪些 c?执行 query(5) 会依次访问哪些 c?两条路径的长度分别是多少?

查看参考答案

update(3, v):3 → 3+lowbit(3)=4 → 4+lowbit(4)=8 → 8+8=16 > 8 停止。

依次更新 c[3], c[4], c[8],共 3 步。

query(5):5 → 5−lowbit(5)=4 → 4−4=0 停止。

依次访问 c[5], c[4],共 2 步。

路径长度上界:update 最多 log₂n + 1 步,query 最多 popcount 步(≤ log₂n)。

考点:「修改向上加、查询向下减」的方向与步数分析。

C2综合应用

火柴排队(洛谷 P1966):两列火柴 a、b 各有 n 根。每次可以交换同一列中相邻两根火柴,代价为 1。求使 Σ(a[i]−b[i])² 最小的最少交换次数。

查看参考答案

第一步(贪心结论):由排序不等式,要让平方和最小,应按同样的大小顺序配对——即 a 中第 k 小的与 b 中第 k 小的位置对齐。

第二步(转化为逆序对):把 a 数组按大小排名 rkA[i],b 按大小排名 rkB[i]。我们要把 b 重排得与 a 的排名顺序一致。设映射 pos[rkB[i]] = i,然后构造数组 p[i] = pos[rkA[i]],则「使 p 变有序所需的最少相邻交换次数 = p 的逆序对数」。

第三步:用树状数组求逆序对,O(n log n)。

// 离散化两列火柴的相对排名
sort(idA, idA+n, cmpA);  // 按 a 的值排序
for(int i=0;i<n;i++) rkA[idA[i]] = i+1;
sort(idB, idB+n, cmpB);
for(int i=0;i<n;i++) pos[rkA[idB[i]]] = i+1;
// 此时 p[k] = pos[k],求 p 的逆序对
for(int i=1;i<=n;i++){
    ans += (i-1) - query(pos[i]);
    add(pos[i], 1);
}

易错:忘记取模(题面要求 99,999,997 取模);或把映射方向搞反,导致逆序对数不是最少交换次数。

C3综合编程

给定 n 个区间 [l, r],求每个区间内有多少个其他区间与它相交(有交集)。n ≤ 105。

查看参考答案

思路:对区间 i 而言,"与它不相交"的区间要么完全在它左边(r_j < l_i),要么完全在它右边(l_j > r_i)。用总数减去这两类即可。

做法:把所有端点排序离散化。用两个 BIT:① 从左到右插入区间的 r,查询 r_j < l_i 的个数;② 从右到左插入区间的 l,查询 l_j > r_i 的个数。

答案:ans[i] = (n−1) − left_i − right_i(注意不要把自己算进去)。

// 离散化所有 l、r
for(int i=1;i<=n;i++){
    left[i] = query1(id(l[i])-1);   // 已经插入的 r < l[i]
    add1(id(r[i]), 1);
}
for(int i=n;i>=1;i--){
    right[i] = query2(m) - query2(id(r[i]));  // 已插入的 l > r[i]
    add2(id(l[i]), 1);
}
for(int i=1;i<=n;i++) printf("%d\n", n-1-left[i]-right[i]);

易错:没有离散化端点;或者把"r_j < l_i"写成"r_j ≤ l_i"(端点重合算相交,应严格小于);或者忘记减去自己。

C4思维题

能否用树状数组求「区间第 k 小」(带修改)?如果能,怎么做?如果不能,为什么?

查看参考答案

单个 BIT 不能直接做。因为 BIT 的下标是"值域",而"区间第 k 小"限制的是"下标范围",两者维度冲突。

可行方案(若允许离线/分块):

① 树状数组套主席树:外层按下标建 BIT,内层每个节点挂一棵值域线段树(动态开点)。修改 O(log²n),查询 O(log²n)。这是「带修区间第 k 小」的标准解法。

② 整体二分:把所有询问按答案二分,用 BIT 统计"某值域内元素在区间中的个数",总复杂度 O(n log n log V)。代码更短,提高组常用。

③ 分块 + BIT:把下标分块,每块内维护一个值域 BIT,查询时整块用"BIT 上二分"、零散块暴力。复杂度 O(√n log n)。

为什么单个 BIT 不行:BIT 只支持"前缀"这一维的分解。求区间第 k 小本质是"在二维约束(下标区间 + 值域排名)下找第 k 小",必须有一维用更强的结构(线段树/主席树)或用整体二分把问题降维。

启发:数据结构的能力边界来自它的"维度"。BIT 是一维前缀结构,遇到二维约束就要组合或换思路。

🏆 洛谷原题精选(12道)

以下题目均与树状数组相关,按难度从入门到提高排列。

1
P3374
【模板】树状数组 1入门
2
P3368
【模板】树状数组 2普及-
3
P2068
统计和普及-
4
P1908
逆序对提高
5
P1774
最接近神的人提高
6
P1637
三元上升子序列提高
7
P1966
火柴排队(逆序对应用)提高
8
P3605
Promotion Counting提高
9
P1439
最长公共子序列(转 LIS)提高
10
P5490
【模板】扫描线提高
11
P4514
上帝造题的七分钟(二维)提高
12
P4054
计数问题(二维树状数组)提高