0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1731,日期见页头。两边不一致时信原站。
题目描述
7 月 17 日是 Mr.W 的生日,ACM-THU 为此要制作一个体积为 Nπ 的 M 层生日蛋糕,
每层都是一个圆柱体。
设从下往上数第 i(1 ≤ i ≤ M)层蛋糕是半径为 Rᵢ、高度为 Hᵢ 的圆柱。
当 i < M 时,要求 Rᵢ > Rᵢ₊₁ 且 Hᵢ > Hᵢ₊₁。
由于要在蛋糕上抹奶油,为尽可能节约经费,我们希望蛋糕外表面
(最下一层的下底面除外)的面积 Q 最小。
请编程对给出的 N 和 M,找出蛋糕的制作方案(适当的 Rᵢ 和 Hᵢ 的值),
使 S = Q / π 最小。(除 Q 外,以上所有数据皆为正整数)
输入格式:第一行为一个整数 N(N ≤ 2 × 10⁴),表示待制作的蛋糕的体积为 Nπ。
第二行为 M(M ≤ 15),表示蛋糕的层数为 M。
输出格式:输出一个整数 S,若无解,输出 0。
来源:NOI 1999。
输入输出样例
输入
100 2
输出
68
体积 100π、两层。最优方案是下层 R = 5, H = 2、上层 R = 4, H = ... 那一类,
S = 68。上面那段输出是仓库里的 p1731.cpp 真跑出来的。
1先把「几何」翻译成「两个数组」
一层就是 (Rᵢ, Hᵢ) 两个正整数。把题面里的两句话翻译过来:
体积: Σ Rᵢ² Hᵢ = N <- π 两边约掉了,所以全程都是整数
面积: S = Σ 2 Rᵢ Hᵢ + R₁² <- 侧面积,加上最下面那层的「上表面」
从上往下看,每一层露出来的是一个圆环。所有圆环加起来,正好等于最下面那层的整个上表面 —— 因为上面每一层挡住的部分,恰好是它下面那层圆环的内圈。
⇒ 所以 S 里只需要加一次 R₁²(最下层的半径平方),别的层的顶面一个都不用单独算。
⚠ 这一步想不到的话,代码会写成「每层各加一个圆环」—— 那也对,但麻烦得多,还容易漏。
2★ 搜索顺序:从最下面那层开始
先定大的,上面的选择范围才被夹住。
从第 M 层(最下面、最大的那层)往上搜,每层的范围立刻被三件事夹住:
R ≤ 下面那层的 R − 1 严格递减
R ≤ √(还剩多少体积) 它自己就吃不下更多了
R ≥ 还剩几层 还剩 dep 层、每层至少 1 且严格递减 ⇒ 最小是 dep
H 同理。反过来从上往下搜就没有这些上界(上面那层可以任意小),搜索树会炸开。
3第 ① 版:只按题意搜,一个剪枝都不加
// 第 ① 版:只按题意搜,一个剪枝都不加//// 从**最下面那层**往上搜(下面那层最大,先定它,上面的选择范围才被夹住):// · 第 dep 层(还剩 dep 层没定)的半径 R:不超过下面那层的 R − 1,也不超过 √剩余体积;// 还剩 dep 层,每层至少 1、且严格递减 ⇒ R 至少是 dep。高 H 同理。// · 用掉体积 R² × H,攒上侧面积 2 × R × H;最下面那层还要额外算一次上表面 R²// (★ 上面每层挡住的那一圈,加起来正好等于最下层的上表面,所以只算一次)。//// ⚠ 它是对的,但慢:`N = 20000`、`M = 15` 时本机跑到天荒地老(正文第 ④ 步有实测)。//// 输入:第一行 N(体积 Nπ),第二行 M(层数)。
#include <bits/stdc++.h>using namespace std;
int n, m;long long best = LLONG_MAX, calls = 0;
/** dep = 还剩几层要定,v = 还剩多少体积,s = 已经攒了多少面积 */void dfs(int dep, int v, long long s, int maxR, int maxH) { calls++; if (dep == 0) { if (v == 0) best = min(best, s); return; } for (int r = min(maxR, (int)sqrt((double)v)); r >= dep; r--) for (int h = min(maxH, (r * r == 0) ? 0 : v / (r * r)); h >= dep; h--) { long long add = 2LL * r * h + (dep == m ? (long long)r * r : 0); dfs(dep - 1, v - r * r * h, s + add, r - 1, h - 1); }}
int main(int argc, char** argv) { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> m; dfs(m, n, 0, 10000, 10000); cout << (best == LLONG_MAX ? 0 : best) << '\n'; if (argc > 1 && string(argv[1]) == "calls") cerr << "dfs 调用了 " << calls << " 次\n"; return 0;}点「运行 ▶」看结果
4★★★ 这道题最值钱的一课:最大的那组数据,不是最慢的那组
// 换一把尺子:加剪枝前后各走了多少个节点//// ★ 正文第 ④ 步那张表就是它打的。两份实现在同一个文件里跑同一组输入,// 数的是同一个动作:**dfs 被调用了多少次**。//// 用法:./p1731Count <N> <M> 两版都跑// ./p1731Count <N> <M> fast ★ 只跑剪枝版(无剪枝版在某些输入上要几分钟)// ./p1731Count <N> <M> csv 只打 `键,值`,给 check:viz 用
#include <bits/stdc++.h>using namespace std;
int n, m;long long callsA = 0, bestA = LLONG_MAX;long long callsB = 0, bestB = LLONG_MAX;long long minV[20], minS[20];
void dfsA(int dep, int v, long long s, int maxR, int maxH) { callsA++; if (dep == 0) { if (v == 0) bestA = min(bestA, s); return; } for (int r = min(maxR, (int)sqrt((double)v)); r >= dep; r--) for (int h = min(maxH, v / (r * r)); h >= dep; h--) dfsA(dep - 1, v - r * r * h, s + 2LL * r * h + (dep == m ? (long long)r * r : 0), r - 1, h - 1);}void dfsB(int dep, int v, long long s, int maxR, int maxH) { callsB++; if (dep == 0) { if (v == 0) bestB = min(bestB, s); return; } if (v < minV[dep]) return; if (s + minS[dep] >= bestB) return; for (int r = min(maxR, (int)sqrt((double)v)); r >= dep; r--) { if (s + 2LL * v / r >= bestB) return; for (int h = min(maxH, v / (r * r)); h >= dep; h--) dfsB(dep - 1, v - r * r * h, s + 2LL * r * h + (dep == m ? (long long)r * r : 0), r - 1, h - 1); }}
int main(int argc, char** argv) { n = (argc > 1) ? atoi(argv[1]) : 100; m = (argc > 2) ? atoi(argv[2]) : 2; string mode = (argc > 3) ? argv[3] : ""; for (int i = 1; i < 20; i++) { minV[i] = minV[i - 1] + (long long)i * i * i; minS[i] = minS[i - 1] + 2LL * i * i; } dfsB(m, n, 0, 10000, 10000); if (mode != "fast") dfsA(m, n, 0, 10000, 10000); if (mode == "csv") { cout << "answer," << (bestB == LLONG_MAX ? 0 : bestB) << '\n'; cout << "callsA," << callsA << '\n'; cout << "callsB," << callsB << '\n'; } else { cout << "N = " << n << ",M = " << m << ",答案 S = " << (bestB == LLONG_MAX ? 0 : bestB) << '\n'; if (mode != "fast") cout << "① 不剪枝 dfs 调用 " << callsA << " 次\n"; cout << "② 三个剪枝 dfs 调用 " << callsB << " 次\n"; if (mode != "fast" && callsB) cout << "⇒ 差 " << (callsA / callsB) << " 倍\n"; } return 0;}点「运行 ▶」看结果
本机实测(B 机:原生 Ubuntu / i5-10210U 8 线程 / 18 GB,2026-08-26,独占):
| N, M | ① 不剪枝 | ② 三个剪枝 | 差 |
|---|---|---|---|
| 100, 2(样例) | 405 次 | 39 次 | 10 倍 |
| 5 000, 8 | 56 175 094 次(0.49 秒) | 5 780 次 | 9 718 倍 |
| 10 000, 10 | 1 177 399 150 次(9.81 秒) | 29 881 次 | 39 402 倍 |
| ★ 20 000, 15(顶格) | ★ 1 931 590 次(0.03 秒) | 22 383 次 | 86 倍 |
| ⚠ 20 000, 7 | 120 秒没跑完(手动掐的) | 21 731 次 | — |
N = 20000、M = 15 是题面允许的最大一组,可不剪枝的版本 0.03 秒就跑完了 ——
拿它测,你会以为「不用剪枝也行」。
真正卡人的是中间的层数:M = 5 ~ 9 那几组,本机 120 秒都没跑完。
⇒ 为什么?M 越大,「每层至少是 dep、且严格递减」这条硬约束就越紧
(15 层最少要吃掉 1³ + 2³ + … + 15³ = 14400 的体积,而总共才 20000,几乎没得选);
M 小的时候这条约束几乎不起作用,搜索树就散开了。
★ 这和第 51 章带回来的那条是同一件事:
「造一组大数据跑一次」不够 —— 要造对形状。
那一章是树的形状(链才抓得到 bug),这一章是层数
(M 落在中间才慢)。「顶格」和「最坏」是两回事。
5第 ② 版:三个剪枝
// 推荐写法:同一棵搜索树,加三个剪枝//// ★ 三个剪枝,一个比一个值钱(正文第 ④ 步逐个量了)://// ① **体积够不够**:还剩 dep 层,每层至少是 (1..dep) 那样的最小配置,// 最少也要吃掉 minV[dep] = Σ j³ 的体积。剩下的比它还少 ⇒ 这条路走不通。// ② **面积已经亏了**:还剩 dep 层,至少还要糊上 minS[dep] = Σ 2j² 的面积。// 已攒的 + 它 ≥ 当前最优 ⇒ 再往下不可能更好。// ③ ★★ **拿剩余体积估剩余面积**:剩下要糊的侧面积是 Σ 2 Rᵢ Hᵢ,// 而 Σ Rᵢ² Hᵢ = 剩余体积 v,且每个 Rᵢ 都小于当前的 R// ⇒ Σ 2 Rᵢ Hᵢ = Σ 2 Rᵢ² Hᵢ / Rᵢ > 2v / R。// **这一条是这道题的分水岭** —— 它把「还没定的那些层」和「已经用掉的体积」绑在了一起。//// ⚠ 三个剪枝都不改变答案,只改变「走到哪一步就掉头」。
#include <bits/stdc++.h>using namespace std;
int n, m;long long best = LLONG_MAX, calls = 0;long long minV[20], minS[20];
void dfs(int dep, int v, long long s, int maxR, int maxH) { calls++; if (dep == 0) { if (v == 0) best = min(best, s); return; } if (v < minV[dep]) return; // ① 体积不够了 if (s + minS[dep] >= best) return; // ② 面积已经亏了 for (int r = min(maxR, (int)sqrt((double)v)); r >= dep; r--) { if (s + 2LL * v / r >= best) return; // ③ ★★ 拿剩余体积估剩余侧面积 for (int h = min(maxH, v / (r * r)); h >= dep; h--) { long long add = 2LL * r * h + (dep == m ? (long long)r * r : 0); dfs(dep - 1, v - r * r * h, s + add, r - 1, h - 1); } }}
int main(int argc, char** argv) { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> m; for (int i = 1; i < 20; i++) { minV[i] = minV[i - 1] + (long long)i * i * i; minS[i] = minS[i - 1] + 2LL * i * i; } dfs(m, n, 0, 10000, 10000); cout << (best == LLONG_MAX ? 0 : best) << '\n'; if (argc > 1 && string(argv[1]) == "calls") cerr << "dfs 调用了 " << calls << " 次\n"; return 0;}点「运行 ▶」看结果
① 体积够不够:还剩 dep 层,最少也要吃掉 minV[dep] = Σ j³
剩下的比它还少 -> 这条路走不通
② 面积已经亏了:还剩 dep 层,至少还要糊 minS[dep] = Σ 2j²
已攒的 + 它 ≥ 当前最优 -> 再往下不可能更好
③ ★★ 拿剩余体积估剩余侧面积:
剩下要糊的是 Σ 2 Rᵢ Hᵢ = Σ 2 Rᵢ² Hᵢ / Rᵢ > 2 × 剩余体积 / 当前 R
已攒的 + 它 ≥ 当前最优 -> 掉头
① 和 ② 都只看「还剩几层」,是静态的下界 —— 它们和当前已经用掉多少体积无关。 ③ 不一样:它把剩余体积换算成了剩余面积的下界。
⇒ 于是「体积快用完了」这件事,第一次能直接推出「面积不可能再省了」。 ★ 一般化:好的剪枝,往往是把题目里两个看似独立的量绑起来的那个不等式。
⚠ 三个剪枝都不改变答案,只改变「走到哪一步就掉头」—— 所以加完一定要拿样例和小数据回头对一遍(这一页的四组数字每一组都对过)。
- ★ 从最下面那层往上搜 —— 先定大的,上面每一层的范围才被 「比下面那层小」「吃不下更多体积」「还剩几层」三件事同时夹住。
- ★★ 好剪枝是把两个量绑起来的那个不等式 ——
剩余侧面积 > 2 × 剩余体积 / 当前 R一条顶前面两条。 - ★★★ 顶格的数据不等于最坏的数据。
M = 15那组不剪枝也秒过,M = 7那组两分钟都跑不完。测性能之前先想清楚:这道题的「形状」旋钮是哪个?