题单 · 习题解析

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

★★ 两个暴力正好是一对反面(改 O(1) 查 O(n) / 改 O(n) 查 O(1)),树状数组把**两头都换成 O(log n)**;⚠ 而 70% 那一档暴力 4 毫秒就跑完 ⇒ **稳拿 70 分**;★★★ 这一页最值钱的是题面最后那行保证:「任意子区间和 ∈ [−2³¹, 2³¹)」**逐字就是 int 的范围**,而 c[i] 和 sum() 的部分和**本身都是子区间和** ⇒ 整棵树都在 int 里(档 1 实测 max|c| = 3.7×10⁸);★★★ 造一档违反它才现形,而三层数字是 211 越界 / **65 答案越界** / **65 被抓**(②≡③ 能证:int 的加减模 2³² 同余 ⇒ **溢出 ≠ 答错**,只有答案本身装不下才露馅);⚠ 别学:溢出仍是 UB,正解照写 long long;★ 真正花时间的是读入 —— 算法 25 ms、读 2×10⁶ 个整数 245 ms(关同步 52),**读入是算法的 10 倍**

原题:洛谷 P3374出自 第 38 章 树状数组 的题单题面本地存档:2026-08-31
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P3374,日期见页头。两边不一致时信原站。

题目描述

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

  • 将某一个数加上 x

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

输入格式

第一行包含两个正整数 nm,分别表示该数列数字的个数和操作的总个数。

第二行包含 n 个用空格分隔的整数,其中第 i 个数字表示数列第 i 项的初始值。

接下来 m 行每行包含 3 个整数,表示一个操作,具体如下:

  • 1 x k 含义:将第 x 个数加上 k

  • 2 x y 含义:输出区间 [x, y] 内每个数的和。

输出格式

输出包含若干行整数,即为所有操作 2 的结果。

数据范围

对于 30% 的数据,1 ≤ n ≤ 81 ≤ m ≤ 10

对于 70% 的数据,1 ≤ n, m ≤ 10⁴

对于 100% 的数据,1 ≤ n, m ≤ 5 × 10⁵1 ≤ x ≤ y ≤ n−2³¹ ≤ k < 2³¹

数据保证对于任意时刻,a 的任意子区间(包括长度为 1 和 n 的子区间)和均在 [−2³¹, 2³¹) 范围内。

时限 1 秒,内存 512 MB。

输入输出样例

输入

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

输出

14
16

★ 样例说明(下面这张表转录自原站,见图):初始数列 1 5 4 2 31 1 3 把第 1 个数加 3 ⇒ 4 5 4 2 3,于是 2 2 5 问的是 5+4+2+3 = 14; 接着 1 3 -11 4 24 5 3 4 32 1 44+5+3+4 = 16

P3374 样例说明:五步操作和每一步之后的数列

1两个暴力 —— 而它们正好是一对反面

这道题只有两种操作,每一种单独看都有 O(1) 的做法

p3374Brute.cpp第 ① 版:改 O(1)、查 O(区间长度) —— ★ 70 分
// P3374 第 ① 版:**题目怎么说就怎么做** —— 改是 O(1),查是 O(区间长度)
//
// ★ 它值多少分,题面自己写着(读数据范围的时候顺手把它乘出来):
// · 「对于 30% 的数据,1 ≤ n ≤ 8,1 ≤ m ≤ 10」 ⇒ 80 次加法,★ 稳拿 30 分;
// · 「对于 70% 的数据,1 ≤ n, m ≤ 10⁴」 ⇒ 10⁸ 次,本机约 0.05 秒 ⇒ ★ 也过得去;
// · 顶格 n = m = 5×10⁵ ⇒ **2.5×10¹¹ 次**,一秒的时限差着四个数量级。
//
// ⇒ 所以这一版不是「白写的」:它是 70 分,而且它是后面所有版本的**参照物**(对拍拿它当标准答案)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<long long> a(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> a[i];
string out;
for (int q = 0; q < m; q++) {
int op;
cin >> op;
if (op == 1) {
int x; long long k;
cin >> x >> k;
a[x] += k; // ★ 改:O(1)
} else {
int x, y;
cin >> x >> y;
long long s = 0;
for (int i = x; i <= y; i++) s += a[i]; // ⚠ 查:一个一个加,O(y − x + 1)
out += to_string(s);
out += '\n';
}
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p3374Prefix.cpp第 ② 版:前缀和 —— 查 O(1)、⚠ 改 O(n)
★★ 难的不是「改」也不是「查」,是要它们同时快
改一个数 查一段和 顶格 n = m = 5 × 10⁵各自最坏的配比
① 直接存数组 ★ O(1) ⚠ O(区间长度) 全是「问整段」⇒ 2.5 × 10¹¹
② 前缀和(第 6 章 ⚠ O(n) ★ O(1) 全是「改第 1 个」⇒ 2.5 × 10¹¹
★ ③ 树状数组 O(log n) O(log n) 怎么配都是约 2 × 10⁷

★ 第三行那句「怎么配都」才是重点:前两行各有一个自己扛得住的配比, 而这道题的操作是混着给的(下面那张秒表是「改查各半」量的)。

本机实测(A 机 · WSL2 · Linux 6.18 / 8 线程 / 7 GB,2026-08-31,独占; p3374Count.cpp 在进程内用 steady_clock 量,不含读入):

n = m ① 一个一个加 ② 前缀和 ★ ③ 树状数组
10⁴(题面 70% 那一档) 4 ms 4 ms ★ 0 ms
10⁵ 403 ms 478 ms 3 ms
5 × 10⁵(顶格) n·m 外推约 10 秒 约 12 秒 25 ms

★ 所以第 ① 版不是白写的:题面「70% 的数据 n, m ≤ 10⁴」那一档它 4 毫秒就跑完了 ⇒ 考场上是实打实的 70 分,而且它是后面所有版本的参照物(对拍拿它当标准答案)。

2★ 正解:把两个 O(1) 换成两个 O(log n)

p3374.cpp★ 这一版就能 AC(顶格端到端 0.11 秒 / 时限 1 秒)
// P3374【模板】树状数组 1 —— ★ 这一版就能 AC(单点加 + 区间和)
//
// 和本章正文那道题(fast.cpp)**几乎是同一份代码**,差别只有两处:
// ① 正文那道题末尾多问一句「整个数组的和」,这道题没有;
// ② ★★ 题面的那句保证不一样 —— 而那句话正好是本页最值钱的东西(见下)。
//
// ============ ★★★ 题面替你把「要不要 long long」这笔账付了 ============
//
// 题面最后一行:**「数据保证对于任意时刻,a 的任意子区间(包括长度为 1 和 n
// 的子区间)和均在 [−2³¹, 2³¹) 范围内。」**
//
// ⇒ 那个区间**逐字就是 int 的范围**(−2 147 483 648 … 2 147 483 647)。
// ⇒ 而它保证的不只是「答案」:
// · c[i] = a[i−lowbit(i)+1] + … + a[i] —— **本身就是一个子区间和** ⇒ 在范围内;
// · sum(r) 一路累加,每一步的部分和是 (j, r] 这一段的和 —— **也是子区间和** ⇒ 在范围内;
// · 答案 sum(r) − sum(l−1) = [l, r] 的和 —— 还是子区间和。
// ⇒ **整棵树、每一个中间值,全在 int 里。**(p3374Count.cpp 在两个档上量过最大绝对值。)
//
// ⚠ 但这里仍然写 long long:**因为那句保证一旦被拿掉,int 立刻就错**
// (生成器档 2 专门造违反它的数据,int 版 300 / 300 全错)——
// ⇒ 这一页的结论不是「用 int」,是**「这道题能用 int,而这是题面给的,不是算法给的」**。
//
// 复杂度 O((n + m) log n):顶格 n = m = 5×10⁵ ⇒ 约 2×10⁷ 次基本操作。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005;
long long c[MAXN];
int n, m;
inline int lowbit(int i) { return i & -i; }
void add(int p, long long x) {
for (int i = p; i <= n; i += lowbit(i)) c[i] += x;
}
long long sum(int r) {
long long s = 0;
for (int i = r; i > 0; i -= lowbit(i)) s += c[i];
return s;
}
int main() {
ios::sync_with_stdio(false); // ★ 这一句在这道题上值 4.7 倍(245 → 52 毫秒),见第 ⑤ 步
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
// ★ O(n) 建树:a[i] 先落进 c[i],再把 c[i] 整个交给它爸爸 i + lowbit(i)
for (int i = 1; i <= n; i++) {
long long x;
cin >> x;
c[i] += x;
int f = i + lowbit(i);
if (f <= n) c[f] += c[i];
}
string out;
for (int q = 0; q < m; q++) {
int op;
cin >> op;
if (op == 1) {
int x; long long k;
cin >> x >> k;
add(x, k);
} else {
int x, y;
cin >> x >> y;
out += to_string(sum(y) - sum(x - 1)); // ⚠ 是 x−1
out += '\n';
}
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 这道题就是本章正文那道题,去掉最后一问

第 38 章从头到尾讲的就是它:lowbit(i) = i & -ic[i] 只攒 lowbit(i) 那么长的一段、改和查各走一条 O(log n) 的路。 正文那道题在末尾多问了一句「整个数组的和」(那一句专门用来抓「add 写成 i < n」), 这道题没有 —— 别的一个字都不差

⇒ 所以这一页不重复讲算法,只讲这道题自己的三件事: 第 ③ 步那个几乎人人都会写错一次的 −1、第 ④ 步题面替你付掉的那笔账、第 ⑤ 步的读入。

3✗ 第一个坑:区间和写成 sum(y) − sum(x)

p3374Off.cpp✗ 少减了那个 1(官方样例当场打死:第一问打出 9)
★★ 说清楚它算了什么,剩下的全是白送的推论

它算的是 [x+1, y] 的和 —— 不是「错了」,是恒等于另一个量

正解 − 它 = a[x]

⇒ 于是不用跑就知道:

  • a[x] = 0 的时候它一个字都不错 —— ⚠ 而「初始数组全是 0」正是最顺手的生成器写法(本章第 ⑫ 步那条「顺手写法」);
  • x = y 的时候它恒输出 0
  • 官方样例第一问 2 2 5:正解 14,它打出 9(= 14 − a[2] = 14 − 5)⇒ 样例挡住了它

★ 实测四个档各 300 轮:300 / 298 / 298 / 297 —— 它几乎躲不掉, 因为「a[x] ≠ 0」这个条件在任何有初值的数据上都近乎必然成立。

4★★★ 题面最后那一行,替你把「要不要 long long」这笔账付了

★★★ 那句保证保住的不是「答案」,是整棵树

题面:「数据保证对于任意时刻,a 的任意子区间(包括长度为 1 和 n 的子区间)和均在 [−2³¹, 2³¹) 范围内。」

★ 第一件事是逐字对一遍[−2³¹, 2³¹) = [−2147483648, 2147483647] —— 这就是 int 的范围,一个数都不多一个数都不少

★★ 第二件事才是关键:它保证的不只是答案。树状数组里出现的每一个数都是某个子区间的和

出现在哪儿 它是哪一段的和
c[i] (i − lowbit(i), i] ⇒ 子区间和 ✓
sum(r) 累加途中的部分和 (j, r]j 是当前走到的下标)⇒ 子区间和 ✓
答案 sum(y) − sum(x−1) [x, y] ⇒ 子区间和 ✓

整棵树、每一个中间值,全在 int 里。 实测(档 1,值域拉到守着那句保证的边上,300 轮): max|c[i]| = 374 381 420max|部分和| = 337 217 021,都在 2³¹ 以内。

⇒ 所以 p3374Int.cpp(整棵树用 int)在这道题上一次都不会错 —— 对拍档 0 / 档 1 / 档 3 各 300 轮,0 次不一致

p3374Int.cpp★ 整棵树用 int —— 在这道题的合法输入上永远对
⚠⚠ 而它是「题面撑着的」,不是「算法撑着的」—— 一句话就能称出来

造一档违反那句保证的数据(档 2:值域 ±10⁹、n 只有十来个 ⇒ 子区间和可达 1.2 × 10¹⁰), int 版当场就现形。⚠ 但被抓的轮数比我草稿里写的少得多,而少下来的部分正好能说清楚:

档 2,300 轮 轮数
① 某一时刻真的存在越出 int 的子区间和 211
被问到的那个区间的答案越出 int 65
int 版真被抓 65 —— ⚠ 和 ② 一个不差

★★★ ②≡③ 是能证的,而且它解释了为什么不是 211: int 的加减是模 2³² 同余的(补码),所以只要答案本身装得进 int, 中间怎么绕都会绕回同一个数 ⇒ 中间值溢出 ≠ 答案错。 ⇒ 这一页因此得到一句可以到处用的话: 「它溢出了」和「它算错了」是两件事,中间隔着一句「答案本身装不装得下」。

⚠ 但结论不是「所以可以写 int」:有符号溢出是 UB, 它这次绕回来是编译器按补码办事的结果,标准一个字都没保证。 ⇒ 正解里仍然写 long long代价是零,而它让这一页的推理整段都不必要。

5⚠ 这道题真正花时间的地方不是算法,是读入

★★ 算法 25 毫秒,读入 245 毫秒 —— 差 10 倍

先把账乘出来(三十秒的算术):

n, m ≤ 5×10⁵ ⇒ 要读 5×10⁵ + 3×5×10⁵ = 2×10⁶ 个整数
实测顶格那份输入 9 376 421 字节(8.94 MB),输出 1 733 736 字节

本机实测(同机同日独占,p3374Count.cpp 在进程内 freopen 同一份文件读两遍):

读 2 × 10⁶ 个整数 毫秒
cin(默认,同步开着) 245
cin + ios::sync_with_stdio(false) 52

⇒ 而整个树状数组算法只要 25 毫秒 —— ★ 读入是算法的 10 倍。 ⇒ 两种读法在 1 秒的时限里都过得去(端到端 0.11 秒), 但「这道题快在哪儿」的答案是:它根本不在算法上

★ 和前面两页对一下,三道题正好排成一条线(倍数跨题几乎不变,变的是绝对时间):

要读多少个数 时限 结论
第 6 章 P2367 2 × 10⁷ 1 秒 scanf 都不够,只有快读过
第 19 章 P1803 2 × 10⁶ 3 秒 ★ 四种读法全都够
★ 这道题 2 × 10⁶ 1 秒 都够,但不关同步就花掉四分之一的时限

6★ 对拍:四个档位各 300 轮

p3374Gen.cpp(七个档位)数据生成器
p3374Count.cpp度量程序(本页的数字都出自它)
300 轮 ① 一个一个加 ② 前缀和 ✗ 少减 1 ★ 整棵树用 int
档 0:顺手(初值 1~100 全正) 0 0 300 0
档 1:大值域,守住题面那句保证 0 0 298 0
⚠ 档 2:故意违反那句保证 0 0 298 65
档 3:查询端点专挑 x=1 / y=n / x=y 0 0 297 0

★ 档 1 那两个 0 是分两步交代的(不然它和「这段代码没在跑」长得一样): ① 度量程序在同一档上数了一遍「真的越界的轮数」,是 0 / 300int 版不出错,是因为那一档根本没越界; ② 而「这段代码确实在跑」由档 2 的 65 保证 —— 同一份 p3374Int.cpp,换一档就被抓了。 (报「精确的 0」之前先拿一个已知错的东西验一遍,这是第 N 次。)

7一页纸

★ 暴力值多少分 题面 70% 档 n, m ≤ 10⁴4 毫秒,稳拿 70 分;顶格最坏 2.5 × 10¹¹ 次
★★ 关键一步 两个暴力各有一头是 O(1)、另一头是 O(n);树状数组把两头都换成 O(log n)
⚠ 最容易写错的一处 sum(y) − sum(x−1) 的那个 −1(写成 sum(x) ⇒ 恒少一个 a[x],300/298/298/297 被抓)
★★★ 题面替你付的账 「任意子区间和 ∈ [−2³¹, 2³¹)逐字就是 int 的范围,而 c[i] 和部分和都是子区间和 ⇒ 整棵树都在 int 里
★★★ 而它保的是哪一层 违反那句保证时:越界 211 轮,答案本身越界 65 轮,int 版被抓 65 轮(一个不差)—— 溢出 ≠ 答错,因为 int 的加减模 2³² 同余
⚠ 别学的地方 有符号溢出是 UB ⇒ 正解仍写 long long(代价是零)
⚠ 真正的时间去哪了 算法 25 ms,读 2 × 10⁶ 个整数 245 ms(关同步 52 ms)—— ★ 读入是算法的 10 倍