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

50 · 模拟赛二:150 分钟 5 题

用一场 150 分钟的限时模拟赛收束第 1~49 节:单调队列、正反两遍 LIS、二分答案、树状数组加离散化、断环成链的区间 DP 各一题,配时间分配表、四张必背模板卡、丢分症状清单与赛后复盘方法。
模拟赛提高-限时 150 分钟模拟赛5 题

这一节不讲新知识。5 道题、150 分钟,考第 1~49 节里复用率最高的五件事:单调队列(第 48 节)、正反两遍 LIS(第 31 节)、二分答案加贪心判定(第 27、16 节)、树状数组加离散化(第 46、13 节)、断环成链的区间 DP(第 33、18 节)。五道题都是洛谷真题,代码只用你已经学过的东西写。满分 500 分,每题 100 分。

模拟赛一考的是“会不会”,模拟赛二考的是“取舍”:五道题里一定有一道你写不完,先决定放弃哪道,比先决定做哪道更值钱。

先看清单。卡住时不要顺着题号往下问,直接翻回对应的节重看那一段。

题号题目对应节一句话考点建议用时
AP1886 滑动窗口第 28、48 节队尾弹逆序、队头出窗口22 分钟
BP1091 合唱队形第 31 节正反两遍 LIS 再合并23 分钟
CP2678 跳石头第 27、16 节二分最短距离 + 贪心数移走几块28 分钟
DP1908 逆序对第 46、13 节离散化后上树状数组30 分钟
EP1880 石子合并第 33、18 节断环成链 + 区间 DP32 分钟

难度上 A~D 是普及/提高-,E 是提高。本场的分水岭在 C 和 E:C 卡在“check 怎么写”,E 卡在“环怎么处理”。这两处都是想通就五分钟、想不通就零分的地方,所以下面每道题的提示都从“先想暴力”开始,而不是从“先想正解”开始。

150 分钟怎么切

总原则:前 55 分钟只做 A、B,并且必须一次过。这两题都是“想到就能写对”的题,一旦返工,后面三道题的时间就是借来的。

时间段做什么关键动作
0 ~ 8通读 5 题在纸上给每题标:会做 / 有思路 / 完全不会。这 8 分钟不写一行代码。
8 ~ 30A 滑动窗口(22)先写最小值那一遍,测完样例,再复制一份把比较方向反一下。
30 ~ 53B 合唱队形(23)两遍 LIS 都不难,难在最后的 n − max(f+g−1) 别算成 max。
53 ~ 81C 跳石头(28)先写 check,再套二分。check 写完用小数据手算两遍再编译。
81 ~ 111D 逆序对(30)离散化那三行写完先输出 sz 验一遍,再写树状数组。
111 ~ 143E 石子合并(32)断环成链 + 长度递增。到点没 AC 就交线性版本保分。
143 ~ 150检查只查数组大小、long long、输出格式,不改算法。

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

  1. 数组开多大:从数据范围推,上限 + 5。A 题 n ≤ 10⁶ 开 1000005;D 题 n ≤ 5×10⁵ 开 500005;E 题 2n ≤ 200 开 205。
  2. 要不要 long long:D 题 n = 5×10⁵ 且完全逆序时答案 ≈ n²/2 = 1.25×10¹¹,早已越过 int;E 题的得分要累加 n 次。
  3. 边界:A 题窗口是 [i−k+1, i] 而不是 [i−k, i];C 题二分的区间是 [1, L] 且中点要上取整;E 题区间长度只枚举到 n,不到 2n。
  4. 输出格式:A 题两行、行末只有换行;E 题先最小后最大,各占一行。
为什么这 4 行比代码重要

键盘会骗你:能编译通过的代码看起来总是对的。而这 4 行一旦写错,编译器一声不响。

本场五道题的丢分点几乎全在这 4 行里:A 的窗口左端点、B 的 n−best、C 的上取整、D 的 long long、E 的 2n 上界。

熟练之后这 5 分钟会压缩到 60 秒,但绝不能省。

开赛前 10 分钟做一道不计分的热身题,把“读题 → 排序 → 双指针 → 输出”这条最常用的链路跑热。它顺手复习了本场 C 题 check 函数要用的贪心:能塞就塞,塞不下就另起。

例题 · 纪念品分组洛谷 P1094普及-去洛谷做这题 ↗

元旦快到了,班长要把买来的纪念品按价格分组发给同学。每组最多只能放两件纪念品,并且每组纪念品的价格之和不能超过一个给定的上限 w。求最少需要分成几组。

输入格式:第一行一个整数 w(每组价格之和的上限);第二行一个整数 n(纪念品件数,n ≤ 30000);第三行 n 个正整数,表示每件纪念品的价格(均不超过 w)。

输出格式:一个整数,即最少的分组数。

输入: 100 9 90 20 20 30 50 60 70 80 90 输出: 6
热身题的 5 分钟怎么想

每组最多两件,所以答案的形状只有两种:一件一组,或一大一小配成一组。配得越多,组数越少。

  1. 排序后用双指针:l 指最小的、r 指最大的。
  2. 如果 p[l] + p[r] ≤ w,最便宜的能配最贵的,那就配——这一步不会让答案变差。
  3. 配不上就让 p[r] 自己一组(它太贵,配谁都超限),r 左移。
  4. l == r 时只剩一件,单独一组,结束。

复杂度 O(n log n),瓶颈在排序;双指针部分只有 O(n) 次比较。n = 30000 时几乎瞬间出答案。

热身题完整代码
#include <bits/stdc++.h>
using namespace std;

int w, n, p[30005];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> w >> n;
    for (int i = 1; i <= n; ++i) cin >> p[i];
    sort(p + 1, p + n + 1);            // 双指针的前提:有序

    int l = 1, r = n, ans = 0;
    while (l <= r) {
        if (l == r) { ++ans; break; }         // 只剩一件,单独一组
        if (p[l] + p[r] <= w) { ++l; --r; }   // 最便宜的配最贵的
        else --r;                             // 配不上:最贵的自己一组
        ++ans;
    }
    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
sort(p + 1, p + n + 1);双指针的正确性依赖有序:只有排好序才能说“最贵的配谁都超”。区间左闭右开,右端写 n+1。
int l = 1, r = n, ans = 0;ans 必须初始化为 0。局部变量不赋值就是垃圾值,累加后答案随机。
while (l <= r)区间还没空就继续。不是 l < r:l == r 时还剩一件,要单独成组。
if (l == r) { ++ans; break; }先处理只剩一件的情况,否则后面 p[l] + p[r] 会把同一件算两遍。
if (p[l] + p[r] <= w)用 ≤ 不用 <:等于上限也是合法的。这里写错,边界数据全错。
++l; --r;配成一组后两个指针都动。只动一个的话,p[l] 会被重复使用。
else --r;最贵的配不上最小的,那它配谁都不行,只能自己一组。l 不动,留给下一轮配更便宜的大件。
cout << ans << "\n";只输出一个整数加换行。题目没要求任何提示语。

热身完,看四张模板卡。它们不是新知识,是把本场五道题各自要用的骨架再默写一遍——赛场上默写不出来的模板,等于没学过。先能一次默写正确,再开始计时。

新函数拆解 · 单调队列:队尾弹逆序、队头出窗口
int h = 1, t = 0; // 手写双端队列,h > t 表示空 for (int i = 1; i <= n; ++i) { while (h <= t && a[q[t]] >= a[i]) --t; // ① 队尾活不下来 q[++t] = i; // ② 新下标入队 if (q[h] < i - k + 1) ++h; // ③ 队头滑出窗口 [i-k+1, i] if (i >= k) ans = a[q[h]]; // ④ 此时队头就是答案 }
组成含义 / 格式要求
q[++t] = i队列里存下标,不存值。存下标才能判断“是否滑出窗口”,也能反查 a[q[h]]。
while (h <= t && a[q[t]] >= a[i]) --t求最小值用 >=;求最大值把 >= 换成 <=,其余不动。方向写反,队头就不是最值。
h <= t空队列判据:h = t + 1 即空。写成 h < t 会把“只剩一个元素”误判成空队列。
if (q[h] < i - k + 1) ++h窗口左端点是 i − k + 1。每步最多过期一个下标,所以用 if 就够,不必 while。
if (i >= k)前 k − 1 步窗口还没填满,不能输出。
总复杂度 O(n)每个下标进队一次、出队一次,while 的总执行次数不超过 n。暴力是 O(nk),最坏 10¹²。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int a[6] = {0, 5, 3, 8, 1, 9};   // 1 开始存,n = 5
int q[10];

int main() {
    int n = 5, k = 3, h = 1, t = 0;
    for (int i = 1; i <= n; ++i) {
        while (h <= t && a[q[t]] >= a[i]) --t;
        q[++t] = i;
        if (q[h] < i - k + 1) ++h;
        if (i >= k) cout << a[q[h]] << " ";   // 输出 3 1 1
    }
    return 0;
}
注意

队列里存下标、不存值——这是这一节唯一需要背死的一句话。

求最大值时,队尾弹出条件和答案取值都要反过来;只改一半是这类题最常见的错法。

新函数拆解 · 树状数组:单点加、前缀和查询
int lowbit(int x) { return x & -x; } void add(int i, int v) { for (; i <= sz; i += lowbit(i)) t[i] += v; } int sum(int i) { int s = 0; for (; i > 0; i -= lowbit(i)) s += t[i]; return s; }
组成含义 / 格式要求
lowbit(x) = x & -x取 x 最低位的 1 所代表的权值:lowbit(6) = 2,lowbit(8) = 8,lowbit(0) = 0。
add(i, v)把 i 这条链上的结点都加 v:i, i+lowbit(i), i+lowbit(i+lowbit(i)) …,直到超过 sz。O(log n)。
sum(i)前缀和:t[i] + t[i−lowbit(i)] + … 直到下标 0。O(log n)。区间和 = sum(r) − sum(l−1)。
下标必须 ≥ 1lowbit(0) = 0 会让 add 的循环变量原地不动,死循环 → TLE。
sz值的上界(本题是离散化后不同值的个数)。add 的终止条件是 i <= sz,不是 i <= n。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int t[20], sz = 8;
int lowbit(int x) { return x & -x; }
void add(int i, int v) { for (; i <= sz; i += lowbit(i)) t[i] += v; }
int sum(int i) { int s = 0; for (; i > 0; i -= lowbit(i)) s += t[i]; return s; }

int main() {
    add(3, 1); add(3, 1); add(6, 1);   // 排名 3 出现两次,排名 6 出现一次
    cout << sum(2) << "\n";            // 0:排名 ≤ 2 的没有
    cout << sum(3) - sum(2) << "\n";   // 2:排名恰为 3 的个数
    cout << sum(6) << "\n";            // 3:排名 ≤ 6 的总个数
    return 0;
}
注意

sum 与 add 的循环方向相反:一个 i -= lowbit(i),一个 i += lowbit(i)。写反就是死循环或答案偏小。

求逆序对时查 sum(r−1) 而不是 sum(r),否则相等元素会被算成逆序对。

本题每次只“加 1”,所以 D 题代码里把 add 简化成 void add(int i) { ++t[i]; },省掉了加数参数。

新函数拆解 · 离散化:把 10⁹ 的值压成 5×10⁵ 的排名
sort(b + 1, b + n + 1); sz = unique(b + 1, b + n + 1) - (b + 1); int r = lower_bound(b + 1, b + sz + 1, a[i]) - b;
组成含义 / 格式要求
sort(b + 1, b + n + 1)先把原数组复制一份到 b,排序。a 本身不动,后面还要按下标扫。
unique(b+1, b+n+1)把相邻的重复元素搬到末尾,返回“去重后区间的尾后指针”。它不删除元素,只是搬家。
- (b + 1)去重后的元素个数 sz。写成 - b 会多 1,是所有离散化 bug 里最常见的一个。
lower_bound(b+1, b+sz+1, x) - b第一个 ≥ x 的位置,即 x 的排名(从 1 开始)。右端点是 sz+1,不是 n+1。
相等元素拿到同一个排名这正是需要的:逆序对要求严格大于,用 r−1 查询天然排除相等的情况。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int a[6] = {0, 1000000000, -5, 7, 7, 233};
int b[6];

int main() {
    int n = 5;
    for (int i = 1; i <= n; ++i) b[i] = a[i];
    sort(b + 1, b + n + 1);
    int sz = unique(b + 1, b + n + 1) - (b + 1);   // 4 个不同值
    for (int i = 1; i <= n; ++i)
        cout << lower_bound(b + 1, b + sz + 1, a[i]) - b << " ";
    // 输出 4 1 2 2 3
    return 0;
}
注意

if (x < 1 || x > sz) 说明离散化写错了:出现这种情况几乎都是 sz 的表达式少减了一次。

离散化不改变答案的正确性,它只是把“值域”换成“排名”,让桶和树状数组开得下。

新函数拆解 · 断环成链:把环拆成 2n 的链再区间 DP
for (int i = 1; i <= n; ++i) { cin >> a[i]; a[i + n] = a[i]; } for (int len = 2; len <= n; ++len) for (int i = 1; i + len - 1 <= 2 * n; ++i) { int j = i + len - 1; for (int k = i; k < j; ++k) f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + pre[j] - pre[i - 1]); } // 答案 = 对 i ∈ [1, n] 取 f[i][i + n - 1] 的最优值
组成含义 / 格式要求
a[i + n] = a[i]把数组复制一份接在后面。环上任意一种“从哪断开”的走法,都对应 2n 链上一个长度为 n 的连续区间。
for (len = 2; len <= n; ++len)按区间长度从小到大算。区间 DP 的方向是“先短后长”,长度 1 的区间是初值。
i + len - 1 <= 2 * n右端点不能越过 2n。只写 i <= n 是不够的:短区间的右端点完全可以超过 n。
for (k = i; k < j; ++k)枚举最后一次合并的分界:左半 a[i..k]、右半 a[k+1..j]。k 不能取到 j,否则右半为空。
+ pre[j] - pre[i - 1]本次得分 = 这一段的总和,用前缀和一次算出。写两层求和会把 O(n³) 变成 O(n⁴)。
答案取 n 个区间环有 n 种断法,对应 n 个长度为 n 的区间,答案在这 n 个里取最优。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

long long pre[10], f[10][10];

int main() {
    int a[4] = {0, 4, 5, 9};                 // 三堆:4 5 9
    for (int i = 1; i <= 3; ++i) pre[i] = pre[i - 1] + a[i];
    for (int len = 2; len <= 3; ++len)
        for (int i = 1; i + len - 1 <= 3; ++i) {
            int j = i + len - 1;
            f[i][j] = LLONG_MAX;
            for (int k = i; k < j; ++k)
                f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + pre[j] - pre[i - 1]);
        }
    cout << f[1][3] << "\n";                 // 27:先 4+5=9,再 9+9=18
    return 0;
}
注意

长度只枚举到 n,不要到 2n:长度大于 n 的区间在环上没有意义,还会把数组写越界。

求最小值的那张表初值要是 INF(memset 0x3f),求最大值的要是 0。两张表不能共用初值。

五道题写完,把下面这张单子对着自己的提交记录看一遍。每一条都写清了症状,症状能对上,就不用再猜了。

本场最容易丢的分,每条都写明症状

单调队列存值不存下标 → WA。A 题 q[] 里存 a[i] 的值,就没法判断队头是否滑出窗口,只能靠“值相等”猜,一遇到重复数字全错。

h <= t 写成 h < t → WA 或 RE。只剩一个元素时被判成空队列,q[h] 读到的是过期下标,输出一个根本不在窗口里的数。

窗口左端点写成 i−k → WA。窗口是 [i−k+1, i],差这一格会让每个窗口多留一个过期元素,最小值普遍偏小、最大值普遍偏大。

LIS 的比较写成 ≤ → WA。B 题要求严格递增,写 a[j] <= a[i] 时,两个同身高的同学会被串进同一条上升链。样例前两个人身高都是 186,答案会小 1。

B 题把 n − best 写成 best → WA。题目问的是“出列几人”,best 却是“留下几人”。样例里 n = 8、best = 4,两种写法都能过样例——这就是为什么必须自己再造一组数据:n = 5、身高 1 2 1 2 1 时 best = 3 而答案是 2,一眼就露。

二分中点用了下取整 → TLE。C 题求“最大可行值”,mid 必须上取整;写成 (lo+hi)/2 配 lo = mid,当 hi = lo+1 时 mid 恒等于 lo,区间不再缩小,程序空转到超时。

check 漏了最后一跳 → WA。C 题只判石头与石头之间、忘了 L − last,样例能过,错在“最后一段太短”的数据上。

逆序对答案用 int → WA 成负数。D 题 n = 5×10⁵ 全逆序时答案 ≈ 1.25×10¹¹,int 上限才 2.1×10⁹,溢出后输出一个负数。

树状数组下标从 0 开始 → TLE。lowbit(0) = 0,add(0) 里 i += 0 原地踏步,死循环。

E 题 f 初值设成 0 → WA。求最小值的表如果不 memset 成 INF,min 永远取到 0,输出 0;而求最大值的表初值又必须是 0,两张表的初值不能共用。

E 题 i + len − 1 越界 → RE。数组只开到 205 而循环边界写错,越界写的可能是另一个数组,表现为“样例全过、一交就崩”。

自己动手复现

关掉页面,开一个空文件,从 #include <bits/stdc++.h> 开始,给 A 题(滑动窗口)掐 22 分钟写完并提交。

计时前先在纸上写三行:队列里存什么、队尾弹出的条件、队头过期的条件。三行写对再动手。

改一改:把窗口长度改成“每一步都不同”(第 i 步的窗口是 [i − kᵢ + 1, i]),单调队列还成立吗?成立,只是过期判断要跟着 kᵢ 走。

再改一改:如果要求每个窗口的中位数,单调队列还够用吗?把答案写下来,第 49 节的对顶堆会回答这道题。

思路卡住时的三级降级

一级:写暴力。A 题每个窗口重扫一遍是 O(nk),小数据点能拿分;C 题从小到大枚举答案 d,每个 d 用 O(n) 判断,也是能过的暴力。有分的代码才叫代码。

二级:换实现。D 题想不出树状数组,就用归并排序统计逆序对——O(n log n) 一样能过,第 46 节的思路本来就是从归并里长出来的。

三级:只写输入输出。哪怕答案是猜的,也要把格式写对,保留“万一”的分数。

绝不做的事:在一道题上耗超过 45 分钟。本场五题分值相同,耗死一题等于放弃四题。

下面是正式的五道题。先掐表自己做,卡满单题用时再看提示;提示看完还没思路,再点开思路;五题全做完之后再点开代码对照——顺序反了,这场模拟赛就白考了。

题目 · 共 5 题

题号题目难度建议用时
A滑动窗口 /【模板】单调队列普及/提高-22 分钟
B合唱队形普及/提高-23 分钟
C跳石头普及/提高-28 分钟
D逆序对普及/提高-30 分钟
E石子合并普及/提高-32 分钟
比赛规则(自己执行)
大题 挑战题 · 滑动窗口 /【模板】单调队列
洛谷 P1886普及/提高-去洛谷做这题 ↗

有一个长为 n 的序列 a,以及一个大小为 k 的窗口。窗口从序列最左端开始,每次向右滑动一个单位,直到右端到达序列末尾。对每一个位置上的窗口,求出窗口内元素的最小值和最大值。

输入格式:第一行两个正整数 n、k(1 ≤ k ≤ n ≤ 10⁶)。第二行 n 个整数,表示序列 a(绝对值不超过 10⁹)。

输出格式:共两行。第一行为每个窗口的最小值,第二行为每个窗口的最大值,同一行的数字之间用一个空格隔开。

样例输入
8 3
1 3 -1 -3 5 3 6 7

样例输出
-1 -3 -3 -3 3 3
3 3 5 5 6 7

引导性提示(先只看这里)

先估一下暴力:n 和 k 都可能到 10⁶,每个窗口重扫一遍是 O(nk),最坏 10¹²,稳 TLE。

换角度看:窗口右移一格,只进来一个新数、只出去一个旧数。哪些数从此再也不可能成为最小值?

如果新来的数比当前候选里最后一个还小,那个候选在过期之前都不可能成为答案——因为新数既更小、又活得更久。

想看思路了?点开完整思路

维护一个下标队列 q,保证 q 里的下标递增、对应的 a 值也递增。此时队头 a[q[h]] 就是当前窗口的最小值。

每步三件事:① 新元素入队前,把队尾所有“值 ≥ 新元素”的下标弹掉——它们既不如新元素小、又比新元素先过期,永远没机会了;② 把 i 放进队尾;③ 检查队头是否还在窗口 [i−k+1, i] 里,不在就弹掉。

正确性:队列里值单调递增,所以队头是整个队列的最小值;队头一定在窗口内(过期就弹了)。被弹掉的下标要么值更大且更靠左,要么已经过期,都不可能成为任何后续窗口的答案。

复杂度:每个下标最多进队一次、出队一次,while 的总执行次数不超过 n,整体 O(n)。

最大值完全同构:把队尾弹出条件从 a[q[t]] ≥ a[i] 改成 a[q[t]] ≤ a[i],别的一个字都不改。

输出细节:前 k−1 步窗口没填满,不输出;行末只换行,不加多余空格。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, k;
int a[1000005];
int q[1000005];                  // 手写双端队列,存的是下标

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> k;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    // ---- 最小值:队列里的下标对应的 a 值严格递增,队头即窗口最小值 ----
    int h = 1, t = 0;                        // h > t 表示队列为空
    for (int i = 1; i <= n; ++i) {
        while (h <= t && a[q[t]] >= a[i]) --t;   // 队尾比新来的大:它这辈子都比不过新来的
        q[++t] = i;
        if (q[h] < i - k + 1) ++h;               // 队头已经滑出窗口左端
        if (i >= k) cout << a[q[h]] << (i == n ? '\n' : ' ');
    }

    // ---- 最大值:同一个骨架,只把比较方向反过来 ----
    h = 1; t = 0;
    for (int i = 1; i <= n; ++i) {
        while (h <= t && a[q[t]] <= a[i]) --t;
        q[++t] = i;
        if (q[h] < i - k + 1) ++h;
        if (i >= k) cout << a[q[h]] << (i == n ? '\n' : ' ');
    }
    return 0;
}
关键片段拆解
片段作用
int q[1000005];手写队列,不比 STL 的 deque 慢,还省掉了迭代器和内存分配。n ≤ 10⁶,开 1000005。
int h = 1, t = 0;队头指针 h、队尾指针 t,初始 h > t 表示空队列。用 1 开始,方便和 a 的下标对齐。
while (h <= t && a[q[t]] >= a[i]) --t;弹掉队尾所有“值比新元素大”的下标。它们更靠左(先过期)、又更大(更差),被新元素彻底支配。求最大值时把 >= 改成 <=。
q[++t] = i;存下标,不存值。存了值就无法判断队头有没有滑出窗口。
if (q[h] < i - k + 1) ++h;窗口是 [i−k+1, i]。每步只可能有一个下标过期,所以用 if 就够。
if (i >= k)前 k−1 步窗口还没填满,此时输出的是“不足 k 个元素”的答案,必须跳过。
cout << a[q[h]] << (i == n ? '\n' : ' ');数字之间一个空格;最后一个窗口后面直接换行,不多输出行末空格。两行的写法完全一样。
h = 1; t = 0; 第二遍重开两个方向必须各自清空队列。不清空的话,最大值那一遍会读到最小值遗留的下标,结果全错。
这题最容易错的地方
  • 队列里存值而不是下标:无法判断过期,一遇到重复数字就 WA。
  • 空队列判据写成 h < t:只剩一个元素时被判空,q[h] 读到过期下标,输出窗口外的数。
  • 窗口左端点写成 i−k:每个窗口多留一个过期元素,最小值偏小、最大值偏大。
  • 两遍共用一个没清空的队列:第二遍的队头是上一遍的残留下标,整行答案错位。
  • n = k 时只输出一个数,却忘了换行——末尾那个字符写错,两行变一行。
  • 求最大值时只把答案取值改了、忘了改队尾弹出方向,队列失去了单调性,答案随机错。
↑ 第 A 题 · 滑动窗口 /【模板】单调队列
大题 挑战题 · 合唱队形
洛谷 P1091普及/提高-去洛谷做这题 ↗

n 位同学站成一排,音乐老师要请其中的 n−k 位同学出列,使剩下的 k 位同学排成“合唱队形”。合唱队形指存在一个“顶峰”位置 i,使身高满足 t₁ < t₂ < … < tᵢ > tᵢ₊₁ > … > t_k。求最少需要几位同学出列。

输入格式:第一行一个整数 n(2 ≤ n ≤ 100)。第二行 n 个整数,第 i 个表示第 i 位同学的身高 tᵢ(130 ≤ tᵢ ≤ 230)。

输出格式:一个整数,即最少需要出列的人数。

样例输入
8
186 186 150 200 160 130 197 220

样例输出
4
样例解释:留下 186、200、160、130 这 4 位同学即可排成合唱队形,其余 4 人出列。

引导性提示(先只看这里)

“留下的人”必须是先升后降的。形状只由一件事决定:谁是那个最高的人。

枚举顶峰 i 之后,左右两段互相独立:左边是“以 i 结尾的最长上升子序列”,右边是“以 i 开头的最长下降子序列”。

n ≤ 100,所以 O(n²) 的朴素 LIS 完全够用,不要上 O(n log n),那样只是给自己增加写错的机会。

想看思路了?点开完整思路

答案 = n − (能留下的最多人数)。所以问题变成“最多能留下几人”。

枚举顶峰 i。左边必须以 i 结尾且严格递增,长度就是 f[i] = 以 i 结尾的最长上升子序列;右边必须以 i 开头且严格递减,长度是 g[i] = 以 i 开头的最长下降子序列。

以 i 为顶峰时的最大保留人数 = f[i] + g[i] − 1,减 1 是因为顶峰自己被两段都算了一次。

f 从左往右推:f[i] = 1 + max{ f[j] : j < i 且 a[j] < a[i] },没有合法的 j 时 f[i] = 1。

g 从右往左推,转移条件同样是 a[j] < a[i]:g[i] = 1 + max{ g[j] : j > i 且 a[j] < a[i] }。

两遍都是 O(n²),总 O(n²) = 10⁴ 次操作。最后输出 n − max(f[i]+g[i]−1)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, a[105];
int f[105], g[105];              // f:以 i 结尾的最长上升长度;g:以 i 开头的最长下降长度

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    // 左半边:以 i 结尾的最长上升子序列
    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);   // 严格递增:不能取等
    }

    // 右半边:以 i 开头的最长下降子序列,从右往左推
    for (int i = n; i >= 1; --i) {
        g[i] = 1;
        for (int j = i + 1; j <= n; ++j)
            if (a[j] < a[i]) g[i] = max(g[i], g[j] + 1);
    }

    int best = 0;                            // 能留下的最多人数
    for (int i = 1; i <= n; ++i)
        best = max(best, f[i] + g[i] - 1);   // 顶峰被算两次,减 1

    cout << n - best << "\n";                // 问的是出列人数,不是留下人数
    return 0;
}
关键片段拆解
片段作用
f[i] = 1;初始化为 1:即使前面没有比自己矮的人,自己也能单独构成长度 1 的上升段。
if (a[j] < a[i])严格小于。写成 <= 时,两个同身高的同学会被接进同一条上升链,样例前两人身高都是 186,答案会偏小。
f[i] = max(f[i], f[j] + 1);枚举 i 前面所有更矮的人,取最长的那个再接上自己。这就是 LIS 的 O(n²) 转移。
for (int i = n; i >= 1; --i)求“以 i 开头”的下降段要从右往左推:算 g[i] 时,它右边所有 g[j] 必须已经算好。
if (a[j] < a[i]) g[i] = ...“往右走必须更矮”等价于“右边的 a[j] < a[i]”,条件和左边一模一样,只是方向相反。
f[i] + g[i] - 1两段在顶峰 i 处相交,i 被算了两次,必须减 1。忘了减,答案会少 1,样例输出变成 3。
n - best题目问的是“出列几人”。这一步最容易漏,而样例里 n−best = 4 恰好等于 best,所以漏了也能过样例——必须自己造数据验。
cout << n - best << "\n";只输出一个整数加换行。
这题最容易错的地方
  • 把 f[i]+g[i]−1 写成 f[i]+g[i]:顶峰算两遍,答案少 1,样例输出 3 而不是 4。
  • 把 n − best 写成 best:样例恰好都是 4,本地完全看不出来,交上去全 WA。
  • LIS 比较写成 a[j] <= a[i]:同身高被串成一条链,只要数据里有重复身高就错。
  • g 也从左往右推:算 g[i] 时右边的 g 还是初值 0,右边段长度全变成 1。
  • 数组只开 100:n = 100 时 a[100] 越界,RE 或答案错乱。开 105。
  • 输出的是“留下人数”而不是“出列人数”,或反过来,两种都有人写错,看清题目问哪个。
↑ 第 B 题 · 合唱队形
大题 挑战题 · 跳石头
洛谷 P2678普及/提高-去洛谷做这题 ↗

比赛在一条笔直的河道中进行,河道中有 N 块岩石(不含起点与终点)。选手从起点出发,每一步跳到相邻的岩石上,直到到达终点。组委会至多可以移走 M 块岩石(起点和终点不能移走),要求移走之后,选手所有跳跃距离中的最小值尽可能大。求这个最大可能的最短跳跃距离。

输入格式:第一行三个整数 L、N、M,分别表示起点到终点的距离、岩石数、至多移走的岩石数;接下来 N 行,每行一个整数 Dᵢ,表示第 i 块岩石到起点的距离,按从小到大给出。(0 ≤ M ≤ N ≤ 50000,0 < L ≤ 10⁹)

输出格式:一个整数,即最短跳跃距离的最大值。

样例输入
25 5 2
2
11
14
17
21

样例输出
4

引导性提示(先只看这里)

“最大值最小 / 最小值最大”这类字眼一出现,先想二分答案。这里问的是“最短跳跃距离的最大值”。

固定一个 d,问:能不能只移走不超过 M 块石头,让每一跳都 ≥ d?这个判断比原问题好想得多。

判断的时候从左往右贪心:能跳到就跳,跳不到就把中间那块石头搬走。搬走一块之后,跨度会变大还是变小?

想看思路了?点开完整思路

二分答案 d:判定“最短跳跃距离 ≥ d”是否可行。d 越大越难满足,单调性成立。

check(d):last 记录上一块“保留下来的落脚点”坐标(起点是 0),从左往右扫岩石。若 a[i] − last < d,这跳太短,只能把第 i 块搬走(cnt++),last 不变;否则保留它,last = a[i]。

扫完还要检查最后一跳 L − last:若它 < d,就把最后保留的那块石头搬走(不是搬终点),这样“上一落脚点 → L”的距离只会更大,一定也 ≥ d。

为什么贪心最优:能保留就保留,保留只会让后续的距离更容易够(last 更靠右)。提前搬走一块只会让跨度更大、消耗更多次数,不可能更优。

判定条件是 cnt ≤ M:不超过 M 块就合法,题目说的是“至多移走 M 块”。

二分区间 [1, L]:答案至少 1,至多 L(一块石头都不留时就是 L)。求最大可行值,中点要上取整。

复杂度 O(N log L) ≈ 5×10⁴ × 30 = 1.5×10⁶,稳过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int L, n, m;
int a[50005];                    // a[1..n] 是岩石坐标;起点 0 由 last 的初值表示

// 判定:能不能只移走 ≤ m 块石头,使每一跳都 ≥ d
bool check(int d) {
    int cnt = 0, last = 0;               // last:上一个保留的落脚点
    for (int i = 1; i <= n; ++i) {
        if (a[i] - last < d) ++cnt;      // 这跳太短,只能搬走第 i 块
        else last = a[i];                // 跳得到,保留它
    }
    if (L - last < d) ++cnt;             // 最后一跳不够长:搬走 last 这块(不是终点)
    return cnt <= m;                     // “至多 m 块”,不是“恰好 m 块”
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> L >> n >> m;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    int lo = 1, hi = L;                  // 答案至少 1,至多 L
    while (lo < hi) {
        int mid = lo + (hi - lo + 1) / 2;    // 上取整:求最大可行值必须这么写
        if (check(mid)) lo = mid;            // 可行,试着更大
        else hi = mid - 1;                   // 不可行,必须更小
    }
    cout << lo << "\n";
    return 0;
}
关键片段拆解
片段作用
if (a[i] - last < d) ++cnt;这一跳到上一落脚点不足 d,唯一办法是把第 i 块搬走。用 < 不用 <=:距离恰好等于 d 是合法的。
else last = a[i];跳得到就落脚。贪心的全部内容就这一行:保留永远不比搬走差。
if (L - last < d) ++cnt;最后一跳也要判。这里搬走的是 last(上一块保留的石头),搬走之后“更早的落脚点 → L”只会更远,一定也 ≥ d。
return cnt <= m;题目说“至多移走 M 块”。写成 == m 会在“不用搬那么多”的数据上判不可行。
int lo = 1, hi = L;下界 1:任意两块石头位置不同,最短跳至少 1。上界 L:一块石头都不留就是 L。写 hi = 1e9 也能过,但 hi = L 更贴题意。
mid = lo + (hi - lo + 1) / 2上取整。求最大可行值时,区间只剩两个元素时 mid 必须取右边那个,否则 lo = mid 会让区间卡死 → TLE。
if (check(mid)) lo = mid;可行就把下界提到 mid(mid 本身可能正是答案,不能写 mid+1)。
else hi = mid - 1;不可行就砍掉 mid 及其右边。
这题最容易错的地方
  • 中点写成下取整 (lo+hi)/2,又配 lo = mid:hi = lo+1 时 mid 恒等于 lo,死循环 TLE。
  • check 里漏了 L − last:样例恰好不需要判最后一跳,本地全过,交上去错在“最后一段太短”的点。
  • 判定条件写成 cnt == m:题目是“至多”,能少搬的时候反而被判不可行,答案偏小。
  • 贪心写成“一跳不够 d 就搬走上一块”:last 回退会让后面的判断全乱,算出的 cnt 偏大。
  • 只测了样例就交:M = 0 时答案应是最小相邻间距(样例数据是 2),M = N 时应是 L(样例数据是 25),这两个边界各写一行测一下,30 秒的事。
  • 数组只开 50000:n = 50000 时 a[50000] 用到最后一个下标,必须开 50005。
↑ 第 C 题 · 跳石头
大题 挑战题 · 逆序对
洛谷 P1908普及/提高-去洛谷做这题 ↗

对于给定的一段正整数序列,逆序对就是序列中满足 aᵢ > aⱼ 且 i < j 的有序对 (i, j)。给定一个长度为 n 的序列,求它的逆序对数目。注意序列中可能有重复数字。

输入格式:第一行一个整数 n(n ≤ 5×10⁵)。第二行 n 个整数,表示给定的序列,每个数字不超过 10⁹。

输出格式:一个整数,即逆序对的数目。

样例输入
6
5 4 2 6 3 1

样例输出
11

引导性提示(先只看这里)

n = 5×10⁵,枚举 (i, j) 是 1.25×10¹¹ 对,稳 TLE。目标复杂度 O(n log n)。

换个计数角度:对每个 j,数一数它左边有多少个数比 aⱼ 大,全部加起来就是答案。

“左边有多少个数比它小”这种前缀计数,值域又只有 5×10⁵ 个不同值——把 10⁹ 压成排名,就能用树状数组一次 log 查出来。

想看思路了?点开完整思路

逆序对总数 = 对每个位置 i,统计“右边比 aᵢ 小的元素个数”,全部累加。

值域到 10⁹,桶开不下。先离散化:把 a 复制成 b,排序去重,用 lower_bound 把每个 aᵢ 换成排名,排名范围 1..sz,sz ≤ n ≤ 5×10⁵。

从右往左扫。树状数组 t 里记的是“某个排名已经出现过几次”。扫到 aᵢ 时,排名是 r,则 sum(r−1) 就是右边比它小的个数,直接加进答案;然后把 r 加进树状数组。

为什么用 r−1 而不是 r:逆序对要求严格大于,相等元素不能算。排名相同意味着值相等,必须排除。

正确性:每个逆序对 (i, j) 在扫描到 i 时被恰好数到一次——那时 j 已经进树状数组,且排名更小。

复杂度 O(n log n):离散化 O(n log n),每次查询和修改 O(log n),总计约 5×10⁵ × 19 × 2 次操作。

答案上界:n(n−1)/2 ≈ 1.25×10¹¹,必须 long long。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n;
int a[500005], b[500005];        // b 是排序去重用的副本
int t[500005];                   // 树状数组:t[i] 记录“排名 i 出现了几次”
int sz;                          // 排名上界 = 不同值的个数

int lowbit(int x) { return x & -x; }

void add(int i) {
    for (; i <= sz; i += lowbit(i)) ++t[i];
}

int sum(int i) {                 // 排名 ≤ i 的元素个数
    int s = 0;
    for (; i > 0; i -= lowbit(i)) s += t[i];
    return s;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) { cin >> a[i]; b[i] = a[i]; }

    // 离散化:排序 + 去重,把值换成排名
    sort(b + 1, b + n + 1);
    sz = unique(b + 1, b + n + 1) - (b + 1);

    long long ans = 0;                       // 逆序对最多 1.25×10¹¹,int 装不下
    for (int i = n; i >= 1; --i) {           // 从右往左,保证树状数组里只有 i 右边的数
        int r = lower_bound(b + 1, b + sz + 1, a[i]) - b;   // a[i] 的排名
        ans += sum(r - 1);                   // 右边严格小于 a[i] 的个数
        add(r);
    }

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
b[i] = a[i];复制一份再排序,a 本身不能动:后面扫的时候还要按原下标取值。
unique(b + 1, b + n + 1) - (b + 1)去重后不同值的个数。它不删除元素,只是把重复的搬到末尾,返回值是去重区间的尾后指针。
lower_bound(b + 1, b + sz + 1, a[i]) - b第一个 ≥ a[i] 的位置,就是从 1 开始的排名。右端点是 sz+1,不是 n+1。
for (int i = n; i >= 1; --i)倒着扫:扫到 i 时,树状数组里装的正是 a[i+1..n],所以 sum(r−1) 天然就是“右边比它小的个数”。
ans += sum(r - 1);用 r−1 排除相等元素。写成 sum(r) 会把值相同的一对也当成逆序对。
add(r);把这个排名计入树状数组,供更左边更小的下标查询。别忘了这一句,否则答案恒为 0。
long long ansn = 5×10⁵ 且完全逆序时答案 = n(n−1)/2 ≈ 1.25×10¹¹,int 上限 2.1×10⁹,溢出后是负数。
int lowbit(int x) { return x & -x; }x 与它的相反数按位与,留下最低位的 1。下标必须 ≥ 1,否则 lowbit(0)=0 造成死循环。
这题最容易错的地方
  • ans 用 int:溢出成负数,WA 且极难自查(样例只有 11,本地完全看不出来)。
  • 相等元素也被算成逆序对:查 sum(r) 而不是 sum(r−1),遇到重复数字就错。
  • 离散化时 sz 写成 unique(...) - b:多算 1,lower_bound 到 sz+1 时越界,RE 或排名错位。
  • 开了树状数组却忘了 add(r),答案恒为 0。
  • 从左往右扫却仍写 sum(r−1):数到的是“左边比它小的”,方向完全反了。
  • 数组只开 100005(只够 10⁵):n = 5×10⁵ 的数据直接越界,RE 或答案错乱。开 500005。
  • a 数组被 sort 之后才拿来遍历:顺序全乱,逆序对数目无意义。所以必须复制到 b 再排序。
↑ 第 D 题 · 逆序对
大题 挑战题 · 石子合并
洛谷 P1880普及/提高-去洛谷做这题 ↗

在一个圆形操场的四周摆放 N 堆石子,现要将石子有次序地合并成一堆。规定每次只能选相邻的 2 堆合并成新的一堆,并把新堆的石子数记为本次合并的得分。求把 N 堆合并成一堆的最小总得分和最大总得分。

输入格式:第一行一个整数 N(N ≤ 100);第二行 N 个整数,依次表示每堆石子的个数(正整数)。

输出格式:共两行,第一行为最小得分,第二行为最大得分。

样例输入
4
4 5 9 4

样例输出
43
54

样例解释:最小方案先合并 4+4=8(得 8 分),再 8+5=13(13 分),再 13+9=22(22 分),共 43。

引导性提示(先只看这里)

先做一条链上的版本:n 堆排成一行,每次合并相邻两堆。把“最后一步”想清楚——它一定是把某个前缀段和某个后缀段合并。

于是需要知道“把 a[i..j] 合并成一堆”的最优得分。这就是区间 DP:先算小区间,再用小区间拼出大区间。

环怎么处理?一种断法对应一种走法,有 n 种断法。有没有办法不写 n 遍,一遍扫完?

想看思路了?点开完整思路

断环成链:把数组复制一份接在后面,得到 a[1..2n]。环上任意一种“从哪断开”的合并方案,都对应 2n 链上一个长度为 n 的连续区间。答案在这 n 个区间里取最优。

状态:f[i][j] = 把 a[i..j] 合并成一堆的最小得分;g[i][j] 同理求最大。

转移:最后一次合并必然把 a[i..k] 与 a[k+1..j] 两堆合成一堆,本次得分 = 这一段总和。f[i][j] = min over k∈[i, j−1] of f[i][k] + f[k+1][j] + (pre[j] − pre[i−1])。

顺序:按区间长度 len 从小到大枚举。长度 1 的区间不需要合并,得分 0。不能用“从 i 推到 j”的方式,因为 f[i][j] 依赖的所有子区间长度都比它短。

边界:f[i][i] = 0,其余 f 初值设成 INF;g 全部初值 0(最大值的下界就是 0)。

答案:对 i = 1..n 取 f[i][i+n−1] 的最小值、g[i][i+n−1] 的最大值。

复杂度 O(n³):区间 O(n²) 个(实际是 2n 个起点 × n 种长度),每个枚举 O(n) 个分界点,n = 100 时约 2×10⁶ 次。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n;
long long a[205], pre[205];
long long f[205][205], g[205][205];      // f:最小得分,g:最大得分

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) { cin >> a[i]; a[i + n] = a[i]; }   // 断环成链
    for (int i = 1; i <= 2 * n; ++i) pre[i] = pre[i - 1] + a[i];     // 前缀和,O(1) 取段和

    memset(f, 0x3f, sizeof(f));              // 求最小值:初值设成极大
    for (int i = 1; i <= 2 * n; ++i) f[i][i] = 0;   // 一堆不用合并,得分 0

    for (int len = 2; len <= n; ++len)                    // 区间长度,先短后长
        for (int i = 1; i + len - 1 <= 2 * n; ++i) {      // 左端点
            int j = i + len - 1;
            long long s = pre[j] - pre[i - 1];            // 本次合并的得分
            for (int k = i; k < j; ++k) {                 // 最后一次合并的分界
                f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + s);
                g[i][j] = max(g[i][j], g[i][k] + g[k + 1][j] + s);
            }
        }

    long long mn = LLONG_MAX, mx = 0;
    for (int i = 1; i <= n; ++i) {                        // n 种断法
        mn = min(mn, f[i][i + n - 1]);
        mx = max(mx, g[i][i + n - 1]);
    }
    cout << mn << "\n" << mx << "\n";
    return 0;
}
关键片段拆解
片段作用
a[i + n] = a[i];把数组复制一份接在后面。环上每一种断法,对应链上一个长度为 n 的连续区间。
pre[i] = pre[i - 1] + a[i];前缀和。段和 pre[j]−pre[i−1] 是 O(1),这一步把 O(n⁴) 的朴素写法压到 O(n³)。
memset(f, 0x3f, sizeof(f));把 f 的每个字节设成 0x3f,得到一个约 4.5×10¹⁸ 的大数,代表“还没算 / 不可达”。
for (int i = 1; i <= 2 * n; ++i) f[i][i] = 0;长度为 1 的区间是递归的底:一堆石子不用合并,得分 0。漏了这一行,所有答案都会带上 INF。
for (int len = 2; len <= n; ++len)区间 DP 的方向:长度从小到大。长度超过 n 的区间在环上没有意义。
i + len - 1 <= 2 * n右端点不能越过 2n。写成 i <= n 会让短区间的右端点越过边界,越界写数组。
for (int k = i; k < j; ++k)枚举最后一次合并的分界点。k 从 i 到 j−1,保证左右两段都非空。
+ s本次合并的得分 = 合并后这一堆的石子数 = 这一段的总和。三项缺一不可。
mn = min(mn, f[i][i + n - 1]);n 种断法里取最优。注意区间长度是 n,右端点是 i+n−1。
这题最容易错的地方
  • f 没有 memset 成 INF:min 永远取到初值 0,输出 0;而 g 的初值又必须是 0,两张表不能共用初值。
  • 忘了 f[i][i] = 0:长度为 1 的区间保留 INF,所有转移都得到天文数字,输出一个十几位的怪数。
  • 区间长度枚举到 2n:长度 > n 的区间在环上无意义,且 i+len−1 会超过 2n 越界。
  • 循环顺序写成“先枚举左端点再枚举长度”:算 f[i][j] 时子区间还没算好,结果全错(有时代码还能跑,最难查)。
  • k 从 i 枚举到 j(含 j):右半段为空,f[j+1][j] 是无效状态,越界读。
  • 用 int 存得分:n = 100、每堆 10⁴ 时最大得分可达 10⁸ 量级,勉强够,但数据一放大就溢出,统一 long long。
  • 输出顺序写反(先最大后最小):样例一眼能看出,但赛场上经常看漏。
↑ 第 E 题 · 石子合并
赛后必做