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

20 · 回顾四:贪心与前缀和

把第 16~19 节压成四句判据:排序贪心看交换论证,区间问题看右端点,区间查询看前缀和、区间修改看差分,连续段最值看双指针。5 道黄题逐条验收,每题都给可提交代码与易错清单。
⭐ 已学范围回顾训练普及-建议 90 分钟回顾5 题

第 16~19 节讲的是四件事:贪心决定“选什么”,排序把选择的标准写成一行 cmp,前缀和 / 差分把“反复算一段区间”换成两三个端点的操作,双指针把“枚举所有区间”压成一条只会右移的窗口。四件工具共享同一个前提:后面要用的信息,能压成一个数。压不成,就只剩 DP 和搜索。

四件工具是四个“压缩包”:贪心压成一个排序键,前缀和压成一个累计量,差分压成两个端点,双指针压成一个窗口边界。
节内容一句话判据判据不成立时
16贪心入门:交换论证与排序贪心总代价能写成 Σ(值 × 位置系数),交换相邻两项不变差按性价比拿 0/1 背包,答案错
17贪心进阶:区间问题与堆区间调度按右端点升序;每次要取最小的两个就上堆按左端点排,选中的区间少一半
18前缀和与差分多次查询区间和用 pre;多次区间加、最后统一输出用 d混着用,复杂度降不下来
19双指针与滑动窗口右端点右移后,左端点只会右移、不回头窗口没有单调性,边缩边漏解

回顾章不讲新知识。下面先看一道能同时走通“前缀和”和“贪心”两条路的题,再用 5 道黄题把四件工具各打一遍——每题都给完整题面、提示、思路、代码和易错点。

例题 · 最大子段和洛谷 P1115普及/提高-去洛谷做这题 ↗

给出一个长度为 n 的序列 a,选出其中连续且非空的一段,使这段的和最大,输出这个最大和。

输入格式:第一行一个整数 n(1 ≤ n ≤ 2×10⁵);第二行 n 个整数 a_i(|a_i| ≤ 10⁴)。

输出格式:一行一个整数,即最大子段和。

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

为什么拿它当例题:它表面是前缀和题,本质是贪心题。会写 pre 表的人很多,能说清“为什么只需要维护一个最小值”的人不多——后者才是第 18 节真正想留下的东西。而且它不到 20 行,改一个初值就能造出三种 WA,是最好的验错材料。

新函数拆解 · pre 与 minpre(前缀和 + 历史最小值)
pre[i] = pre[i-1] + a[i]; ans = max(ans, pre[i] - minpre); // minpre = min(pre[0..i-1]) minpre = min(minpre, pre[i]);
组成含义 / 格式要求
pre[i]前 i 个数的和,是累计量而不是原数组。区间和靠两个前缀相减得到。
pre[r] - pre[l-1]区间 [l, r] 的和。一次减法顶替一次长度为 len 的循环。
minpre已经扫过的所有前缀里的最小值(含 pre[0] = 0)。固定右端点 r 时,减数越小答案越大。
更新顺序先用旧的 minpre 算答案,再把 pre[i] 并进 minpre。顺序反了就等于允许空区间。
ans 初值必须取负无穷(LLONG_MIN)。子段非空,全负数时答案是“最大的那个负数”,不是 0。
量级n = 2×10⁵、|a| = 10⁴ 时 |pre| 可达 2×10⁹,已经贴住 int 上限,用 long long。
最小可运行示例
示例
long long pre = 0, minpre = 0, ans = LLONG_MIN;   // minpre 的初值 0 就是 pre[0]
for (int i = 1; i <= n; ++i) {
    pre += a[i];                    // pre 此刻等于 pre[i]
    ans = max(ans, pre - minpre);   // ① 先算答案,保证区间非空
    minpre = min(minpre, pre);      // ② 再把 pre[i] 并进历史最小值
}
// a = {2, -4, 3, -1, 2, -4, 3} → ans = 4,来自 3 + (-1) + 2
注意

把 ①② 换序:先 minpre = min(minpre, pre) 再算答案,减数可能取到 pre[i] 本身,等于允许空段。症状是全负数数据输出 0。

存了 pre 数组却每次从 1 到 r 找最小值,复杂度退回 O(n²)——minpre 的意义就是“顺手记下过去的最好值”。

新函数拆解 · d 数组(差分)与“正部之和”
d[i] = a[i] - a[i-1]; ans += max(0, a[i] - a[i-1]); // 只累加上升量
组成含义 / 格式要求
d[i]相邻两项的变化量。区间加 z 只需要 d[l] += z、d[r+1] -= z 两次单点修改。
还原对 d 求一遍前缀和才得到真实值。d 本身不是答案,它只是记账。
正部之和把一段序列“削平”(每次让一个连续区间减 1)的最少次数 = d[1] + Σ max(0, d[i] − d[i−1]),也就是所有上升量之和。
为什么每次操作最多消掉一个单位的高度差,所以“上升量之和”是下界;按“每逢上升就新开几次操作”的策略恰好取到这个下界,于是它也是上界。
数组大小要写 d[r+1],而 r 可以等于 n,所以开 n+2。
流式写法只求和时不用存数组:读一个数、与上一个比较,O(1) 空间。
最小可运行示例
示例
// 铺设道路:n 段深度 d[i],每次把一段连续区间每段减 1,求最少次数
int prev = 0;   long long ans = 0;      // prev 相当于 d[0] = 0
for (int i = 1; i <= n; ++i) {
    int d; cin >> d;
    if (d > prev) ans += d - prev;      // 只累加“上升”的部分
    prev = d;
}
// 4 3 2 5 3 5 → 4 + 3 + 2 = 9
注意

算成 Σ|d[i] − d[i−1]| 会把下降量也算进去,答案偏大。

忘掉 prev 的初值 0,第一段就少了 d[1] 次操作。

新函数拆解 · priority_queue 小根堆
priority_queue<int, vector<int>, greater<int>> pq;
组成含义 / 格式要求
三个模板参数元素类型、底层容器 vector、比较器 greater<int>(越小越优先)。少写后两个就是大根堆,行为正好相反。
pq.push(x)O(log n) 插入一个元素。
pq.top()O(1) 取出堆顶,即当前最小值。只读不删。
pq.pop()O(log n) 删除堆顶,没有返回值:必须 top 之后再 pop。
pq.size() / empty()元素个数 / 是否为空。size() 返回无符号数,与 int 比较时先转 int。
用在哪每次决策都要“取当前最小的一两个”——合并果子、哈夫曼编码、多路归并、以及第 17 节里“已经选过、但还能反悔”的贪心。
最小可运行示例
示例
priority_queue<int, vector<int>, greater<int>> pq;   // 小根堆
pq.push(5); pq.push(1); pq.push(3);

while (pq.size() >= 2) {
    int a = pq.top(); pq.pop();     // 最小
    int b = pq.top(); pq.pop();     // 次小
    pq.push(a + b);                 // 合并后的新堆放回去
}
注意

写成 priority_queue<int> 就是大根堆:合并果子会每次合并最大的两堆,答案偏大。

top 之后忘了 pop,循环里永远取到同一个值——不是死循环就是答案错。

新函数拆解 · sort + cmp:把贪心策略写成一行不等式
bool cmp(const Seg &a, const Seg &b); // true 表示 a 排在 b 前面
组成含义 / 格式要求
返回值 bool只回答“a 该不该排在 b 前面”,表达的是优先级,不是“大小”。
const Seg &引用传参,不拷贝也不修改。值传递每次比较多拷一份结构体。
主键区间调度按右端点升序,不是左端点。改这一个符号就是另一个算法,必须有证明撑着。
次键(兜底)主键相等时补一条次键,输出顺序才固定可复现。不补不算违反严格弱序(平局都返回 false 是合法的“等价”),只是结果依赖 sort 的内部顺序。
严格弱序cmp(a, a) 必须为 false;cmp(a,b) 与 cmp(b,a) 不能同时为 true。写成 <= 就破坏了它。
最小可运行示例
示例
struct Seg { int l, r; };

bool cmp(const Seg &x, const Seg &y) {
    if (x.r != y.r) return x.r < y.r;   // 主键:结束越早越优先
    return x.l < y.l;                   // 兜底:结束相同,开始早的在前
}

sort(v.begin(), v.end(), cmp);
int cnt = 0, last = -1;                 // last:上一场结束的时刻
for (auto &s : v) if (s.l >= last) { ++cnt; last = s.r; }
// 0 2 / 2 4 / 1 3 → 2
注意

把 cmp 写成 x.r <= y.r(平局也返回 true)才真的破坏严格弱序:sort 可能越界写内存,症状是 RE 或随机 WA。

cmp 里不要改数据、不要输出:sort 会调用它 O(n log n) 次。

例题怎么想:三条路,一条比一条快

路 1:暴力。枚举左右端点 l ≤ r,内层求和,O(n³);前缀和预处理后变 O(n²)。n = 2×10⁵ 时约 2×10¹⁰ 次加法,必然 TLE。

路 2:前缀和改写。区间和 = pre[r] − pre[l−1],于是答案 = max over r of ( pre[r] − min{ pre[j] : 0 ≤ j < r } )。这一步把“枚举两个端点”换成了“枚举一个端点 + 一个历史最小值”。

路 3:贪心维护。固定 r 时减数越小答案越大;而“r 之前的最小前缀”只由已经读过的数决定,与后面读什么无关——它不消耗未来资源、没有后效性,所以一遍扫描就够:O(n) 时间、O(1) 空间。

骨架只有 5 行
long long pre = 0, minpre = 0, ans = LLONG_MIN;
for (int i = 1; i <= n; ++i) {
    pre += x;                       // 边读边累加,连数组都不用存
    ans = max(ans, pre - minpre);
    minpre = min(minpre, pre);
}

两个初值决定 AC 还是 WA。① ans = LLONG_MIN:数组全负时答案是“最大的那个负数”,写成 0 会输出 0(空子段,非法)。② minpre = 0 代表 pre[0],让“从第一个数开始”的子段能被取到;再配合“先算答案、后更新 minpre”,就永远不会取到空区间。

完整代码 · O(n) 时间、O(1) 空间
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

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

    int n;
    cin >> n;

    ll pre = 0;            // 当前前缀和 pre[i]
    ll minpre = 0;         // 已经扫过的前缀里的最小值,初值 0 就是 pre[0]
    ll ans = LLONG_MIN;    // 子段必须非空,所以用负无穷而不是 0

    for (int i = 1; i <= n; ++i) {
        ll x;
        cin >> x;
        pre += x;                        // 读一个数就累加,省掉数组
        ans = max(ans, pre - minpre);    // ① 先算答案:右端点是 i,左端点 ≤ i
        minpre = min(minpre, pre);       // ② 再更新历史最小值,供后面的右端点使用
    }

    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
ios::sync_with_stdio(false);2×10⁵ 个数谈不上瓶颈,但这是所有竞赛代码的默认动作,别在这种地方养成例外。
typedef long long ll;下面几个变量的量级都能到 2×10⁹,统一用 ll,读代码时不必再逐个判断类型。
ll pre = 0;pre 就是 pre[i],只是不存整张表。第 18 节的 pre 数组在这里退化成一个变量。
ll minpre = 0;0 代表 pre[0](前 0 个数之和)。有了它,l = 1 的情况不用特判。
ll ans = LLONG_MIN;−9.22×10¹⁸,比任何可能的答案都小。换成 0 会在全负数据上错。
pre += x;累加。此时 pre 已经不是“前 i−1 个”,而是“前 i 个”,后面所有判断都用新值。
ans = max(ans, pre - minpre);以 i 结尾的最大子段和 = pre[i] − (i 之前最小的前缀)。此刻 minpre 还是旧值,所以减出来的一定非空。
minpre = min(minpre, pre);把当前前缀并入历史最小值。这一句放在答案更新之后,是本题唯一的顺序要求。
cout << ans << "\n";只要一个整数加换行。不要输出提示语,也不要在行末留空格。
题目里的信号该用哪件工具判据复杂度
顺序决定总代价(谁先谁后)排序贪心交换相邻两项,答案不变差O(n log n)
尽量多选互不重叠的区间区间调度按右端点升序,能放就放O(n log n)
每次都要取当前最小的两个小根堆需要“反悔”的选择交给堆O(n log n)
多次询问区间和前缀和运算可逆(有减法)O(n) 建表,O(1) 查
多次区间加,最后统一输出差分一次区间加 = 两个端点改O(1) 改,O(n) 还原
最长 / 最短的满足条件的连续段双指针、滑动窗口右端点右移后左端点不回头O(n)
第 17 节回头看一眼:区间调度为什么按右端点

两场活动能先后参加,条件是“前一场结束 ≤ 后一场开始”。每选一场,代价是这段时间被占用。

结束得越早,留给后面的时间越多——这正是“局部选择不消耗未来资源”。交换论证:设最优解第一场是 x,结束最早的是 e,因为 e 的结束 ≤ x 的结束,把 x 换成 e 后后面只会更宽松,所以存在含 e 的最优解。

反例记一下:按开始时间排序,遇到 (0, 100)、(1, 2)、(3, 4) 会先拿 (0, 100),只得 1 场,正确是 2 场。

代码只有三行:按 (右端点升序、左端点升序) 排序,last = -1,if (l >= last) { ++cnt; last = r; }。

这一章最容易踩的 7 个坑(每个都带症状)

最大子段和把 ans 初始化成 0:数组全负时输出 0。症状是全负数数据 WA、正数数据全过。

先更新 minpre 再算答案:等于允许空区间,全负数据同样输出 0。顺序错和初值错,症状一模一样,所以这两句要一起背。

前缀和 / 区间和用 int:n = 2×10⁵、|a| = 10⁴ 时 |pre| 可达 2×10⁹,只差 7% 就撞上 int 上限,再累加或再乘一次就溢出成负数 → WA。

差分数组只开 n 个:d[r+1] 在 r = n 时写到下标 n+1 → RE 或随机 WA。开 n+2。

区间调度按左端点排序:遇到“开始早、特别长”的区间就把后面全占掉。症状是答案偏小,样例却常常能过。

小根堆写成 priority_queue<int>:本该每次取最短的两段,变成取最长的两段,答案偏大 → WA。

双指针 while 里忘记移动下标,或把窗口条件写成“不满足才缩”:前者死循环 TLE,后者窗口收不紧、答案偏大。

自己动手复现

关掉页面,把上面 P1115 的完整代码从零敲一遍,提交后确认样例输出 4。

改一改①:把 ans = LLONG_MIN 改成 ans = 0,输入 5 / -3 -1 -4 -2 -5,看它输出什么——亲手见一次“空子段”的错。

改一改②:把 max 与 min 全反过来(ans = min(...)、minpre 换 maxpre、初值换 LLONG_MAX),求最小子段和。用例题样例 2 -4 3 -1 2 -4 3 应当得到 −4,验证对称性。

改一改③:额外输出最大子段的左右端点。提示:minpre 更新时顺手记下它所在的位置,答案被更新时记下当前的 i;想一想为什么“位置”不能事后补算。

这次训练怎么用 90 分钟

0~10 分钟:只读 5 道题,各写一个关键词(排序?前缀和?窗口?堆?),再按“能立刻写 → 有思路 → 没思路”排序。

10~25 分钟:A 题 P1094。最短的一道,练双指针手感,重点盯 l == r 这个边界。

25~45 分钟:B 题 P2240 与 C 题 P1334。一个考排序键(单价),一个考堆(把“锯”倒着看成“合并”),两题都不超过 35 行。

45~75 分钟:D 题 P5019。先手算 4 3 2 5 3 5 得 9,再把“只累加上升量”写成三行。

75~90 分钟:E 题 P1147。写不动就只核对循环边界、全局变量、行末空格,不写新代码。

题编号主要考什么最容易错在哪
AP1094排序 + 双指针的配对贪心普及-
BP2240排序贪心:按单价降序取满普及-
CP1334小根堆:把“锯”倒看成“合并”写成大根堆;用 int 存累计代价
DP5019差分的正部之和普及/提高-
EP1147双指针维护连续段的和循环写成 l <= r 输出单数段;找到一段就 break
提交前的 30 秒检查清单(本章专用)

答案要 long long 吗?凡是“求和”“乘积”,先按最坏情况估一遍量级。

数组开够了吗?差分要 n+2,计数数组要值域+5,10⁶ 级数组必须放全局(静态区)。

排序键是主键吗?区间题问自己一句:按左端点还是右端点?兜底次键写了吗?

三个初值对不对?minpre = 0、ans = LLONG_MIN、sum = 3, l = 1, r = 2(P1147 的初始窗口)——初值各错一次,就是三次 WA。

输出格式:行末有没有多余空格?多测题有没有清空数组和计数器?

本次训练 · 共 5 题

做题方式

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

大题 挑战题 1 · 纪念品分组
洛谷 P1094普及-去洛谷做这题 ↗

把 n 件纪念品分成若干组,每组最多两件,且组内价格之和不能超过 w。求最少分成几组。

输入格式:第一行一个整数 w(80 ≤ w ≤ 200),表示每组价格之和的上限;第二行一个整数 n(1 ≤ n ≤ 30000);接下来 n 行,每行一个整数 p_i(5 ≤ p_i ≤ w),表示一件纪念品的价格。

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

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

先排序。最贵的那件最难找到搭档,应该让它第一个被安排掉。

最贵的能配上一件时,配最便宜的那件,还是配一件“刚好装得下”的?想想哪一件更难被安排。

把最便宜的放左端、最贵的放右端,两个下标往中间走,每次至少有一件被安排掉。

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

贪心策略:排序后让最贵的 p[r] 先决定命运——若能配上最便宜的 p[l](p[l] + p[r] ≤ w),就配成一组;否则 p[r] 单独一组。

交换论证:设最优解里 p[r] 与 x 配对。若 x 不是最便宜的 p[l],因为 p[l] ≤ x,把 x 换成 p[l],和不会超限、组数不变。若最优解里 p[r] 单独一组,而 p[l] 与 y 配对,把这两组合并成 (p[r], p[l]) 和 (y) ,组数同样不变。所以“能配就配最便宜的”一定存在最优解。

双指针实现:l 从左、r 从右。每轮至少出局一件(配成一组出局两件),共 O(n) 轮;总复杂度是排序的 O(n log n)。

只剩一件(l == r)时不能自己配自己,直接单独一组,这也是最容易漏的边界。

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

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

    int w, n;
    cin >> w >> n;
    vector<int> p(n);
    for (int i = 0; i < n; ++i) cin >> p[i];
    sort(p.begin(), p.end());          // 排序后两端才是“最便宜 / 最贵”

    int l = 0, r = n - 1, cnt = 0;
    while (l <= r) {
        if (l == r) { ++cnt; break; }               // 只剩一件:单独一组
        if (p[l] + p[r] <= w) { ++l; --r; }         // 能配就配,两件一起出局
        else { --r; }                              // 配不上,最贵的单独一组
        ++cnt;
    }

    cout << cnt << "\n";
    return 0;
}
关键片段拆解
片段作用
sort(p.begin(), p.end());贪心的两个决策对象就在两端:最便宜的、最贵的。排序是全部代价。
while (l <= r)两个下标相遇即结束。每轮至少出局一件,所以最多 n 轮。
if (l == r) { ++cnt; break; }区间只剩一件时不能自己和自己配对,直接单独一组;漏掉这一句要么死循环要么少算一组。
if (p[l] + p[r] <= w)能配就配。连最便宜的都配不上最贵的,说明最贵的和谁都配不上。
else { --r; }配不上时最贵的只能单独一组,右端点左移一件。
++cnt;两个分支都消耗一组,统一在分支外计数,避免两处各写一次。
这题最容易错的地方
  • 用静态数组却照着 w ≤ 200 开成 p[205]:n 最多 30000,越界 → RE。用 vector 或开 30005。
  • 配不成时忘记 --r,循环不动 → TLE。
  • 把 <= w 写成 < w:和恰好等于上限的组合被拆成两组,答案偏大。
  • 以为“每组最多两件”就是两两配对:n 为奇数时最后一件必须单独一组。
大题 挑战题 2 · 部分背包问题
洛谷 P2240普及-去洛谷做这题 ↗

有 n 种物品和一个容量为 t 的背包。第 i 种物品重 m_i、总价值 v_i。与 0/1 背包不同,这里的物品可以分割:允许只取其中一部分,按比例拿到对应的价值。求背包能装下的最大总价值。

输入格式:第一行两个整数 n、t(1 ≤ n ≤ 100,1 ≤ t ≤ 1000);接下来 n 行,每行两个整数 m_i、v_i(1 ≤ m_i, v_i ≤ 100)。

输出格式:一行一个实数,表示最大总价值,保留两位小数。

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

先把“该先装谁”变成一个能算的量:每单位重量值多少钱。

如果用一个单价低的东西挤掉了一部分单价高的,交换之后总价值会变多还是变少?

排序后从前往后拿;遇到装不下的那一种,能拿多少拿多少,然后立刻停。

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

贪心策略:按单价 v_i / m_i 从大到小排序,依次尽量取满;遇到装不下的那一种,只取剩余容量的那一份,然后结束。

交换论证:设最优解里取了一部分单价较低的物品 B,而单价较高的物品 A 没有取完。把 B 的一份重量换成 A 的一份重量:总重量不变,总价值不减。所以一定存在一个“按单价降序取满”的最优解。

为什么这题能贪、0/1 背包不能:物品可以切开,“取多少”是连续的,交换时总能换出任意小的量;0/1 背包只能整件拿,交换会破坏可行性。这正好落在第 16 节那张判据表的“是否消耗未来资源”上。

实现要点:排序比较用交叉相乘 a.v * b.m > b.v * a.m,不写除法;答案用 double,最后一种物品按重量比例折算。

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

struct Item { int m, v; };

bool cmp(const Item &a, const Item &b) {
    // 单价降序:a.v/a.m > b.v/b.m,交叉相乘,避开浮点误差
    return 1LL * a.v * b.m > 1LL * b.v * a.m;
}

int main() {
    int n, t;
    cin >> n >> t;
    vector<Item> a(n);
    for (int i = 0; i < n; ++i) cin >> a[i].m >> a[i].v;
    sort(a.begin(), a.end(), cmp);

    double ans = 0;
    for (int i = 0; i < n; ++i) {
        if (t >= a[i].m) {                  // 装得下就整件拿
            t -= a[i].m;
            ans += a[i].v;
        } else {                            // 只能拿剩余容量的那一份
            ans += 1.0 * a[i].v * t / a[i].m;
            t = 0;
            break;                          // 背包已满,后面的单价更低,不用再看
        }
    }

    printf("%.2f\n", ans);
    return 0;
}
关键片段拆解
片段作用
bool cmp(const Item &a, const Item &b)只比较单价,不改数据。返回 true 表示 a 的单价更高、应排在前面。
1LL * a.v * b.m > 1LL * b.v * a.m交叉相乘代替除法:两边都是整数,比较精确;写成 a.v/a.m 会把 2/3 和 3/5 都算成 0。
if (t >= a[i].m)装得下就整件拿——这就是“单价最高优先”的直接体现。
ans += 1.0 * a[i].v * t / a[i].m;只装一部分:按重量比例折算价值。先乘 1.0 转浮点,否则整数除法把小数截掉。
break;容量已满,继续循环不会再加东西,反而可能让 t 变成负数。
printf("%.2f\n", ans);两位小数。一次性格式化输出用它最省事,不受 cout 流状态影响。
这题最容易错的地方
  • cmp 里写 a.v / a.m > b.v / b.m:整数除法把 2/3 与 3/5 都算成 0,排序结果乱。
  • 把最后一种物品也整件累加:剩余容量装不下整件,答案偏大 → WA。
  • 把本题当 0/1 背包去写 DP:物品本可分割,按整件拿会把答案算小 → WA。
  • 忘记保留两位小数,或用 cout 的默认精度输出 240 而不是 240.00 → WA。
大题 挑战题 3 · 瑞瑞的木板
洛谷 P1334普及-去洛谷做这题 ↗

瑞瑞要把一根长木板锯成 n 段,第 i 段长 L_i。木板的初始长度就是这 n 段之和。每次锯开一块木板,代价等于这块木板锯之前的长度,锯完得到两块更短的木板。求锯成这 n 段所需的最小总代价。

输入格式:第一行一个整数 n(1 ≤ n ≤ 20000);接下来 n 行,每行一个整数 L_i(1 ≤ L_i ≤ 50000)。

输出格式:一行一个整数,即最小总代价。

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

把过程倒过来看:锯是“一块变两块”,倒过来就是“两块并成一块”,代价同样是并完的长度。

倒过来之后,它和 17 节的合并果子是同一道题:每次合并最短的两段。

合并出来的新段还要继续参与合并,所以它必须放回容器里。

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

反向思考:锯一次,一块长 S 的木板的代价是 S,得到长 a、b 的两块,且 S = a + b;倒过来看,就是“把 a、b 两块并成 S,代价 S = a + b”。两种过程的总代价一一对应。

于是问题等价于:每次合并最短的两段,直到只剩一段,求最小总代价——这就是哈夫曼(合并果子)。第 17 节已经证过:若某次合并的不是最短的两段,换成最短的两段,本次代价变小,而且新段更短,对后续只会更有利。

复杂度:n − 1 次合并,每次两次 pop 一次 push,O(n log n);n = 2×10⁴ 时不到 3×10⁵ 次堆操作。

量级:每段 5×10⁴、n = 2×10⁴ 时累计代价可达 10¹⁰ 量级,堆和答案都用 long long。

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

int main() {
    int n;
    cin >> n;

    // 小根堆:每次都要取最短的两段
    priority_queue<long long, vector<long long>, greater<long long>> pq;
    for (int i = 0; i < n; ++i) {
        long long x;
        cin >> x;
        pq.push(x);
    }

    long long ans = 0;
    while (pq.size() >= 2) {
        long long a = pq.top(); pq.pop();   // 最短的一段
        long long b = pq.top(); pq.pop();   // 次短的一段
        ans += a + b;                       // 合并代价 = 拼接后的长度
        pq.push(a + b);                     // 新段放回去,继续参与合并
    }

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
priority_queue<long long, vector<long long>, greater<long long>> pq;堆里存段长,用 long long;比较器换成 greater 才是小根堆,否则每次取到的是最长的一段。
while (pq.size() >= 2)n 段要合并 n − 1 次,循环次数正好对上;n = 1 时一次都不做,答案 0。
long long a = pq.top(); pq.pop();先取再删,两句不能合成一句:pop() 没有返回值。
ans += a + b;这次“合并”的代价等于新段长度,也就是原来那次“锯”的代价。
pq.push(a + b);新段必须回堆,否则下一次取到的还是旧值,答案偏小。
这题最容易错的地方
  • 把代价理解成“每段最终长度之和”:那样答案是 sum(L) = 22,与样例的 44 对不上 → WA。
  • 用 int 存堆和答案:累计代价可到 10¹⁰,溢出成负数 → WA。
  • 忘记把合并后的新段 push 回堆:n > 2 时结果必然偏小。
  • 写成大根堆(少写 greater):每次合并最长的两段,答案偏大 → WA。
大题 挑战题 4 · 铺设道路
洛谷 P5019普及/提高-去洛谷做这题 ↗

一条道路分 n 段,第 i 段下陷深度为 d_i。每次操作可以选一段连续区间 [l, r],把区间内每段的深度都减 1。求把整条道路填平(所有 d_i 变为 0)的最少操作次数。

输入格式:第一行一个整数 n(1 ≤ n ≤ 100000);第二行 n 个整数 d_i(0 ≤ d_i ≤ 10000)。

输出格式:一行一个整数,即最少操作次数。

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

先想 n = 1:答案是 d_1。再想 n = 2、d = 4 3:答案是多少?和 4 4 比差在哪?

从第 i−1 段走到第 i 段,如果变深了,多出来的那一截必须靠新开的操作去削。

把所有“上升量”加起来:d = 4 3 2 5 3 5 时答案是 9 吗?

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

换个说法(差分):令 d_0 = 0,答案 = Σ max(0, d_i − d_{i−1}),即所有上升量之和。

下界:每次操作只让“某一段连续区间”各减 1,因此一个上升沿有多高,就至少需要那么多次操作去削平它;把所有上升沿加起来,就是任何方案都逃不掉的下界。

可达:从左往右扫,遇到 d_i > d_{i−1} 就新开 d_i − d_{i−1} 次操作,并让这些操作与前面仍有“余量”的操作并行推进——每个位置都能恰好被削到 0。下界可达,即答案。

实现:连数组都不用开:读一个数与上一个比较,只累加上升量,O(n) 时间、O(1) 空间。

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

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

    int n;
    cin >> n;

    long long ans = 0;   // 答案上限 10^5 × 10^4 = 10^9,long long 更稳
    int prev = 0;        // 相当于 d[0] = 0

    for (int i = 1; i <= n; ++i) {
        int d;
        cin >> d;
        if (d > prev) ans += d - prev;   // 只累加“上升”的部分
        prev = d;
    }

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
int prev = 0;pre 数组的 pre[0] 在这里换成了 prev 的初值。少了它,第一段就漏掉 d_1 次操作。
if (d > prev) ans += d - prev;上升量 = 新开操作数。下降时一句都不加:那部分由前面开好的操作顺手削掉。
prev = d;处理完立刻前移。注意是赋值,不是累加。
long long ans本题 int 也够(10⁹),但同一份代码换到 d ≤ 10⁹ 就溢出,习惯用 long long。
cout << ans << "\n";一个整数加换行,不要输出多余内容。
这题最容易错的地方
  • 算成 Σ|d_i − d_{i−1}|:把下降量也算进去,答案偏大。症状是样例 4 3 2 5 3 5 输出 13 而不是 9。
  • prev 初值漏写(或写成第一段的值):第一段贡献丢失 → WA。
  • 把“每次必须选一段连续区间”看成“每次可以任意挑若干段”:那样答案会变成 max(d),和本题差一个数量级。
  • 先存数组再做差分再前缀和:结果一样,但多开 10⁵ 的空间,纯属绕路。
大题 挑战题 5 · 连续自然数和
洛谷 P1147普及-去洛谷做这题 ↗

给定一个自然数 M,求出所有至少包含两个数的连续自然数段,使段内所有数之和恰好等于 M。按首数从小到大输出每一段的首数和尾数。

输入格式:一行一个整数 M(10 ≤ M ≤ 2×10⁶)。

输出格式:若干行,每行两个整数,依次为这一段的首数与尾数,按首数升序排列。

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

固定首数 l,段和随尾数 r 增大而增大——所以对每个 l 至多只有一个 r 能用。

更省事的写法:维护一个连续段 [l, r] 的和。和太小就让右端右移,和太大就让左端右移。

题目要求每段至少两个数,所以 l == r 时即使和恰好是 M 也不能输出。

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

双指针:维护窗口 [l, r] 的整数和 sum,初始 l = 1、r = 2、sum = 1 + 2 = 3。

sum < M:右端右移一格,sum 加上新的 r。sum > M:左端右移一格,sum 减去旧的 l。sum == M:输出 l、r,再把左端右移,继续找下一段。

为什么 O(M):l 与 r 都只增不减,各自最多走到 M,总移动次数 O(M);M = 2×10⁶ 时不到 10⁷ 次加减,几十毫秒就完。

为什么不会漏:固定 l 时 sum 随 r 严格递增,一旦 sum ≥ M 就没有更长的段适合这个 l,只能右移 l——这就是“窗口单调”的全部依据。

边界:循环写 while (l < r)。l == r 时窗口只剩一个数,题目不允许;而且此时任何以 l 开头的更长的段之和都已超过 M。

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

int main() {
    int M;
    cin >> M;

    int l = 1, r = 2;
    long long sum = 3;              // 当前窗口 [l, r] 内所有数之和

    while (l < r) {                 // l == r 表示只剩一个数,题目要求至少两个数
        if (sum == M) {
            cout << l << " " << r << "\n";
            sum -= l;
            ++l;                    // 这一段找完,左端右移继续找下一段
        } else if (sum < M) {
            ++r;                    // 先让 r 前进
            sum += r;               // 再把新的 r 纳入窗口
        } else {
            sum -= l;               // 和太大,把左端移出窗口
            ++l;
        }
    }

    return 0;
}
关键片段拆解
片段作用
int l = 1, r = 2; long long sum = 3;最短的合法段是 1 + 2,从它开始。sum 用 long long,换更大的 M 也不用改。
while (l < r)l == r 时窗口只有一个数,不合法,直接结束循环。
if (sum == M)找到一段,输出首尾两个数。这里不能 break——还有别的段。
sum -= l; ++l;“相等”和“过大”两种分支都要收缩左端,写的动作完全一样。
++r; sum += r;顺序不能反:先把 r 加 1,再把新的 r 加进 sum;反过来会加进旧的 r,整条和都错位。
cout << l << " " << r << "\n";行内一个空格、行末只换行。
这题最容易错的地方
  • 循环写成 while (l <= r):M = 10 时会多输出一行 10 10(单数段)→ WA。
  • 找到一段就 break:M = 10000 有 4 段,只输出第一段 → WA。
  • 把 ++r; sum += r; 写成 sum += r; ++r;:加进去的是旧的 r,和永远对不上。
  • 用 int 存更大的 M 的和(同类题 M 可到 10⁹):溢出成负数,窗口判断全部失效 → WA。
做完之后自问
延伸阅读