0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P5019,日期见页头。两边不一致时信原站。
题目背景
NOIP2018 提高组 D1T1
题目描述
春春是一名道路工程师,负责铺设一条长度为 n 的道路。
铺设道路的主要工作是填平下陷的地表。整段道路可以看作是 n 块连续的区域,
一开始,第 i 块区域下陷的深度为 dᵢ。
春春每天可以选择一段连续区间 [L, R],填充这段区间中的每块区域,让其下陷深度减少 1。
在选择区间时,需要保证,区间内的每块区域在填充前下陷深度均不为 0。
春春希望你能帮他设计一种方案,可以在最短的时间内将整段道路的下陷深度都变为 0。
输入格式
输入文件包含两行,第一行包含一个整数 n,表示道路的长度。
第二行包含 n 个整数,相邻两数间用一个空格隔开,第 i 个整数为 dᵢ。
输出格式
输出文件仅包含一个整数,即最少需要多少天才能完成任务。
数据规模与约定
- 对于 30% 的数据,
1 ≤ n ≤ 10; - 对于 70% 的数据,
1 ≤ n ≤ 1000; - 对于 100% 的数据,
1 ≤ n ≤ 100000,0 ≤ dᵢ ≤ 10000。
输入输出样例
输入
6 4 3 2 5 3 5
输出
9
样例解释给了一种最优方案,依次选择:
[1,6]、[1,6]、[1,2]、[1,1]、[4,6]、[4,4]、[4,4]、[6,6]、[6,6] —— 共 9 天。
第 20 章的题单给这道题写的是: 「贪心是对的,但你得说得出为什么。先写暴力对拍,再想证明。」 ⇒ 第 ② 步把那个证明写全(两半,缺一半都不算证完)。
而这一页真正的主角在第 ⑤ 步:这道题有第二种解法(分治),它的答案永远是对的,
复杂度却取决于数据形状 —— 随机数据上 O(n log n),单调数列上 O(n²)。
⇒ 本书前面五道题连着量出「官方样例是个一测就死的过滤器」。 这一页给出它的补集:那个过滤器筛的是「答案错」, 对「答案对但跑不完」完全无能为力 —— 而对拍也一样。
1正解:一行公式
// P5019 [NOIP 2018 提高组] 铺设道路 —— ★ 这一版就能 AC//// 题意:n 块区域,第 i 块下陷 d_i。每天可以选一段**连续区间**,让区间里每块的深度减 1,// 前提是区间内每块的深度都不为 0。问最少几天能全部填平。//// ★ 关键一步:答案 = **所有「上升沿」的高度之和**//// ans = Σ max(0, d[i] − d[i−1]) (约定 d[0] = 0)//// 证明要两半,缺一半都不算证完(页面第 ② 步):// · **下界**:一天只能选一段连续区间 ⇒ 一天最多让**一个**上升沿降低 1// ⇒ 至少需要「上升沿总高度」那么多天;// · **上界**:按这个数确实能填完(页面第 ② 步给了构造)。//// ⚠ 两处要算一算的地方:// ① `d[0] = 0` 这个约定不能省 —— 第一块本身就是一个上升沿;// ② 答案最大约 `10⁴ × n/2 = 5 × 10⁸`(相邻 0 / 10⁴ 交替),`int` 够用 ——// ★ **算完确认够用,和没算过是两回事**。//// 复杂度 `O(n)`,读入 10⁵ 个数,随便怎么读都行。// ★ 顺带:洛谷 P1969「积木大赛」和这道题是同一道题(问法反过来,答案一模一样)。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; long long ans = 0, prev = 0; // ★ prev 从 0 开始 —— 这就是 d[0] = 0 for (int i = 0; i < n; i++) { long long d; cin >> d; if (d > prev) ans += d - prev; // 只有「升上去」的那一截要单独花天数 prev = d; } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
ans = Σ max(0, d[i] − d[i−1]) 约定 d[0] = 0
拿样例走一遍(4 3 2 5 3 5,前面补一个 0):
0 → 4 升 4 ans = 4
4 → 3 降 ans = 4
3 → 2 降 ans = 4
2 → 5 升 3 ans = 7
5 → 3 降 ans = 7
3 → 5 升 2 ans = 9
9 天,和样例解释里那个方案对上了。
2★ 证明要两半 —— 缺一半都不算证完
一天只能选一段连续区间,区间里每块都减 1。
盯住任意一个「上升沿」—— 位置 i 处 d[i] > d[i−1],高度 h = d[i] − d[i−1]。
一天之内,d[i] 和 d[i−1] 要么都减 1(i 和 i−1 都在选中的区间里),
要么只有 d[i] 减 1(区间从 i 开始)—— 不可能只减 d[i−1](那样区间里就有 d[i] 断掉了)。
⇒ 一天最多让一个上升沿的高度减少 1。
⇒ 天数 ≥ Σ 所有上升沿的高度。
构造很直接:只要还有没填平的地方,就挑第一个还没填平的极大连续段,整段减 1。 这一天恰好让「第一个上升沿」降低 1,而且不会把别的上升沿变高。
⇒ 每天正好消耗 1,Σ 上升沿高度 天之后全部填平。
上下界撞在一起 ⇒ 那个公式就是答案。 而「一天一天地填」这个构造本身也能写成程序, 它是这一页的第三条路(第 ③ 步)。
3三条完全不同的路,算出同一个数
// P5019 的第二种解法:**分治** —— 它也是对的,放在这儿当参照物//// 想法:在区间 `[l, r]` 里找最矮的那一块(深度 m)。// 整段先一起降 m 天(这 m 天每天都能选整个区间),剩下的被最矮那块切成若干独立小段,// 各自递归。⇒ `solve(l, r) = m + Σ solve(每个子段)`。//// ★ 它和差分那一行**一个字都不共享** —— 所以它是个像样的参照物。//// ⚠⚠ 而它的复杂度**取决于数据形状**,这一点很值得量(页面第 ⑤ 步):// · 随机数据上,最矮的那块大致把区间劈成两半 ⇒ `O(n log n)`;// · **单调数列**上,最矮的永远在一头 ⇒ 每次只剥掉一块 ⇒ `O(n²)`,顶格 10¹⁰ 级别。// ★ 而随机数据**看不出这件事** —— 这是[第 53 章](/ch/53-hld/)那条// 「有一类 bug 只坏复杂度不坏答案」的又一次现场。//// ⚠ 递归深度也跟着形状走,所以这里写成显式栈,免得单调数据直接爆栈。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; vector<long long> d(n); for (auto& x : d) cin >> x;
long long ans = 0; // 栈里存 (l, r, base):这一段还没被填掉的部分,已经整体降过 base vector<array<long long, 3>> st; st.push_back({0, (long long)n - 1, 0}); while (!st.empty()) { auto [l, r, base] = st.back(); st.pop_back(); if (l > r) continue; long long m = LLONG_MAX; for (long long i = l; i <= r; i++) m = min(m, d[i]); ans += m - base; // 这一段整体再降 (m - base) 天 long long start = l; for (long long i = l; i <= r; i++) { if (d[i] == m) { // 最矮的那些块把区间切开 if (start <= i - 1) st.push_back({start, i - 1, m}); start = i + 1; } } if (start <= r) st.push_back({start, r, m}); } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
分治的想法和差分公式一个字都不共享:在 [l, r] 里找最矮的那块(深度 m),
整段先一起降 m 天,剩下的被最矮那块切成若干独立小段,各自递归。
| 300 组随机数据 | |
|---|---|
| 差分公式 vs 分治 | 不一致 0 组 |
| 差分公式 vs 一天一天地填(第 ② 步那个构造) | 不一致 0 组 |
4★★ 两个错法:说清楚它们「算了什么」
// P5019 错法一:忘了 `max(0, ·)`,直接把差累加起来//// ★★ 说清楚它**算了什么**比说它「错了」有用得多 —— 这一版算的是一个望远镜求和://// Σ (d[i] − d[i−1]) = d[n] − d[0] = d[n]//// 也就是说:**它永远输出最后一块的深度**(页面第 ④ 步实测逐组相等)。// ⇒ 这个结论一出来,它的所有表现都是白送的推论:// · 数列**单调不减**时它是对的(那时候确实没有下降沿);// · 只要出现过一次下降,它就偏小。//// ★ 而官方样例 `4 3 2 5 3 5` 最后一块是 5 ⇒ 它输出 5,答案是 9 ⇒ **样例挡住了它**。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; long long ans = 0, prev = 0; for (int i = 0; i < n; i++) { long long d; cin >> d; ans += d - prev; // ← 少了 max(0, ·) prev = d; } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
把 max(0, ·) 丢掉之后,累加的是一个望远镜求和:
Σ (d[i] − d[i−1]) = d[n] − d[0] = d[n]
// P5019 错法二:漏掉「`d[0] = 0`」这个约定 —— 从第二块才开始数上升沿//// 第一块本身就是从平地(0)升上来的,它那一截也要花天数。// 漏掉之后,答案正好少了 `d[1]`。//// ★★ 而这个 bug 也能一句话说清楚它算了什么:**它算的是「答案 − 第一块的深度」**。// ⇒ 只有当第一块是 0 的时候它才对。// ★ 官方样例第一块是 4 ⇒ 它输出 5,答案 9 ⇒ **样例挡住它**。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; vector<long long> d(n); for (auto& x : d) cin >> x; long long ans = 0; for (int i = 1; i < n; i++) // ← 从 i = 1 开始,第一块那一截丢了 if (d[i] > d[i - 1]) ans += d[i] - d[i - 1]; cout << ans << "\n"; return 0;}点「运行 ▶」看结果
第一块本身就是从平地升上来的,漏掉之后答案正好少了 d[1]。
| 随机 300 轮 | |
|---|---|
「忘了 max」那版 ≡ 最后一块的深度 |
★ 300 / 300 逐组相等 |
「漏掉 d[0]=0」那版 ≡ 答案 − 第一块的深度 |
★ 300 / 300 逐组相等 |
| 它们各自被抓的轮数 | 261 / 300 和 276 / 300 |
- 「忘了
max」那版恒等于d[n]⇒ 数列单调不减时它是对的(那时候确实没有下降沿), 只要出现过一次下降就偏小; - 「漏掉
d[0]」那版恒等于「答案 −d[1]」⇒ 只有第一块是 0 时它才对。
⇒ 这是本书反复用的那个动作(P1332 那页说得最清楚, 同一天的 P1048 又用了一次): 先说清楚 bug 算的是什么,再说它错在哪。
★ 官方样例把两个都挡住了(最后一块是 5、第一块是 4,两版都输出 5,而答案 9)—— 因为它们都属于「几乎每一组都错」的那一类。
5★★★ 而真正会挂人的第三个问题,样例和对拍都看不见
分治那一版答案永远是对的(上面 300 组验过)。可它的复杂度取决于数据长什么样:
- 随机数据:最矮的那块大致把区间劈成两半 ⇒
O(n log n); - 单调数列:最矮的永远在一头 ⇒ 每次只剥掉一块 ⇒
O(n²)。
用一把机器无关的尺子量(数「找最矮那块」一共比了多少次):
n |
1 000 | 4 000 | 16 000 |
|---|---|---|---|
| 随机数据 | 12 226 | 58 966 | 267 688 |
| 单调数列 | 474 513 | 6 658 705 | 63 939 885 |
| 倍数 | 38.8 | 112.9 | ★ 238.9 |
n 每翻 4 倍:
随机数据的比较次数 ×4.8 ×4.5 <- 接近 n log n
单调数列的比较次数 ×14.0 ×9.6 <- 接近 n^2 的 ×16⇒ 顶格 n = 10⁵ 时,单调数列上大约要比 5 × 10⁹ 次 —— 一秒钟的时限下必挂。
★★ 而样例看不见它(答案对)、对拍也看不见它(答案对)。 本书前面五道题连着量出「官方样例是个一测就死的过滤器」, 这一页正好给出它的边界:那个过滤器筛的是「答案错」。 「答案对但跑不完」要靠另一件事发现 —— 数一数次数,而且要造对形状 (第 53 章那条「有一类 bug 只坏复杂度不坏答案」的又一次现场)。
⇒ 所以这道题的正解就写那一行公式:O(n)、没有形状依赖、没有递归深度问题。
分治留在这一页,是为了当参照物,也是为了把上面这件事量出来。
6★ 顺手算一笔:int 够不够
最坏形状是 0, 10⁴, 0, 10⁴, … 交替 —— 每一个 10⁴ 都是一个完整的上升沿:
顶格最坏(n = 10⁵)的答案 |
500 000 000 |
2³¹ |
2 147 483 648 |
| ⇒ | int 够用(余量 4.3 倍) |
★ 这一笔仍然要算 —— 算完确认够用,和没算过是两回事 (第 6 章 P2367 那页立的规矩)。
7度量程序和生成器
// P5019 的度量程序 —— 这一页所有数字都出自这一份。//// `./p5019Count` 人看的版本// `./p5019Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用//// 五段:// ① ★ 三条完全不同的路算出同一个数:差分公式 / 分治 / 一天一天地填;// ② ★★ 两个错法**算了什么**(不是「错了」):一个恒等于最后一块的深度,// 一个恒等于「答案 − 第一块的深度」;// ③ ★★★ 分治那版的复杂度**取决于数据形状** —— 用「比较次数」这把机器无关的尺子量;// ④ ★ 顶格答案有多大(`int` 到底够不够,算一遍);// ⑤ ★ 两个错法在随机数据上的抓获率。
#include <bits/stdc++.h>using namespace std;typedef long long ll;
static bool CSV = false;static void row(const char* key, const vector<ll>& v) { if (!CSV) return; printf("%s", key); for (ll x : v) printf(",%lld", x); printf("\n");}
/** 正解:上升沿之和 */static ll byDiff(const vector<ll>& d) { ll ans = 0, prev = 0; for (ll x : d) { if (x > prev) ans += x - prev; prev = x; } return ans;}/** 错法一:忘了 max(0, ·) */static ll noMax(const vector<ll>& d) { ll ans = 0, prev = 0; for (ll x : d) { ans += x - prev; prev = x; } return ans;}/** 错法二:漏掉 d[0] = 0 */static ll noZero(const vector<ll>& d) { ll ans = 0; for (size_t i = 1; i < d.size(); i++) if (d[i] > d[i - 1]) ans += d[i] - d[i - 1]; return ans;}/** 第二条路:分治。cmp 记录「找最矮那块」一共比了多少次 —— 机器无关的尺子 */static ll byDivide(const vector<ll>& d, ll* cmp = nullptr) { ll ans = 0; if (cmp) *cmp = 0; vector<array<ll, 3>> st{{0, (ll)d.size() - 1, 0}}; while (!st.empty()) { auto [l, r, base] = st.back(); st.pop_back(); if (l > r) continue; ll m = LLONG_MAX; for (ll i = l; i <= r; i++) { m = min(m, d[i]); if (cmp) (*cmp)++; } ans += m - base; ll start = l; for (ll i = l; i <= r; i++) if (d[i] == m) { if (start <= i - 1) st.push_back({start, i - 1, m}); start = i + 1; } if (start <= r) st.push_back({start, r, m}); } return ans;}/** 第三条路:一天一天地填(每天挑第一个还没填平的极大连续段)—— 只在小数据上用 */static ll bySimulate(vector<ll> d) { ll days = 0; while (true) { int i = 0, n = d.size(); while (i < n && d[i] == 0) i++; if (i == n) break; int j = i; while (j < n && d[j] > 0) j++; for (int k = i; k < j; k++) d[k]--; days++; } return days;}
static vector<ll> gen(mt19937& rng, int n, int dHi, int shape) { vector<ll> d(n); for (int i = 0; i < n; i++) d[i] = (ll)(rng() % (unsigned)(dHi + 1)); if (shape == 1) sort(d.begin(), d.end()); else if (shape == 2) sort(d.rbegin(), d.rend()); return d;}
int main(int argc, char** argv) { CSV = argc > 1 && string(argv[1]) == "csv";
/* ① 三条路一致 */ { mt19937 rng(20260829u); int groups = 0, badDiv = 0, badSim = 0; for (int rep = 0; rep < 300; rep++, groups++) { vector<ll> d = gen(rng, (int)(rng() % 12) + 1, 10, 0); ll ok = byDiff(d); if (byDivide(d) != ok) badDiv++; if (bySimulate(d) != ok) badSim++; } if (!CSV) printf("① %d 组:差分公式 vs 分治不一致 %d 组;差分公式 vs 一天一天地填不一致 %d 组\n", groups, badDiv, badSim); row("three", {groups, badDiv, badSim}); }
/* ② 两个错法「算了什么」 */ { mt19937 rng(5019u); int rounds = 300, eqLast = 0, eqMinusFirst = 0; for (int r = 0; r < rounds; r++) { vector<ll> d = gen(rng, (int)(rng() % 30) + 1, 20, 0); if (noMax(d) == d.back()) eqLast++; if (noZero(d) == byDiff(d) - d[0]) eqMinusFirst++; } if (!CSV) printf("② %d 轮:「忘了 max」那版 ≡ 最后一块的深度 %d 轮;" "「漏掉 d[0]=0」那版 ≡ 答案 − 第一块深度 %d 轮\n", rounds, eqLast, eqMinusFirst); row("what", {rounds, eqLast, eqMinusFirst}); }
/* ③ ★★★ 分治的复杂度取决于形状 —— 数比较次数,不看秒表 */ { const int NS[] = {1000, 4000, 16000}; vector<ll> out; for (int n : NS) { mt19937 rng(n * 7919u + 7u); ll cRand = 0, cSorted = 0; { vector<ll> d = gen(rng, n, 10000, 0); byDivide(d, &cRand); } { vector<ll> d = gen(rng, n, 10000, 1); // 单调不减 byDivide(d, &cSorted); } out.push_back(cRand); out.push_back(cSorted); if (!CSV) printf("③ n = %5d:分治在随机数据上比了 %lld 次,在单调数列上比了 %lld 次" "(差 %.1f 倍)\n", n, cRand, cSorted, (double)cSorted / cRand); } row("shape", out); }
/* ④ 顶格答案有多大 */ { int n = 100000; vector<ll> d(n); for (int i = 0; i < n; i++) d[i] = (i % 2) ? 10000 : 0; // 0 / 10^4 交替 —— 最坏形状 ll ans = byDiff(d); if (!CSV) printf("④ 顶格最坏(n = 10⁵,0 / 10⁴ 交替):答案 %lld,而 2³¹ = 2147483648 " "⇒ int %s\n", ans, ans < 2147483648LL ? "够用" : "不够"); row("worst", {ans, 2147483648LL, ans < 2147483648LL ? 1 : 0}); }
/* ⑤ 两个错法的抓获率 */ { mt19937 rng(31337u); int rounds = 300, a = 0, b = 0; for (int r = 0; r < rounds; r++) { vector<ll> d = gen(rng, (int)(rng() % 12) + 1, 10, 0); ll ok = byDiff(d); if (noMax(d) != ok) a++; if (noZero(d) != ok) b++; } if (!CSV) printf("⑤ %d 轮随机:「忘了 max」被抓 %d 次,「漏掉 d[0]=0」被抓 %d 次\n", rounds, a, b); row("catch", {rounds, a, b}); } return 0;}点「运行 ▶」看结果
// P5019 对拍生成器:`./p5019Gen <seed> [n 上限] [d 上限] [形状]`// 形状:0 = 随机(默认) 1 = 单调不减 2 = 单调不增//// ★ 「形状」这个旋钮不是为了抓答案错的 bug —— 两个答案错法在随机数据上就被抓得干干净净。// 它是为了**量分治那版的复杂度**(页面第 ⑤ 步):// 随机数据上最矮的那块大致把区间劈成两半 ⇒ `O(n log n)`;// **单调数列上最矮的永远在一头 ⇒ 每次只剥掉一块 ⇒ `O(n²)`**。// ⚠ 而这件事**答案是对的**,任何比对答案的对拍都看不见它。
#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1; int nHi = argc > 2 ? atoi(argv[2]) : 12; int dHi = argc > 3 ? atoi(argv[3]) : 10; int shape = argc > 4 ? atoi(argv[4]) : 0; nHi = max(1, min(100000, nHi)); dHi = max(0, min(10000, dHi));
mt19937 rng(seed * 2654435761u + 19u); int n = (int)(rng() % (unsigned)nHi) + 1; vector<int> d(n); for (int i = 0; i < n; i++) d[i] = (int)(rng() % (unsigned)(dHi + 1)); if (shape == 1) sort(d.begin(), d.end()); else if (shape == 2) sort(d.rbegin(), d.rend());
printf("%d\n", n); for (int i = 0; i < n; i++) printf("%d%c", d[i], i + 1 == n ? '\n' : ' '); return 0;}点「运行 ▶」看结果
⚠ 生成器的第四个参数是形状(0 随机 / 1 单调不减 / 2 单调不增)。 它不是为了抓答案错的 bug —— 那两个在随机数据上就被抓得干干净净; 它是为了量分治那版的复杂度。
8一页纸
| 关键的一步 | ans = Σ max(0, d[i] − d[i−1]),约定 d[0] = 0(O(n) 一行) |
| 哪一版能 AC | p5019.cpp |
| 证明 | 两半:下界(一天最多让一个上升沿降 1)+ 上界(每天填第一个极大段) |
| 三条路 | 差分公式 / 分治 / 一天一天地填 —— 300 组各不一致 0 组 |
| ★★ 两个错法 | 「忘了 max」≡ 最后一块的深度、「漏掉 d[0]=0」≡ 答案 − 第一块深度(各 300 / 300 逐组相等)⇒ 它们的所有表现都是白送的推论 |
| ★★★ 这一页的主线 | 样例挡得住「答案错」,挡不住「跑不完」 —— 分治答案永远对,而它在单调数列上退化成 O(n²):比较次数比随机数据多 38.8 → 112.9 → 238.9 倍(倍数在涨 = O(n²) 的签名) |
| 顺手一笔 | 顶格最坏答案 5 × 10⁸,int 够用(余量 4.3 倍)—— 但要算过才能这么说 |
| 同一道题 | 洛谷 P1969「积木大赛」问法反过来,答案一模一样 |