题单 · 习题解析

洛谷 P1731 生日蛋糕

★★★ 顶格的数据不是最坏的数据:M = 15 那组不剪枝也 0.03 秒跑完,M = 7 那组两分钟都跑不完

原题:洛谷 P1731出自 第 4 章 回溯与状态恢复:N 皇后 的题单题面本地存档:2026-08-26
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

7 月 17 日是 Mr.W 的生日,ACM-THU 为此要制作一个体积为 M 层生日蛋糕, 每层都是一个圆柱体。

设从下往上数第 i1 ≤ i ≤ M)层蛋糕是半径为 Rᵢ、高度为 Hᵢ 的圆柱。 当 i < M 时,要求 Rᵢ > Rᵢ₊₁Hᵢ > Hᵢ₊₁

由于要在蛋糕上抹奶油,为尽可能节约经费,我们希望蛋糕外表面 (最下一层的下底面除外)的面积 Q 最小。

请编程对给出的 NM,找出蛋糕的制作方案(适当的 RᵢHᵢ 的值), 使 S = Q / π 最小。(除 Q 外,以上所有数据皆为正整数

输入格式:第一行为一个整数 NN ≤ 2 × 10⁴),表示待制作的蛋糕的体积为 。 第二行为 MM ≤ 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第 ① 版:只按题意搜,一个剪枝都不加

p1731Naive.cpp第 ① 版:没有剪枝
样例(100 / 2)它是对的,只用了 405 次调用。⚠ 换成大数据就完全是另一回事了 —— 见下一步。
// 第 ① 版:只按题意搜,一个剪枝都不加
//
// 从**最下面那层**往上搜(下面那层最大,先定它,上面的选择范围才被夹住):
// · 第 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

4★★★ 这道题最值钱的一课:最大的那组数据,不是最慢的那组

p1731Count.cpp换一把尺子:加剪枝前后各走了多少个节点
两版跑同一组输入,数 dfs 被调用了多少次。⚠ 想试 M = 7 那种「中间层数」的话,第三个参数写 fast —— 不剪枝的那版在那儿要跑几分钟。
// 换一把尺子:加剪枝前后各走了多少个节点
//
// ★ 正文第 ④ 步那张表就是它打的。两份实现在同一个文件里跑同一组输入,
// 数的是同一个动作:**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 = 20000M = 15 是题面允许的最大一组,可不剪枝的版本 0.03 秒就跑完了 —— 拿它测,你会以为「不用剪枝也行」。

真正卡人的是中间的层数M = 5 ~ 9 那几组,本机 120 秒都没跑完

⇒ 为什么?M 越大,「每层至少是 dep、且严格递减」这条硬约束就越紧 (15 层最少要吃掉 1³ + 2³ + … + 15³ = 14400 的体积,而总共才 20000,几乎没得选); M 小的时候这条约束几乎不起作用,搜索树就散开了。

★ 这和第 51 章带回来的那条是同一件事: 「造一组大数据跑一次」不够 —— 要造对形状。 那一章是树的形状(链才抓得到 bug),这一章是层数 (M 落在中间才慢)。「顶格」和「最坏」是两回事。

5第 ② 版:三个剪枝

p1731.cpp第 ② 版:三个剪枝(能 AC)
// 推荐写法:同一棵搜索树,加三个剪枝
//
// ★ 三个剪枝,一个比一个值钱(正文第 ④ 步逐个量了):
//
// ① **体积够不够**:还剩 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
① 体积够不够:还剩 dep 层,最少也要吃掉 minV[dep] = Σ j³
              剩下的比它还少 -> 这条路走不通

② 面积已经亏了:还剩 dep 层,至少还要糊 minS[dep] = Σ 2j²
                已攒的 + 它 ≥ 当前最优 -> 再往下不可能更好

③ ★★ 拿剩余体积估剩余侧面积:
       剩下要糊的是 Σ 2 Rᵢ Hᵢ = Σ 2 Rᵢ² Hᵢ / Rᵢ > 2 × 剩余体积 / 当前 R
       已攒的 + 它 ≥ 当前最优 -> 掉头
★★ 第 ③ 条是分水岭,因为它把两件没关系的事绑在了一起

① 和 ② 都只看「还剩几层」,是静态的下界 —— 它们和当前已经用掉多少体积无关。 ③ 不一样:它把剩余体积换算成了剩余面积的下界

⇒ 于是「体积快用完了」这件事,第一次能直接推出「面积不可能再省了」。 ★ 一般化:好的剪枝,往往是把题目里两个看似独立的量绑起来的那个不等式。

⚠ 三个剪枝都不改变答案,只改变「走到哪一步就掉头」—— 所以加完一定要拿样例和小数据回头对一遍(这一页的四组数字每一组都对过)。

这一页记住三句话
  1. 从最下面那层往上搜 —— 先定大的,上面每一层的范围才被 「比下面那层小」「吃不下更多体积」「还剩几层」三件事同时夹住。
  2. ★★ 好剪枝是把两个量绑起来的那个不等式 —— 剩余侧面积 > 2 × 剩余体积 / 当前 R 一条顶前面两条。
  3. ★★★ 顶格的数据不等于最坏的数据。 M = 15 那组不剪枝也秒过, M = 7 那组两分钟都跑不完。测性能之前先想清楚:这道题的「形状」旋钮是哪个?