0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1090,日期见页头。两边不一致时信原站。
题目描述
在一个果园里,多多已经将所有的果子打了下来,而且按果子的不同种类分成了不同的堆。 多多决定把所有的果子合成一堆。
每一次合并,多多可以把两堆果子合并到一起,消耗的体力等于两堆果子的重量之和。
可以看出,所有的果子经过 n-1 次合并之后,就只剩下一堆了。
多多在合并果子时总共消耗的体力等于每次合并所耗体力之和。
因为还要花大力气把这些果子搬回家,所以多多在合并果子时要尽可能地节省体力。 假定每个果子重量都为 1,并且已知果子的种类数和每种果子的数目, 你的任务是设计出合并的次序方案,使多多耗费的体力最少,并输出这个最小的体力耗费值。
例如有 3 种果子,数目依次为 1,2,9。可以先将 1、2 堆合并,新堆数目为 3,耗费体力为 3。
接着,将新堆与原先的第三堆合并,又得到新的堆,数目为 12,耗费体力为 12。
所以多多总共耗费体力 = 3 + 12 = 15。可以证明 15 为最小的体力耗费值。
输入格式
共两行。第一行是一个整数 n (1 ≤ n ≤ 10⁴),表示果子的种类数。
第二行包含 n 个整数,用空格分隔,第 i 个整数 aᵢ (1 ≤ aᵢ ≤ 2 × 10⁴) 是第 i 种果子的数目。
输出格式
一个整数,也就是最小的体力耗费值。输入数据保证这个值小于 2³¹。
数据规模与约定
对于 30% 的数据,保证有 n ≤ 10³;对于 50% 的数据,保证有 n ≤ 5 × 10³;
对于全部的数据,保证有 n ≤ 10⁴。
输入输出样例
输入
3 1 2 9
输出
15
就是题面里手算的那一组。 ⚠ 而它放过了本页那个错法 —— 第 ③ 步会说为什么这几乎是必然的。
这题的标准写法是拿第 37 章的堆反复取最小 —— 而第 19 章还没讲堆。 第 19 章的题单里给它写了一句提示: 「这题在等第 37 章的堆,先用 sort 硬做也能过」。
这一页做两件事:
- 给出一个正解 —— 它根本不用堆,也不是硬做:两个有序队列,
O(n log n)(第 ② 步); - 把那句「硬做也能过」量一遍(第 ④ 步)——「能过」是真的,而余量比想象的小。
1贪心:每次合并最小的两堆
直觉一句话就够:一堆果子被合并了几次,它的重量就被数几次 —— 所以越重的堆越要晚合并,也就是「每次挑最小的两堆先合」。
这正是霍夫曼编码的构造过程;答案有一个等价的写法:
总代价 = Σ a_i × (第 i 堆在合并树上的深度)
把合并过程显式建成一棵二叉树,再算每片叶子的深度,最后求 Σ aᵢ × depthᵢ ——
这条路一次「取最小的两堆」的循环都不共享。
随机 300 组:答案 vs Σ aᵢ × 霍夫曼深度 |
不同 0 组 |
2★★★ 正解:两个有序队列,一个堆都不用
标准写法要一个「反复取最小」的数据结构,所以人人都去搬小根堆。 但这道题有一个现成的结构可以白拿:
每一次取走的是当前最小的两堆,所以这两堆的和只会一次比一次大。
⇒ 于是把「原始的那些堆」排好序放进队列 A,把「合并出来的新堆」按生成顺序放进队列 B ——
两个队列各自都是有序的。每次要取最小,只要比一下两个队头,谁小取谁。
排序 O(n log n) + 合并 O(n)。这就是它能挂在「排序型贪心」这一章的原因。
// P1090 [NOIP 2004 提高组] 合并果子 —— ★ 这一版就能 AC,而且**不用堆**//// 题意:n 堆果子,每次把两堆合并,代价是两堆之和;合并成一堆,求最小总代价。//// ★ 贪心是「每次合并**最小的两堆**」(这就是霍夫曼编码的过程)。// 交换论证的直觉版:一堆果子被合并了几次,它的重量就被数几次 ——// 所以**越重的堆越要晚合并**。//// ★★★ 而这道题挂在第 19 章(排序型贪心)的题单里,是因为它**真的只用排序就能做**:// 通常的写法是拿[第 37 章的堆](/ch/37-heap/)反复取最小,但这里有一个更省的办法 ——//// **合并产生的新堆,重量是单调不减的。**// (每次取走的是当前最小的两堆,它们的和只会越来越大。)// ⇒ 于是把「原始堆」排好序放进队列 A,「新产生的堆」按生成顺序放进队列 B,// **两个队列都是有序的** —— 每次只要比一下两个队头,谁小取谁。// ⇒ 排序 `O(n log n)` + 合并 `O(n)`,一个堆都不用。//// ⚠ 题面保证「答案小于 2^31」,也就是说 int 刚好够 —— 但这里仍然写 long long:// 一是不吃亏,二是「算完确认够用」和「没算过」是两回事。
#include <bits/stdc++.h>using namespace std;
int main() { int n; if (!(cin >> n)) return 0; vector<int> a(n); for (int& x : a) cin >> x; sort(a.begin(), a.end());
queue<long long> A, B; // A:原始堆(已排序) B:合并出来的新堆 for (int x : a) A.push(x);
auto take = [&]() { // 取当前最小的一堆 long long x; if (B.empty() || (!A.empty() && A.front() <= B.front())) { x = A.front(); A.pop(); } else { x = B.front(); B.pop(); } return x; };
long long ans = 0; for (int i = 1; i < n; i++) { // n 堆要合并 n-1 次 long long x = take(), y = take(); ans += x + y; B.push(x + y); // ★ 新堆压到 B 尾部,B 自然还是有序的 } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
作为对照,堆的写法长这样(第 37 章会正式讲):
3错法:排完序一路往下累(而样例放过了它)
// P1090 错法:排完序**从头一路累下去**(把新堆直接当成「下一个」)//// 写法看着很自然:排好序,先合并最小的两堆,再把结果和第三堆合并,再和第四堆……// 它的毛病是**新堆没有回到队列里重新排队** —— 而新堆往往不再是最小的那一档了。//// ★★ 而官方样例**放过了它**:`1 2 9` 排好之后,// `1+2 = 3`,而 3 确实仍然是当时最小的 ⇒ 它和正解走了同一条路,都输出 15。// ⇒ 又一次:**样例挡住的是「每组都错」的,放过的是「偶尔才错」的**// (这一天四道题都撞上了同一条规律)。// 最小的反例只要四个 1:正解 `(1+1) (1+1) (2+2)` = 8,这一版 `2, 3, 4` = 9。
#include <bits/stdc++.h>using namespace std;
int main() { int n; if (!(cin >> n)) return 0; vector<long long> a(n); for (auto& x : a) cin >> x; sort(a.begin(), a.end());
long long cur = a[0], ans = 0; for (int i = 1; i < n; i++) { cur += a[i]; // ← 新堆直接接着往下累,没有重新排队 ans += cur; } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
写法很自然:排好序,先合最小的两堆,再把结果和第三堆合,再和第四堆…… 毛病是新堆没有回到队列里重新排队 —— 它往往已经不是最小的那一档了。
而官方样例放过了它:1 2 9 里 1+2 = 3,而 3 确实仍然是当时最小的,
于是它和正解走了同一条路,都输出 15。最小的反例只要四个 1:
4
1 1 1 1
正解 : (1+1)=2, (1+1)=2, (2+2)=4 -> 2+2+4 = 8
这一版: (1+1)=2, (2+1)=3, (3+1)=4 -> 2+3+4 = 9
它的触发条件写得出来:「某个新堆已经不再是当前最小」。数一数两边:
aᵢ 的上限(n ≤ 12,各 300 轮) |
2 | 20 | 2 000 | 20 000 |
|---|---|---|---|---|
| 「新堆不再是最小」出现的轮数 | 210 | 190 | 185 | 188 |
| 它真被抓的轮数 | 210 | 190 | 185 | 188 |
四档全部一个不差。 ⇒ 等于证明了「只要出现过一次,就一定错」。
⚠ 而这张表还打回了我自己的一个猜想:草稿里写「值域越小越容易现形」——
值域拉了一万倍(2 → 20 000),抓获率几乎没动(210 → 188)。
真正管用的旋钮是 n(合的次数多了,迟早撞上),不是值域。
★ 这一天四道题量了五次「触发条件 ↔ 抓获数」: P1223 差 1.9 倍、P1803 差 60 倍、 P2240 一个不差、P1094 一个不差、这一页四档全部一个不差。 ⇒ 这两个数的关系只能量,不能推。
4★★★ 「先用 sort 硬做也能过」—— 把这句话量一遍
第 19 章的题单里给这道题写了一句「先用 sort 硬做也能过」。
「硬做」其实有两种写法,它们差着一个 log:
本机实测(顶格 n = 10⁴,aᵢ ≤ 2 × 10⁴,只计算不含读入;时限 800 毫秒):
| 写法 | 复杂度 | 毫秒 |
|---|---|---|
| 两个有序队列 | O(n log n) |
1 |
| 小根堆(第 37 章) | O(n log n) |
0 |
| 每轮线性扫两个最小 | O(n²) |
93 |
每轮重新 sort |
O(n² log n) |
220 |
两种「硬做」都过得去(93 和 220 毫秒,时限 800)。 所以题单那句提示成立:在等到第 37 章之前,这题确实可以先硬做。
⚠ 而值得注意的是余量:O(n² log n) 那版是 220 毫秒,离 800 只有 3.6 倍 ——
换一台慢一点的评测机、或者读入再写得随便一点,就不那么稳了。
★ 而两个有序队列那版是 1 毫秒,快了 220 倍,代码还更短。
⇒ 「能过」和「该这么写」是两回事。这一页给出的正解, 既不用等第 37 章,也不是硬做。
5★★ 题面那句「保证答案小于 2³¹」挡掉了什么
题面在输出格式那一行写着:「输入数据保证这个值小于 2³¹。」
按第 12 章那套分法,这是一条情报 —— 它在告诉你 int 够用。
但它还顺手做了另一件事,把数据范围的顶格排除在外了:
顶格数据(n = 10⁴,每堆都是 2 × 10⁴) |
|
|---|---|
| 果子总重 | 200 000 000 |
| 最小体力耗费 | 2 672 320 000 |
2³¹ |
2 147 483 648 |
| 比值 | ★ 1.24 倍 —— 超了 |
本书反复说「顶格是顶到题面的边上,不是类型的边上」。 这道题给了它一个新形态:题面的两个约束联立之后,真正的顶格比「n 和 aᵢ 各自顶格」要小。
n = 10⁴ 且每堆 2 × 10⁴ —— 这组数据每一项都在题面范围内,
可它的答案超过 2³¹,所以题目根本不会给你这种数据。
⇒ 自己造顶格数据来测的时候,要连输出侧的保证一起满足;
不然你测的是一组「题目不会出现」的输入。★ 而这也顺带说明那句保证不是废话:
没有它,int 就真的不够了。
6度量程序和生成器
// P1090 的度量程序 —— 这一页所有数字都出自这一份。//// `./p1090Count` 人看的版本// `./p1090Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用//// 五段:// ① ★★ 验算走一条**和贪心完全无关**的路:答案 ≡ Σ aᵢ × 它在霍夫曼树上的深度;// ② ★ 四种正确写法(两队列 / 堆 / 每轮重排 / 每轮线性扫)算出来的是不是同一个数;// ③ ★ 那个「一路往下累」的错法的抓获率,以及它的触发条件;// ④ ★★★ 第 19 章题单说「先用 sort 硬做也能过」—— 顶格 n = 10⁴ 计时,看这句话对不对;// ⑤ ★★ 题面那句「输入数据保证这个值小于 2³¹」是**情报**还是废话 ——// 算一算顶格数据的答案有多大。
#include <bits/stdc++.h>#include <chrono>using namespace std;using namespace std::chrono;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 twoQueues(vector<ll> a) { int n = a.size(); if (n <= 1) return 0; sort(a.begin(), a.end()); queue<ll> A, B; for (ll x : a) A.push(x); auto take = [&]() { ll x; if (B.empty() || (!A.empty() && A.front() <= B.front())) { x = A.front(); A.pop(); } else { x = B.front(); B.pop(); } return x; }; ll ans = 0; for (int i = 1; i < n; i++) { ll x = take(), y = take(); ans += x + y; B.push(x + y); } return ans;}/** 小根堆 */static ll byHeap(const vector<ll>& a) { priority_queue<ll, vector<ll>, greater<ll>> q; for (ll x : a) q.push(x); ll ans = 0; while (q.size() > 1) { ll x = q.top(); q.pop(); ll y = q.top(); q.pop(); ans += x + y; q.push(x + y); } return ans;}/** 每轮重新 sort */static ll byResort(vector<ll> a) { int n = a.size(); ll ans = 0; for (int step = 1; step < n; step++) { sort(a.begin(), a.end()); ll s = a[0] + a[1]; ans += s; a.erase(a.begin()); a[0] = s; } return ans;}/** 每轮线性扫两个最小 */static ll byScan(vector<ll> a) { int n = a.size(); vector<char> gone(n, 0); ll ans = 0; for (int step = 1; step < n; step++) { int i1 = -1, i2 = -1; for (int i = 0; i < n; i++) { if (gone[i]) continue; if (i1 < 0 || a[i] < a[i1]) { i2 = i1; i1 = i; } else if (i2 < 0 || a[i] < a[i2]) i2 = i; } ll s = a[i1] + a[i2]; ans += s; gone[i2] = 1; a[i1] = s; } return ans;}/** 错法:排完序一路往下累。touch 记录「有没有出现过『新堆不再是最小』」 */static ll byChain(vector<ll> a, bool* trig = nullptr) { sort(a.begin(), a.end()); int n = a.size(); if (trig) *trig = false; ll cur = a[0], ans = 0; for (int i = 1; i < n; i++) { if (trig && i + 1 < n && cur > a[i + 1]) *trig = true; // 新堆比后面某个原始堆还大 cur += a[i]; ans += cur; } return ans;}
/** ★ 一条完全无关的路:显式建霍夫曼树,答案 = Σ a_i × depth_i */static ll byHuffmanDepth(const vector<ll>& a) { int n = a.size(); if (n <= 1) return 0; // 结点 0..n-1 是叶子,之后是内部结点 vector<ll> w(a); vector<int> lc, rc; lc.assign(n, -1); rc.assign(n, -1); priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<pair<ll, int>>> q; for (int i = 0; i < n; i++) q.push({w[i], i}); while (q.size() > 1) { auto [wx, x] = q.top(); q.pop(); auto [wy, y] = q.top(); q.pop(); int id = w.size(); w.push_back(wx + wy); lc.push_back(x); rc.push_back(y); q.push({wx + wy, id}); } int root = w.size() - 1; // 算每个叶子的深度,答案 = Σ 叶子权 × 深度 ll total = 0; vector<pair<int, int>> st{{root, 0}}; while (!st.empty()) { auto [u, d] = st.back(); st.pop_back(); if (lc[u] < 0) { total += w[u] * d; continue; } st.push_back({lc[u], d + 1}); st.push_back({rc[u], d + 1}); } return total;}
static vector<ll> gen(mt19937& rng, int n, int hi) { vector<ll> a(n); for (int i = 0; i < n; i++) a[i] = 1 + (ll)(rng() % (unsigned)hi); return a;}
int main(int argc, char** argv) { CSV = argc > 1 && string(argv[1]) == "csv";
/* ① 霍夫曼深度验算 + ② 四种写法一致 */ { mt19937 rng(20260829u); int groups = 0, badHuff = 0, badImpl = 0; for (int rep = 0; rep < 300; rep++, groups++) { int n = (int)(rng() % 40) + 1; vector<ll> a = gen(rng, n, 100); ll ok = twoQueues(a); if (byHuffmanDepth(a) != ok) badHuff++; if (byHeap(a) != ok || byResort(a) != ok || byScan(a) != ok) badImpl++; } if (!CSV) printf("① 验算 %d 组:答案 ≡ Σ a_i × 霍夫曼深度,不同 %d 组\n" "② 四种正确写法(两队列 / 堆 / 每轮重排 / 每轮线性扫)不一致 %d 组\n", groups, badHuff, badImpl); row("verify", {groups, badHuff, badImpl}); }
/* ③ 「一路往下累」那个错法 */ { const int HIS[] = {2, 20, 2000, 20000}; vector<ll> out; for (int hi : HIS) { mt19937 rng(hi * 7919u + 13u); int bad = 0, trig = 0, both = 0; for (int r = 0; r < 300; r++) { int n = (int)(rng() % 12) + 1; vector<ll> a = gen(rng, n, hi); bool t; ll got = byChain(a, &t); bool caught = got != twoQueues(a); if (t) trig++; if (caught) bad++; if (t && caught) both++; } out.push_back(bad); out.push_back(trig); out.push_back(both); if (!CSV) printf("③ a ≤ %5d(n ≤ 12,300 轮):一路往下累那版错 %d 次;" "「新堆不再是最小」出现 %d 轮,两者同时成立 %d 轮\n", hi, bad, trig, both); } row("chain", out); }
/* ④ ★★★ 「先用 sort 硬做也能过」—— 顶格 n = 10^4 计时 */ { mt19937 rng(4321u); vector<ll> a = gen(rng, 10000, 20000); ll ans = twoQueues(a); vector<ll> out; auto timeit = [&](const char* name, ll (*f)(vector<ll>)) { auto t0 = steady_clock::now(); ll got = f(a); ll ms = duration_cast<milliseconds>(steady_clock::now() - t0).count(); if (!CSV) printf("④ %-24s %6lld 毫秒 答案 %lld%s\n", name, ms, got, got == ans ? "" : " ← 不一致"); out.push_back(ms); return ms; }; timeit("两个有序队列", twoQueues); { // 堆那份的签名是 const&,单独写一次 auto t0 = steady_clock::now(); ll got = byHeap(a); ll ms = duration_cast<milliseconds>(steady_clock::now() - t0).count(); if (!CSV) printf("④ %-24s %6lld 毫秒 答案 %lld%s\n", "小根堆(第 37 章)", ms, got, got == ans ? "" : " ← 不一致"); out.push_back(ms); } timeit("每轮线性扫 O(n^2)", byScan); timeit("每轮重新 sort O(n^2 log n)", byResort); out.push_back(ans); row("timing", out); }
/* ⑤ ★★ 题面那句「答案小于 2^31」挡掉了什么 */ { vector<ll> worst(10000, 20000); // 顶格:10^4 堆,每堆 2×10^4 ll ans = twoQueues(worst); ll total = 0; for (ll x : worst) total += x; if (!CSV) printf("⑤ 顶格数据(n = 10^4,每堆 2×10^4):总重 %lld,答案 %lld," "而 2^31 = 2147483648 —— 答案是它的 %.2f 倍\n", total, ans, (double)ans / 2147483648.0); row("worst", {total, ans, 2147483648LL, (ll)((double)ans / 2147483648.0 * 100 + 0.5)}); } return 0;}点「运行 ▶」看结果
// P1090 对拍生成器:`./p1090Gen <seed> [n 上限] [a 上限]`// 默认 `n ≤ 12`、`a ≤ 20` —— ★ 故意造得**小而且重复多**。//// ★ 理由:这一页那个错法(排完序一路往下累)只在// 「新合出来的堆不再是当前最小」的时候才现形(最小的反例是四个 1)。//// ⚠ 而「值域越小越容易现形」这句话**是我猜的,实测基本不成立**:// `a ≤ 2 / 20 / 2000 / 20000` 四档各 300 轮,抓获数是 **210 / 190 / 185 / 188** ——// 值域拉了一万倍,抓获率几乎没动(页面第 ③ 步那张表)。// ⇒ 真正决定它的是 **n**(堆数),不是值域:合的次数多了,// 「新堆不再是最小」迟早会发生。默认档把 n 压到 12 是为了让暴力参照物跑得动,// 顺带说明**这个 bug 根本不需要大数据**。
#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 aHi = argc > 3 ? atoi(argv[3]) : 20; nHi = max(1, min(10000, nHi)); aHi = max(1, min(20000, aHi));
mt19937 rng(seed * 2654435761u + 90u); int n = (int)(rng() % (unsigned)nHi) + 1; printf("%d\n", n); for (int i = 0; i < n; i++) printf("%d%c", 1 + (int)(rng() % (unsigned)aHi), i + 1 == n ? '\n' : ' '); return 0;}点「运行 ▶」看结果
7一页纸
| 关键的一步 | 每次合并最小的两堆(霍夫曼);★ 而「取最小」不一定要堆 |
| 哪一版能 AC | p1090.cpp —— 两个有序队列,O(n log n),一个堆都不用(顶格 1 毫秒) |
| 凭什么能不用堆 | 合并出来的新堆重量单调不减 ⇒ 新堆队列天然有序,两路归并就够了 |
| 验算 | 答案 ≡ Σ aᵢ × 霍夫曼深度(300 组不同 0 组)—— 一条和贪心无关的路 |
| 错法 | 排完序一路往下累(新堆没重新排队);★ 官方样例放过,最小反例是四个 1 |
| ★ 触发条件 | 「新堆不再是最小」的轮数 ≡ 被抓轮数,四档全部一个不差(这一天第五次量) |
| ⚠ 被实测打回的猜想 | 「值域越小越容易现形」—— 值域拉一万倍,抓获率 210 → 188,几乎没动 |
| 「sort 硬做能过吗」 | 能 —— O(n²) 93 毫秒、O(n² log n) 220 毫秒(时限 800);⚠ 但余量只有 3.6 倍,而正解是 1 毫秒 |
| ★★ 题面那句保证 | 「答案 < 2³¹」把数据范围的顶格排除掉了(顶格答案是 2³¹ 的 1.24 倍) |