题单 · 习题解析

洛谷 P1090 [NOIP 2004 提高组] 合并果子

★★★ 「取最小」不一定要堆 —— 合并出来的新堆重量单调不减,两个有序队列就够了(顶格 1 毫秒);顺带把题单那句「先用 sort 硬做也能过」量了一遍

⚠ 先自己写一遍,再往下看

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

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

就是题面里手算的那一组。 ⚠ 而它放过了本页那个错法 —— 第 ③ 步会说为什么这几乎是必然的。

★ 这道题挂在第 19 章,是因为它真的只用排序就能做

这题的标准写法是拿第 37 章的堆反复取最小 —— 而第 19 章还没讲堆。 第 19 章的题单里给它写了一句提示: 「这题在等第 37 章的堆,先用 sort 硬做也能过」。

这一页做两件事:

  1. 给出一个正解 —— 它根本不用堆,也不是硬做:两个有序队列,O(n log n)(第 ② 步);
  2. 把那句「硬做也能过」量一遍(第 ④ 步)——「能过」是真的,而余量比想象的小。

1贪心:每次合并最小的两堆

直觉一句话就够:一堆果子被合并了几次,它的重量就被数几次 —— 所以越重的堆越要晚合并,也就是「每次挑最小的两堆先合」。

这正是霍夫曼编码的构造过程;答案有一个等价的写法:

    总代价 = Σ a_i × (第 i 堆在合并树上的深度)
✓ 拿这个等价写法验一遍(一条和贪心完全无关的路)

把合并过程显式建成一棵二叉树,再算每片叶子的深度,最后求 Σ aᵢ × depthᵢ —— 这条路一次「取最小的两堆」的循环都不共享。

随机 300 组:答案 vs Σ aᵢ × 霍夫曼深度 不同 0 组

2★★★ 正解:两个有序队列,一个堆都不用

标准写法要一个「反复取最小」的数据结构,所以人人都去搬小根堆。 但这道题有一个现成的结构可以白拿:

★ 合并出来的新堆,重量是单调不减的

每一次取走的是当前最小的两堆,所以这两堆的和只会一次比一次大

⇒ 于是把「原始的那些堆」排好序放进队列 A,把「合并出来的新堆」按生成顺序放进队列 B —— 两个队列各自都是有序的。每次要取最小,只要比一下两个队头,谁小取谁。

排序 O(n log n) + 合并 O(n)这就是它能挂在「排序型贪心」这一章的原因。

p1090.cpp★ 这一版就能 AC(不用堆)
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

作为对照,堆的写法长这样(第 37 章会正式讲):

p1090Heap.cpp对照:小根堆

3错法:排完序一路往下累(而样例放过了它)

p1090Chain.cpp错法:新堆没有重新排队
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

写法很自然:排好序,先合最小的两堆,再把结果和第三堆合,再和第四堆…… 毛病是新堆没有回到队列里重新排队 —— 它往往已经不是最小的那一档了。

而官方样例放过了它1 2 91+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 倍、P180360 倍P2240 一个不差、P1094 一个不差、这一页四档全部一个不差。 ⇒ 这两个数的关系只能量,不能推。

4★★★ 「先用 sort 硬做也能过」—— 把这句话量一遍

第 19 章的题单里给这道题写了一句「先用 sort 硬做也能过」。 「硬做」其实有两种写法,它们差着一个 log

p1090Scan.cpp硬做一:每轮线性扫两个最小 O(n²)
p1090Sort.cpp硬做二:每轮重新 sort O(n² log n)

本机实测(顶格 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
★ 结论:那句话是对的 —— 但余量只有 3.6 倍,不是十倍

两种「硬做」都过得去(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度量程序和生成器

p1090Count.cpp度量程序
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p1090Gen.cpp数据生成器
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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 倍)