☰
静态区间和为何不能用循环?前缀和O(1)查询全解析
2026/10/20 21:52:57 网站建设 项目流程

2026年1月21日的每日一题是道很典型的静态区间和,不少朋友看到题面第一反应是:这不就是一个 for 循环从 l 加到 r 吗?然后提交,超时。再换成 long long,结果溢出;再调整输入输出,发现本地和评测机表现完全不一样。这不是你菜,是这类基础题把好几个知识点藏在“简单”标签下面了。这篇文章我不想只给一个能 AC 的代码,而是按我平时做题的顺序,把朴素做法为什么挂、前缀和怎么推、代码里每个细节怎么取舍、提交前要检查哪些坑,全部讲透。适合刚开始刷题、或者总在区间求和上栽跟头的朋友。

1. 这道每日一题想考察的,不只是“你会不会求和”

1.1 题面其实很朴素

很多刷题平台的每日一题栏目都爱放这类题,题面通常长这样:第一行给两个整数 n 和 q,n 是数组长度,q 是询问次数;第二行是 n 个整数;接下来连续 q 行,每行两个整数 l、r,要求输出从第 l 个数到第 r 个数的累加和。比较常见的约定是下标从 1 开始,且保证 l ≤ r。

约束范围往往决定了解法。如果 n 和 q 都只有 100,那朴素循环毫无压力;但这类题目把 n、q 拉到 1e5 甚至 1e6 时,简单循环就吃不了兜着走了。数值上也不会白给,ai 可能到 1e9 甚至出现负数,区间累加起来的答案会远远超过 int 能表达的范围。我一般拿到题会先把可能的约束列一下,心里有个底:

参数常见范围
n1e5 ~ 1e6
q1e5 ~ 1e6
ai-1e9 ~ 1e9
区间和结果可能达到 1e14 ~ 1e15

表里最后一行最容易被忽略,后面讲溢出的时候会专门再提。

1.2 关键字拆开看:静态、区间、前缀和

题目里最关键的一个词是“静态”。它的意思是:整个操作过程里数组不会发生任何修改,所有询问都发生在同一个固定数组上。这直接决定了我们可以提前把一些东西算好,用内存换时间。如果题目改成查询之间会插入、删除、修改元素,那就是动态版本,前缀和会过期,通常得上树状数组或线段树。

“区间和”就是连续一段的和。而“前缀和”的定义很直白:pre[i] 等于数组前 i 个元素的累加和,特别地 pre[0] 等于 0。有了 pre,任意区间 [l, r] 的和就是 pre[r] - pre[l-1],两次数组访问加一次减法,查询时间是 O(1)。

1.3 每日一题到底图什么

每日一题栏目里的基础题不是拿来送分的,是拿来帮你把知识点钉死的。区间求和几乎是一切的起点:后续的二维前缀、差分、单调队列、滑动窗口,甚至更高级的数据结构,都建立在“如何快速得到一个区间的聚合信息”这个观念上。所以这道题真正想考察的,不是你会不会写一段从 l 加到 r 的循环,而是在约束变大之后,你有没有意识到该把“重复计算”转化成“预处理加查询”。想明白这一点,比多背十个模板都重要。

2. 直接循环求和:思路简单,但复杂度会追着你跑

2.1 每个人第一反应都会写的版本

初学者写这道题,十有八九会这样写:

#include <bits/stdc++.h> using namespace std; int main() { int n, q; cin >> n >> q; vector<int> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i]; while (q--) { int l, r; cin >> l >> r; long long sum = 0; for (int i = l; i <= r; i++) sum += a[i]; cout << sum << '\n'; } return 0; }

这段代码逻辑一点毛病都没有,样例甚至能全过。它的问题不在正确性,而在效率。每次询问都老老实实遍历一遍区间,区间越长,代价越大。

2.2 运算量一算就笑不出来了

一次询问最坏要遍历 n 个数,一共 q 次询问,总工作量就是最坏 O(nq)。听起来抽象,算一下具体数字:假设 n 和 q 都是 1e5,最坏情况下总操作量是 1e10 次。评测机一秒钟大概能跑 1e8 到 1e9 次简单运算,也就是说这段代码在极端数据下可能要跑几十秒甚至更久。如果 n 和 q 都到 1e6,那就是 1e12 次操作,基本没有跑完的可能。

数据规模朴素做法总操作量大致耗时量级
n = q = 1e31e6毫秒级
n = q = 1e41e8一秒上下
n = q = 1e51e10几十秒级
n = q = 1e61e12无法在时限内完成

“无法在时限内完成”不是夸张。等数据规模再翻一倍,等待时间就不是线性增长,而是乘方增长。更麻烦的是,这里还没算输入输出本身的开销。数据量大起来之后,输入解析也可能成为瓶颈。你会看到 TLE,但你的代码并没有跑错,它是真的跑不完。

2.3 “静态”两个字就是最强的提示

如果你当时认真读题,会发现题目特意强调数组是静态的。静态意味着每次询问之间没有任何修改操作,那我完全可以提前把“前 i 个数字的和”全部算好,查询时直接相减。这就完成了一个关键转移:把耗时的重复求和,换成一次性的预处理,以及每次都只要 O(1) 的查询。数据结构里很多优化,本质上都是这么回事——把高频查询的代价往低频预处理那边挪。

3. 前缀和:从一次遍历到 O(1) 查询

3.1 用流水账理解前缀和

前缀和可以用一个特别生活化的类比:假设你在记账,每天记一笔收支,pre[i] 就是到第 i 天为止的累计余额。这时你想知道第 l 天到第 r 天一共发生了多少收支,不需要把中间每一天的账都翻出来重新加一遍——直接用第 r 天的累计余额,减去第 l-1 天的累计余额,剩下的恰好就是中间这一段。关键点:pre 里存的是从开头一直积累下来的总量,而不是某一天的数值,所以它能“一笔勾销”前面的部分。

举个例子。数组 a = [3, 1, 4, 1, 5, 9](下标从 1 开始),构建出的 pre 是:

i0123456
a[i]-314159
pre[i]034891423

查询 [2, 5] 的和:pre[5] - pre[1] = 14 - 4 = 10,正好等于 1 + 4 + 1 + 5 = 10。注意 pre 之所以从 0 开始,是因为当 l=1 时需要 pre[0] 来占位,这样公式统一,不用特判。

3.2 推导过程其实就一行

设 pre[i] 表示前 i 个元素的和。区间和的定义是 sum_{j=l}^{r} a[j]。把 pre[r] 展开,是 a1 + a2 + ... + a[l-1] + a[l] + ... + a[r]。把 pre[l-1] 展开,是 a1 + a2 + ... + a[l-1]。两式相减,前面那一截全部抵消,剩下来的正好是 a[l] 到 a[r]。这就是公式的来源,没有任何高深的东西。它成立的前提是 pre 数组能从一开始一直累加到底,中间不能有修改,否则抵消的前提就不存在了。

3.3 时间和空间复杂度

构建 pre 只需要遍历数组一次,O(n);每次查询只需要两次数组访问和一次减法,O(1);总复杂度是 O(n + q)。很多人第一次看到 O(n + q) 会疑惑:这不是还有一个 n 吗?注意这里的 n 是构建时的一次预处理,而不是乘以询问次数。同样的 n=1e5、q=1e5,朴素做法是 1e10 次操作,前缀和只有 2e5 次操作,差了五个数量级。内存上,pre 使用一个长度 n+1 的数组,如果元素用 8 字节的 long long,n=1e6 时约 8MB,完全在大多数题目的空间限制内。

到这里,算法本身的正确性和复杂度都已经清楚了。但真正提交之前,还有几个实现细节能让你从 AC 变成 WA 或者 TLE。

4. 完整题解:C++ 与 Python 的双版本代码与取舍

4.1 C++ 的参考解法

直接给一个我平时在 OJ 上习惯用的 C++17 版本:

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, q; cin >> n >> q; vector<long long> pre(n + 1, 0); for (int i = 1; i <= n; i++) { long long x; cin >> x; pre[i] = pre[i - 1] + x; } while (q--) { int l, r; cin >> l >> r; cout << pre[r] - pre[l - 1] << '\n'; } return 0; }

代码很短,但每一处都有讲究。

4.2 这段代码里每个细节都是故意的

首先是vector<long long>。为什么不用 int?如果 n=1e5、ai=1e9,累加和最大能到 1e14,而 int 上限只有约 2.1e9,早就溢出了。更稳妥的做法是连读入的 x 都用 long long,避免在累加之前就被截断。有些选手习惯先开 int 数组,最后再转 long long,不是不行,但不如直接在读取时就用 long long 省心。

其次是ios::sync_with_stdio(false); cin.tie(nullptr);这两行。它们把 cin 和 cout 与标准 C 库的同步关掉,让 C++ 的输入输出快很多。代价是:使用之后不要再混用 scanf 和 printf,否则可能出现录入错位之类的奇怪问题。如果你平时更喜欢 scanf 和 printf,也可以不用这两行,但就不要把 scanf 和 cin 混着写。

第三是输出用'\n'而不用endl。endl 会在每次输出后强制刷新缓冲区,输出行数多了之后,刷新本身会变成明显的耗时来源。在 OJ 场景里,'\n'才是正解。

4.3 Python 选手可以这样写

如果只能用 Python,input()一行行读 q 次,在 n、q 到 1e6 时会非常吃力。推荐一次性读入所有数据再处理:

import sys data = list(map(int, sys.stdin.buffer.read().split())) n, q = data[0], data[1] pre = [0] * (n + 1) for i in range(1, n + 1): pre[i] = pre[i - 1] + data[1 + i] out = [] idx = n + 2 for _ in range(q): l, r = data[idx], data[idx + 1] idx += 2 out.append(str(pre[r] - pre[l - 1])) sys.stdout.write("\n".join(out))

这段代码把整个输入按空白符切分成一个大列表,一次性完成解析。优点是真的快;缺点是内存占用会高一些,但常见 1e6 数量级的问题不大。如果内存限制特别紧,可以改用sys.stdin.buffer.readline循环读取,效率也不错,只是代码会啰嗦一点。

4.4 原始数组到底要不要单独存

一个很自然的疑问是:前缀和需要先有数组 a,那我是不是必须把 a 完整地存下来?答案是不需要。因为 pre[i] 只依赖 pre[i-1] 和当前的 a[i],读到一个数字就能立刻更新 pre,之后 a[i] 再也不会被用到。参考代码里并没有单独存储原始数组,既省了一份内存,也少了一次遍历。“能边读边算,就不要额外存一份”这个思路,在内存紧张的题目里很实用。

5. 从 TLE 到 AC:边界条件与评测环境排查手册

5.1 四个一不注意就挂的边界

第一个边界是 l=1。公式 pre[r] - pre[l-1] 会变成 pre[r] - pre[0],如果 pre 没有预留第 0 个位置,或者 pre[0] 没有初始化为 0,就会读到垃圾值,甚至越界。所以vector<long long> pre(n + 1, 0)里的 n+1 和初始化 0 是必须的。

第二个边界是 r=n。这不会让程序出错,但它是个很好的自测用例:当查询覆盖整个数组时,输出必须是 pre[n],也就是所有元素的总和。如果你手推样例时发现总和都对不上,说明前面的构建过程有问题。

第三个边界是负数。数组里可能有负数,前缀和因此并不单调,比如 a=[-100, 50],pre 就是 [0, -100, -50]。有些同学看到 pre 变小就慌,以为代码写错了——其实前缀和的定义就是累加,不需要单调递增。只要按公式做减法,结果依然正确。

第四个边界是读入顺序和下标约定。题目说 l≤r、1≤l,r≤n,这是一个漂亮的约定,但它需要你在代码里老实遵守。一旦把读入顺序搞反,或者把数组长度写错任何一位,程序可能跑出未定义行为,而你还在傻傻地查算法。

5.2 本地秒过、OJ 超时的排查顺序

如果你遇到“本地秒过,一交 TLE”的经典场景,按这个顺序排查:

  1. 算法复杂度是不是 O(nq)?先算最坏总操作量。
  2. 输出是不是用了 endl,或者 Python 里一行行 print?改成 '\n' 或一次性拼接输出。
  3. C++ 有没有关掉 cin/cout 同步?没有的话把两行加上再测。
  4. Python 是不是用 input() 读了几百万行?换成 sys.stdin.buffer 整套读入。
  5. 有没有多余调试输出忘了删?有没有把 scanf 和 ios::sync_with_stdio(false) 混用?

这些原因里,第 1 条最常见,属于算法问题;后面几条属于 IO 工程问题。很多选手以为自己输在算法上,其实是被输入输出拖死的。

5.3 怎么确认前缀和没有算错

写完之后别急着提交,先用足够小的数据暴力验证一遍。我的习惯是写两个函数,一个叫 solve_naive,一个叫 solve_fast,随机生成几组 n=20、q=50 的数据,对比两边输出是否完全一致。一致了再上大样例,最后再提交。这个方法在以后做任何数据结构题都用得上,几乎是保命技能。很多你觉得天衣无缝的代码,一跑随机对比就露馅了,因为边界条件只会在特定的数据组合下出现。

6. 前缀和思想的延伸:下一个题型就在拐角处

6.1 从一维到二维:矩形和

学会一维前缀和之后,二维前缀和几乎是顺理成章的事。如果有一个 n 行 m 列的矩阵,预处理 S[i][j] 表示以 (1,1) 为左上角、(i,j) 为右下角的矩形内所有元素的和。递推式是:

S[i][j] = S[i-1][j] + S[i][j-1] - S[i-1][j-1] + a[i][j]

查询子矩形 (x1, y1) 到 (x2, y2) 的和时,用容斥:

ans = S[x2][y2] - S[x1-1][y2] - S[x2][y1-1] + S[x1-1][y1-1]

这里第一次见会绕:为什么减掉两块之后还要加回一块?因为左上角那块被减了两次。只要记住“多减的部分要加回来”,二维和一维就只是同一件事的两个版本。

6.2 换个运算符:前缀异或

前缀和不只是加法。把加号换成异或,就得到前缀异或:preXor[i] = preXor[i-1] ^ a[i],区间异或值就是 preXor[r] ^ preXor[l-1]。异或的逆运算还是异或,所以它和加法一样可以做区间查询。如果把加号换成乘号,就得处理取模和逆元的问题,那是另一套玩法。核心观念是一致的:只要区间的聚合信息能由前缀信息做可逆运算还原,前缀和思路就有立足之处。

6.3 和“和为 K 的子数组”的关系

前缀和最常见的衍生题型是统计有多少个子数组满足和为 K。思路是:子数组 [l, r] 的和为 K,等价于 pre[r] - pre[l-1] == K。把式子变一下形,得到 pre[l-1] == pre[r] - K。于是从左到右扫一遍,用一个哈希表记录出现过哪些 pre[l-1] 以及出现次数,再在当前位置 r 查询 pre[r] - K 出现了多少次,就能 O(n) 求出答案。前缀和从“算区间和”升级到了“根据前缀值反推区间数量”,这是它的另一个高频赛道。

6.4 什么时候前缀和会失效

前缀和成立的前提是数组静态,聚合操作可逆。一旦查询之间出现单点修改,pre 数组就过期了,这时候需要用支持动态维护前缀和的数据结构,比如树状数组或线段树。如果题目讨论的是区间同时增加一段值,再查询单点值,那更适合差分数组,而不是前缀和。搞清楚“哪些场景该用哪一套”,比多背几个模板更重要,因为很多 WA 不是代码写错,而是选错了工具。

这道题做完之后,我自己的习惯是固定问三句话:数组在查询过程中变不变?查询要求的是区间的什么信息?这个信息能不能由前缀信息简单还原?三句话问完,该不该用前缀和基本就清楚了。如果你也在每天刷题,花点时间把这几个边界、溢出和 IO 优化记熟,后面的基础题会顺畅很多。剩下的就是多写几道同类型的题,把这种“先预处理、再查答案”的感觉养出来,它以后会反复用到。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询