← 目录 第 35 / 60 节 全部 60 节

35 · 模拟赛一:120 分钟 4 题

用一场 120 分钟的限时模拟赛收束第 1~34 节:贪心加堆、双指针、二分答案、记忆化搜索各一题,配时间分配表、四张必背模板卡、丢分症状清单与赛后复盘方法。
模拟赛普及/提高-限时 120 分钟模拟赛4 题

这一节不讲新知识。4 道题、120 分钟,考第 1~34 节里复用率最高的四件事:贪心 + 堆(第 17 节)、双指针(第 19 节)、二分答案(第 27 节)、记忆化搜索(第 22 节)。题目都是洛谷真题,代码只用你已经学过的东西写。本场满分 400 分,每题 100 分;除 A 题外都有部分分可拿。

模拟赛暴露的第一件事,永远不是“我不会什么”,而是“我把 35 分钟花在了一道只能拿 30 分的题上”。

先看清单:四道题分别压在哪几节上。卡住时不要顺着题号往下问,直接回来查对应的节。

题号题目对应节一句话考点建议用时
AP1091 合唱队形第 30、31 节正反两遍 LIS,再合并22 分钟
BP1638 逛画展第 19 节窗口覆盖全部种类 → 双指针26 分钟
CP1182 数列分段第 27 节最小化最大值 → 二分答案32 分钟
DP1434 滑雪第 22 节网格上求最长下降路 → 记忆化24 分钟

四道题的难度都是普及/提高-,没有难题,但每题都有一个“想不到就 0 分、想到就 5 分钟”的关键点。这场的真正对手是时间。

120 分钟怎么切

总原则:先把手速题的分全额拿到,再回头啃需要想的题。题号不代表难度顺序,但本场特意把最省时间的 A 放在最前面,你必须在前 28 分钟内交掉它——A 题交得晚,后面三道题的心态就全乱了。

时间段做什么关键动作
0 ~ 8通读 4 题 + 排序在纸上给每道题标:会做 / 有思路 / 完全不会。这 8 分钟不许写代码。
8 ~ 28A 合并果子(20)难度最低,必须一次写对。测样例 + 自造 1 组再交。
28 ~ 54B 逛画展(26)双指针边界是本场最容易错的地方,写完先手算一遍样例。
54 ~ 86C 数列分段(32)二分答案:区间、check、防溢出三件事都要想全再动手。
86 ~ 110D 滑雪(24)记忆化:只多两行,但漏了就是 TLE。到点就停。
110 ~ 120检查只查数组大小、long long、输出格式,不改算法。

每题内部再切一刀:想 4 分钟、写 12 分钟、测 4 分钟。“想”的这 4 分钟不许碰键盘,纸上必须出现 4 行东西:

  1. 数组开多大:从数据范围推,上限 + 5。B 题 N ≤ 10⁶ 就开 1000005。
  2. 要不要 long long:和、积、平方、乘法,出现就估量级。C 题的 sum 到 10¹³。
  3. 循环边界:含不含端点,写 < 还是 <=,窗口是 [l, r] 还是 [l, r)。
  4. 输出格式:几个数、什么分隔、行末有没有多余空格。
为什么“想”的 4 分钟不许碰键盘

键盘会骗你:能编译通过的代码看起来总是对的。而纸上那 4 行一旦写错,编译是发现不了的。

本场四道题的丢分点全部在这 4 行里:A 的堆类型、B 的 while、C 的 lo 下界、D 的 dp 初值。

养成习惯后,这 4 分钟会缩短到 60 秒——但绝不能省。

开赛前先花 10 分钟做一道不计分的热身题:把“读题 → 写 → 测”的流程跑热,顺便确认编译环境、快读模板、以及“多关键字排序”这个你已经用过十几次的动作没生疏。

例题 · 奖学金洛谷 P1093普及-去洛谷做这题 ↗

某小学最近得到一笔赞助,打算拿出其中一部分为前 5 名学生发奖学金。期末,每个学生都有语文、数学、英语 3 门课的成绩。先按总分从高到低排序,如果两个同学总分相同,再按语文成绩从高到低排序,如果语文成绩也相同,规定学号小的同学排在前面。这样,每个学生的排序是唯一确定的。

输入格式:第一行一个整数 n(n ≤ 300);接下来 n 行,每行三个 0~100 的整数,依次是学号为 1、2、…、n 的学生的语文、数学、英语成绩。

输出格式:共 5 行,每行两个整数,依次为前 5 名学生的学号和总分;若 n < 5,则输出全部学生。

输入: 6 90 67 80 87 66 91 78 89 91 88 99 77 67 89 64 78 89 98 输出: 6 265 4 264 3 258 2 244 1 237
热身题的 4 分钟怎么想

这题就是第 12 节的原样:结构体 + 多关键字排序。分值全在 cmp 上,思路一分不值。

  1. 排序键有三个:总分降序 → 语文降序 → 学号升序。cmp 就写三行,一行一个键。
  2. 数学、英语只在读入时用一次(拿来算总分),不必存进结构体。
  3. 输出只要前 5 名:循环上界写 min(n, 5),n < 5 时不会越界。
  4. cmp 的最后一行不需要再判“全部相等”:学号唯一,两个元素不可能在三个键上全相同。

如果这题你写了超过 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 小根堆:取最小两个、合并后放回
priority_queue<int, vector<int>, greater<int>> pq;
组成含义 / 格式要求
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。

新函数拆解 · 双指针滑窗:右端点扩张、左端点收缩
for (int r = 1; r <= n; ++r) { 把 a[r] 加进窗口; while (窗口满足条件) { 更新答案; 把 a[l] 移出窗口; ++l; } }
组成含义 / 格式要求
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 再更新答案:记录到的是已经变短的窗口,长度算错,甚至把最短的那个漏掉。

新函数拆解 · 二分答案:把“求最小”变成“判定可行”
long long lo = L, hi = R; while (lo < hi) { long long mid = lo + (hi - lo) / 2; if (check(mid)) hi = mid; else lo = mid + 1; } // 循环结束时 lo == hi == 答案
组成含义 / 格式要求
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。

新函数拆解 · 记忆化搜索:算过就存,下次直接取
int dfs(int x, int y) { if (dp[x][y]) return dp[x][y]; // 查表 ... 递归 ... return dp[x][y] = best; // 存表并返回 }
组成含义 / 格式要求
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 分钟。本场四题分值相同,耗死一题等于放弃三题。

下面是正式的四道题。先掐表自己做,卡满单题用时再看提示;提示看完还没思路,再点开思路;四题全做完之后再点开代码对照。

题目 · 共 4 题

题号题目难度建议用时
A合唱队形普及/提高-20 分钟
B逛画展普及/提高-26 分钟
C数列分段 Section II普及/提高-32 分钟
D滑雪普及/提高-24 分钟
比赛规则(自己执行)
大题 挑战题 · 合唱队形
洛谷 P1091普及/提高-去洛谷做这题 ↗

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 - bestbest 是“最多能留几人”,题目问的是“最少出列几人”,所以要 n 减 best。
这题最容易错的地方
  • 忘记把 down 数组的下标翻回来,up[i] + down[i] 对不上号,答案随机错。
  • 比较写成了 a[j] <= a[i]:相同身高的人被连成一段,样例中 186、186 会同时留下,答案偏小。
  • 直接输出 best(留下人数)而不是 n − best(出列人数)。
  • down 的方向搞反:写成“从左往右看 i 右边”却没有反转数组,逻辑上不成立。
  • 数组开成 101 而 n 可达 100、又用 1 基下标,需要至少 105 才安全。
↑ 第 A 题 · 合唱队形
大题 挑战题 · 逛画展
洛谷 P1638普及/提高-去洛谷做这题 ↗

博览馆展出 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 或答案错乱。
这题最容易错的地方
  • while 写成 if:只缩一次,漏掉更短解,WA;样例恰好只需缩一次,本地完全看不出来。
  • 先 ++l 再更新答案:记录到的是已经变短的窗口,长度算错,甚至把最短的那个区间漏掉。
  • bestLen 初始化为 0 或 n:0 让任何长度都不小于它,永不更新,输出 0 0。
  • cnt 数组只开 105,M 到 2000 时严重越界,RE 或把别的全局变量写坏。
  • 把“新画家”判断写成 if (cnt[a[r]] == 0) 但顺序放在自增之后,逻辑反掉,have 永远到不了 m。
  • 输出顺序写成 r l,以及忘记行末只换行。
↑ 第 B 题 · 逛画展
大题 挑战题 · 数列分段 Section II
洛谷 P1182普及/提高-去洛谷做这题 ↗

给出一个长度为 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], sumAᵢ ≤ 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 = mxk 小于最大值时,那个数自己单独成段都超,必然不可行,直接砍掉这一段搜索区间。
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。
这题最容易错的地方
  • check 里写成 cnt < m 或 cnt == m:前者漏掉恰好等于 m 的情况,后者漏掉可拆的情况,两种都 WA。
  • lo 从 0 或 1 开始:k 比某个 Aᵢ 还小时 check 仍可能返回 true(因为没判单个元素),得出错误答案。
  • 用 int 存 sum:10¹³ 溢出成负数,lo、hi 关系错乱,表现为 TLE 或输出负数。
  • 写成 lo = mid:hi == lo + 1 时 mid 恒等于 lo,区间不再缩小,死循环 → TLE。
  • 写成 hi = mid - 1 却仍返回 lo:答案正好是 mid 时被跳过,WA。
  • check 里 cur 没在开新段时重置为 a[i],而是直接清零,导致每个数都单独成段,段数虚高。
↑ 第 C 题 · 数列分段 Section II
大题 挑战题 · 滑雪
洛谷 P1434普及/提高-去洛谷做这题 ↗

给出一个 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(爆栈)。
  • dp 初值写成 1 而不是 0:分不清“没算过”和“答案是 1”,记忆化彻底失效,退化成纯 DFS。
  • 多组测试数据时忘了清空 dp/h:上一组的结果污染下一组,WA。本题单测,但习惯要先建立。
  • 输出的是格子数而不是步数。本题要求经过的格子数,起点那一格也算,所以 best 从 1 开始。
↑ 第 D 题 · 滑雪
赛后必做