
UVa 1110 Pyramids 是 UVa Online Judge 上流传很广的一道数学枚举题也是很多入门选手刷题时会遇到的第一道“卡脖子题”。题目描述了一座由小正方体砌成的金字塔第 k 层恰好是 k×k×k 个小方块换句话说是 k^3。给定一个正整数 n要找出所有连续层段 a 到 b使得这一段从第 a 层到第 b 层包含的方块总数正好等于 n也就是满足 a^3(a1)^3…b^3n。这道题表面上是枚举实际上考的是两件事会不会用连续整数立方和公式把区间查询变成 O(1) 的减法以及能不能把枚举边界定准。刚接触 ACM/ICPC 的读者可以把它当作“数学推导二分查找”的入门题已经刷过一段时间的人也能从里面挖出不少边界细节。这篇文章我从题意的几何模型讲起把三种复杂度不同的解法全部写一遍最后给出一份可以直接提交的 C 代码再聊聊我实际提交时踩过的坑。1. 题目到底在问什么1.1 金字塔模型把几何问题翻译成公式题目里的金字塔你可以想象成一叠往上堆的积木第1层只有 1 个方块也就是 1×1×1第2层是 2×2×2 的方块共 8 个第3层是 3×3×3共 27 个。这里说的“层数”是从底部往上编号的不是从顶部往下数。于是从第 a 层到第 b 层方块总数就是 a^3(a1)^3…b^3。题目给你一个整数 n要你找出所有可能的 a 和 b使这个和恰好等于 n。这类问题的难点从来不在几何而在把几何语言翻译成数学语言。只要写下这个求和式原题就变成了一个纯粹的整数方程。你不需要真的在脑子里建一座金字塔也不需要画图只需要知道“连续层”对应“连续整数”“方块数”对应“立方和”。至于为什么叫 Pyramids 而不叫 Cubes我猜是因为每一层都叠在上一层上面从侧面看像一个阶梯状的金字塔。这个背景知识对解题没有直接帮助但至少看到题面不会觉得莫名其妙。1.2 输入输出与数据范围UVa 老题常见的风格是多重输入一直读到 EOF 结束每组给一个整数 n。对每个 n输出所有满足条件的 (a,b)一般按 a 升序排列如果一组解都没有就输出 No solution. 之类的提示。具体字符串和空格、句点的细节要以原题 PDF 为准提交前最好先看一眼样例。关于数据范围网上题解和原题描述里常见的是 n 不超过 10^9。这个范围很重要因为它直接决定了枚举上界。后面你会看到b 最多只需要枚举到 1000。如果 n 的上限稍微大一点比如 2^31 数量级结论也只是把 1000 改成 1300 左右思路完全一样。所以不要纠结这个数字到底是多少关键在于知道它有上限不是无限大。举个具体例子n9 时1^32^39所以解是 (1,2)n1000 时只有 10^31000所以解是 (10,10)如果 n21^312^38189中间拼不出 2自然无解。2. 数学推导立方和公式如何把复杂度降下来2.1 连续整数立方和公式很多人在高中就背过自然数立方和公式1^32^3…x^3 等于 (x(x1)/2)^2。比如 x3 时182736而 (3×4/2)^26^236正好相等。这个公式有几个证明方法最容易理解的还是数学归纳法假设前 x-1 项成立那么前 x 项等于 [(x-1)x/2]^2x^3化简以后正好等于 [x(x1)/2]^2。这个代数展开不复杂建议自己推一遍推完印象会深很多。对这道题更有用的结论是区间和从 a 到 b 的立方和等于前缀立方和的差也就是 sum_{ia}^{b} i^3 [b(b1)/2]^2 - [(a-1)a/2]^2。这就是把“区间之和”变成 O(1) 减法的关键。你不需要真的构造出金字塔只需要有一张“层数 → 前缀立方和”的对照表。顺便提一句k^3 还可以看成 k 个 k×k 的正方形把这些正方形按边长 1 到 x 排起来可以拼成一个边长为 12…x 的大正方形。这个几何解释很漂亮但对写代码没有直接帮助纯当兴趣了解。2.2 区间和与枚举上界有了公式思路就变成枚举 a 和 b检验等式是否成立。但问题来了枚举到什么程度一个关键观察是当 a≤b 时a^3(a1)^3…b^3 一定不小于 b^3。换句话说如果 b^3 已经比 n 大那任何包含 b 的区间和都不可能等于 n。因此 b 的上限是 floor(cuberoot(n))。当 n≤10^9 时因为 1000^310^9所以 b 最多取到 1000a 当然也不会超过 1000。整个搜索空间不超过 1000×100010^6 组对现代 CPU 来说非常小。这里有一个很容易犯的错误有些人会想“pre[b] 不能超过 n否则就 break”。这个想法是错的。pre[b] 是 1^3…b^3而区间从 a 开始时pre[b]-pre[a-1]n所以 pre[b]npre[a-1]完全可以大于 n。比如 n1000 时解是 (10,10)此时 pre[10]3025远大于 1000。正确的上界判断是 b^3≤n而不是 pre[b]≤n。注意枚举上界用 b^3≤n不要用 pre[b]≤n。这是一个非常经典的边界错误。3. 三种解法从三层循环到二分查找3.1 基线三层循环先写一个最朴素版本当作思考的起点。枚举 a再枚举 b再用一个循环从 a 加到 b判断累加和是否等于 n。复杂度 O(N^3)N1000 时大约是 10^9 量级的运算肯定超时。但它的价值是几乎不可能写错可以拿来做对拍验证后续优化版本的正确性。很多初学者上来就想写最优解结果边界错了还不知道错在哪。我建议这种数学枚举题先花两分钟写一个暴力版再逐步优化。三层循环版本和优化版本输出相同就能放心提交。别小看这个笨办法它在竞赛里是一个很实用的调试手段最优解写出来之后拿随机数据对拍暴力版就是最可靠的“参考答案”。3.2 改进前缀和双重枚举O(N^3) 慢在“算区间和”这件事上。用前缀和数组 pre[i]1^3…i^3区间和就变成 pre[b]-pre[a-1]一次减法搞定。于是两层循环就够外层枚举 b内层枚举 a判断 pre[b]-pre[a-1]n。复杂度 O(N^2)10^6已经能过不少题目了。代码大概长这样vectorlong long pre(1, 0); for (int i 1; i 5000; i) { pre.push_back(pre.back() 1LL * i * i * i); } for (int b 1; b 5000; b) { if (1LL * b * b * b n) break; for (int a 1; a b; a) { if (pre[b] - pre[a - 1] n) { // 记录答案 (a, b) } } }这个版本每处理一个 n 大约不到 50 万次判断速度完全够。那为什么还要往二分走因为 UVa 这种老题往往会有很多组测试数据假设给 100 组 n10^6×10010^8虽然 C 也勉强能跑但不够优雅而且练习的目的就是学会二分查找这才是这道题真正想教的东西。3.3 推荐枚举 b 二分查找 a把等式重写一下pre[b]-n 实际上就是我们要找的 pre[a-1]。因为 pre 数组严格递增所以可以用 lower_bound 在 pre[0..b-1] 里查找目标值。找到后pre[pos]target那么 apos1。复杂度 O(N log N)。N1000 时每 n 大约一万次操作就算来几百组数据也毫无压力。代码片段是这样for (int b 1; b 5000; b) { if (1LL * b * b * b n) break; long long target pre[b] - n; int pos lower_bound(pre.begin(), pre.begin() b, target) - pre.begin(); if (pos b pre[pos] target) { int a pos 1; // 记录答案 (a, b) } }这里要说明一下 lower_bound 的范围。STL 的 lower_bound(first, last, value) 默认在 [first, last) 中找第一个不小于 value 的位置。我们传入 pre.begin()b表示范围只到下标 b-1也就是 a-1 的最大值 b-1这正好对应 a≤b 的限制。pre[0]0 一定要在数组里因为 a1 时 a-10而 pre[0] 代表空前缀。3.4 额外思路哈希表与滑动窗口除了二分还有两个可以顺手一提的优化。第一个是哈希表把 pre 的值到下标的映射存进 unordered_map枚举 b 时直接查询 target。理论复杂度 O(N)但 STL 哈希表有常数开销数据量这么小不一定比二分快。第二个是滑动窗口左右指针 l、r 维护当前窗口的立方和窗口和小于 n 就右指针右移大于 n 就左指针右移相等就记录并继续。由于 i^3 单调递增窗口和也是单调变化的复杂度 O(N)代码也很短。这两种写法都可以不过滑动窗口在指针边界上容易出错比如出现 lr、输出重复解、漏掉窗口滑动到末尾时的最后一组解。所以我个人主推二分版本思路直白边界容易控制也符合这道题“练二分”的定位。哈希表版本可以当课后练习写一写加深对查找的印象。4. 完整 C 实现与逐段解析4.1 可直接提交的代码下面是完整代码加了注释和上面讲的二分思路保持一致。这里把 pre 数组构建到 5000是为了覆盖 n 上限可能到 2×10^9 的情况如果以后碰到 n 更大的题目可以把这个数字继续调大反正 long long 装得下。#include bits/stdc.h using namespace std; typedef long long ll; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); // 构建前缀和数组pre[i] 表示 1^3 2^3 ... i^3 // 这里取 5000 是为了覆盖常见数据范围实际枚举时 b^3 n 会直接跳出 vectorll pre(1, 0); for (int i 1; i 5000; i) { pre.push_back(pre.back() 1LL * i * i * i); } int n; bool firstCase true; while (cin n) { // 老题常见要求每组输出之间空一行 if (!firstCase) cout \n; firstCase false; bool found false; for (int b 1; b 5000; b) { // 关键剪枝区间和一定 b^3 if (1LL * b * b * b n) break; ll target pre[b] - n; int pos lower_bound(pre.begin(), pre.begin() b, target) - pre.begin(); if (pos b pre[pos] target) { int a pos 1; found true; // 按等式形式输出例如 1^32^39 cout a ^3; for (int k a 1; k b; k) { cout k ^3; } cout n \n; } } if (!found) { cout No solution.\n; } } return 0; }4.2 关键点解析为什么 pre 用 vector 而不是普通数组因为要配合 lower_bound 使用迭代器vector 最自然。同时 pre 的值是立方和累加pre[5000] 已经到 10^14 级别int 根本装不下所以必须用 long long。为什么枚举 b 之前要判断 b^3n这是剪枝也是防止无意义的二分。如果不写这个判断代码也不会错因为当 target 是负数时 lower_bound 会返回 pre.begin()pre[0]0不会匹配负值但如果 n 比较小循环会白白跑几千次虽然影响不大但写清楚更好。为什么 lower_bound 范围是 pre.begin() 到 pre.begin()b因为我们要找的是 pre[a-1]而 a≤b所以 a-1 的范围是 [0, b-1]。用 pre.begin()b 作为右端点正好把范围限制在这个区间内。还要注意 STL 是左闭右开区间所以pre.begin()b表示最后一个合法位置是 b-1。为什么 posb 的判断不能省如果 target 比 pre[b-1] 还大lower_bound 会返回 pre.begin()b此时 pos 等于 b直接访问 pre[pos] 就访问到了哨兵位置 pre[b]虽然 vector 里存在这个元素但它不属于当前二分范围逻辑上不应该使用。所以先判断 posb再判断 pre[pos]target是最稳妥的写法。5. 常见问题与排查技巧5.1 int 溢出最容易犯的错这题最大的坑就是 int。pre[1000] 大约是 2.5×10^11远超 int 能表示的 21 亿。如果 pre 数组用 int 存小 n 看不出问题一旦 n 稍大减法结果就会变成奇怪的负数然后怎么都找不到答案。解决办法是一律用 long long尤其是在写 iii 的时候要写成 1LLiii。有人喜欢写成 (ll)ii*i也可以。我习惯把类型别名成 ll一眼就知道自己用的是 64 位整数。5.2 枚举上界不是 pre[b]n而是 b^3n前面已经强调过这里再展开一次。n1000 时答案是 (10,10)pre[10]3025已经大于 1000。如果你用“pre[b]n 就 break”就会漏掉这个解。原因在于 pre[b] 是前缀和而题目判断的是区间和。正确的上界只取决于 b^3区间和一定大于等于 b^3所以 b^3n 时不可能有解。这个错误非常隐蔽因为很多时候小数据测不出来只有大数据才会暴露。5.3 二分查找的边界条件二分时最容易犯的错误有两种。第一种是 pre 数组从 pre[1] 开始存导致 a1 的情况查不到因为 a-10 对应的 pre[0] 根本没被包含。第二种是lower_bound之后忘了判断 pos 是否等于 b直接访问 pre[pos]在某些数据下会越界或者读到一个不属于当前范围的元素。我的建议是写成这样int pos lower_bound(pre.begin(), pre.begin() b, target) - pre.begin(); if (pos b pre[pos] target) { // 这里才是合法答案 }固定这个模板以后做类似的题就不会在边界上翻车。5.4 输出格式Presentation Error 的坑UVa 老题的输出格式经常有雷是否在每组输出之间空行、无解时输出什么字符串、句子末尾有没有句点都会导致 WA 或 PE。第一次提交如果碰到 PE不要慌这表示答案本身对只是格式没对齐。我的做法是先把原题 PDF 的样例输出原封不动对一遍如果本地对拍正确还是 PE大概率是空行问题。代码里的 firstCase 变量就是应付这个的。还有一个小技巧在本地写一个随机 n 生成器把程序输出重定向到文件再人工检查边界格式。5.5 无解与多 case 的标记重置found 必须在每组 n 的开头重置为 false。这个错误很低级但我在帮别人调代码时真见过上一组找到了解found 还是 true下一组无解时就不输出 No solution.。另外输出 No solution. 之后别忘了换行否则下一组输出会直接接在后面。多 case 题目还有一个常见要求是每组之间空行代码里用 firstCase 区分第一组和后面的组这是最稳的写法。下面把常见症状和原因整理成一张速查表症状原因解决办法小数据对大数据错pre 数组用 int 溢出全部改成 long longiii 写成 1LLii*i漏掉某些解b 上限写成 pre[b]n改成 b^3n输出缺空行或多余空行每组输出之间没有正确处理用 firstCase 变量第一组前不输出之后先换行找不到 a1 的解pre[0] 没被包含二分范围从 pre.begin() 开始pre[0]0越界或读出奇怪值lower_bound 后没判断 posb先判断 posb再访问 pre[pos]6. 实战心得与扩展思考6.1 我最初是怎么翻车的我第一次做这道题pre 用的是 int并且把上界写成了 pre[b]n。小数据全过提交直接 WA。后来打印中间变量才发现pre[10]3025而 n1000 时目标值是 2025pre[9]2025所以解是 (10,10)但我的循环在 b5 的时候就因为 pre[5]1000 提前结束了。改成 b^3n 之后立刻 AC。所以遇到“小数据对、大数据错”优先检查类型和边界这两个地方最容易藏着鬼。6.2 这个套路还能用到哪先预计算一个单调递增的前缀数组再枚举一个变量、二分另一个变量这是竞赛里非常常见的模式。比如 UVa 10539 Almost Prime Numbers先筛出所有素数预处理素数的幂再对两个边界做二分计数再比如很多“区间计数”题都是枚举左端点然后二分右端点。你会发现这道题只是这个套路的最小演示版理解了它后面遇到一堆题目都能秒想到“前缀和二分”。6.3 还能怎么扩展如果把立方改成平方就是连续整数平方和公式是 x(x1)(2x1)/6同样可以前缀和二分。如果把区间改成不连续的集合就要用别的办法了。另外这道题用滑动窗口也可以做左右指针维护窗口立方和小于 n 右指针右移大于 n 左指针右移相等就记录并继续。实际写一遍会加深对单调性的理解。我个人建议至少把二分和滑动窗口各写一遍写的时候自然就能分清什么时候用哪种。最后再分享一个小技巧如果你不确定 n 的最大值就把 pre 多构造几千项反正 long long 装得下运行时也只是多几次乘法和加法不会拖慢提交。真正决定代码正确性的永远是对数学关系的理解而不是硬编码的边界。