题单 · 习题解析

洛谷 P1873 [COCI 2011/2012 #5] EKO / 砍树

★ 「能不能用双指针」有个动手能验的判据:打乱输入,答案变不变;三个 bug 要三种不同的火力

原题:洛谷 P1873出自 第 7 章 双指针与滑动窗口 的题单出自 第 8 章 二分查找 的题单题面本地存档:2026-08-27
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1873,日期见页头。两边不一致时信原站。

题目描述

伐木工人 Mirko 需要砍 M 米长的木材。对 Mirko 来说这是很简单的工作, 因为他有一个漂亮的新伐木机,可以如野火一般砍伐森林。不过,Mirko 只被允许砍伐一排树。

Mirko 的伐木机工作流程如下:Mirko 设置一个高度参数 H(米),伐木机升起一个巨大的锯片到高度 H, 并锯掉所有树比 H 高的部分(当然,树木不高于 H 米的部分保持不变)。Mirko 就得到树木被锯下的部分。

例如,如果一排树的高度分别为 20, 15, 1017,Mirko 把锯片升到 15 米的高度, 切割后树木剩下的高度将是 15, 15, 1015,而 Mirko 将从第 1 棵树得到 5 米, 从第 4 棵树得到 2 米,共得到 7 米木材。

Mirko 非常关注生态保护,所以他不会砍掉过多的木材。这也是他尽可能高地设定伐木机锯片的原因。 请帮助 Mirko 找到伐木机锯片的最大的整数高度 H,使得他能得到的木材至少M 米。 换句话说,如果再升高 1 米,他将得不到 M 米木材。

输入格式

12 个整数 NMN 表示树木的数量,M 表示需要的木材总长度。

2N 个整数表示每棵树的高度。

输出格式

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第 ① 版:从最高的那棵树一路往下试

p1873Brute.cpp第 ① 版:H 从 max(h) 往下枚举
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

想法完全正确 —— f(H) 单调,所以从高往低走,第一个满足 f(H) ≥ M 的就是答案。

⚠ 但它慢的来源和别的暴力不一样:不是 N 大,是「答案的取值范围」大

它要算 max(h) + 1f(),每次 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.cpp第 ② 版:二分答案(能 AC)
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

满数据只要 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 取最大。

p1873Sort.cpp第 ③ 版:排序 + 后缀和(也能 AC)
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 双指针那点直觉,落在了这里

排完序之后,确实有一个指针在单调地走:就是 k(不被锯到的树的数量)。 H 越大,被锯到的树越少,k 单调不减 —— 这就是双指针的味道。

⇒ 所以第 1 步那句「不能用双指针」要说准一点: 不能在题目给的那排树上滑窗口(顺序无关,没有区间可言); 但排序之后的高度轴上,「阈值」和「被锯到的树的数量」这两者是同向单调的, 那点直觉在这儿有了着落。

★ 这也是很多「二分答案」题的双生解法:要么二分那个阈值,要么排序后直接解出来。

5★★★ 顺手一个反直觉:复杂度更优的那版,反而慢 5.8 倍

p1873Count.cpp尺子 + 两笔溢出账 + 秒表
参数是「n 树高上限」,默认就是满数据。
// 换一把尺子 + 两笔溢出账
//
// 用法:./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.6log₂(10⁶) ≈ 19.9 —— 两个 log 几乎一样大。 所以复杂度那一栏其实没差别,真正的差别在常数:

  • 二分那版是 19 趟顺序扫描,缓存友好到不能再友好;
  • 排序那版要真的排一遍 10⁶ 个数,比较、搬移、随机访问全都算钱。

⇒ 第 5 章 P1219 那条「两把尺子会打架」的又一个版本: 渐进复杂度分不出高下的时候,胜负全在常数上,而常数只有秒表量得出来。

6第 ④ 版(错的):判定写成 f(mid) > M —— 两组样例都抓得到

p1873Gt.cpp演示错误写法:少了一个等号
样例 1 上它输出 14(正确答案 15)。
// 演示错误写法:判定写成 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

题目说的是「木材至少M 米」—— 可以正好等于。写成 > 就把「正好等于」判成了不行。

★ 出题人显然想到了这一条:两组样例的最优解都恰好 f(H) = M (样例 1 是 5+2=7,样例 2 是 6+4+10=20)。⇒ 这个 bug 样例就挂

对拍档位 2 专门让 M = f(H),那一档 300 / 300 全抓到;随机档是 121 / 300

7第 ⑤ 版(错的):二分下界写成 1 —— 两组样例都抓不到

p1873Lo.cpp演示错误写法:lo 从 1 开始
样例 1 上它输出 15 —— 和正解一模一样。
// 演示错误写法:二分的下界从 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

答案真的可能是 0:树都很矮、M 又贴着总和的时候,锯片抬到 1 米就已经不够了。

3 棵 1 米的树,M = 2:
  f(0) = 3 >= 2   行
  f(1) = 0 <  2   不行
=> 答案是 0,而这一版会输出 1
★ 和上一步凑成一对:一个明、一个暗
错版 样例抓得到吗 靠什么抓
p1873Gt> 少了等号) 两组样例都挂 出题人替你造好了
p1873Lolo 从 1 开始) 两组样例都过 只能专门造「答案是 0」的数据

⇒ 对拍档位 1 就是干这个的:树全是 1~3 米,M 贴着总和 —— 那一档 300 / 300 全抓到, 而随机档只抓到 14 / 300。 ★ 「样例过了」在这道题上挡掉了一个坑,也放过了一个坑 —— 这两件事同时成立,才是考场的真实样子。

8第 ⑥ 版(错的):sum 用了 int —— 对拍算术上抓不到

p1873Int.cpp演示错误写法:f() 里的 sum 是 int

木材总量能有多大?

int2 147 483 647
木材总量上界 N × maxh 4×10¹¹ 差 186 倍
M 的上界 2×10⁹ ✓ 装得下,余量只有 7%
★★★ 两笔账,两种结论 —— 而两笔都得算

sumlong long 不可;Mint刚好装得下,但余量只有 7%, 省这一下没什么道理。

⚠ 溢出之后 s 会变成负数,于是二分一路往左走,满数据上它输出 0 —— 看起来像「二分边界没调对」,其实和边界一点关系都没有。 ★ 这类误诊很贵:你会去改一个根本没错的地方。

⇒ 对拍抓不到它,而且不是概率问题:小数据(几棵树、高度几十)的木材总量是几百, 离 2³¹ 差七八个数量级。生成器够不够,是一道算术题 (第 6 章 P3406 那条)。所以这一条靠的是顶格档单跑一次

满数据(N = 10⁶、树高 4×10⁵ long long int
随机树高 117194 0
全部顶格(每棵都 4×10⁵ 米) 200000 0

9★ 对拍:900 轮,三档

p1873Gen.cpp生成器:三个档位
参数是「种子 档位」。档位 1 逼答案变成 0,档位 2 让 f(H) 正好等于 M。
// 数据生成器(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;
}
点一下即可编辑
输出
点「运行 ▶」看结果
p1873GenBig.cpp大数据生成器:随机 vs 全部顶格
参数是「n 树高上限 档位」。档位 1 每棵树都顶格,把木材总量顶到 4e11。
// 大数据生成器(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 / 30014 / 300 / 00 / 0 / 0 —— 那三个 0 各有各的原因,见下一步。

10六个版本并排

版本 想法 满数据 结论
p1873Brute 从高往低枚举 外推 185 秒 ✗ TLE
p1873 二分答案 0.08 秒 AC
p1873Sort 排序 + 后缀和 0.16 秒 AC
p1873Gt ② 少一个等号 WA(样例就挂)
p1873Lo ② 下界写成 1 WA(样例抓不到)
p1873Int ② 的 sumint WA(对拍算术上抓不到)

三个错版 × 三个对拍档位,每档 300 轮

抓到的轮数 档位 0(随机) 档位 1(逼答案为 0) 档位 2(f(H) 正好等于 M
p1873Gt 121 ★★★ 0 300
p1873Lo 14 300 ★★★ 0
p1873Int 0 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 那一页刚碰到过同一件事,那次是周期串让 ifwhile 等价。 两页凑在一起,这条就不是巧合了。)

★ 而第三行整排的 0 是另一种原因:不是形状不对,是数量级不对 —— 小数据的木材总量是几百,离 2³¹ 差七八个数量级, 再怎么改形状也够不着。这一条只能靠满规模数据单跑。

这一页记住三句话
  1. ★★★ 「能不能用双指针」有一个动手就能验的判据:把输入打乱,答案变不变。 变 ⇒ 问的是区间,双指针有对象(P1638 就是); 不变 ⇒ 问的是一个阈值,该想二分答案(本题)。
  2. ★★ 双指针那点直觉别急着丢。 排序之后,「阈值」和「被锯到的树的数量」 是同向单调的 —— 第 4 步那版就是顺着这条走出来的,而且也能 AC。
  3. ★★★ 三个 bug 要三种不同的火力> 少个等号(样例就挂)、 lo 从 1 开始(要专门造「答案是 0」)、sumint (小数据算术上够不着那条线,只能拿满规模数据单跑)。 ⚠ 而且那两个专门档互相是对方的盲区(各有一个精确的 0)—— 「我对拍了 900 轮全绿」和「我验过了」之间,隔着的就是这两件事。