ACM 一个月学习计划 · DAY 24

Day 24 · 二分与数据结构综合

把二分、贪心、前缀和、DP 串成一条线 —— 从今天起,你开始写「组合拳」代码

今日目标:会写实数二分;会用「前缀和 + 同余」;会写最大子段和的 DP
预计用时:约 3 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P2678 / P1163 / P3131 / P1115
← Day 23 · 二分答案:把「求解」变成「判定」

⏱今日时间安排

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。

为什么把它们放在一天?因为计划里这一天的定位是「综合」: 真正比赛里的题,很少只考一个知识点。今天开始,你要习惯「先拆解,再组合」。

1二分 + 贪心:跳石头

先回忆一下昨天的第 2 节

看到「最短跳跃距离的最大值」这种问法,条件反射应该是:

判断结论
答案是「最大」还是「最小」?要最大化最短距离 → A 类,用模板 A(ans 记录)
check(x) 单调吗?x(要求的最短距离)越大,越难做到 → true…true…false…false ✅
check(x) 好写吗?好写:一次 O(n) 贪心扫描

三条全中,所以 P2678 就是一道标准的二分答案题。

check 的贪心怎么想

题目允许我们搬走至多 M 块石头,然后看所有「相邻落脚点」的距离里最小的那个能有多大。

现在假设我们已经定死了一个要求:所有跳跃距离都必须 ≥ x。那么从左往右走:

🎯 为什么「太近就搬走当前这块」是对的

因为我们要尽可能少搬。碰到一块太近的石头,只有两种办法:搬走它,或者搬走前面那块。
搬走前面那块有害无益 —— 那会让 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→终点)。

2实数二分:银行贷款(今天的第一个新东西)

整数二分不够用了

昨天四道题的答案都是整数:高度、长度、段和、间距。 但 P1163 问的是月利率,答案是 2.9 这种小数。这时候二分还能用吗?能用, 而且更简单 —— 因为实数没有「相邻整数」的问题。

🎯 实数二分 vs 整数二分

相同点:都是「猜一个值 → check → 砍掉一半」,都要求单调性。
不同点:整数二分的循环条件是 l <= r(区间缩到空), 实数二分不能这么写 —— 因为实数区间「永远缩不到空」。

实数二分的循环怎么写?固定次数!

很多人会写 while(r - l > 1e-12)。这么写不算错,但有两个毛病:

更稳的做法是:循环固定次数,比如 100 次。

for(int it = 0; it < 100; it++){     // 固定 100 次,绝不死循环
    double mid = (lo + hi) / 2;
    if(check(mid)) ...;
    else ...;
}
💡 为什么 100 次绝对够

每迭代一次,区间长度就除以 2。
循环 100 次后,区间长度变成原来的 1 / 2¹⁰⁰ —— 这个数大约是 7.9 × 10⁻³¹, 远远超过 double 能表示的精度(double 只有效 15~16 位十进制)。

也就是说,循环到 60 次左右,lo 和 hi 在计算机看来已经是同一个数了, 再迭代也不会变。100 次属于「肯定够、还便宜」的选择(本题 m ≤ 3000,100 次也就 30 万次运算)。

P1163 的 check 怎么想

题意:借了 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,确实还清了。

3前缀和 + 同余:余数为 7

先复习前缀和(Day 15 学过)

前缀和数组 pre[i] = a[1] + a[2] + ... + a[i],那么:

区间 [l, r] 的和 = pre[r] - pre[l-1];      // O(1) 求任意区间和

比起每次都重新累加(O(n)),前缀和把「求区间和」变成了 O(1),这是拿空间换时间的老套路。

今天的新技巧:把「是 7 的倍数」翻译成「余数相等」

题目要求:找一段连续奶牛,使它们的 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,我们只需要记住两件事:

那么余数为 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。

ia[i]pre[i]pre[i] mod 7该余数的 first该余数的 last
0—0000
133311
258122
319233
4615124
5217315
61431316
71041677

看余数 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」就是「前缀和相等的最远点对」,也是同一个套路。 这个思想在后面学「哈希 + 前缀」的时候还会再遇到。

4最大子段和:Kadane 算法(线性 DP 的第一课)

题目很简单:找一段连续的子数组,让和最大

数列 2 -4 3 -1 2 -4 3,选一段连续的,让和最大。 选 3 -1 2 和为 4 —— 这就是样例答案。

从暴力到 O(n),一步步来

做法思路复杂度
三重循环枚举左端点、右端点,再套一层算区间和O(n³)
二重循环 + 边扩边加枚举左端点,右端点往右扩展时顺手累加O(n²)
Kadane / DP一趟扫描,边读边决定「接上」还是「重新开始」O(n)

n 最大 2×10⁵,O(n²) 是 4×10¹⁰ 次运算,肯定超时。必须用 O(n)。

核心思想:以 i 结尾的最大子段和

定义 f[i] = 「以第 i 个数结尾」的所有子段里,和最大的那个。

那 f[i] 只有两种来源:

🎯 转移方程(背下来)

f[i] = max(a[i], f[i-1] + a[i]),答案 = max(f[1], f[2], ..., f[n])

为什么可以「重新开始」?因为如果 f[i-1] 是负数,那把它接在前面只会拖累我 —— 还不如从 a[i] 重新起头。负的累赘,果断丢掉。

走一遍样例

ia[i]f[i-1] + a[i]a[i]f[i]当前最优
12—222
2-42-4 = -2-4-22
33-2+3 = 1333
4-13-1 = 2-123
522+2 = 4244
6-44-4 = 0-404
730+3 = 3334

看第 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),思路非常漂亮:

下面这段拓展代码我用洛谷 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,就是把这个思路正式系统化。

5四道例题详解

同样地,下面四段代码都用 g++(-std=c++14 -O2 -Wall)实际编译过,并用洛谷官方样例跑过。

P2678 [NOIP 2015 提高组] 跳石头 普及/提高- · 二分 + 贪心(A 类)

题意:河道里起点、终点之间排着 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 组,全部一致。

易错点

  • 忘了把起点 0 和终点 L 加进去:只对输入的 N 个位置做判断,会漏掉「起点→第一块」和 「最后一块→终点」这两段距离,答案必然偏大。
  • 数组开成 a[50005] 但下标要写到 n+1:n 最大 50000,所以至少要 50002 个元素。 写 a[50005] 是安全的(我留了余量),但别写 a[50000]。
  • 把 M 理解成「必须搬走恰好 M 块」:题面是「至多移走 M 块」, 少搬是可以的。所以判断条件是 cnt <= m。
  • 如果把「太近就搬走当前这块」改成「搬走前一块」,贪心就不成立了,答案可能偏小。
P1163 银行贷款 普及/提高- · 实数二分

题意:贷款 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 就全错了。
  • 没做溢出保护:m = 3000 且利率偏大时 (1+r)^3000 会变成 inf, 虽然符号恰好还对,但加上 1e18 的提前跳出更干净、也更快。
  • 参数用 int 读进来再参与浮点运算:金额上限 2³¹−1,读进 double 最安全。
P3131 [USACO16JAN] Subsequences Summing to Sevens S 普及/提高- · 前缀和 + 同余

题意: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 个变量, 是这道题最优雅的写法。
  • 无解时输出 0 还是 -1:题面明确说「如果不存在这样的组,则输出 0」,所以 ans 初值取 0 正好。
P1115 最大子段和 普及- · 线性 DP(Kadane)

题意:给一个长度为 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,同样错。
  • 以为要开 dp 数组:其实只依赖前一项,一个变量滚动就够。开了数组也不算错,只是没必要。
  • 用 int 存累加和:2×10⁹ 已经贴着 int 上限了,多一个数就溢出。

6今日常见错误汇总

错误写法后果正确写法
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

✓自测:你真的会了吗

第 1 题:P2678 里,为什么把终点当成「可以搬走的石头」是对的?

答:因为我们的贪心是「能留就留」,留下的石头数量已经最多。 如果扫到终点时发现它离最后留下的那块石头太近,说明连最后留下的那块都得牺牲掉。

真把那一块搬走之后,「上一块保留的石头 → 终点」的距离只会更远,不会引出新问题。 所以代码里「给终点记一次 cnt++」和「真实地多搬走一块」数量上完全等价。

第 2 题:实数二分为什么要循环 100 次?用 eps 判断不行吗?

答:用 while(hi - lo > eps) 也不算错,但要自己定 eps: 定大了精度不够,定太小(比如 1e-15)时,由于 double 的有效位数只有约 15~16 位十进制, hi - lo 可能永远大于 eps,导致死循环。

固定 100 次的好处:区间长度变成 1/2¹⁰⁰ ≈ 7.9×10⁻³¹, 远小于 double 的精度,所以答案已经精确到极限了;同时循环次数完全确定,绝不会死循环,也方便估算耗时。

第 3 题:P3131 里,为什么「余数相同」就意味着「区间和是 7 的倍数」?

答:设区间是 (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 个桶里放心地找「最远的同余点对」。

第 4 题:P1115 的 cur = max(x, cur + x) 里的 x 代表什么?

答:代表「从当前位置重新开一段」—— 也就是把前面所有累加全部丢掉,只留当前这个数。

什么时候该这么干?当 cur(前面那些数的和)是负数时。 把负数接在前面只会让结果更小。负的累赘,果断不要。

这也解释了为什么全负数时答案不是 0:因为「重新开始」拿到的仍然是那个负数本身, 而不是空集 —— 题目要求子段非空。

第 5 题(手写代码):把二维矩阵压成一维求最大子矩形,核心是哪两层循环?
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)

→明天预告 · Day 25

动态规划入门:线性 DP

今天你已经偷偷用过一次 DP 了 —— 最大子段和的 cur = max(a[i], cur + a[i]) 就是一个正儿八经的状态转移方程。 明天我们要把这个「凭感觉写出来」的东西,变成有章法可循的套路。

明天会见到的四道题:

每道题都按四要素来分析:① 状态定义 ② 转移方程 ③ 初始化 ④ 答案在哪。 今天先把这个口诀记住,明天逐题套。

另外,明天开始务必养成习惯:写完 DP 先手推样例表格, 就像今天最大子段和那张表一样 —— 表算对了,代码基本不会错。