把二分、贪心、前缀和、DP 串成一条线 —— 从今天起,你开始写「组合拳」代码
| 0 - 30 min | 二分 + 贪心:跳石头(第 1 节,昨天的模板 A 升级版) |
| 30 - 65 min | 实数二分:银行贷款(第 2 节)—— 今天的第一个新东西 |
| 65 - 100 min | 前缀和 + 同余:余数为 7(第 3 节) |
| 100 - 130 min | 最大子段和 + 二维拓展(第 4 节) |
| 130 - 170 min | 做 4 道题,每题限时 20 分钟 |
| 170 - 180 min | 做自测 + 抄速查卡 |
昨天你学会了二分答案的两套模板,以及「二分的核心是 check」。 今天前两道题还是二分(一道整数、一道实数),后两道题完全换赛道 —— 变成前缀和和线性 DP。
为什么把它们放在一天?因为计划里这一天的定位是「综合」: 真正比赛里的题,很少只考一个知识点。今天开始,你要习惯「先拆解,再组合」。
看到「最短跳跃距离的最大值」这种问法,条件反射应该是:
| 判断 | 结论 |
|---|---|
| 答案是「最大」还是「最小」? | 要最大化最短距离 → A 类,用模板 A(ans 记录) |
| check(x) 单调吗? | x(要求的最短距离)越大,越难做到 → true…true…false…false ✅ |
| check(x) 好写吗? | 好写:一次 O(n) 贪心扫描 |
三条全中,所以 P2678 就是一道标准的二分答案题。
题目允许我们搬走至多 M 块石头,然后看所有「相邻落脚点」的距离里最小的那个能有多大。
现在假设我们已经定死了一个要求:所有跳跃距离都必须 ≥ x。那么从左往右走:
last(一开始是起点 0)。a[i] - a[last] < x,说明从 last 跳到 i 太近了 —— 那就把 i 搬走,跳过它,last 不动。last = i。因为我们要尽可能少搬。碰到一块太近的石头,只有两种办法:搬走它,或者搬走前面那块。
搬走前面那块有害无益 —— 那会让 before→i 的距离变得更远,但同时也让「更前面 → 前面那块」的距离全部作废重算,
而且会白扔一次机会。所以能不动就不动,只在被逼无奈时搬走当前这块。
这就是贪心的味道:每一步都做当下最不亏的选择,并且这个选择不会让后面的路变窄。
题目说「不能移走起点和终点的岩石」,所以我们要另外对付终点 L。
做法很巧妙:把终点 L 也当成一块「石头」塞进数组末尾(下标 n+1),然后照常扫描。
扫描过程中如果终点离 last 也太近,代码一样会 cnt++。
这个 +1 的含义其实是:「连最后一块留着的石头也保不住,必须把它搬走」。
因为我们是从左往右贪心「能留就留」的,所以留下的石头数量已经是最多的了。 如果连最后留下那块的落地距离都不够,说明必须再牺牲一块 —— 而搬走它之后,跳到终点的距离只会更长,不会有新麻烦。
所以「cnt 里多算的那一次」正好等于「真实需要多搬的那一块」。
结论:这段贪心扫完得到的 cnt,就是最少需要搬走的石头数。
样例:L = 25,石头位置 2、11、14、17、21,最多搬走 M = 2 块。
| 试 x | 扫描过程 | 搬走数 | ≤ 2? |
|---|---|---|---|
| 4 | 2-0=2<4 搬走 ①;11-0=11≥4 留;14-11=3<4 搬走 ②;17-11=6≥4 留;21-17=4≥4 留;25-21=4≥4 留 | 2 | ✅ |
| 5 | 2-0=2<5 搬①;11-0=11≥5 留;14-11=3<5 搬②;17-11=6≥5 留;21-17=4<5 搬③;25-17=8≥5 留 | 3 | ❌ |
所以 x = 4 做得到、x = 5 做不到 —— 答案就是 4,和洛谷样例一致。
题面提示也印证了:搬走 2 和 14 之后,最短距离是 4(17→21,或者 21→终点)。
昨天四道题的答案都是整数:高度、长度、段和、间距。
但 P1163 问的是月利率,答案是 2.9 这种小数。这时候二分还能用吗?能用,
而且更简单 —— 因为实数没有「相邻整数」的问题。
相同点:都是「猜一个值 → check → 砍掉一半」,都要求单调性。
不同点:整数二分的循环条件是 l <= r(区间缩到空),
实数二分不能这么写 —— 因为实数区间「永远缩不到空」。
很多人会写 while(r - l > 1e-12)。这么写不算错,但有两个毛病:
eps 不好定:写大了答案不精确,写小了可能因为浮点误差永远不收敛(死循环)。更稳的做法是:循环固定次数,比如 100 次。
for(int it = 0; it < 100; it++){ // 固定 100 次,绝不死循环
double mid = (lo + hi) / 2;
if(check(mid)) ...;
else ...;
}
每迭代一次,区间长度就除以 2。
循环 100 次后,区间长度变成原来的 1 / 2¹⁰⁰ —— 这个数大约是 7.9 × 10⁻³¹,
远远超过 double 能表示的精度(double 只有效 15~16 位十进制)。
也就是说,循环到 60 次左右,lo 和 hi 在计算机看来已经是同一个数了,
再迭代也不会变。100 次属于「肯定够、还便宜」的选择(本题 m ≤ 3000,100 次也就 30 万次运算)。
题意:借了 w0 元,每月还 w 元,还 m 个月正好还清。求月利率。
给定一个月利率 r,我们可以模拟一遍还款过程:
double rem = w0; // rem = 现在还欠多少 for(int i = 0; i < m; i++){ rem = rem * (1.0 + r) - w; // 先按利率涨利息,再还掉 w } // 还完之后:rem > 0 表示还没还完;rem < 0 表示还多了
单调性很直观:利率越高,钱涨得越快,最后欠的就越多。
所以 rem 随 r 递增:r 太小 → rem 是负数(还多了),r 太大 → rem 是正数(还不完)。
我们要找的,就是让 rem ≈ 0 的那个 r。
看 rem 的正负来决定往哪边收:
· rem < 0(还多了)→ 说明利息太少 → 利率太低 → lo = mid
· rem > 0(还不完)→ 说明利息太多 → 利率太高 → hi = mid
我第一版代码把这两行写反了,样例 1 直接输出 300.0(一路冲到上界)。
写反了不会报错,只会输出一个离谱的数 —— 所以一定要拿样例验。
上界取 300%(即 r = 3)时,如果 m = 3000,那么中间过程会出现 1.05 × ... × 4³⁰⁰⁰ 这种
天文数字,直接变成 inf。虽然 inf 的正负号是对的(不会影响二分方向),
但为了干净,我们在累乘过程中加一道「提前跳出」:
if(rem > 1e18){ rem = 1e18; break; } // 已经爆炸,符号已确定,不用再算
if(rem < -1e18){ rem = -1e18; break; }
题目要「月利率(用百分数表示),四舍五入精确到 0.1%」。所以最后要 × 100 再用
printf("%.1f") —— %.1f 自带四舍五入,正好符合要求。
样例 1000 100 12 的真实利率其实是 2.922854%,四舍五入到 0.1% 就是 2.9。
我另外拿 1000 200 6 试了一下,程序输出 5.5,真实值是 5.471793%,
而且我用「拿 5.5% 正向模拟还款」独立验证过:6 个月后剩余欠款 1.23 元 ≈ 0,确实还清了。
前缀和数组 pre[i] = a[1] + a[2] + ... + a[i],那么:
区间 [l, r] 的和 = pre[r] - pre[l-1]; // O(1) 求任意区间和
比起每次都重新累加(O(n)),前缀和把「求区间和」变成了 O(1),这是拿空间换时间的老套路。
题目要求:找一段连续奶牛,使它们的 ID 之和是 7 的倍数,并且这段尽可能长。
如果暴力枚举左右端点,N = 50000 时要枚举 12.5 亿个区间,太慢。用前缀和就快多了:
区间 (l..r) 的和 = pre[r] - pre[l-1]。
要它被 7 整除,就是要 pre[r] - pre[l-1] ≡ 0 (mod 7),
也就是 pre[r] ≡ pre[l-1] (mod 7)。
翻译成人话:只要两个前缀和在模 7 意义下余数相同,它们之间那一段的和就一定是 7 的倍数。
余数只有 0~6 七种。对于每一种余数 r,我们只需要记住两件事:
first[r]:余数 r 第一次出现在哪个下标;last[r]:余数 r 最后一次出现在哪个下标。那么余数为 r 的最长区间长度就是 last[r] - first[r](下标之差就是元素个数,
因为 pre[i] 和 pre[j] 之间的区间是 (i+1 .. j),长度 = j - i)。
七种余数各算一遍,取最大,就是答案。整个算法 O(n),7 个桶。
样例:7 个数,分别是 3、5、1、6、2、14、10。
| i | a[i] | pre[i] | pre[i] mod 7 | 该余数的 first | 该余数的 last |
|---|---|---|---|---|---|
| 0 | — | 0 | 0 | 0 | 0 |
| 1 | 3 | 3 | 3 | 1 | 1 |
| 2 | 5 | 8 | 1 | 2 | 2 |
| 3 | 1 | 9 | 2 | 3 | 3 |
| 4 | 6 | 15 | 1 | 2 | 4 |
| 5 | 2 | 17 | 3 | 1 | 5 |
| 6 | 14 | 31 | 3 | 1 | 6 |
| 7 | 10 | 41 | 6 | 7 | 7 |
看余数 3:first = 1,last = 6,长度 = 6 - 1 = 5 ✅
看余数 1:first = 2,last = 4,长度 = 2;余数 0 只有下标 0,长度 0;余数 2 只有下标 3;余数 6 只有下标 7。
最大值是 5,正好是样例答案。
验证:下标 2~6 对应的数是 5、1、6、2、14,和 = 28 = 7 × 4 ✅(题面提示里也是这个例子)。
细节 1:pre[0] = 0 别忘了。「从第 1 个数开始」的区间,需要用到
pre[0] = 0(余数 0 在下标 0 就出现过)。所以循环前要 first[0] = last[0] = 0;。
细节 2:长度是 last - first,不是 last - first + 1。
因为 pre 的下标差本身就等于元素个数:pre[6] - pre[1] 对应的是第 2~6 个数,一共 5 个。
「区间和能被 k 整除」问题都可以这么解:把前缀和全部对 k 取模, 然后找「同余的最远点对」。余数只有 k 种,开 k 个桶就够。
类似地,「区间和等于 0」就是「前缀和相等的最远点对」,也是同一个套路。 这个思想在后面学「哈希 + 前缀」的时候还会再遇到。
数列 2 -4 3 -1 2 -4 3,选一段连续的,让和最大。
选 3 -1 2 和为 4 —— 这就是样例答案。
| 做法 | 思路 | 复杂度 |
|---|---|---|
| 三重循环 | 枚举左端点、右端点,再套一层算区间和 | O(n³) |
| 二重循环 + 边扩边加 | 枚举左端点,右端点往右扩展时顺手累加 | O(n²) |
| Kadane / DP | 一趟扫描,边读边决定「接上」还是「重新开始」 | O(n) |
n 最大 2×10⁵,O(n²) 是 4×10¹⁰ 次运算,肯定超时。必须用 O(n)。
定义 f[i] = 「以第 i 个数结尾」的所有子段里,和最大的那个。
那 f[i] 只有两种来源:
f[i-1] + a[i];a[i](前面的统统不要)。f[i] = max(a[i], f[i-1] + a[i]),答案 = max(f[1], f[2], ..., f[n])
为什么可以「重新开始」?因为如果 f[i-1] 是负数,那把它接在前面只会拖累我 ——
还不如从 a[i] 重新起头。负的累赘,果断丢掉。
| i | a[i] | f[i-1] + a[i] | a[i] | f[i] | 当前最优 |
|---|---|---|---|---|---|
| 1 | 2 | — | 2 | 2 | 2 |
| 2 | -4 | 2-4 = -2 | -4 | -2 | 2 |
| 3 | 3 | -2+3 = 1 | 3 | 3 | 3 |
| 4 | -1 | 3-1 = 2 | -1 | 2 | 3 |
| 5 | 2 | 2+2 = 4 | 2 | 4 | 4 |
| 6 | -4 | 4-4 = 0 | -4 | 0 | 4 |
| 7 | 3 | 0+3 = 3 | 3 | 3 | 4 |
看第 3 步:f[2] = -2 是负数,所以 f[3] 果断选择「从 3 重新开始」而不是「-2 + 3 = 1」。
这就是 Kadane 的灵魂。最后答案是 4。
题目原话是「选出其中连续且非空的一段」。所以如果整个数列全是负数,例如 -2 -5 -1,
答案应该是 -1(挑最大的那个负数),而不是 0!
很多人写 DP 时习惯把 best 初始化成 0,结果全负数输出 0 —— 直接 WA。
正确做法:best 初始化为第一个数,或者 -inf。
我做了一组边界实测:3 / -2 -5 -1 → 输出 -1,正确 ✅
n = 2×10⁵、ai ≤ 10⁴,最坏总和 = 2×10⁹。
而 int 上限是 2147483647 ≈ 2.147×10⁹ —— 理论上刚好装得下,但只剩 1 亿的余量,太贴边了。
一律用 long long 最省心。
实测:n = 200000 且全为 10000 时,输出 2000000000,没有溢出。
计划里还提了一句「二维前缀和 + 枚举:把二维压成一维再求最大子段和」。 这就是经典的「最大加权矩形」问题(Day 15 的 P1719),思路非常漂亮:
col[]。col[] 求最大子段和—— 找到的那一段就对应矩形的左右边界。下面这段拓展代码我用洛谷 P1719 的官方样例实测过:
4 0 -2 -7 0 9 2 -6 2 -4 1 -4 1 -1 8 0 -2
15
#include <iostream> #include <algorithm> using namespace std; int n; int a[125][125]; int col[125]; // col[j] = 当前上下边界之间,第 j 列的元素和 int main(){ cin >> n; for(int i = 1; i <= n; i++) for(int j = 1; j <= n; j++) cin >> a[i][j]; long long best = -1000000000LL; // ★ 答案可能是负数,不能初始化成 0 // 枚举上边界 top 和下边界 bottom,把这两行之间的每一列压缩成一个数 for(int top = 1; top <= n; top++){ for(int j = 1; j <= n; j++) col[j] = 0; for(int bottom = top; bottom <= n; bottom++){ for(int j = 1; j <= n; j++) col[j] += a[bottom][j]; // 现在问题变成了「一维数组 col[1..n] 的最大子段和」,直接 Kadane long long cur = col[1], mx = col[1]; for(int j = 2; j <= n; j++){ cur = max((long long)col[j], cur + col[j]); mx = max(mx, cur); } best = max(best, mx); } } cout << best << endl; return 0; }
P1719 官方样例(4×4 矩阵)→ 输出 15 与标准答案完全一致
另测全负数矩阵 2 / -1 -2 / -3 -4 → 输出 -1 正确(最大矩形就是单格 -1)。
这个「降维」思路能解决一大类二维区间问题。更重要的是 ——
你已经写出了人生第一个「非平凡」DP 的转移方程 cur = max(col[j], cur + col[j])。
明天 Day 25 的线性 DP,就是把这个思路正式系统化。
同样地,下面四段代码都用 g++(-std=c++14 -O2 -Wall)实际编译过,并用洛谷官方样例跑过。
题意:河道里起点、终点之间排着 N 块岩石。最多移走 M 块(起点终点不能动), 让选手「每一步跳跃距离」的最小值尽可能大,求这个最大值。
20% 的数据 0 ≤ M ≤ N ≤ 10;50% 的数据 N ≤ 100; 100% 的数据 0 ≤ M ≤ N ≤ 50000,1 ≤ L ≤ 10⁹。
注意三点:
· M 可以等于 0(一块都不许搬),也可以等于 N(全搬光);
· L 最大 10⁹,加上还有石头位置,所以位置要用 int 以上存,比较时注意别溢出;
· 二分约 log(10⁹) ≈ 30 层,每层 O(N) = 5×10⁴,总共 1.5×10⁶ —— 非常轻松。
这是昨天模板 A 的应用:答案「最短跳跃距离」越大越难满足,我们要最大的可行值。
check(x):从左往右贪心扫描(起点 0 当作已经踩上),
距离不够就搬走当前石头,最后看搬走的数量是否 ≤ M。
技巧:把终点 L 放进数组最后一格(a[n+1] = L),让它也参与扫描,
这样「最后一段距离够不够」就自然被处理了。
25 5 2 2 11 14 17 21
4
#include <iostream> using namespace std; int L, n, m; int a[50005]; // a[0]=起点0, a[1..n]=中间石头, a[n+1]=终点L // check(x):能不能通过搬走 <= m 块石头,让所有「相邻落脚点」的距离都 >= x bool check(int x){ int cnt = 0; // 搬走的石头数 int last = 0; // 上一块留下来的石头(下标),0 号是起点 for(int i = 1; i <= n + 1; i++){ if(a[i] - a[last] < x){ // 从 last 跳到 i 太近了 -> 把 i 搬走 cnt++; } else { last = i; // 距离够远,这块踩上去 } } return cnt <= m; // 搬走的不超过 m 块,x 就可行 } int main(){ ios::sync_with_stdio(false); cin.tie(0); cin >> L >> n >> m; a[0] = 0; for(int i = 1; i <= n; i++) cin >> a[i]; a[n + 1] = L; // ★ 终点当成最后一块「石头」参与判断 // 要求「最短跳跃距离的最大值」:x 越大越难做到 -> 单调 int l = 1, r = L, ans = 0; while(l <= r){ // 模板 A:ans 记录 + 区间跨越 mid int mid = l + (r - l) / 2; if(check(mid)){ // mid 能做到,记下来,再贪心地试试更大 ans = mid; l = mid + 1; } else { r = mid - 1; // 做不到,只能往小找 } } cout << ans << endl; return 0; }
官方样例 25 5 2 / 2 11 14 17 21 → 输出 4 与标准答案完全一致
边界 25 5 0 / 2 11 14 17 21(M = 0,一块都不能搬)→ 输出 2 正确
(此时最短距离就是最小的相邻距离:起点到 2 只有 2)。
极限数据 L = 10⁹、N = 50000 块等间距 19999 的岩石、M = 25000 →
输出 39998(正好等于「隔一块搬一块」后的间距 2×19999,符合预期),
实测 51 ms。同一组数据令 M = 0 → 输出 19999,也正确。
与暴力对拍(枚举搬走哪几块石头的所有子集)随机 300 组,全部一致。
a[50005] 但下标要写到 n+1:n 最大 50000,所以至少要 50002 个元素。
写 a[50005] 是安全的(我留了余量),但别写 a[50000]。cnt <= m。题意:贷款 w0 元,每月还 w 元,恰好 m 个月还清。
利率按月累计,求月利率(用百分数表示,四舍五入到 0.1%)。
1 ≤ w0, w ≤ 2³¹ − 1,1 ≤ m ≤ 3000;数据保证答案不超过 300.0%。
三点提醒:
· 金额可能到 21 亿,要用 double / long long 级别的容器,别用 int 参与运算;
· m 最大 3000,如果利率较大,中间过程会溢出 double → 需要保护;
· 「答案不超过 300%」这句话直接告诉我们二分上界取 3 就行了。
二分月利率 r,范围 [0, 3](0% ~ 300%)。
check(r):拿这个 r 模拟一遍还款,看 m 个月后还剩多少欠款。
· rem < 0(还多了)→ 利率太低 → 抬高下界 lo = mid
· rem > 0(还不完)→ 利率太高 → 压低上界 hi = mid
循环固定 100 次而不是判断 hi - lo > eps,这样既精确又绝不会死循环。
最后 × 100 用 %.1f 输出(自带四舍五入)。
1000 100 12
2.9
#include <iostream>
using namespace std;
int main(){
double w0, w; // w0 = 贷款原值, w = 每月还款额
int m; // m = 还款月数
cin >> w0 >> w >> m;
// 二分月利率 r:r 越大,还完 m 个月后剩下的欠款越多 -> 单调
// 题目保证答案不超过 300%,所以利率(小数形式)一定在 [0, 3] 内
double lo = 0.0, hi = 3.0;
for(int it = 0; it < 100; it++){ // 固定迭代 100 次,比 while(hi-lo>eps) 更稳
double mid = (lo + hi) / 2;
double rem = w0; // rem = 当前还欠银行多少
for(int i = 0; i < m; i++){
rem = rem * (1.0 + mid) - w; // 每月先算利息,再还掉 w
if(rem > 1e18){ rem = 1e18; break; } // 数值已经爆炸,符号已确定,直接收工
if(rem < -1e18){ rem = -1e18; break; }
}
if(rem < 0) lo = mid; // 最后还多了 -> 利率太低,要调高
else hi = mid; // 最后还欠着 -> 利率太高,要调低
}
// 换成百分数,四舍五入到 0.1%
printf("%.1f\n", (lo + hi) / 2 * 100);
return 0;
}
官方样例 1000 100 12 → 输出 2.9 与标准答案完全一致
程序内部实际收敛到的利率是 2.922854%,四舍五入到 1 位小数正好是 2.9。 我还用「拿 2.9% 正向模拟 12 个月」独立验证过:剩余欠款 −1.93 元 ≈ 0,确实还得刚刚好。
另外测了 1000 200 6 → 输出 5.5(真值 5.471793%,正向模拟剩余 1.23 ≈ 0 ✅);
300 50 12 → 输出 12.7(真值 12.694680% ✅);
1000000 50000 60 → 输出 4.7(真值 4.678192% ✅)。
rem < 0 和 rem > 0 该往哪边收搞反:
这就是我第一次写的 bug,答案会一路冲到上界(输出 300.0)。务必拿样例验一遍。while(hi - lo > 1e-12) 而不是固定次数:eps 定得太小可能因浮点误差死循环;
固定 100 次既简单又稳妥。× 100 或忘了保留 1 位小数:题目要的是百分数形式,输出 0.0 而不是 2.9 就全错了。(1+r)^3000 会变成 inf,
虽然符号恰好还对,但加上 1e18 的提前跳出更干净、也更快。double 最安全。题意:N 头奶牛排成一排,每头有一个整数 ID。找一段连续的奶牛, 让 ID 之和是 7 的倍数,并且这段奶牛数量最多。不存在就输出 0。
1 ≤ N ≤ 50000,每头奶牛的 ID 在 0 ~ 1000000 之间。
N = 50000 时暴力枚举所有区间是 12.5 亿次,太慢;但 ID 之和最大 50000 × 10⁶ = 5×10¹⁰, 超过 int。不过我们只关心「模 7 的余数」,所以边读边取模(余数永远在 0~6), 既不会溢出,也不需要开大数组。
设前缀和 pre[i] = a[1] + ... + a[i]。区间 (l..r) 的和 = pre[r] - pre[l-1]。
要是 7 的倍数,等价于 pre[r] ≡ pre[l-1] (mod 7) ——
只要两个前缀和余数相同,中间那一段就是 7 的倍数。
于是只开 7 个桶,记录每个余数第一次和最后一次出现的下标,
答案就是 max(last[r] - first[r])。
别忘了前缀和的起点 pre[0] = 0:所以循环开始前 first[0] = last[0] = 0。
7 3 5 1 6 2 14 10
5
题面提示:5 + 1 + 6 + 2 + 14 = 28,是 7 的倍数,一共 5 头奶牛。
#include <iostream>
#include <cstdio>
using namespace std;
int main(){
int n;
scanf("%d", &n);
int first[7], last[7]; // 每个余数第一次/最后一次出现的前缀下标
for(int r = 0; r < 7; r++){ first[r] = -1; last[r] = -1; }
int pre = 0; // 前缀和 mod 7,即 sum[0] = 0
first[0] = 0; last[0] = 0; // ★ 余数 0 在下标 0 就出现过,别忘了
for(int i = 1; i <= n; i++){
int x;
scanf("%d", &x);
pre = (pre + x % 7) % 7; // 边读边取模,永远不会溢出
if(first[pre] == -1) first[pre] = i;
last[pre] = i;
}
// 若 sum[r] ≡ sum[l-1] (mod 7),则区间 (l..r) 的和是 7 的倍数
// 长度 = r - (l-1),所以每个余数都取「最后一次出现 - 第一次出现」
int ans = 0;
for(int r = 0; r < 7; r++){
if(first[r] != -1 && last[r] - first[r] > ans)
ans = last[r] - first[r];
}
printf("%d\n", ans);
return 0;
}
官方样例 → 输出 5 与标准答案完全一致
边界 1 / 1(只有一个数且不是 7 的倍数)→ 输出 0 正确(题面要求无解输出 0)。
极限数据 N = 50000 随机 ID(0~10⁶)→ 输出 49995,实测 38 ms;
同一规模下让所有 ID 都是 7 的倍数 → 输出 50000 正确(整段都是 7 的倍数)。
与暴力 O(n²) 枚举所有区间随机对拍 300 组,全部一致。
first[0] = last[0] = 0:这样「从第 1 头奶牛开始」的合法区间会被漏掉,
答案可能偏小。last - first + 1:前缀和的下标差本身就是元素个数
(pre[6] − pre[1] 对应第 2~6 个数,共 5 个),多加 1 就错了。long long 也不会溢出(5×10¹⁰),
但开一个 50005 的 long long 数组多花 400KB,而边读边取模只用 7 个变量,
是这道题最优雅的写法。ans 初值取 0 正好。题意:给一个长度为 n 的整数序列,选出一段连续且非空的子段,使这段的和最大,输出这个和。
40% 的数据 n ≤ 2×10³;100% 的数据 1 ≤ n ≤ 2×10⁵,−10⁴ ≤ ai ≤ 10⁴。
· n = 2×10⁵ 意味着 O(n²) 会超时,必须 O(n);
· ai 可以是负数;最坏总和 2×10⁵ × 10⁴ = 2×10⁹,理论上刚好卡在 int 边缘,
用 long long 保险;
· 洛谷在 2026/01/21 还给这题加过一组 hack 数据(针对「全负数输出 0」这种错解),
别踩这个坑。
设 f[i] = 「以第 i 个数结尾」的最大子段和。那么:
f[i] = max(a[i], f[i-1] + a[i]); // 要么接上前面,要么从自己重新开始
因为 f[i] 只依赖 f[i-1],所以不用开数组,
一个变量 cur 边读边滚动就行,空间 O(1)。
答案就是所有 f[i] 里的最大值。初始化时 best 要取第一个数
(不能取 0,否则全负数会错)。
7 2 -4 3 -1 2 -4 3
4
题面解释:选子段 [3, 5],即 {3, −1, 2},和为 4。
#include <iostream>
using namespace std;
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
int n;
cin >> n;
long long best = 0, cur = 0; // best=全局最优, cur=以当前数结尾的最大子段和
for(int i = 1; i <= n; i++){
long long x;
cin >> x;
if(i == 1){
best = cur = x; // ★ 第一个数直接作为初始状态(不能初始化成 0)
continue;
}
// f[i] = max(a[i], f[i-1] + a[i])
// 前面的累加和如果是负的,还不如从 x 重新开始
cur = (cur + x > x) ? (cur + x) : x;
if(cur > best) best = cur;
}
cout << best << endl;
return 0;
}
官方样例 7 / 2 -4 3 -1 2 -4 3 → 输出 4 与标准答案完全一致
边界 3 / -2 -5 -1(全负数)→ 输出 -1 正确
(子段非空,答案是最大的那个负数,不是 0)。
极限数据 n = 200000 且全部为 10000 → 输出 2000000000(无溢出,实测 98 ms);
全部为 −10000 → 输出 -10000 正确。
与暴力 O(n²) 枚举所有子段随机对拍 300 组,全部一致。
best 初始化成 0:全负数时输出 0,但 0 根本不是一个合法子段的和。
这是本题被 hack 的头号错误。cur = max(0, cur + x):这是「最大子段和允许为空」的版本,
全负数会输出 0,同样错。| 错误写法 | 后果 | 正确写法 |
|---|---|---|
| P2678 忘了把起点 0 / 终点 L 放进数组 | 漏算首尾两段距离,答案偏大 | a[0]=0; a[n+1]=L; 一起扫 |
P1163 的 lo = mid / hi = mid 写反 |
答案一路冲到上界,样例输出 300.0 | rem<0 → lo=mid;rem>0 → hi=mid |
实数二分用 while(hi-lo > 1e-12) |
eps 取值不当可能不收敛(死循环) | 固定循环 100 次,稳且准 |
P1163 忘了 × 100 或忘了 %.1f |
输出格式不符,直接判错 | printf("%.1f\n", r * 100) |
P3131 忘了 first[0] = last[0] = 0 |
漏掉「从第 1 头开始」的合法区间,答案偏小 | 前缀和下标 0 处余数为 0,要预先登记 |
P3131 长度算成 last - first + 1 |
答案偏大 1 | 前缀和下标差就是元素个数 |
P1115 把 best 初始化成 0 |
全负数时输出 0,被 hack 数据卡掉 | best = cur = a[1] 或 -inf |
累加类变量用 int |
P1115、P3131 的相关中间量会溢出 | 统一 long long,或边读边取模 |
| 贪心题把「至多 M 块」当成「恰好 M 块」 | P2678 答案错误 | 判断写成 cnt <= m |
答:因为我们的贪心是「能留就留」,留下的石头数量已经最多。 如果扫到终点时发现它离最后留下的那块石头太近,说明连最后留下的那块都得牺牲掉。
真把那一块搬走之后,「上一块保留的石头 → 终点」的距离只会更远,不会引出新问题。
所以代码里「给终点记一次 cnt++」和「真实地多搬走一块」数量上完全等价。
答:用 while(hi - lo > eps) 也不算错,但要自己定 eps:
定大了精度不够,定太小(比如 1e-15)时,由于 double 的有效位数只有约 15~16 位十进制,
hi - lo 可能永远大于 eps,导致死循环。
固定 100 次的好处:区间长度变成 1/2¹⁰⁰ ≈ 7.9×10⁻³¹,
远小于 double 的精度,所以答案已经精确到极限了;同时循环次数完全确定,绝不会死循环,也方便估算耗时。
答:设区间是 (l..r),它的和 = pre[r] - pre[l-1]。
如果 pre[r] mod 7 == pre[l-1] mod 7,那么它们的差
pre[r] - pre[l-1] 一定是 7 的倍数(同余的两个数相减,余数抵消为 0)。
反过来,如果区间和是 7 的倍数,两个前缀和的余数也必然相等。 所以这是充要条件 —— 我们可以在 7 个桶里放心地找「最远的同余点对」。
cur = max(x, cur + x) 里的 x 代表什么?答:代表「从当前位置重新开一段」—— 也就是把前面所有累加全部丢掉,只留当前这个数。
什么时候该这么干?当 cur(前面那些数的和)是负数时。
把负数接在前面只会让结果更小。负的累赘,果断不要。
这也解释了为什么全负数时答案不是 0:因为「重新开始」拿到的仍然是那个负数本身, 而不是空集 —— 题目要求子段非空。
for(int top = 1; top <= n; top++){
memset(col, 0, sizeof(col)); // 清空列和
for(int bottom = top; bottom <= n; bottom++){
for(int j = 1; j <= n; j++) col[j] += a[bottom][j]; // 累加列和
// 在这里对 col[] 跑一次 Kadane
}
}
★ 要点:外层枚举上下边界,内层把「这两行之间每一列的和」当成一个一维数组, 对它求最大子段和 —— 找到的那一段就决定了矩形的左右边界。
复杂度 O(n³):外层两层 n²,里面 Kadane 一层 n。n ≤ 120 时完全跑得动。
【二分 + 贪心(P2678 型:最大化最小值)】
把起点 0 和终点 L 一起放进数组
check(x):
cnt = 0; last = 0(起点下标);
for i = 1 .. n+1:
if(a[i] - a[last] < x) cnt++; // 太近,搬走当前
else last = i; // 够远,踩上去
return cnt <= M;
用昨天模板 A:可行 → ans = mid, l = mid + 1
【实数二分(P1163 型)】
double lo = 下界, hi = 上界; // 有解区间,别乱开
for(int it = 0; it < 100; it++){ // ★ 固定次数,不死循环
double mid = (lo + hi) / 2;
if(check(mid)) hi = mid; // 或 lo = mid,看单调方向
else lo = mid;
}
printf("%.1f\n", (lo + hi) / 2 * 100); // %.1f 自带四舍五入
// 中间量可能爆 double 时,加 if(rem > 1e18){ rem = 1e18; break; }
【前缀和 + 同余(P3131 型)】
// 区间和能被 k 整除 ⟺ 两个前缀和 mod k 相等
int first[7], last[7]; // 全初始化为 -1
int pre = 0;
first[0] = last[0] = 0; // ★ pre[0] = 0 要先登记
for(i = 1..n){
cin >> x;
pre = (pre + x % 7) % 7; // 边读边取模,不溢出
if(first[pre] == -1) first[pre] = i;
last[pre] = i;
}
ans = max(last[r] - first[r]); // 长度就是下标差,别 +1
【最大子段和 Kadane(P1115)】
long long cur = a[1], best = a[1]; // ★ 不能用 0 初始化!
for(i = 2..n){
cur = max(a[i], cur + a[i]); // f[i] = max(a[i], f[i-1]+a[i])
best = max(best, cur);
}
// 子段必须非空 → 全负数时输出最大的那个负数
【二维压一维(最大加权矩形)】
for top = 1..n:
col[] 清零
for bottom = top..n:
col[j] += a[bottom][j] // 累加列和
对 col[] 跑一次 Kadane
// 复杂度 O(n^3)
动态规划入门:线性 DP
今天你已经偷偷用过一次 DP 了 —— 最大子段和的
cur = max(a[i], cur + a[i]) 就是一个正儿八经的状态转移方程。
明天我们要把这个「凭感觉写出来」的东西,变成有章法可循的套路。
明天会见到的四道题:
每道题都按四要素来分析:① 状态定义 ② 转移方程 ③ 初始化 ④ 答案在哪。 今天先把这个口诀记住,明天逐题套。
另外,明天开始务必养成习惯:写完 DP 先手推样例表格, 就像今天最大子段和那张表一样 —— 表算对了,代码基本不会错。