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

30 · 回顾六:BFS、二分与容器

把第 26~29 节压成一套动作:BFS 求无权最短路,二分答案处理单调性,queue / stack / priority_queue 决定「下一次取谁」。先抄一遍 P1443 的完整模板,再用 5 道洛谷黄题限时训练,每题都带完整思路与参考代码。
回顾训练普及/提高-建议 95 分钟回顾5 题

第 26~29 节给的东西可以压成三句话:BFS 按层扩散,第一次访问就是最短路;答案有单调性时,把「求最优」换成「判可行」再二分;容器只决定一件事——下一次该取谁(queue 取最早、stack 取最晚、priority_queue 取最小)。这一节不教新算法,只用一道完整例题把 BFS 模板从头抄到尾,再把二分、堆、队列的骨架各拆一遍,最后用 5 道洛谷黄题限时训练。

BFS 的答案在第一次访问时定下;二分的答案在最后一次收缩时定下;容器的答案在弹出顺序里定下。
节内容看到这些字就上它
26BFS:一层一层地扩散「最少几步」「最短时间」「同时从多个点出发」,且每一步代价相同
27二分查找与二分答案「最大值最小」「最小值最大」「第 k 个满足…」,且答案随判定单调
28双端队列与优先队列「每次取当前最小 / 最大」;边权只有 0 和 1 的最短路
29栈与队列的应用「括号」「后缀表达式」「出栈顺序」;「最近一段时间内到达的人」
例题 · 马的遍历洛谷 P1443普及/提高-去洛谷做这题 ↗

有一个 n × m 的棋盘(1 ≤ n, m ≤ 400),在 (x, y) 处有一个马。

马走日字:一步可以从 (a, b) 走到 (a±1, b±2) 或 (a±2, b±1),共 8 个落点。

求马到达棋盘上每个格子最少要走几步,到达不了的输出 −1。

输入格式:一行四个整数 n、m、x、y。

输出格式:n 行,每行 m 个整数,第 i 行第 j 个数表示马到 (i, j) 的最少步数。相邻整数用空格或合理场宽分隔(旧题解常用的 printf("%-5d") 仍判对)。

输入: 3 3 1 1 输出: 0 3 2 3 -1 1 2 1 4

题意换个说法:把 n × m 个格子看成图的点,8 种马步看成 8 条边,每条边长都是 1,题目问的就是单源最短路。边权全相同时不需要 Dijkstra,BFS 按层扩展,某个格子第一次被访问到时的层数,就是它的最短路长度。

先看清这 6 个东西的结构。它们的名字和参数写错一个字符,整道题就从 WA 开始。

新函数拆解 · queue(队列)
queue<int> q; q.push(x); q.front(); q.pop(); q.empty();
组成含义 / 格式要求
queue<int>先进先出(FIFO)容器。BFS 靠它保证「先被发现的点先扩展」,于是步数单调不减。
q.push(x)放到队尾。BFS 里就是「发现一个新点,排队等处理」。
q.front()读队头元素,只读不删。
q.pop()删掉队头,返回 void,不能写 int u = q.pop();。
q.empty()空返回 true。循环固定写 while (!q.empty())。
q.size()元素个数,返回无符号数,和 int 比较时留意类型。
最小可运行示例
示例
queue<int> q;
q.push(1); q.push(2); q.push(3);   // 队列内:1 2 3

int u = q.front();                 // u = 1,元素还在队里
q.pop();                           // 队列内:2 3

cout << q.front() << " " << q.size() << "\n";   // 2 2
注意

front() 取值和 pop() 删元素是两步。少写一步 = 同一个点被处理两次。

对空队列调用 front()/pop() 是未定义行为:本地可能不崩,评测机上就是 RE。

新函数拆解 · pair 与结构化绑定(C++17)
queue<pair<int,int>> q; q.push({x, y}); auto [x, y] = q.front();
组成含义 / 格式要求
pair<int,int>把两个 int 打包成一个类型。网格题用它存 (行, 列),就不必开两个队列、两个数组。
q.push({x, y})花括号直接构造一个 pair 压入队列。
auto [x, y] = ...结构化绑定:一次把 first、second 拆成两个变量。C++17 起可用。
老写法int x = q.front().first, y = q.front().second;,机子只支持 C++14 时改这样。
嵌套尖括号queue<pair<int,int>> 结尾两个 > 之间留空格,C++11 之前会当成右移。
最小可运行示例
示例
queue<pair<int,int>> q;
q.push({2, 5});
q.push({3, 1});

auto [x, y] = q.front();      // x = 2, y = 5(拷贝,改它不影响队列)
q.pop();

int a = q.front().first;      // 不用结构化绑定的等价写法
int b = q.front().second;
注意

auto [x, y] 拿到的是副本;想改队列里的元素要用引用,而 queue 本来就不给改队头。

坐标顺序全程统一:都用 (行, 列)。方向数组那里如果换成 (列, 行),答案会整体转置。

新函数拆解 · lower_bound / upper_bound(有序区间二分)
int p = lower_bound(a + 1, a + n + 1, x) - a;
组成含义 / 格式要求
区间左闭右开 [first, last),必须已按 < 升序排好。
lower_bound返回第一个大于等于 x 的位置。
upper_bound返回第一个严格大于 x 的位置。
换成下标数组写 - a,vector 写 - v.begin(),否则得到的是迭代器。
找不到返回 last。判断不存在要写 it != last,不能只看 *it == x。
复杂度O(log n) 次比较。n = 10⁶ 时约 20 次,比扫一遍快 5 万倍。
最小可运行示例
示例
int a[6] = {0, 1, 3, 3, 3, 7};              // a[1..5] 有序,下标从 1 起
int p = lower_bound(a + 1, a + 6, 3) - a;   // p = 2,第一个 3
int q = upper_bound(a + 1, a + 6, 3) - a;   // q = 5,第一个大于 3 的位置
int c = q - p;                              // c = 3,即 3 的个数
注意

没排序就 lower_bound,结果毫无意义:在 1 2 1 里查 1,返回的是中间那个。

手写二分有 while (l <= r) 与 while (l < r) 两套模板,混着写必死循环;用库函数没有这个风险。

新函数拆解 · 二分答案的固定骨架(check + 收缩)
while (l < r) { int mid = ...; if (check(mid)) ...; else ...; }
组成含义 / 格式要求
check(x)返回「答案不超过 x(或 x 可行)」。必须单调:x 变大时结果只从 false 变 true,不能反复。
l、r区间永远是「答案所在的范围」。l、r 的初值要真的把答案包住,宁可放宽一格。
mid 防溢出l + (r - l) / 2,别写 (l + r) / 2:两者都接近 2×10⁹ 时会溢出成负数。
求最小值下取整 mid;check 为真 r = mid,否则 l = mid + 1。
求最大值上取整 l + (r - l + 1) / 2;check 为真 l = mid,否则 r = mid - 1。
复杂度O(log 值域 × check)。值域 10⁹ 时约 30 次 check,10⁶ 个数也就 3×10⁷ 次运算。
最小可运行示例
示例
// 求最小的可行值:check 形如 false false ... false true true ...
int l = 0, r = 1000000000;
while (l < r) {
    int mid = l + (r - l) / 2;
    if (check(mid)) r = mid;        // mid 可行,答案不超过 mid
    else            l = mid + 1;    // mid 不可行,答案更大
}
cout << l << "\n";                  // 收敛时 l == r,就是答案
注意

「r = mid 配下取整」「l = mid 配上取整」这两对必须成套。配错了就是死循环 TLE。

check 写错方向(把 ≥ 写成 >、把「不超过」写成「不少于」)时,二分照样跑完,只是答案是错的——这类 WA 最难查。

新函数拆解 · priority_queue(优先队列 / 堆)
priority_queue<int, vector<int>, greater<int>> pq;
组成含义 / 格式要求
priority_queue<int>默认大根堆:top() 是当前最大值。
三个参数元素类型、底层容器、比较方式。greater<int> 表示小的优先,即小根堆。
pq.push(x)插入,O(log n)。
pq.top()读堆顶(最值),O(1),只读不删。
pq.pop()删堆顶,O(log n),同样不返回值。
底层完全二叉树压在数组里;建堆 O(n),单次操作 O(log n)。
最小可运行示例
示例
priority_queue<int, vector<int>, greater<int>> pq;   // 小根堆

for (int x : {5, 1, 4, 2}) pq.push(x);
while (!pq.empty()) {
    cout << pq.top() << " ";   // 1 2 4 5
    pq.pop();
}
注意

写成 priority_queue<int> 就是大根堆,取出的顺序整个反过来——合并果子这类题会直接 WA。

堆里存结构体要自己写比较规则;比较规则写反、或漏了兜底字段,是堆题的第一大 WA 来源。

新函数拆解 · stack(栈)
stack<int> st; st.push(x); st.top(); st.pop(); st.empty();
组成含义 / 格式要求
stack<int>后进先出(LIFO)容器。
st.push(x)压栈,放到栈顶。
st.top()读栈顶,O(1),只读不删。
st.pop()弹出栈顶,返回 void。
用之前必须判空对空栈 top()/pop() 是未定义行为,代码里永远「先判空、再 top」。
最小可运行示例
示例
stack<char> st;
string s = "(()";
for (char c : s) {
    if (c == '(') st.push(c);
    else if (!st.empty()) st.pop();   // 先判空
}
cout << (st.empty() ? "匹配" : "不匹配") << "\n";
注意

括号匹配的判据是「扫完栈为空」;中途遇到右括号而栈已空,同样直接判不匹配。

把 stack 换成 queue 就是 BFS,换成 deque 就是 0-1 BFS——三个容器只差一两个函数名。

容器怎么选:只看「下一次该取谁」

取最早进来的 → queue。BFS、按时间滑动的窗口(海港)。

取最后进来的 → stack。括号匹配、后缀表达式、非递归 DFS。

取当前最小 / 最大 → priority_queue。合并果子、Dijkstra、贪心选最优。

两头都要取 → deque。0-1 BFS(边权只有 0 和 1):权 0 塞队头、权 1 塞队尾,一次 BFS 就得到最短路。

例题怎么想:把「最少几步」翻译成「最短路」

题目只问步数不问路径,所以每个格子只记一个数:从起点走到它最少几步。一个数组就够。

BFS 的三要素:状态 (行, 列);转移 8 个马步;代价 全部为 1。三者齐了,BFS 就是标准答案。

为什么第一次访问一定最短:队列里的步数单调不减(先 0,再一层 1,再一层 2…)。某个格子第一次被碰到时,是由步数最小的那一层扩展出来的;任何更长的路径都要经过步数更大的层,排在队列更后面。所以「第一次」= 「最短」。

距离数组 d 一物两用:既记答案,又当 visited。初值 −1 同时表示「没访问过」和题目要的「到不了」。

起点必须置 0。漏了这一行,全表答案整体多 1,样例从 0 3 2 变成 1 4 3,一眼可见。

入队时就写距离、就标记,不要等出队。写在一起,判重和赋值共用一次判断,也堵死了「同一点被多次入队」的路。

完整代码 · 网格 BFS 模板
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 405;
int n, m, sx, sy;
int d[MAXN][MAXN];      // d[i][j]:起点到 (i,j) 的最少步数;-1 = 没访问到

// 马的 8 种走法,(行增量, 列增量)
const int dx[8] = {-2, -1, 1, 2, 2, 1, -1, -2};
const int dy[8] = {1, 2, 2, 1, -1, -2, -2, -1};

int main() {
    scanf("%d%d%d%d", &n, &m, &sx, &sy);

    memset(d, -1, sizeof d);        // 全设 -1:既是「未访问」,也是要输出的「到不了」
    queue<pair<int,int>> q;         // 队列里放待扩展的格子
    d[sx][sy] = 0;                  // 起点 0 步,先定值再入队
    q.push({sx, sy});

    while (!q.empty()) {
        auto [x, y] = q.front();    // 先取值
        q.pop();                    // 再出队
        for (int k = 0; k < 8; ++k) {
            int nx = x + dx[k], ny = y + dy[k];
            if (nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 出界
            if (d[nx][ny] != -1) continue;                       // 访问过:BFS 里不必再看
            d[nx][ny] = d[x][y] + 1;    // 层数 = 父亲层数 + 1
            q.push({nx, ny});
        }
    }

    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) printf("%-5d", d[i][j]);    // 负号 = 左对齐,5 = 场宽
        puts("");                                                // 每行结束换行
    }
    return 0;
}
逐行拆解
代码片段含义与理由
const int dx[8] = {-2, -1, 1, 2, 2, 1, -1, -2};方向数组:把 8 种走法写成两张常量表,循环里只做一次加法。手写 8 个 if 不仅长,还极容易漏一个方向——症状是「某些格子莫名其妙变成 −1」。
memset(d, -1, sizeof d);按字节填 0xFF,32 位 int 的 0xFF 正好是 −1。必须在 BFS 之前:它同时是判重标记,晚一步初始化就等于没初始化。
d[sx][sy] = 0;起点 0 步。写在 push 之前,保证「在队列里」的点一定已经有确定的 d 值。
auto [x, y] = q.front(); q.pop();先取值再出队。反过来写(先 pop 再 front)拿到的是下一个格子,答案整体错乱,而且这类错很难靠读代码看出来。
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;棋盘下标 1 起,边界必须是 1 与 n(而不是 0 与 n−1)。写成 0 起会让第一行第一列永远出界,输出一大片 −1。
if (d[nx][ny] != -1) continue;BFS 的灵魂。第一次访问即最优,后来的路只会更长;不跳过就会反复入队,队列大小指数级膨胀 → TLE。
d[nx][ny] = d[x][y] + 1;距离在入队前写好。放在出队时再算也能过,但那样每个点要额外存层数,判重和赋值就成了两处逻辑,容易不同步。
printf("%-5d", d[i][j]);负号是左对齐,5 是场宽。本题现在也接受空格分隔,但场宽写法最稳,且行内对齐一眼能看出矩阵形状。
puts("");每行末尾换行。漏掉它,整个 n×m 矩阵会挤成一行,直接 WA。
看到这些话第一反应复杂度
最少几步 / 最短时间,每步代价相同BFS,距离数组兼作 visitedO(状态数)
最大值最小 / 最小值最大二分答案 + checkO(log 值域 × check)
每次取当前最小 / 最大priority_queue(注意小根堆三参数)O(n log n)
括号 / 后缀式 / 出栈顺序stack 模拟O(n)
按时间滑动的区间queue 存元素 + cnt 计数,队头过期就弹出O(总元素数)
这一章最容易踩的 7 个坑(都带症状)

BFS 把「标记已访问」写在出队时而不是入队时:同一个点被多个父亲反复推进队列。症状是小数据全对、大数据 TLE 或 MLE,队列长度能到状态数的几倍。

距离数组初值用 0 而不是 −1:全局数组默认就是 0,「到不了」的格子被当成「0 步到达」。症状是输出一片 0,样例第一眼就能看出来。

方向数组与棋盘下标起点不匹配(0 起 vs 1 起):症状是第一行或第一列全是 −1,而且小样例往往只差一两个格子,极易被忽略。

二分求最大值时 mid 用了下取整 (l + r) / 2:l 与 r 相邻且 check 为真时 l 不动。症状是死循环 TLE,最后几个点全挂。

check 里累加用 int:砍树 n = 10⁶、树高 4×10⁵,总和到 4×10¹¹,溢出成负数后 check 永远为真。症状是输出二分上界而不是答案。

二分的上界开小了:H 只开到 10⁵,比它高的答案全被截断。症状是WA 但样例照过,因为样例的树矮——上界一定要按题目给的最大值开。

priority_queue<int> 当小根堆用:取出的永远是大值。症状是合并果子样例就是错答案(1 2 9 输出 23 而不是 15),因为先合并了 9 和 2。

自己动手复现

关掉页面,把上面 P1443 的完整代码手打一遍并提交。

改一改①:只保留 dx/dy 的前 4 组(这 4 组都只往右跳),跑 3 3 1 1,数一数输出里有多少个 −1——方向少一半,可达区域立刻被切掉一大块。

改一改②:把 if (d[nx][ny] != -1) continue; 删掉,用 400×400 的数据跑,观察队列长度飞涨、程序迟迟不结束(判重是 BFS 复杂度的唯一保证)。

改一改③:把 memset(d, -1, sizeof d) 改成 memset(d, 0, sizeof d),再跑一次 3 3 1 1:输出变成整片 0。因为 −1 同时是答案和判重标记,初值一改,BFS 一步都走不出去。

题编号主要考什么最容易错在哪
AP1141BFS 连通块 + 预处理答案普及/提高-
BP1873二分答案:找最后一个可行值普及/提高-
CP1182二分答案:找第一个可行值下界取 1 而不是 max(aᵢ)
DP1090小根堆的反复弹压普及/提高-
EP2058queue 维护 24 小时滑动窗口边界写成严格小于;计数加减顺序写反
这次训练怎么用 95 分钟

0~10 分钟:只读题,不写代码。按「能立刻写」→「有思路」→「没思路」给 5 题排个序。

10~30 分钟:A(01迷宫)。重点不是 BFS 模板,而是「每个连通块只算一次」那句优化。

30~55 分钟:B(砍树)。二分答案的第一个方向:找最后一个可行值,mid 要上取整。

55~75 分钟:C(数列分段)。第二个方向:找第一个可行值,下界必须取 max(aᵢ)。

75~85 分钟:D(合并果子)。把小根堆的三个模板参数写熟,循环条件写 size() > 1。

85~95 分钟:E(海港)。只写队列 + 计数数组,不写复杂结构;最后核对边界与计数顺序。

提交前的 30 秒检查清单

BFS:距离数组初值 −1、起点 0、入队即标记,三件事都做了吗?

二分:l、r 的初值真的把答案包住了吗?mid 的取整方向与收缩方向配套吗?

check 里的累加变量是 long long 吗?最大值估一遍:个数 × 单值。

小根堆是不是写全了 <int, vector<int>, greater<int>>?

多组数据 / 多组询问:全局数组、栈、队列都清空了吗?

输出:行末有没有多余空格?每组询问是否各自换行?

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 01迷宫
洛谷 P1141普及/提高-去洛谷做这题 ↗

有一个 n × n 的迷宫,每格是 0 或 1。站在 0 上只能走到相邻 4 格中的 1,站在 1 上只能走到相邻 4 格中的 0。

给出 m 个询问 (i, j),每次求从这一格出发能走到的格子数(包含自身)。

输入格式:第一行 n、m(1 ≤ n ≤ 1000,1 ≤ m ≤ 10⁵);接下来 n 行每行 n 个 0/1 字符、字符间没有空格;再接下来 m 行,每行两个整数 i、j。

输出格式:m 行,每行一个整数,表示对应询问的答案。

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

照题面直译:每个询问从 (i,j) 做一次 BFS。先估一下最坏要做多少次、每次多大。

如果两个格子互相可达,它们的答案必然相同。想一想「互相可达」把整张棋盘分成了什么。

既然同块同答案,能不能每块只算一次,之后每个询问只查表?

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

把格子看成点,相邻且数字不同就连一条边。「数字不同」是对称的,所以这是一张无向图。

询问「从 (i,j) 能走到多少格」等价于求 (i,j) 所在连通块的大小。

同一连通块里任意两点可达集合相同,块内一点也走不到块外,所以块内所有格子的答案都等于块大小。

于是每个块只 BFS 一次:数出块大小,再把大小写回块内所有格子。之后每个询问 O(1) 查表。

实现上让 ans 数组兼作访问标记:0 = 没处理,−1 = 已入队、大小待定,正数 = 块大小。

复杂度 O(n² + m):每格只入队一次,询问只查表。若每个询问单独 BFS,最坏 10⁵ × 10⁶ = 10¹¹,必 TLE。

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

const int MAXN = 1005;
int n, m;
char g[MAXN][MAXN];            // g[i][j]:'0' 或 '1',行列从 1 开始
int ans[MAXN][MAXN];           // 0 = 未处理,-1 = 已入队待定,正数 = 块大小
int cells[MAXN * MAXN];        // 当前块内的格子,压成 i * MAXN + j
int dx[4] = {1, -1, 0, 0};
int dy[4] = {0, 0, 1, -1};

// 把 (sx, sy) 所在的整个连通块算完,并把块大小写回块内每一格
void fill_block(int sx, int sy) {
    queue<pair<int,int>> q;
    q.push({sx, sy});
    ans[sx][sy] = -1;          // 入队就标记,不能等出队

    int cnt = 0;
    while (!q.empty()) {
        auto [x, y] = q.front(); q.pop();
        cells[cnt++] = x * MAXN + y;       // 记下块内格子,等会儿统一回填
        for (int d = 0; d < 4; ++d) {
            int nx = x + dx[d], ny = y + dy[d];
            if (nx < 1 || nx > n || ny < 1 || ny > n) continue;
            if (ans[nx][ny] != 0) continue;            // 已属于某个块
            if (g[nx][ny] == g[x][y]) continue;        // 只能走到数字不同的格
            ans[nx][ny] = -1;
            q.push({nx, ny});
        }
    }
    for (int k = 0; k < cnt; ++k)                  // 同块同答案
        ans[cells[k] / MAXN][cells[k] % MAXN] = cnt;
}

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

    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= n; ++j) cin >> g[i][j];   // 读 char 会自动跳过换行

    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= n; ++j)
            if (ans[i][j] == 0) fill_block(i, j);      // 每块只算一次

    while (m--) {
        int i, j;
        cin >> i >> j;
        cout << ans[i][j] << "\n";                     // 询问退化成查表
    }
    return 0;
}
关键片段拆解
片段作用
int ans[MAXN][MAXN];同时当答案表和访问标记。省掉一个 vis 数组,也省掉一次「两块表不同步」的 bug。
ans[sx][sy] = -1;入队立刻标记。等出队再标记的话,同一块里的格子会被多个父亲重复压入,块大小就数大了。
cells[cnt++] = x * MAXN + y;BFS 时还不知道块最终多大,只能先记下来;把坐标压成一个 int,一个数组就够。
if (g[nx][ny] == g[x][y]) continue;这就是「边」的定义:只能走到数字不同的相邻格。写反成 != 时 continue,整题崩。
ans[cells[k] / MAXN][cells[k] % MAXN] = cnt;回填:同一块所有格子的答案都等于块大小。
while (m--)询问阶段只查表,O(1)。这正是把 10¹¹ 降到 10⁶ 的地方。
这题最容易错的地方
  • 对每个询问单独 BFS:m = 10⁵、n = 1000 时约 10¹¹ 次操作 → TLE。
  • 入队时不标记、只在出队时标记 → 同一格被重复压入,块大小数大 → WA。
  • cells 数组按 10⁵ 开:n = 1000 时一整块能有 10⁶ 个格子,越界 → RE。
  • 询问下标 1 起、数组开成 0 起,混用后答案整体错位一行一列。
大题 挑战题 2 · 砍树
洛谷 P1873普及/提高-去洛谷做这题 ↗

有 N 棵树,第 i 棵高 hᵢ。设定锯片高度 H 后,每棵高于 H 的树被锯掉 hᵢ − H 米,得到木材总量 Σ max(0, hᵢ − H)。

求最大的整数 H,使木材总量至少为 M。

输入格式:第一行两个整数 N、M(1 ≤ N ≤ 10⁶,1 ≤ M ≤ 2×10⁹);第二行 N 个整数表示每棵树的高度(≤ 4×10⁵),保证所有树高之和 > M。

输出格式:一个整数,表示锯片能升到的最大高度 H。

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

枚举 H 从 0 到最高的树,每次扫一遍 N 棵树:这样是多少次操作?N = 10⁶ 时能过吗?

H 增大时木材总量怎么变?把这句话翻译成一个单调的判定函数。

二分的上界不用猜——最高的那棵树就是自然上界。

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

check(H):Σ max(0, hᵢ − H) ≥ M。H 越大能锯到的越少,check 单调(真…真假…假)。

要的是最后一个 check 为真的 H,于是用「上取整 mid + 可行则 l = mid」那一套。

上界取 mx = max(hᵢ)(再高就一棵也锯不到),下界 0(题目保证总和 > M,H = 0 一定可行)。

check 里的 sum 必须 long long:10⁶ 棵 4×10⁵ 的树,总量 4×10¹¹,int 直接溢出。

复杂度 O(N log mx) ≈ 10⁶ × 19 ≈ 2×10⁷ 次运算,1 秒内稳过。

边界提醒:答案可能是 0(树都很矮),也可能是 mx,所以 l、r 的初值必须覆盖这两端。

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

const int MAXN = 1000005;
int n, h[MAXN];
long long need;              // M 到 2×10⁹,用 int 比较会溢出

// 锯片高度为 H 时,得到的木材是否够 M 米
bool check(int H) {
    long long sum = 0;
    for (int i = 1; i <= n; ++i) {
        if (h[i] > H) {
            sum += h[i] - H;
            if (sum >= need) return true;   // 够了就提前退出
        }
    }
    return false;
}

int main() {
    scanf("%d%lld", &n, &need);

    int mx = 0;
    for (int i = 1; i <= n; ++i) {
        scanf("%d", &h[i]);
        if (h[i] > mx) mx = h[i];
    }

    int l = 0, r = mx;                      // 答案一定在 [0, 最高的树] 内
    while (l < r) {
        int mid = l + (r - l + 1) / 2;      // 上取整:求最大值必须这么写
        if (check(mid)) l = mid;            // mid 可行,答案不小于 mid
        else            r = mid - 1;        // mid 不可行,答案更小
    }
    printf("%d\n", l);
    return 0;
}
关键片段拆解
片段作用
bool check(int H)把「H 行不行」封装成函数,主函数的二分只剩四行,也方便单独手测 check。
long long sum = 0;木材总量最大 4×10¹¹。用 int 溢出后变负,check 永远为真,输出上界而不是答案。
if (h[i] > H) sum += h[i] - H;严格高于 H 才锯。恰好等于 H 的树贡献 0,写 >= 结果一样,但按题意写更好读。
if (sum >= need) return true;提前退出。木材够了就没必要继续扫后半段,n = 10⁶ 时这一句能省掉不少循环。
int mid = l + (r - l + 1) / 2;上取整。若用下取整,l 与 r 相邻且 check 为真时 l 永远不动 → 死循环。
if (check(mid)) l = mid; else r = mid - 1;可行就往上走,不可行就退一格。循环结束时 l == r,就是最后一个可行值。
这题最容易错的地方
  • 上界写成 10⁵ 或 n:树高可以到 4×10⁵,比上界高的答案被截断 → WA。
  • check 里 sum 用 int:总量 4×10¹¹ 溢出 → check 恒真 → WA(输出上界)。
  • mid 用下取整又配 l = mid:l = r − 1 时死循环 → TLE。
  • scanf 用 %d 读 M:M 到 2×10⁹ 时溢出 → 比较全错。
大题 挑战题 3 · 数列分段 Section II
洛谷 P1182普及/提高-去洛谷做这题 ↗

给定一个长度为 N 的数列 A,把它分成 M 段连续的子段,要求每段和的最大值尽量小,输出这个最小值。

输入格式:第一行两个正整数 N、M(1 ≤ N ≤ 10⁵,M ≤ N);第二行 N 个空格隔开的非负整数 Aᵢ(Aᵢ < 10⁸)。

输出格式:一个整数,即每段和最大值的最小可能值(答案不超过 10⁹)。

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

「最大值最小」是二分答案的固定信号。先回答一个问题:如果限定每段和 ≤ X,最少要几段?

段数与 X 之间是单调的:X 越大,需要的段数越少。这就是能二分的理由。

二分的下界不能随便取 1。想一想:一个数本身就超过 X 时,你的 check 会怎么数段?

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

check(X):从左往右贪心累加,能装就装,装不下就开新段,数出最少段数 cnt。

cnt ≤ M 说明 X 可行——段数不够可以把某段再切开,段数只会更多,而上限仍是 X。

段数随 X 单调不增,所以「最小可行 X」可以二分,用「下取整 mid + 可行则 r = mid」那一套。

下界必须取 max(Aᵢ):否则 X 比某个数还小时,check 会把「单个元素已超限」的段也当成一段,二分会输出一个装不下的假答案。例:A = 10 1 1、M = 3,正确答案 10,下界取 1 会输出 2。

上界取总和 ΣAᵢ(只分一段),10⁵ × 10⁸ = 10¹³,必须 long long。

复杂度 O(N log ΣA) ≈ 10⁵ × 44,稳过。

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

const int MAXN = 100005;
int n, m, a[MAXN];

// 每段和不超过 X 时,最少要分成几段
bool check(long long X) {
    int cnt = 1;                 // 至少一段
    long long sum = 0;           // 当前段的和
    for (int i = 1; i <= n; ++i) {
        if (sum + a[i] <= X) {
            sum += a[i];         // 能装就装:段数越少越容易满足 cnt <= m
        } else {
            ++cnt;               // 装不下,开新段
            sum = a[i];
        }
    }
    return cnt <= m;
}

int main() {
    scanf("%d%d", &n, &m);

    long long l = 0, r = 0;
    for (int i = 1; i <= n; ++i) {
        scanf("%d", &a[i]);
        if (a[i] > l) l = a[i];  // 下界:最大的那个数
        r += a[i];               // 上界:全部装进一段
    }

    while (l < r) {
        long long mid = l + (r - l) / 2;
        if (check(mid)) r = mid;        // X 够大,试试更小
        else            l = mid + 1;    // X 太小,段数超了
    }
    printf("%lld\n", l);
    return 0;
}
关键片段拆解
片段作用
int cnt = 1; long long sum = 0;段数从 1 开始(哪怕只有一个数也是一段)。把 cnt 写成 0 会让答案差 1。
if (sum + a[i] <= X)能塞就塞。贪心为什么对:段越少越容易满足 cnt ≤ m,而「尽量往右扩」不会让后面更难分。
else { ++cnt; sum = a[i]; }装不下就开新段,并把当前数放进去。别写 sum = 0 再 += a[i],多一步就多一次写错的机会。
return cnt <= m;段数不超过 M 就算可行——多出来的段可以把某段切开,上限仍然是 X。
if (a[i] > l) l = a[i];关键一行。下界取 1 会在 X < max(aᵢ) 时把超限的段也数进去,二分会给出假答案。
if (check(mid)) r = mid; else l = mid + 1;求最小值:可行就收缩右边界。配下取整的 mid,l = r 时收敛,不会死循环。
这题最容易错的地方
  • 下界写成 0 或 1:X < max(aᵢ) 时 check 仍会算出一个段数,于是输出比 max(aᵢ) 还小的假答案 → WA。
  • 段数和 sum 用 int:总和可到 10¹³,直接溢出 → 二分区间错乱。
  • cnt 初值写 0:所有数装得下时正确答案是 1,你会输出 0。
  • 上界只写 max(aᵢ):那只够分一段,M ≥ 2 时真实答案超过上界,二分会卡在 max(aᵢ) → WA。
大题 挑战题 4 · 合并果子
洛谷 P1090普及/提高-去洛谷做这题 ↗

有 n 堆果子,第 i 堆 aᵢ 个。每次可以把两堆合并成一堆,消耗的体力等于两堆数量之和,合并 n − 1 次后只剩一堆。

求总共消耗的最小体力。

输入格式:第一行一个整数 n(1 ≤ n ≤ 10⁴);第二行 n 个整数 aᵢ(1 ≤ aᵢ ≤ 2×10⁴)。数据保证答案小于 2³¹。

输出格式:一个整数,最小的体力耗费值。

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

先手算 n = 3、1 2 9:先合并 1 和 2 得 3、再合并 3 和 9 得 12,总 15。换成先合并 2 和 9 呢?

每一堆被合并后还会被后面的合并反复计入。哪一堆应该合并得最早?

每次都要取「当前最小的两堆」,n = 10⁴ 时用哪个容器最快?

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

贪心:每次取当前最小的两堆合并。

论证:把合并过程画成一棵二叉树,叶子是原堆。某堆被计入答案的次数等于它在树中的深度,深度大的位置应该放小重量——所以最小的两堆适合做最深的兄弟。

交换论证:若最深的两个兄弟不是当前最小的两堆,把最小的两堆换到那两个位置上,总和不会变大。所以第一步合并最小的两堆一定不亏,合并出的新堆继续参与同样的问题。

实现:小根堆,重复 n − 1 次「弹两个、累加、压回」。每次两个 pop 一个 push,共 O(n log n)。

n = 1 时不进入循环,答案 0——循环条件写 size() > 1 自动处理。

答案上限是「总和 × 合并次数」量级,用 long long 一劳永逸(题目保证 < 2³¹,但别省这个类型)。

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

int main() {
    int n;
    scanf("%d", &n);

    // 小根堆:top() 是当前最小的那一堆
    priority_queue<int, vector<int>, greater<int>> pq;
    for (int i = 0; i < n; ++i) {
        int x;
        scanf("%d", &x);
        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);                  // 新堆放回去,继续参与
    }
    printf("%lld\n", ans);
    return 0;
}
关键片段拆解
片段作用
priority_queue<int, vector<int>, greater<int>>三个参数缺一不可:元素类型、底层容器、比较方式。greater 表示小的优先。
int a = pq.top(); pq.pop();取值和出堆是两步。只取值不出堆,下一轮还会拿到同一堆。
ans += a + b;合并代价累加。先合并的堆会被后面的合并反复计入,所以小的要早合并。
pq.push(a + b);新堆必须回到堆里,否则下一轮没有堆可取,堆空后 top() 直接 RE。
while (pq.size() > 1)n 堆并成一堆要 n − 1 次;写成 > 0 会在只剩一堆时再想弹两个,越界。
这题最容易错的地方
  • 用 priority_queue<int>(大根堆):每次合并最大的两堆,1 2 9 会输出 23 → WA。
  • 忘记 pq.push(a + b):第二轮堆已空,top() 对空堆 → RE。
  • 只 pop 一次却取两次 top:把同一堆算了两次,答案偏小 → WA。
  • 用 vector + 每次合并后重排:10⁴ 次合并 × 每次 10⁴ 量级,总比较次数约 10⁹ → TLE。
大题 挑战题 5 · 海港
洛谷 P2058普及/提高-去洛谷做这题 ↗

有 n 艘船依次到港,第 i 艘船在 tᵢ 秒到达,船上有 kᵢ 名乘客,国籍分别是 xᵢ,₁ … xᵢ,kᵢ。tᵢ 递增。

对每艘船,统计「到它到达为止的 24 小时(86400 秒)内」到港的所有乘客来自多少个不同的国家,即统计满足 tᵢ − 86400 < tⱼ ≤ tᵢ 的船上所有乘客的不同国籍数。

输入格式:第一行 n(1 ≤ n ≤ 10⁵,Σkᵢ ≤ 3×10⁵);接下来 n 行,每行先是 tᵢ、kᵢ,再是 kᵢ 个国籍编号(≤ 10⁵)。

输出格式:n 行,每行一个整数,表示第 i 艘船到达后的统计结果。

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

窗口随时间往后滑,过期的人要从统计里去掉。哪种容器适合「先进先出、按时间排队」?

如果记录「每个国家在窗口里有几个人」,一个国家从 0 变 1 时答案加 1,从 1 变 0 时答案减 1。

队头什么时候该弹?注意题面是 tᵢ − 86400 < tⱼ,边界的等号要小心。

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

把所有乘客按到达顺序排进一个队列,每个元素存 (到达时刻, 国籍)。船与人的层级被压平。

维护 cnt[c] = 窗口内国籍 c 的人数,以及 ans = 窗口内不同国籍数。

一艘船到达:先把它的 kᵢ 个乘客逐个入队并更新 cnt(从 0 到 1 时 ans++)。

再把队头所有「到达时刻 ≤ tᵢ − 86400」的乘客弹出,弹出时 cnt−−,减到 0 时 ans−−。

为什么用 queue:时间递增,过期的永远在队头,且不会有人「重新进来」。每个乘客进队一次、出队一次。

复杂度 O(Σkᵢ) = 3×10⁵ 次操作。若每艘船都把窗口内的乘客重扫一遍,是 O(n·Σkᵢ),必 TLE。

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

const int MAXN = 100005;
int cnt[MAXN];                 // cnt[c]:窗口内国籍 c 的乘客数

int main() {
    int n;
    scanf("%d", &n);

    queue<pair<int,int>> q;    // (到达时刻, 国籍),按时间排队
    int ans = 0;               // 窗口内不同国籍数

    for (int i = 0; i < n; ++i) {
        int t, k;
        scanf("%d%d", &t, &k);
        for (int j = 0; j < k; ++j) {
            int c;
            scanf("%d", &c);
            q.push({t, c});            // 同一艘船的人分享同一个到达时刻
            if (cnt[c] == 0) ++ans;    // 这个国家在窗口里从无到有
            ++cnt[c];
        }
        // 队伍左端:超过 24 小时的人出队
        while (!q.empty() && q.front().first <= t - 86400) {
            int c = q.front().second;
            q.pop();
            --cnt[c];
            if (cnt[c] == 0) --ans;    // 这个国家在窗口里彻底消失
        }
        printf("%d\n", ans);
    }
    return 0;
}
关键片段拆解
片段作用
queue<pair<int,int>> q;队列里放「每个乘客」,不是「每艘船」:出队时按人扣减,计数才对得上。
if (cnt[c] == 0) ++ans; ++cnt[c];先判「从无到有」再加人数。顺序反了(先 ++cnt 再判 0)就永远不成立,ans 一直是 0。
while (!q.empty() && q.front().first <= t - 86400)窗口左边界:t − 86400。用 ≤ 正好把「恰好 86400 秒前」的人排除,符合题面的严格小于。
--cnt[c]; if (cnt[c] == 0) --ans;出队一个人:人数减一;减到 0 说明这个国家整个离开窗口,国家数减一。
printf("%d\n", ans);每艘船处理完立刻输出当前窗口答案——题目要的是每艘船一个结果,不是最后一次性输出。
这题最容易错的地方
  • 计数顺序写反(先 ++cnt 再判是否为 0)→ ans 永远不加 → 全输出 0。
  • 弹出条件写成 < t - 86400:恰好 86400 秒前到的人被留在窗口里 → 边界数据 WA。
  • 用 bool 数组标记「这个国家在窗口里出现过」:同一国家有多人时,走掉一个就以为整个国家消失了 → 答案偏小。必须用计数。
  • cnt 只开到 1000:国籍编号最大 10⁵ → 越界,表现为乱答案或 RE。
做完之后自问
延伸阅读