0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1873,日期见页头。两边不一致时信原站。
题目描述
伐木工人 Mirko 需要砍 M 米长的木材。对 Mirko 来说这是很简单的工作,
因为他有一个漂亮的新伐木机,可以如野火一般砍伐森林。不过,Mirko 只被允许砍伐一排树。
Mirko 的伐木机工作流程如下:Mirko 设置一个高度参数 H(米),伐木机升起一个巨大的锯片到高度 H,
并锯掉所有树比 H 高的部分(当然,树木不高于 H 米的部分保持不变)。Mirko 就得到树木被锯下的部分。
例如,如果一排树的高度分别为 20, 15, 10 和 17,Mirko 把锯片升到 15 米的高度,
切割后树木剩下的高度将是 15, 15, 10 和 15,而 Mirko 将从第 1 棵树得到 5 米,
从第 4 棵树得到 2 米,共得到 7 米木材。
Mirko 非常关注生态保护,所以他不会砍掉过多的木材。这也是他尽可能高地设定伐木机锯片的原因。
请帮助 Mirko 找到伐木机锯片的最大的整数高度 H,使得他能得到的木材至少为 M 米。
换句话说,如果再升高 1 米,他将得不到 M 米木材。
输入格式
第 1 行 2 个整数 N 和 M,N 表示树木的数量,M 表示需要的木材总长度。
第 2 行 N 个整数表示每棵树的高度。
输出格式
1 个整数,表示锯片的最高高度。
说明 / 提示
对于 100% 的测试数据,1 ≤ N ≤ 10⁶,1 ≤ M ≤ 2×10⁹,树的高度 ≤ 4×10⁵,
所有树的高度总和 > M。
输入输出样例 1
输入
4 7 20 15 10 17
输出
15
锯片升到 15 米:第 1 棵得 5 米、第 4 棵得 2 米,共 7 米 —— 正好是 M。
再升到 16 米就只有 5 米了。上面那段输出是仓库里的 p1873.cpp 真跑出来的。
输入输出样例 2
输入
5 20 4 42 40 26 46
输出
36
锯片升到 36 米:42−36 + 40−36 + 46−36 = 6 + 4 + 10 = 20 —— 又是正好等于 M。
⚠ 两组样例的最优解都「正好等于 M」,这不是巧合,见第 6 步。
1★★★ 先回答题单里那个问题:这题能不能用双指针?
第 7 章的题单把这道题排在最后,附了一句「先自己想想能不能用双指针 —— 想清楚「为什么不能」,比会做还有价值」。所以这一页从这儿开始。
滑动窗口(双指针)处理的对象是序列上的一段连续区间 —— 所以只要题目问的是「区间」,打乱输入,答案一定会变。
拿第 7 章题单里的另一道题P1638 逛画展试:
把那 n 个编号随机打乱,答案 x y 立刻就变了(区间换了地方)。
这道题呢?把那一排树随机打乱 —— 答案一个字都不变(实测 117194 → 117194)。
⇒ 结论就摆在这儿了:这道题的答案和树的顺序无关, 它问的是「一个高度阈值」,不是「一段区间」。 没有区间可滑,双指针就没有对象。
★ 这个判据可以随手用在任何一道拿不准的题上,而且它是可以真跑一遍的:
check:viz 里就钉着这两条 —— P1638 打乱后答案变,P1873 打乱后答案不变。
那这题是什么形状?——「答案在一条数轴上,而『这个答案行不行』是单调的」:
f(H) = 锯片高 H 时得到的木材 = sum of max(h_i - H, 0)
H 越高 -> f(H) 越小 (单调不增)
要的是 -> 最大的 H 使 f(H) >= M
这就是二分答案,也就是第 9 章的内容。 ⚠ 顺带一提:双指针那点直觉在这里没有白费,只是它落到了别的地方 —— 见第 4 步。
2第 ① 版:从最高的那棵树一路往下试
// P1873 的第 ① 版:从最高的那棵树一路往下试//// f(H) 随 H 增大而减小,所以从 H = max(h) 往下走,**第一个**让 f(H) >= M 的 H 就是答案。// 想法完全正确,一行都不用怀疑。//// ⚠ 它的代价是 O(max(h) × N):满数据 4×10⁵ × 10⁶ = **4×10¹¹**,没有任何机会。// 而且注意,慢的来源是「**答案的取值范围**」而不是「输入规模」——// 哪怕 N 只有 10,只要树高到 4×10⁵,它照样要试四十万次。//// ★ 这是「二分答案」这类题的标志性形状:**暴力枚举的是答案,而答案的范围很大**。// ⇒ 一旦发现「换成更大的答案只会更难满足」,就该想到二分。
#include <bits/stdc++.h>using namespace std;
static int h[1000006];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; long long m; cin >> n >> m; int hi = 0; for (int i = 0; i < n; i++) { cin >> h[i]; hi = max(hi, h[i]); }
for (int H = hi; H >= 0; H--) { long long s = 0; for (int i = 0; i < n; i++) if (h[i] > H) s += h[i] - H; if (s >= m) { cout << H << '\n'; return 0; } } cout << 0 << '\n'; return 0;}点「运行 ▶」看结果
想法完全正确 —— f(H) 单调,所以从高往低走,第一个满足 f(H) ≥ M 的就是答案。
它要算 max(h) + 1 次 f(),每次 O(N):
满数据:400001 次 f() x 1e6 棵树 = 4.0e11 次加法★ 注意这句:哪怕 N 只有 10,只要树高到 4×10⁵,它照样要试四十万次。
⇒ 这正是「二分答案」这类题的标志:暴力枚举的是答案本身,而答案的范围很大。
本机实测(B 机:原生 Ubuntu 7.0.0-30 / i5-10210U 8 线程 / 18 GB,2026-08-27,时限 1.5 秒;
⚠ 不是独占(机器上同时开着浏览器,load average 约 3)—— 秒数只当量级看,倍数才是重点):
N |
树高上限 | 秒表 |
|---|---|---|
| 10⁵ | 1 000 | 0.06 秒 |
| 10⁵ | 4 000 | 0.21 秒 |
| 10⁵ | 16 000 | 0.74 秒 |
| 10⁶ | 400 000(满数据) | 外推约 185 秒 |
3第 ② 版:二分答案(正解)
f(H) 单调不增,要找最大的可行 H —— 标准的「可行就往右、不可行就往左」:
lo = 0, hi = max(h)
while (lo <= hi):
mid = (lo + hi) / 2
if f(mid) >= M: ans = mid; lo = mid + 1 # 还能再高,往右找
else: hi = mid - 1 # 太高了,往左找
// P1873 EKO / 砍树 —— 能 AC 的那一版:二分答案//// 设 f(H) = 锯片高 H 时能得到的木材 = Σ max(h_i − H, 0)。// H 越高,f(H) 越小(**单调不增**)—— 要的是**最大**的 H 使 f(H) >= M。//// ★ 这就是「二分答案」:答案本身在一条数轴上,而「这个答案行不行」是单调的。// H 的范围是 [0, max(h)],一共 log2(4×10⁵) ≈ 19 次判定,每次 O(N)。//// ⚠ 三个边界,各值一条测试点:// ① 下界必须是 **0** 不是 1 —— 树很矮的时候答案真的可能是 0(见 p1873Lo.cpp);// ② 判定要写 **f(mid) >= M** 不是 > M —— 「至少 M 米」是可以正好等于的(见 p1873Gt.cpp);// ③ sum 必须是 **long long**:N = 10⁶、h_i <= 4×10⁵ ⇒ 总和最大 4×10¹¹(见 p1873Int.cpp)。//// ★ M 本身呢?M <= 2×10⁹ 而 int 上限 2 147 483 647 —— **装得下,但余量只有 7%**。// (这笔账见 p1873Count.cpp。用 long long 读它更省心,这里就是这么做的。)
#include <bits/stdc++.h>using namespace std;
static int h[1000006];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; long long m; cin >> n >> m; int hi = 0; for (int i = 0; i < n; i++) { cin >> h[i]; hi = max(hi, h[i]); }
auto f = [&](int H) { // 高度 H 时能得到多少木材 long long s = 0; for (int i = 0; i < n; i++) if (h[i] > H) s += h[i] - H; return s; };
int lo = 0, hiB = hi, ans = 0; // ① 下界是 0 while (lo <= hiB) { int mid = lo + (hiB - lo) / 2; if (f(mid) >= m) { ans = mid; lo = mid + 1; } // ② >= 不是 > else hiB = mid - 1; } cout << ans << '\n'; return 0;}点「运行 ▶」看结果
满数据只要 19 次 f()(log₂(4×10⁵) ≈ 18.6),端到端 0.08 秒。
比枚举少了 21 052 倍。
lo 必须从 0 开始 答案真的可能是 0 -> 第 7 步
判定要写 f(mid) >= M 「至少 M 米」可以正好等于 -> 第 6 步
sum 必须是 long long 木材总量最大 4e11 -> 第 8 步4★★ 第 ③ 版:排序 + 后缀和 —— 一次二分都不做
把树高排序(升序)。设有 k 棵树不高于 H,那么
f(H) = (后 n-k 棵的高度和) - (n - k) x H
在「恰好有 k 棵不高于 H」的那一段里,f 是一条直线 ——
于是可以直接解出这一段里最大的可行 H,再对所有 k 取最大。
// P1873 的第 ③ 版:排序 + 后缀和,**一次二分都不做**//// 把树高排好序(升序)。对给定的 H,设有 k 棵树不高于 H,那么//// f(H) = (后 n−k 棵的高度和) − (n − k) × H//// 在「恰好有 k 棵树不高于 H」的那一段 H 里,f 是一条直线。// 于是对每个 k 直接解出「这一段里最大的、还能满足 f(H) >= M 的 H」://// H = (suf[k] − M) / (n − k) (向下取整,再夹回这一段的范围里)//// 取所有 k 里最大的那个 H 就是答案。排序 O(N log N),之后只扫一趟。//// ★★ 这一版是这一页回答「双指针能不能用」的地方:// **排完序之后确实有一个指针在单调地走** —— 就是那个 k(被锯到的树的数量)。// H 越大,被锯到的树越少,k 单调不减。// ⇒ 「双指针」的那点直觉没有白费,只是它作用在**排好序的高度轴**上,// 而不是题面给的那排树上。区别在正文第 3 步。
#include <bits/stdc++.h>using namespace std;
static int h[1000006];static long long suf[1000007];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; long long m; cin >> n >> m; for (int i = 0; i < n; i++) cin >> h[i]; sort(h, h + n);
suf[n] = 0; for (int i = n - 1; i >= 0; i--) suf[i] = suf[i + 1] + h[i];
long long ans = 0; for (int k = 0; k < n; k++) { // 前 k 棵不高于 H,后 n−k 棵被锯 long long cntCut = n - k; long long H = (suf[k] - m) / cntCut; // 这一段里最大的可行 H if (H < 0) continue; long long loH = (k == 0) ? 0 : h[k - 1]; // 这一段 H 的下界 long long hiH = h[k] - 1; // 上界(再高就轮到第 k 棵了) if (H > hiH) H = hiH; if (H < loH) continue; ans = max(ans, H); } cout << ans << '\n'; return 0;}点「运行 ▶」看结果
排完序之后,确实有一个指针在单调地走:就是 k(不被锯到的树的数量)。
H 越大,被锯到的树越少,k 单调不减 —— 这就是双指针的味道。
⇒ 所以第 1 步那句「不能用双指针」要说准一点: 不能在题目给的那排树上滑窗口(顺序无关,没有区间可言); 但排序之后的高度轴上,「阈值」和「被锯到的树的数量」这两者是同向单调的, 那点直觉在这儿有了着落。
★ 这也是很多「二分答案」题的双生解法:要么二分那个阈值,要么排序后直接解出来。
5★★★ 顺手一个反直觉:复杂度更优的那版,反而慢 5.8 倍
// 换一把尺子 + 两笔溢出账//// 用法:./p1873Count <n> <maxh> 人话版(带秒表)// ./p1873Count <n> <maxh> csv 只打 `键,值`,给 check:viz 用//// ① 尺子:这道题的暴力枚举的是**答案**,所以它的次数由**答案的取值范围**决定,// 和 N 只是相乘的关系:枚举要算 maxh+1 次 f(),二分只要 ⌊log2(maxh)⌋+1 次。// ⇒ 满数据 4×10⁵ 次 vs 19 次,**差两万倍**。//// ② 溢出账之一(**木材总量**):N × maxh = 4×10¹¹,int 差 186 倍 ⇒ 必须 long long。// ③ 溢出账之二(**M 本身**):题面 M <= 2×10⁹,int 上限 2 147 483 647 ——// **装得下,余量只有 7%**。⇒ 能用 int 读,但没什么道理省这一下。
#include <bits/stdc++.h>using namespace std;
static double now_ms() { timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec * 1000.0 + t.tv_nsec / 1e6;}
int main(int argc, char** argv) { long long n = (argc > 1) ? atoll(argv[1]) : 1000000; long long maxh = (argc > 2) ? atoll(argv[2]) : 400000; bool csv = (argc > 3 && string(argv[3]) == "csv");
long long intMax = 2147483647LL; long long steps = 0; for (long long x = maxh; x >= 1; x >>= 1) steps++; // ⌊log2(maxh)⌋ + 1 long long bruteCalls = maxh + 1; long long bruteOps = bruteCalls * n; long long binOps = steps * n;
long long woodMax = n * maxh; long long mMax = 2000000000LL;
/* 秒表:造一份顶格数据,二分 vs 排序 + 后缀和 */ mt19937 rng(20260827u); vector<int> h((size_t)n); long long total = 0; for (long long i = 0; i < n; i++) { h[i] = 1 + (int)(rng() % (unsigned)maxh); total += h[i]; } long long m = total / 2;
double t0 = now_ms(); long long ansBin = 0; { int lo = 0, hiB = (int)maxh; while (lo <= hiB) { int mid = lo + (hiB - lo) / 2; long long s = 0; for (long long i = 0; i < n; i++) if (h[i] > mid) s += h[i] - mid; if (s >= m) { ansBin = mid; lo = mid + 1; } else hiB = mid - 1; } } double tBin = now_ms() - t0;
t0 = now_ms(); long long ansSort = 0; { vector<int> g = h; sort(g.begin(), g.end()); vector<long long> suf((size_t)n + 1, 0); for (long long i = n - 1; i >= 0; i--) suf[i] = suf[i + 1] + g[i]; for (long long k = 0; k < n; k++) { long long cut = n - k; long long H = (suf[k] - m) / cut; if (H < 0) continue; long long loH = (k == 0) ? 0 : g[k - 1]; long long hiH = (long long)g[k] - 1; if (H > hiH) H = hiH; if (H < loH) continue; ansSort = max(ansSort, H); } } double tSort = now_ms() - t0;
if (csv) { printf("n,%lld\nmaxh,%lld\nbruteCalls,%lld\nbinCalls,%lld\ncallRatio,%lld\n" "bruteOps,%lld\nbinOps,%lld\n" "woodMax,%lld\nwoodFitsInt,%d\nwoodOverInt,%lld\n" "mMax,%lld\nmFitsInt,%d\nmHeadroomPct,%lld\n" "same,%d\n", n, maxh, bruteCalls, steps, bruteCalls / steps, bruteOps, binOps, woodMax, woodMax <= intMax ? 1 : 0, woodMax / intMax, mMax, mMax <= intMax ? 1 : 0, (intMax - mMax) * 100 / mMax, ansBin == ansSort ? 1 : 0); } else { printf("n = %lld, 树高上限 = %lld\n\n", n, maxh); printf("(1) 从高往低枚举 %10lld 次 f() => %lld 次加法\n", bruteCalls, bruteOps); printf("(2) 二分答案 %10lld 次 f() => %lld 次加法\n", steps, binOps); printf("=> 差 %lld 倍\n\n", bruteCalls / steps); printf("木材总量的上界 n x maxh = %lld,int 上限 %lld => %s(差 %lld 倍)\n", woodMax, intMax, woodMax <= intMax ? "装得下" : "装不下", woodMax / intMax); printf("M 的上界 %lld,int 上限 %lld => %s(余量 %lld%%)\n\n", mMax, intMax, mMax <= intMax ? "装得下" : "装不下", (intMax - mMax) * 100 / mMax); printf("顶格数据上的秒表(答案 %lld):\n", ansBin); printf(" 二分答案 %8.1f ms\n", tBin); printf(" 排序 + 后缀和 %8.1f ms\n", tSort); printf("\n两版答案%s\n", ansBin == ansSort ? "一致" : "不一致!"); } return 0;}点「运行 ▶」看结果
满数据 N = 10⁶、树高上限 4×10⁵(同机同日,秒表在一个进程里量,不含读入;
⚠ 不是独占(机器上同时开着浏览器,load average 约 3)—— 秒数只当量级看,倍数才是重点):
| 版本 | 复杂度 | 秒表 |
|---|---|---|
| ② 二分答案 | O(N log maxh) = 19 趟扫描 |
★ 15.6 ms |
| ③ 排序 + 后缀和 | O(N log N) |
90.7 ms |
log₂(4×10⁵) ≈ 18.6,log₂(10⁶) ≈ 19.9 —— 两个 log 几乎一样大。
所以复杂度那一栏其实没差别,真正的差别在常数:
- 二分那版是 19 趟顺序扫描,缓存友好到不能再友好;
- 排序那版要真的排一遍 10⁶ 个数,比较、搬移、随机访问全都算钱。
⇒ 第 5 章 P1219 那条「两把尺子会打架」的又一个版本: 渐进复杂度分不出高下的时候,胜负全在常数上,而常数只有秒表量得出来。
6第 ④ 版(错的):判定写成 f(mid) > M —— 两组样例都抓得到
// 演示错误写法:判定写成 f(mid) > M,而题目要的是「**至少** M 米」//// 差一个等号。f(H) 正好等于 M 的时候,这一版判它「不行」,于是答案比正确答案小。//// ★ 这个 bug 明得很:**两组样例都抓得到**(样例 1 的答案 15 对应 f = 7 = M,// 样例 2 的答案 36 对应 f = 20 = M)—— 出题人是故意的。// ⇒ 和 p1873Lo.cpp 正好凑一对:一个样例就挂,一个样例上完全正确。
#include <bits/stdc++.h>using namespace std;
static int h[1000006];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; long long m; cin >> n >> m; int hi = 0; for (int i = 0; i < n; i++) { cin >> h[i]; hi = max(hi, h[i]); }
auto f = [&](int H) { long long s = 0; for (int i = 0; i < n; i++) if (h[i] > H) s += h[i] - H; return s; };
int lo = 0, hiB = hi, ans = 0; while (lo <= hiB) { int mid = lo + (hiB - lo) / 2; if (f(mid) > m) { ans = mid; lo = mid + 1; } // ← 少了一个等号 else hiB = mid - 1; } cout << ans << '\n'; return 0;}点「运行 ▶」看结果
题目说的是「木材至少为 M 米」—— 可以正好等于。写成 > 就把「正好等于」判成了不行。
★ 出题人显然想到了这一条:两组样例的最优解都恰好 f(H) = M
(样例 1 是 5+2=7,样例 2 是 6+4+10=20)。⇒ 这个 bug 样例就挂。
对拍档位 2 专门让 M = f(H),那一档 300 / 300 全抓到;随机档是 121 / 300。
7第 ⑤ 版(错的):二分下界写成 1 —— 两组样例都抓不到
// 演示错误写法:二分的下界从 1 开始 —— 于是永远输出不了 0//// 答案真的可能是 0:树都很矮、M 又贴着总和的时候,锯片抬到 1 米就已经不够了。// 例如 3 棵 1 米的树、M = 2:f(0) = 3 >= 2,f(1) = 0 < 2 ⇒ 答案是 **0**。//// ★ 这个 bug 暗得很:**两组样例都抓不到**,随机数据也几乎抓不到 ——// 要「总高度只比 M 大一点点,而且树矮到抬一米就没了」才露头。// ⇒ 对拍档位 1 就是专门造这种数据的。
#include <bits/stdc++.h>using namespace std;
static int h[1000006];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; long long m; cin >> n >> m; int hi = 0; for (int i = 0; i < n; i++) { cin >> h[i]; hi = max(hi, h[i]); }
auto f = [&](int H) { long long s = 0; for (int i = 0; i < n; i++) if (h[i] > H) s += h[i] - H; return s; };
int lo = 1, hiB = hi, ans = 1; // ← 下界写成了 1,ans 也从 1 起 while (lo <= hiB) { int mid = lo + (hiB - lo) / 2; if (f(mid) >= m) { ans = mid; lo = mid + 1; } else hiB = mid - 1; } cout << ans << '\n'; return 0;}点「运行 ▶」看结果
答案真的可能是 0:树都很矮、M 又贴着总和的时候,锯片抬到 1 米就已经不够了。
3 棵 1 米的树,M = 2:
f(0) = 3 >= 2 行
f(1) = 0 < 2 不行
=> 答案是 0,而这一版会输出 1
| 错版 | 样例抓得到吗 | 靠什么抓 |
|---|---|---|
p1873Gt(> 少了等号) |
★ 两组样例都挂 | 出题人替你造好了 |
p1873Lo(lo 从 1 开始) |
✗ 两组样例都过 | 只能专门造「答案是 0」的数据 |
⇒ 对拍档位 1 就是干这个的:树全是 1~3 米,M 贴着总和 —— 那一档 300 / 300 全抓到,
而随机档只抓到 14 / 300。
★ 「样例过了」在这道题上挡掉了一个坑,也放过了一个坑 ——
这两件事同时成立,才是考场的真实样子。
8第 ⑥ 版(错的):sum 用了 int —— 对拍算术上抓不到
木材总量能有多大?
| 值 | int(2 147 483 647) |
|
|---|---|---|
木材总量上界 N × maxh |
4×10¹¹ | ✗ 差 186 倍 |
M 的上界 |
2×10⁹ |
✓ 装得下,余量只有 7% |
sum 非 long long 不可;M 用 int 读刚好装得下,但余量只有 7%,
省这一下没什么道理。
⚠ 溢出之后 s 会变成负数,于是二分一路往左走,满数据上它输出 0 ——
看起来像「二分边界没调对」,其实和边界一点关系都没有。
★ 这类误诊很贵:你会去改一个根本没错的地方。
⇒ 对拍抓不到它,而且不是概率问题:小数据(几棵树、高度几十)的木材总量是几百,
离 2³¹ 差七八个数量级。生成器够不够,是一道算术题
(第 6 章 P3406 那条)。所以这一条靠的是顶格档单跑一次:
满数据(N = 10⁶、树高 4×10⁵) |
long long 版 |
int 版 |
|---|---|---|
| 随机树高 | 117194 |
★ 0 |
全部顶格(每棵都 4×10⁵ 米) |
200000 |
★ 0 |
9★ 对拍:900 轮,三档
// 数据生成器(P1873 对拍用):`./p1873Gen <seed> [level]`//// level 0(默认)随机:n <= 8、树高 <= 20、M 随机取在 (0, 总和) 里// level 1 **逼答案变成 0**:树全都很矮(1~3 米),M 贴着总和 ——// 锯片抬到 1 米就已经不够了// level 2 **让 f(H) 正好等于 M**:先随机一批树和一个 H,再令 M = f(H)//// ★ 三个档位各盯一个错版:// level 1 抓 p1873Lo(二分下界写成 1,永远输出不了 0);// level 2 抓 p1873Gt(判定写成 > 而不是 >=);// level 0 兜底。//// ⚠ 题面保证「所有树的高度总和 > M」,所以三个档位都得自己守住这一条 ——// 否则造出来的是**无解**的数据,两个程序会「一致地输出垃圾」,看着全绿其实什么都没验。
#include <bits/stdc++.h>using namespace std;
static mt19937 rng;static int ri(int lo, int hi) { return lo + (int)(rng() % (unsigned)(hi - lo + 1)); }
int main(int argc, char** argv) { unsigned seed = (argc > 1) ? (unsigned)atoi(argv[1]) : 1; int level = (argc > 2) ? atoi(argv[2]) : 0; rng.seed(seed);
int n = ri(1, 8); vector<int> h(n); long long total = 0, m = 1;
if (level == 1) { do { for (int i = 0; i < n; i++) h[i] = ri(1, 3); total = accumulate(h.begin(), h.end(), 0LL); long long f1 = 0; for (int x : h) if (x > 1) f1 += x - 1; if (f1 + 1 <= total - 1) { m = ri((int)f1 + 1, (int)total - 1); break; } n = ri(2, 8); h.assign(n, 0); } while (true); } else if (level == 2) { do { for (int i = 0; i < n; i++) h[i] = ri(1, 20); total = accumulate(h.begin(), h.end(), 0LL); int H = ri(0, 20); long long f = 0; for (int x : h) if (x > H) f += x - H; if (f >= 1 && f < total) { m = f; break; } // M = f(H),且守住 total > M n = ri(1, 8); h.assign(n, 0); } while (true); } else { do { for (int i = 0; i < n; i++) h[i] = ri(1, 20); total = accumulate(h.begin(), h.end(), 0LL); } while (total < 2); m = ri(1, (int)total - 1); } printf("%d %lld\n", (int)h.size(), m); for (size_t i = 0; i < h.size(); i++) printf("%d%c", h[i], i + 1 == h.size() ? '\n' : ' '); return 0;}点「运行 ▶」看结果
// 大数据生成器(P1873 计时 / 溢出用):`./p1873GenBig [n] [maxh] [level]`//// level 0(默认)随机树高 ∈ [1, maxh],M 取总和的一半// level 1 **全都顶格**:每棵树都是 maxh 米,M 取总和的一半// ⇒ 木材总量 4×10¹¹,int 版当场翻车//// ★ 这两档要的不是「更慢」,是**把那个溢出的门槛跨过去**:// int 只到 2.1×10⁹,而这里的木材总量是它的 186 倍。// 小数据(几棵树、高度几十)离这条线差七八个数量级 —— 对拍**算术上**抓不到。
#include <bits/stdc++.h>using namespace std;
static mt19937 rng;
int main(int argc, char** argv) { int n = (argc > 1) ? atoi(argv[1]) : 1000000; int maxh = (argc > 2) ? atoi(argv[2]) : 400000; int level = (argc > 3) ? atoi(argv[3]) : 0; rng.seed(12345); vector<int> h(n); long long total = 0; for (int i = 0; i < n; i++) { h[i] = (level == 1) ? maxh : (1 + (int)(rng() % (unsigned)maxh)); total += h[i]; } printf("%d %lld\n", n, total / 2); for (int i = 0; i < n; i++) printf("%d%c", h[i], i == n - 1 ? '\n' : ' '); return 0;}点「运行 ▶」看结果
check:viz 每档 300 轮,暴力当标准答案 —— 三档 900 轮,二分和排序两版都逐字节相同。
三个错版被抓到的轮数是 121 / 0 / 300、14 / 300 / 0 和 0 / 0 / 0 ——
那三个 0 各有各的原因,见下一步。
10六个版本并排
| 版本 | 想法 | 满数据 | 结论 |
|---|---|---|---|
① p1873Brute |
从高往低枚举 | 外推 185 秒 | ✗ TLE |
② p1873 |
二分答案 | ★ 0.08 秒 | ★ AC |
③ p1873Sort |
排序 + 后缀和 | 0.16 秒 | ★ AC |
④ p1873Gt |
② 少一个等号 | — | ✗ WA(样例就挂) |
⑤ p1873Lo |
② 下界写成 1 | — | ✗ WA(样例抓不到) |
⑥ p1873Int |
② 的 sum 是 int |
— | ✗ WA(对拍算术上抓不到) |
三个错版 × 三个对拍档位,每档 300 轮:
| 抓到的轮数 | 档位 0(随机) | 档位 1(逼答案为 0) | 档位 2(f(H) 正好等于 M) |
|---|---|---|---|
④ p1873Gt |
121 | ★★★ 0 | ★ 300 |
⑤ p1873Lo |
14 | ★ 300 | ★★★ 0 |
⑥ p1873Int |
0 | 0 | 0 |
档位 1 对 p1873Gt 是精确的 0:这一档逼出来的答案是 H = 0,
而题面保证「所有树的高度总和 > M」,所以 f(0) > M 是严格大于 ——
> 和 >= 在这一档里判定完全一致。
档位 2 对 p1873Lo 是精确的 0:这一档取 M = f(H) 且要求 M < 总和,
于是 H 一定 ≥ 1,答案永远不是 0 —— lo 从 0 还是从 1 起根本无所谓。
⇒ 一句话:你为某个 bug 精心造的形状,往往正是另一个 bug 的盲区。
(P1638 那一页刚碰到过同一件事,那次是周期串让 if 和 while 等价。
两页凑在一起,这条就不是巧合了。)
★ 而第三行整排的 0 是另一种原因:不是形状不对,是数量级不对 ——
小数据的木材总量是几百,离 2³¹ 差七八个数量级,
再怎么改形状也够不着。这一条只能靠满规模数据单跑。
- ★★★ 「能不能用双指针」有一个动手就能验的判据:把输入打乱,答案变不变。 变 ⇒ 问的是区间,双指针有对象(P1638 就是); 不变 ⇒ 问的是一个阈值,该想二分答案(本题)。
- ★★ 双指针那点直觉别急着丢。 排序之后,「阈值」和「被锯到的树的数量」 是同向单调的 —— 第 4 步那版就是顺着这条走出来的,而且也能 AC。
- ★★★ 三个 bug 要三种不同的火力:
>少个等号(样例就挂)、lo从 1 开始(要专门造「答案是 0」)、sum用int(小数据算术上够不着那条线,只能拿满规模数据单跑)。 ⚠ 而且那两个专门档互相是对方的盲区(各有一个精确的 0)—— 「我对拍了 900 轮全绿」和「我验过了」之间,隔着的就是这两件事。