这一节不讲新知识。4 道题、120 分钟,考第 1~34 节里复用率最高的四件事:贪心 + 堆(第 17 节)、双指针(第 19 节)、二分答案(第 27 节)、记忆化搜索(第 22 节)。题目都是洛谷真题,代码只用你已经学过的东西写。本场满分 400 分,每题 100 分;除 A 题外都有部分分可拿。
先看清单:四道题分别压在哪几节上。卡住时不要顺着题号往下问,直接回来查对应的节。
| 题号 | 题目 | 对应节 | 一句话考点 | 建议用时 |
|---|---|---|---|---|
| A | P1091 合唱队形 | 第 30、31 节 | 正反两遍 LIS,再合并 | 22 分钟 |
| B | P1638 逛画展 | 第 19 节 | 窗口覆盖全部种类 → 双指针 | 26 分钟 |
| C | P1182 数列分段 | 第 27 节 | 最小化最大值 → 二分答案 | 32 分钟 |
| D | P1434 滑雪 | 第 22 节 | 网格上求最长下降路 → 记忆化 | 24 分钟 |
四道题的难度都是普及/提高-,没有难题,但每题都有一个“想不到就 0 分、想到就 5 分钟”的关键点。这场的真正对手是时间。
总原则:先把手速题的分全额拿到,再回头啃需要想的题。题号不代表难度顺序,但本场特意把最省时间的 A 放在最前面,你必须在前 28 分钟内交掉它——A 题交得晚,后面三道题的心态就全乱了。
| 时间段 | 做什么 | 关键动作 |
|---|---|---|
| 0 ~ 8 | 通读 4 题 + 排序 | 在纸上给每道题标:会做 / 有思路 / 完全不会。这 8 分钟不许写代码。 |
| 8 ~ 28 | A 合并果子(20) | 难度最低,必须一次写对。测样例 + 自造 1 组再交。 |
| 28 ~ 54 | B 逛画展(26) | 双指针边界是本场最容易错的地方,写完先手算一遍样例。 |
| 54 ~ 86 | C 数列分段(32) | 二分答案:区间、check、防溢出三件事都要想全再动手。 |
| 86 ~ 110 | D 滑雪(24) | 记忆化:只多两行,但漏了就是 TLE。到点就停。 |
| 110 ~ 120 | 检查 | 只查数组大小、long long、输出格式,不改算法。 |
每题内部再切一刀:想 4 分钟、写 12 分钟、测 4 分钟。“想”的这 4 分钟不许碰键盘,纸上必须出现 4 行东西:
< 还是 <=,窗口是 [l, r] 还是 [l, r)。键盘会骗你:能编译通过的代码看起来总是对的。而纸上那 4 行一旦写错,编译是发现不了的。
本场四道题的丢分点全部在这 4 行里:A 的堆类型、B 的 while、C 的 lo 下界、D 的 dp 初值。
养成习惯后,这 4 分钟会缩短到 60 秒——但绝不能省。
开赛前先花 10 分钟做一道不计分的热身题:把“读题 → 写 → 测”的流程跑热,顺便确认编译环境、快读模板、以及“多关键字排序”这个你已经用过十几次的动作没生疏。
某小学最近得到一笔赞助,打算拿出其中一部分为前 5 名学生发奖学金。期末,每个学生都有语文、数学、英语 3 门课的成绩。先按总分从高到低排序,如果两个同学总分相同,再按语文成绩从高到低排序,如果语文成绩也相同,规定学号小的同学排在前面。这样,每个学生的排序是唯一确定的。
输入格式:第一行一个整数 n(n ≤ 300);接下来 n 行,每行三个 0~100 的整数,依次是学号为 1、2、…、n 的学生的语文、数学、英语成绩。
输出格式:共 5 行,每行两个整数,依次为前 5 名学生的学号和总分;若 n < 5,则输出全部学生。
这题就是第 12 节的原样:结构体 + 多关键字排序。分值全在 cmp 上,思路一分不值。
min(n, 5),n < 5 时不会越界。如果这题你写了超过 12 分钟,说明第 12 节的 cmp 还没变成肌肉记忆,那 B、C、D 三道题里凡是要排序的地方都会拖慢你。热身的意义就在这里。
#include <bits/stdc++.h>
using namespace std;
struct Stu { int id, ch, sum; }; // 只存比较要用的字段:学号、语文、总分
bool cmp(const Stu &a, const Stu &b) {
if (a.sum != b.sum) return a.sum > b.sum; // 第一关键字:总分降序
if (a.ch != b.ch ) return a.ch > b.ch; // 第二关键字:语文降序
return a.id < b.id; // 第三关键字:学号升序(学号唯一,不必再判相等)
}
Stu s[305]; // n ≤ 300,305 留余量;全局数组默认全 0
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 1; i <= n; ++i) {
int c, m, e;
cin >> c >> m >> e;
s[i] = {i, c, c + m + e}; // 总分当场算掉,后面不再碰数学、英语
}
sort(s + 1, s + n + 1, cmp); // 下标从 1 开始,区间是左闭右开,右端写 n+1
for (int i = 1; i <= min(n, 5); ++i)
cout << s[i].id << " " << s[i].sum << "\n";
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
struct Stu { int id, ch, sum; }; | 只留比较时用得到的字段。数学、英语读完就没用了,存进结构体只会让拷贝变慢。 |
if (a.sum != b.sum) return a.sum > b.sum; | 第一关键字。不能写成 >=:相等时也返回 true,会违反严格弱序,sort 可能越界崩溃(第 12 节踩过这个坑)。 |
if (a.ch != b.ch) return a.ch > b.ch; | 前面的 if 已经拦掉了总分相等的情况,走到这里才轮到语文。顺序不能反。 |
return a.id < b.id; | 最后一键用“唯一字段”收尾:学号互不相同,所以永远走不到“三个键全相等”,cmp 天然满足严格弱序。 |
sort(s + 1, s + n + 1, cmp); | 按 1 开始的下标排序,区间左闭右开,右端点是 n+1 而不是 n。写 n 会漏掉最后一个学生。 |
s[i] = {i, c, c + m + e}; | 聚合初始化,按结构体成员的声明顺序填。总分在这里算掉,比每次比较时重算快得多。 |
i <= min(n, 5) | 题目只要前 5 名。写死 i <= 5 而 n = 3 时,会输出两行没初始化的垃圾数据。 |
cout << s[i].id << " " << s[i].sum << "\n"; | 学号 + 一个空格 + 总分 + 换行。行末只有换行,没有多余空格。 |
热身完,看四张模板卡。它们不是新知识,是把本节四道题各自要用的骨架再默写一遍——赛场上默写不出来的模板,等于没学过。先能一次默写正确,再开始计时。
| 组成 | 含义 / 格式要求 |
|---|---|
priority_queue<int, vector<int>, greater<int>> | 三个参数:元素类型、底层容器、比较器。少写一个都编译不过,报错会指向 <bits/stl_queue.h>。 |
greater<int> | 让“大的”优先级更低,于是 top() 变成最小值。写 greater 才是小根堆,这与 sort 里 cmp 的方向刚好相反。 |
pq.top() | 读堆顶,O(1),不删除。空堆上调用是未定义行为,多半直接 RE。 |
pq.pop() | 删堆顶,O(log n),返回 void。必须先 top() 存下来再 pop(),否则这个值就丢了。 |
while (pq.size() > 1) | 合并类问题的循环条件:每合并一次少一堆,剩一堆自然结束。n = 1 时一次都不进。 |
ans += a + b; pq.push(a + b); | 三件事缺一不可:累加花费、把新堆放回、让堆里始终是“当前所有堆”。 |
priority_queue<int, vector<int>, greater<int>> pq;
for (int x : {1, 2, 9}) pq.push(x);
long long ans = 0;
while (pq.size() > 1) {
int a = pq.top(); pq.pop(); // 最小的
int b = pq.top(); pq.pop(); // 次小的
ans += a + b; // 这次合并的代价
pq.push(a + b); // 新堆放回去,参与后面的合并
}
cout << ans; // 15写 int a = pq.pop(); 是编译错误——pop 不返回值。
只 pop 不 push:堆越来越空,答案偏小;若循环条件还成立就会读空堆,RE。
| 组成 | 含义 / 格式要求 |
|---|---|
r | 右端点,只增不减。每轮右移一格,把 a[r] 加进窗口的统计量。 |
把 a[r] 加进窗口 | 维护统计量:计数数组 cnt[]、不同种类数 have、当前区间和 sum。加进来时同步更新它们。 |
while (窗口满足条件) | 必须是 while,不能是 if:缩一次可能还满足,要一直缩到刚好不满足为止。 |
更新答案 | 写在“移出 a[l]”之前。此刻的 [l, r] 是“以 r 结尾的最短合法窗口”,所有长度更长的以 r 结尾的窗口都不可能更优。 |
移出 a[l]; ++l | 左端点右移,同时撤销 a[l] 对统计量的影响。窗口始终是闭区间 [l, r],长度 r − l + 1。 |
为什么是 O(n) | l 和 r 都只往右走,各走不超过 n 步,总操作数 2n。 |
int l = 1, have = 0, bestLen = INT_MAX, bestL = 0, bestR = 0;
for (int r = 1; r <= n; ++r) {
if (++cnt[a[r]] == 1) ++have; // 有新种类进来
while (have == m) { // 已覆盖全部 m 种
if (r - l + 1 < bestLen) { // 严格更短才更新
bestLen = r - l + 1; bestL = l; bestR = r;
}
if (--cnt[a[l]] == 0) --have; // 撤销左端点
++l;
}
}写成 if:只缩一次就跳出,最短解被漏掉。样例往往恰好只需要缩一次,本地看不出来。
先 ++l 再更新答案:记录到的是已经变短的窗口,长度算错,甚至把最短的那个漏掉。
| 组成 | 含义 / 格式要求 |
|---|---|
lo, hi | 答案的取值区间,必须保证答案落在里面:下界取“一定可行”的最小值,上界取“一定可行”的值。 |
check(mid) | 判定 mid 行不行,必须是 O(n) 以内的函数,且关于 mid 单调:mid 可行则更大的都可行。 |
if (check(mid)) hi = mid; | mid 可行 → 答案 ≤ mid,上界收到 mid。不能写 mid - 1,答案可能正好是 mid。 |
else lo = mid + 1; | mid 不可行 → 答案严格大于 mid,下界推到 mid + 1。 |
while (lo < hi) | 区间缩到长度 1 时停。此时 lo == hi,就是最小可行值。 |
lo + (hi - lo) / 2 | 防溢出写法。本题 hi 到 10¹³,习惯成自然。 |
long long lo = mx, hi = sum; // 下界:最大的单个数;上界:全部放一段
while (lo < hi) {
long long mid = lo + (hi - lo) / 2;
if (check(mid)) hi = mid; // mid 可行,往小里收
else lo = mid + 1; // mid 不可行,往大里推
}
cout << lo << "\n";写成 lo = mid:当 hi == lo + 1 时 mid 恒等于 lo,区间再也不缩小,死循环 → TLE。
check 的方向弄反(可行时写 lo = mid + 1),求出来的就是“最大可行值”,本题直接 WA。
| 组成 | 含义 / 格式要求 |
|---|---|
dp[x][y] | “从 (x, y) 出发的答案”。初值 0 表示还没算过,所以真实答案必须 ≥ 1,否则 0 与“没算”无法区分。 |
if (dp[x][y]) return dp[x][y]; | 记忆化的全部价值就在这一行。没有它,同一个格子会被重复搜索,退化成指数级。 |
int best = 1; | 递归的“底”:没有更优的邻居时,答案至少是 1(自己这一格也算长度)。 |
return dp[x][y] = best; | 赋值表达式的值就是赋进去的值,一行完成“存表 + 返回”。 |
递归能停的前提 | 状态转移必须无环:高度严格递减、下标严格递增之类。有环就得换成按顺序递推。 |
int dp[105][105], h[105][105];
int r, c;
const int dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1};
int dfs(int x, int y) {
if (dp[x][y]) return dp[x][y]; // 算过:O(1) 直接给
int best = 1;
for (int k = 0; k < 4; ++k) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 1 || nx > r || ny < 1 || ny > c) continue; // 越界
if (h[nx][ny] < h[x][y]) best = max(best, dfs(nx, ny) + 1);
}
return dp[x][y] = best; // 存下来,下次不再搜
}忘了存表(写成 return best;):每次调用都重新搜,指数级 → TLE。
把 < 写成 <=:等高相邻的格子互相调用,无限递归,栈爆 → RE。
数组开小 → RE。B 题的 N ≤ 10⁶,数组只开 100005,交上去前 3 个点过、第 4 个点崩。数组大小永远从数据范围推:上限 + 5。
int 存和 → WA 成负数。C 题的 Σaᵢ 到 10¹³,用 int 会溢出成负数,二分区间直接乱掉,输出一个负的答案。看到“求和”“乘积”先估量级。
双指针 while 写成 if → WA。样例恰好只需缩一次,本地点过,交上去第 4 个点错。自查方法:拿一段连续 3 个相同画家的数据,看长度算对没有。
二分写成 lo = mid → TLE。hi = lo + 1 时 mid 恒等于 lo,区间不再缩小,程序空转 1 秒、2 秒、直到超时。
记忆化漏了存表 → TLE。D 题不加 dp[x][y] 就是纯 DFS,10⁴ 个格子的搜索树大到不可能跑完。
行末多一个空格 → WA。热身题那种“学号 总分”的输出,最后一个数后面直接换行;严格评测会逐字节比对。
局部变量没初值。int cnt; 不赋值就是垃圾值,写 ++cnt 之后答案随机,本地跑十次可能十次都一样,交上去就错。一切计数变量写 = 0。
关掉页面,开一个空文件,从 #include <bits/stdc++.h> 开始,给 A 题(合并果子)掐 20 分钟写完并提交。
计时前先在纸上写三行:堆的类型、循环条件、ans 的类型。三行写对再动手。
改一改:把 aᵢ 的上限从 20000 改成 10⁹、n 从 10⁴ 改成 10⁵。这时总花费最大约多少?int 还够吗?把 ans 改成 long long 再交一遍。
再改一改:如果题目问的是“合并成 k 堆的最小花费”,本节的做法还够用吗?先把你的猜测写下来,第 49 节的对顶堆会回答它。
一级:写暴力。B 题可以 O(n²) 枚举左右端点(小数据点够用),先交一版有分的,再想 O(n) 的窗口。有分的代码才叫代码。
二级:换实现。C 题想不出二分,就从小到大枚举 k,每个 k 用 O(n) 判断——小数据点能得分。
三级:只写输入输出。哪怕答案是猜的,也把格式写对,保留“万一”的分数。
绝不做的事:在一道题上耗超过 35 分钟。本场四题分值相同,耗死一题等于放弃三题。
下面是正式的四道题。先掐表自己做,卡满单题用时再看提示;提示看完还没思路,再点开思路;四题全做完之后再点开代码对照。
| 题号 | 题目 | 难度 | 建议用时 |
|---|---|---|---|
| A | 合唱队形 | 普及/提高- | 20 分钟 |
| B | 逛画展 | 普及/提高- | 26 分钟 |
| C | 数列分段 Section II | 普及/提高- | 32 分钟 |
| D | 滑雪 | 普及/提高- | 24 分钟 |
n 位同学排成一排,第 i 位同学的身高是 aᵢ。要从中选出 k 位同学,让他们按身高先递增、再递减排成一列(可以是纯递增或纯递减),且保持原来的相对顺序不变。
求最少需要让多少位同学出列。
输入格式:第一行一个整数 n(1 ≤ n ≤ 100);第二行 n 个整数 aᵢ(130 ≤ aᵢ ≤ 230),空格分隔。
输出格式:一个整数,表示最少出列人数。
样例输入8
186 186 150 200 160 130 197 200
样例输出4
样例解释:留下 4 人(150 200 160 130 或 186 200 197 200 等),出列 8 − 4 = 4 人。
“先增后减”的形状,能不能拆成“从某个位置往左看递增”和“从同一个位置往右看递增”两件事?
固定中间那个最高的人 i,左边能留下的最多人数是多少?右边呢?这两件事相互独立吗?
同一件事(以某点结尾的最长递增子序列)要算两次:一次从左往右,一次从右往左。
设留下的人里最高的那位站在位置 i。那么:
· i 左边的人必须构成以 i 结尾的严格递增序列,最多能留 up[i] 人(含 i 自己);
· i 右边的人必须构成从 i 开始的严格递减序列;把数组倒过来看,就是以 i 结尾的严格递增序列,最多能留 down[i] 人(含 i 自己)。
两段共享 i 这个点,所以以 i 为峰能留下 up[i] + down[i] − 1 人。枚举所有 i 取最大值 best,答案就是 n − best。
up 的求法就是 LIS 的 O(n²) 写法:up[i] = 1 + max{ up[j] : j < i 且 a[j] < a[i] }。down 可以用同一段代码算两次:先把数组反转,跑一遍 up,再反转回来。
为什么“只看 i 左边”就够:因为左右两侧的人选互相独立——左边的人只受左半边约束,右边只受右半边约束,唯一的交集是峰 i 本身,所以直接相加再减 1。
复杂度 O(n²)。n ≤ 100 时只有 10⁴ 次比较,随便过。
注意“严格”二字:身高相同的两人不能同时出现在递增段里,所以比较必须写 a[j] < a[i],不能写 <=。
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
int n, a[N], up[N], down[N];
// 求以每个位置结尾的最长严格上升子序列长度,存进 f[]
void lis(int f[]) {
for (int i = 1; i <= n; ++i) {
f[i] = 1; // 至少能只留自己
for (int j = 1; j < i; ++j)
if (a[j] < a[i]) // 严格递增:不能取等
f[i] = max(f[i], f[j] + 1);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
lis(up); // 从左往右:以 i 结尾的最长递增
reverse(a + 1, a + n + 1); // 倒过来
lis(down); // 现在还叫 down,含义是"以 i 开头的递减"
reverse(down + 1, down + n + 1); // 把 down 的下标对回去,与原来的 i 对齐
reverse(a + 1, a + n + 1); // a 恢复原样(其实后面不用了,但别留坑)
int best = 0;
for (int i = 1; i <= n; ++i)
best = max(best, up[i] + down[i] - 1); // 峰 i 被两边都算了一次,减 1
cout << n - best << "\n"; // 问的是出列人数
return 0;
}| 片段 | 作用 |
|---|---|
f[i] = 1; | LIS 的初值。以 i 结尾的序列至少包含自己,长度 1。漏了这一句长度会偏小。 |
if (a[j] < a[i]) | 严格递增。写成 <= 会把身高相同的人接进来,样例中两个 186 会同时被留,答案偏小。 |
lis(up); | 第一次:从左往右,up[i] 是“以 i 结尾的最长递增段”。 |
reverse(a + 1, a + n + 1); | 把数组倒过来,于是“从 i 往右递减”变成“倒序数组里以对应位置结尾的递增”。 |
reverse(down + 1, down + n + 1); | 关键一步。down 是在倒序数组上算的,下标是反的,必须翻回来才能和 up[i] 对齐。 |
up[i] + down[i] - 1 | 峰 i 在 up 里算了一次、在 down 里又算了一次,所以减 1。 |
n - best | best 是“最多能留几人”,题目问的是“最少出列几人”,所以要 n 减 best。 |
博览馆展出 M 位画家的作品,共 N 幅画排成一行,第 i 幅由画家 pᵢ 所画。买门票时必须说明两个数字 a、b,表示只看第 a 幅到第 b 幅(含两端)之间的所有画,票价是段内画的幅数。要保证看完全部 M 位画家的作品,求总票价最小的区间。
输入格式:第一行两个整数 N、M;第二行 N 个整数,表示每幅画的画家编号(1 ≤ M ≤ 2000,N ≤ 10⁶,保证有解)。
输出格式:两个整数 a、b,表示所选区间。若有多组解,输出 a 最小的一组;若仍有多个,输出 b 最小的一组。
样例输入12 5
2 5 3 1 3 2 4 1 1 5 4 3
样例输出2 7
样例解释:区间 [2, 7] 里的画是 5 3 1 3 2 4,5 位画家全部出现,长度 6,是最短的。
“一段连续的画”这个形状,说明答案是一个区间。枚举两个端点有 N² 个,N = 10⁶ 时不可能。
想一件事:把右端点往右挪,“覆盖全部 M 位”只会更容易满足;把左端点往右挪,只会更难满足。
于是对于一个固定的右端点,满足条件的区间里,最短的那一个左端点位置是唯一确定的——它就在“再往右挪一步就不满足”的那一格。
条件具有单调性:区间变长只会更容易覆盖全部 M 种,变短只会更难。这就是双指针的入场券。
维护窗口 [l, r]、计数数组 cnt[画家] 和 have = 窗口内出现的不同画家数。
r 从 1 扫到 N,每步把 a[r] 加入窗口;只要 have == M,就尝试收缩 l:先用当前长度更新答案,再把 a[l] 移出、l 右移。
正确性:对固定的 r,while 循环退出时 [l, r] 是“以 r 结尾的最短合法窗口”。任何以 r 结尾的合法窗口都包含它、更长,所以不可能更优——只检查这一个就够,不漏解。
字典序 tie-break:长度相同取 a 最小。r 是从小到大扫的,而长度相同的两个区间,右端点更小的那个左端点也更小;配合“只在严格更短时更新答案”,第一个被记下的最短解就是 a 最小的。
复杂度 O(N):l、r 各走不超过 N 步。N = 10⁶ 时约 2×10⁶ 次操作,稳过。
#include <bits/stdc++.h>
using namespace std;
int a[1000005]; // N ≤ 10⁶,上限 +5,少开就是 RE
int cnt[2005]; // M ≤ 2000:cnt[x] = 窗口内画家 x 的画有几幅
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; ++i) cin >> a[i];
int l = 1, have = 0; // have:窗口内不同画家的种数
int bestLen = INT_MAX, bestL = 0, bestR = 0;
for (int r = 1; r <= n; ++r) {
if (++cnt[a[r]] == 1) ++have; // 加进来后计数为 1:这是新画家
while (have == m) { // 已覆盖全部画家,才值得缩短
if (r - l + 1 < bestLen) { // 严格小于:同长度保留先出现的(a 更小)
bestLen = r - l + 1;
bestL = l; bestR = r;
}
if (--cnt[a[l]] == 0) --have; // 移出左端点;减到 0 说明这个画家没了
++l;
}
}
cout << bestL << " " << bestR << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
if (++cnt[a[r]] == 1) ++have; | 先把计数加一并判断:加完恰好等于 1,说明这个画家是第一次进入窗口,种类数才加一。写 >= 2 会把重复画也算成新种类。 |
while (have == m) | 用 while 而不是 if:缩一次之后可能仍然覆盖全部画家,要一直缩到刚好不满足。 |
if (r - l + 1 < bestLen) | 严格小于。长度相同时不更新,保留先被扫到的那个——它的左端点更小。 |
bestL = l; bestR = r; | 此刻 [l, r] 就是最短合法窗口,两个端点一起存。 |
if (--cnt[a[l]] == 0) --have; | 减完变 0 才说明这一种画家彻底离开窗口,种类数减一。先移出再改 have 的顺序不能反。 |
int bestLen = INT_MAX | 初值必须比任何可能的长度都大。写 0 会导致永不更新,输出 0 0。 |
a[1000005] | N ≤ 10⁶,开 1e6 + 5。开成 1e5 时 N 大的数据点直接越界,RE 或答案错乱。 |
if (cnt[a[r]] == 0) 但顺序放在自增之后,逻辑反掉,have 永远到不了 m。r l,以及忘记行末只换行。给出一个长度为 N 的正整数数列 A。要把它划分成 M(M ≤ N)段,每段必须连续且不能为空,设每段数字之和的最大值为 k。求 k 的最小可能值。
输入格式:第一行两个正整数 N、M(1 ≤ N ≤ 10⁵,1 ≤ M ≤ N);第二行 N 个正整数 Aᵢ(Aᵢ ≤ 10⁸)。
输出格式:一个正整数,即 k 的最小值。
样例输入5 3
4 2 4 5 1
样例输出6
样例解释:分成 4 2 | 4 | 5 1,三段和分别是 6、4、6,最大值 6;k = 5 时怎么分都至少要 5 段,不满足 M = 3。
别急着猜“平均每段多少”。先问自己:如果别人告诉你一个 k,你能不能在 O(N) 内判断它行不行?
判断只有一种做法:从左往右尽量往当前段里塞,塞不下就另起一段。这样得到的段数是最少的。
k 越小越难分(段数越多),k 越大越好分(段数越少)。这个单调性就是二分的全部依据。k 最小可能取多少?最大可能取多少?
答案有明显单调性:若某个 k 可行,那么所有更大的 k 也可行。所以可以二分 k,把“求最小可行 k”转成 N 次“判定 k 行不行”。
check(k):贪心地扫一遍,cur 表示当前段的和,装不下(cur + Aᵢ > k)就新开一段。得到的段数 cnt 就是“每段和 ≤ k”时能划分出的最少段数。
为什么 cnt ≤ M 就等于可行:题目要求恰好 M 段。如果 cnt 小于 M,因为 M ≤ N,总可以把某些段再切开(切开只会让每段和更小),补到恰好 M 段。所以判定条件是 cnt ≤ M。
贪心为什么给出最少段数:对任意可行划分,前 i 段覆盖到的位置不会超过贪心的前 i 段;归纳一次就得到贪心段数最少。
边界:lo = max(Aᵢ),因为 k 连最大的一个数都装不下时必然不可行;hi = ΣAᵢ,全部放一段一定可行。
类型:ΣAᵢ ≤ 10⁵ × 10⁸ = 10¹³,远超 int,全程 long long。复杂度 O(N log ΣA) ≈ 10⁵ × 44。
#include <bits/stdc++.h>
using namespace std;
int n, m;
long long a[100005];
// check(k):每段和都不超过 k 时,最少要分成几段
bool check(long long k) {
long long cur = 0;
int cnt = 1; // 至少一段
for (int i = 1; i <= n; ++i) {
if (cur + a[i] > k) { // 当前段装不下 -> 另起一段
++cnt;
cur = a[i];
} else {
cur += a[i]; // 能塞就塞:段数才会最少
}
}
return cnt <= m; // 段数不超过 m 即可:多出来的段可以继续拆
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
long long sum = 0, mx = 0;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
sum += a[i];
mx = max(mx, a[i]);
}
long long lo = mx, hi = sum; // 下界:最大的单个数;上界:全部放一段
while (lo < hi) {
long long mid = lo + (hi - lo) / 2;
if (check(mid)) hi = mid; // mid 可行 -> 答案不超过 mid
else lo = mid + 1; // mid 不可行 -> 答案必然更大
}
cout << lo << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
long long a[100005], sum | Aᵢ ≤ 10⁸、N ≤ 10⁵,ΣAᵢ 到 10¹³。用 int 会在二分区间就溢出成负数,整个搜索跑飞。 |
int cnt = 1; | 段数从 1 开始:循环里只统计“新开了几段”,最后一段不算新开。写成 0 会少算一段。 |
if (cur + a[i] > k) | 贪心的核心:能塞就塞,塞不下才开新段。用 > 不用 >=,等于 k 是合法的。 |
return cnt <= m; | 判定条件是“不超过 m 段”。写成 cnt == m 会漏掉“可以继续拆段”的情况,WA。 |
long long lo = mx | k 小于最大值时,那个数自己单独成段都超,必然不可行,直接砍掉这一段搜索区间。 |
long long mid = lo + (hi - lo) / 2 | 防溢出的中点写法。hi 到 10¹³ 时 lo + hi 也在 long long 内,但这个写法是习惯,换更大的数据也不用改。 |
if (check(mid)) hi = mid; else lo = mid + 1; | 找最小可行值:可行就往小收(mid 本身要保留,所以是 hi = mid 不是 mid - 1),不可行就推到 mid + 1。 |
cout << lo << "\n"; | 循环退出时 lo == hi,就是最小可行的 k。 |
cnt < m 或 cnt == m:前者漏掉恰好等于 m 的情况,后者漏掉可拆的情况,两种都 WA。lo = mid:hi == lo + 1 时 mid 恒等于 lo,区间不再缩小,死循环 → TLE。hi = mid - 1 却仍返回 lo:答案正好是 mid 时被跳过,WA。给出一个 R 行 C 列的高度矩阵。从任意一格出发,每步可以走到上下左右相邻的一格,但要求目标格的高度严格小于当前格。求一条滑坡最多能经过多少个格子。
输入格式:第一行两个整数 R、C(1 ≤ R, C ≤ 100);接下来 R 行,每行 C 个整数表示高度。
输出格式:一个整数,最长滑坡经过的格子数。
样例输入5 5
1 2 3 4 5
16 17 18 19 6
15 24 25 20 7
14 23 22 21 8
13 12 11 10 9
样例输出25
样例解释:从 25 一路滑到 1,恰好经过全部 25 个格子。
高度严格递减,所以同一个格子不可能被经过两次——所有“走法”构成一张有向无环图。
从 (i, j) 出发的最长长度,只由它四周更低的格子决定:等于 1 加上那些格子答案的最大值。这是一个可以递归定义的式子。
如果每个格子都朴素地往下搜,同一个格子会被重复算几十上百次。怎样让它一辈子只算一次?
状态:f[i][j] = 从 (i, j) 出发能经过的最多格子数(含自己)。
转移:f[i][j] = 1 + max{ f[nx][ny] },其中 (nx, ny) 与 (i, j) 相邻、在界内、且高度更低;四周没有更低的格子时 f[i][j] = 1。
因为高度严格递减,转移关系不可能成环,可以放心递归。
记忆化:f 的初值 0 表示“还没算过”。进入 dfs 先查表,算完再存表。每个格子只会真正计算一次,之后都是 O(1) 返回。
答案 = 所有 f[i][j] 的最大值,因为起点任意。
复杂度:状态 R×C ≤ 10⁴ 个,每个状态看 4 个方向,总 O(RC) = 4×10⁴ 次操作。不加记忆化则是每个格子都搜一遍,最坏路径长度 10⁴,总量级不可接受。
#include <bits/stdc++.h>
using namespace std;
int r, c;
int h[105][105]; // 高度
int dp[105][105]; // dp[i][j]:从 (i,j) 出发的最长滑坡长度;0 表示还没算
const int dx[4] = {1, -1, 0, 0};
const int dy[4] = {0, 0, 1, -1};
int dfs(int x, int y) {
if (dp[x][y]) return dp[x][y]; // 算过就直接给:没有这一行就是指数级搜索
int best = 1; // 至少包含自己这一格
for (int k = 0; k < 4; ++k) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 1 || nx > r || ny < 1 || ny > c) continue; // 越界
if (h[nx][ny] < h[x][y]) // 严格更低才能滑过去
best = max(best, dfs(nx, ny) + 1);
}
return dp[x][y] = best; // 先存表再返回,下次 O(1)
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> r >> c;
for (int i = 1; i <= r; ++i)
for (int j = 1; j <= c; ++j)
cin >> h[i][j];
int ans = 0;
for (int i = 1; i <= r; ++i)
for (int j = 1; j <= c; ++j)
ans = max(ans, dfs(i, j)); // 起点任意,全试一遍
cout << ans << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
if (dp[x][y]) return dp[x][y]; | 记忆化的全部价值。dp 初值 0 表示未计算,答案至少是 1,两者不会混淆。 |
int best = 1; | 递归的底:没有更低的邻居时,滑坡就是这一格自己,长度 1。 |
if (nx < 1 || nx > r || ny < 1 || ny > c) continue; | 四个方向都要判界。少判一个方向就是越界读,表现为 WA 或 RE。 |
if (h[nx][ny] < h[x][y]) | 严格小于。写成 <= 时,两个等高格子会互相调用,无限递归 → 栈爆 RE。 |
best = max(best, dfs(nx, ny) + 1); | 取四个方向里最长的。+1 是把自己这一格加上。 |
return dp[x][y] = best; | 赋值表达式的值就是赋进去的值,一行完成“存表 + 返回”。写成 return best; 则记忆化失效。 |
int h[105][105], dp[105][105] | R、C ≤ 100,开 105 留余量。用 1 开始的下标,配合 x < 1 判界。 |
ans = max(ans, dfs(i, j)); | 起点可以是任意格子,所以全部枚举一遍取最大。不需要额外判断“最深的那个”。 |
if (dp[x][y]) return dp[x][y];:每个格子重复搜索,最坏 4^10⁴ 量级,TLE 或直接栈爆。return best; 而没有存表:编译能过、样例能过,大数据全是重复计算 → TLE。< 写成 <=:等高相邻的格子互相递归,无限循环 → RE(爆栈)。