做在线判题练习的时候,我习惯从基础题开始热身。东华OJ的基础题第25题“汽水瓶”就是一道让我印象挺深的题目。乍一看是简单的“空瓶换汽水”,三个空瓶换一瓶,换成C++代码也就几行的事;可真正动手的时候,不少人会在“剩两个空瓶”这个细节上栽跟头,答案差出整整一瓶。这道题非常适合刚接触OJ、想巩固C++循环和输入输出处理的新手,同时也值得稍微进阶一点的读者停下来想一想:为什么标准答案可以直接写成 n / 2?
这篇文章从题干读懂开始,把模拟解法、数学优化、常见踩坑和延伸扩展完整过一遍。我会尽量把每个选择背后的原因都讲清楚,而不是只丢给你一段能AC的代码。
1. 读懂题目:三个空瓶换一瓶背后的隐藏规则
1.1 这道题到底在算什么
“汽水瓶”这道题在不同OJ上出现过很多版本,核心规则都差不多:三个空汽水瓶可以换一瓶汽水,喝完之后,这个瓶子又变成了一个空瓶。题目会给你一个起始空瓶数 n,让你算“最多能喝到多少瓶汽水”。
比较典型的输入形态是:多组测试数据,每行一个正整数 n,当读到 0 时结束。输出对应每一行输入,最多能喝到的汽水瓶数。比如输入 10,输出 5;输入 81,输出 40。
我刷到的东华OJ版本,在题目描述里补充了一个关键条件:手里剩 2 个空瓶时,可以先向老板借一瓶汽水,喝完后把空瓶还回去。这个条件不是可有可无的补充说明,它直接决定了结果的走向。没有这句话,10 个空瓶最多只能喝 4 瓶;有了这句话,答案才是 5 瓶。很多第一次做这道题的人,其实是靠“试错”才意识到这个隐藏规则的。
1.2 借瓶规则:理解答案的分水岭
我们先别急着写代码,手动算一组 10 个空瓶的情况。
如果不允许借瓶,过程是这样的:10 个空瓶先换 3 瓶,喝完得到 3 个空瓶,加上原来剩下的 1 个空瓶,共 4 个;4 个再换 1 瓶,喝完得到 1 个空瓶,加上剩下的 1 个空瓶,共 2 个;此时已经换不动了。累计喝到 3 + 1 = 4 瓶。
如果允许剩 2 个空瓶时借一瓶,过程就变成:同样先换到 3 瓶、再换到 1 瓶,最后手里剩 2 个空瓶;这时候向老板借 1 瓶汽水,喝掉后得到 1 个空瓶,加上原来 2 个空瓶里剩下的那一个,凑成 3 个空瓶还给老板。注意,还回去之后账就平了,这瓶汽水实打实是白喝的。累计就是 3 + 1 + 1 = 5 瓶。
所以“借瓶规则”相当于给了一个特例:当剩余空瓶数为 2 时,你还有最后“榨出”一瓶的机会。这个特例不是出题人故意刁难,而是生活中真实存在的行为,老板通常也乐意做这笔生意。
1.3 先看几组边界数据
在做任何优化之前,先把边界情况摸清楚。n = 1 时,显然一瓶都喝不到,答案是 0。n = 2 时,如果允许借瓶,答案是 1;这也是最容易漏掉的情况,很多模拟写法会在这里出错。
继续往下推:n = 3,直接换 1 瓶,喝完瓶子没了,答案 1。n = 4,先换 1 瓶,喝完手里剩 2 个空瓶,借 1 瓶再还回去,总共 2 瓶。n = 5,先换 1 瓶,喝完手里剩 3 个空瓶,再换 1 瓶,总共 2 瓶。n = 6,先换 2 瓶,喝完手里剩 2 个空瓶,再借 1 瓶,总共 3 瓶。
| 起始空瓶数 n | 最多喝到瓶数 |
|---|---|
| 1 | 0 |
| 2 | 1 |
| 3 | 1 |
| 4 | 2 |
| 5 | 2 |
| 6 | 3 |
| 10 | 5 |
| 81 | 40 |
看到最后一列的时候,很多人会敏锐地注意到:答案几乎就是 n / 2,而且是向下取整的整数除法。这个发现非常关键,但先别急着直接交代码,我们还要在后续章节证明它,并且理解它为什么成立。
2. 从模拟解法开始:把规则翻译成代码
2.1 最直观的模拟思路
对于一道基础题,最稳妥的思路不是套公式,而是老老实实把题目描述中的过程翻译成代码。这样代码和题意一一对应,出错了也容易排查。
模拟思路可以这样定:维护一个变量记录当前手里有多少个空瓶,另一个变量累计已经喝到了多少瓶。只要空瓶数大于等于 3,就不断执行“换一瓶、喝掉、把新空瓶加回去”的操作。循环结束后,如果手里正好剩 2 个空瓶,按借瓶规则再喝一瓶。
为什么循环条件是大于等于 3,而不是大于 0?因为只有凑够 3 个空瓶才能发起一次正常兑换。每次兑换消耗 3 个空瓶、收回 1 个空瓶,净消耗 2 个空瓶。这个“净消耗”的概念,是后面推导公式的钥匙。模拟时只要照着文字规则写,就不会漏掉借瓶特例。
2.2 C++ 模拟代码与逐步运行
下面这版模拟代码,我建议新手先自己敲一遍,不要直接复制:
#include <iostream> using namespace std; int main() { int n; while (cin >> n && n != 0) { int empty = n; // 当前空瓶数 int total = 0; // 已经喝到的瓶数 while (empty >= 3) { int exchanged = empty / 3; total += exchanged; empty = empty % 3 + exchanged; } if (empty == 2) { total += 1; // 借一瓶,喝完还空瓶 } cout << total << endl; } return 0; }重点看 while 循环里的两行计算。exchanged = empty / 3 表示这一步能换多少瓶;empty % 3 是换完之后剩下的空瓶;exchanged 又是喝掉之后新增的空瓶数。两者相加,才是下一轮开始时的真实空瓶数。
以 n = 10 为例手动跑一遍:第一轮 empty = 10,exchanged = 3,total = 3,empty = 10 % 3 + 3 = 4。第二轮 empty = 4,exchanged = 1,total = 4,empty = 4 % 3 + 1 = 2。循环结束,empty == 2,借一瓶,total = 5。输出 5,正确。
2.3 模拟解法的适用场景和局限
模拟解法的最大优点是可靠、直观,不容易漏规则。尤其当你不确定题目里是否有隐含条件时,模拟是检验其他方案正确性的“基准参照”。但它也有明显的局限:当 n 很大时,while 循环的执行次数虽然不会太多,但毕竟不是常数时间。更重要的是,模拟代码比一行公式复杂,在限时很严格的竞赛中不是最优解。
不过以“汽水瓶”这道题的数据范围(通常 n 不超过 100),模拟法和公式法在运行时间上几乎看不出差别。我依然建议在草稿阶段先用模拟把答案算清楚,再用公式去优化,而不是一上来就背结论。很多同学写公式写错了却浑然不觉,就是因为没有模拟结果做参照。
3. 数学优化:为什么答案就是 n / 2
3.1 每次换瓶,空瓶数净减少 2
现在来证明那个看起来过于简单的结论:最多喝到的瓶数等于 n / 2 的整数部分。
关键思路是“净消耗”。每一次兑换,你从手里拿出 3 个空瓶给老板,换来 1 瓶汽水,喝完后又得到 1 个空瓶。从空瓶数量上看,这一轮操作让手里的空瓶数减少了 3 - 1 = 2。也就是说,每喝到一瓶汽水,平均需要消耗 2 个空瓶。
这个过程可以持续到什么程度?只要手里的空瓶数还够凑成一次“净消耗 2 瓶”的兑换,就能继续喝。当 n 是偶数时,n 个空瓶可以完整消耗 n / 2 轮,最后手里 0 个空瓶。当 n 是奇数时,最后会剩下 1 个空瓶,不够再换,所以还是向下取整的 n / 2。这里向下取整是整数除法的天然行为,不需要额外调用函数。
3.2 最简 C++ 解法与代码细节
有了上面的推导,代码短到令人惊讶:
#include <iostream> using namespace std; int main() { int n; while (cin >> n && n != 0) { cout << n / 2 << endl; } return 0; }这里的 n / 2 是两个 int 的除法,结果自动截断小数部分,所以 n = 3 输出 1,n = 4 输出 2,n = 81 输出 40,完全符合预期。
有几个细节需要提醒。第一,读入逻辑里的cin >> n && n != 0不能写反,也不能漏掉&& n != 0,否则遇到 0 结束标志时会把它当成一组有效输入处理。第二,虽然题目数据范围小,int 足够用,但如果想推广到更大范围,可以把 n 定义成 long long,不至于在扩大场景后翻车。第三,千万不要为了追求“精确”把 n 转成 double 再除以 2,浮点运算在输出上可能会引入多余的小数点或科学计数法,纯属给自己找麻烦。
3.3 递归递推视角下的同一道题
除了公式法,还有一个递推视角能加深理解。设 f(n) 表示 n 个空瓶最多能喝到的瓶数,当 n >= 3 时,可以先换 n / 3 瓶,喝掉后得到 n / 3 个新空瓶,剩下的空瓶数变成 n % 3 + n / 3,于是:
f(n) = n / 3 + f(n % 3 + n / 3)
这个递推关系其实和模拟法是同一件事的两种写法。但要注意,如果写成递归函数,程序会多次调用自身;当 n 到达一定规模时,虽然不大可能栈溢出,但显然不如 O(1) 的公式法干净。在做题时,理解这个递推可以帮你验证公式的正确性:任取 n = 10 代入,f(10) = 3 + f(4),f(4) = 1 + f(2),f(2) = 1,所以 f(10) = 3 + 1 + 1 = 5。
从递推再往前走一步,就能看到公式法和模拟法本质上是同一个过程的不同抽象层次:模拟法描述“每一步具体做什么”,递推法描述“每一步结果怎么从上一步得到”,公式法则直接给出终点结论。能把这三层串起来,说明这道题真正吃透了。
4. 实测踩坑:常见错误与本地排查方法
4.1 高频错误速查表
在OJ上提交“汽水瓶”这道题,常见的错误大致有下面几类。我把它们整理成表格,方便你对照排查:
| 错误现象 | 可能原因 | 解决办法 |
|---|---|---|
| 10 输出 4 | 没有处理剩 2 个空瓶借瓶的情况 | 在循环结束后判断 empty == 2,total 加 1 |
| 2 输出 0 | 循环条件没覆盖特例 | 单独判断 n == 2 的情况,或在最后统一处理 empty == 2 |
| 输出多了一行 0 | 把 n == 0 当成有效数据输出了 | 检查 while 条件是否写成cin >> n而漏掉了&& n != 0 |
| 本地结果正确,提交后答案错误 | 可能混用了多组数据的变量状态 | 确认每组数据的 total 和 empty 都在循环内部重新初始化 |
| 用了递归但栈溢出 | 递归深度和 n 规模不匹配 | 改用迭代或公式法 |
你可能会觉得这些错误都很低级,但我见过不少同学栽在“顺手把最后那行 0 也输出去”这种细节上。OJ 的比对是严格文本比对,多一个换行或少一个空格,都可能判为 Wrong Answer。
4.2 用自测数据替代眼睛调试
很多初学者调试时,最喜欢用“眼睛盯着代码看”,但这并不是高效的方式。我的习惯是准备一个输入文件,把题目里能想到的边界情况都放进去:
1 2 3 4 10 81 0对应的期望输出是:
0 1 1 2 5 40然后在命令行把程序和文件对接起来,做一次完整的重定向测试。在 Linux 或 macOS 上,命令是这样的:
g++ -o vase vase.cpp ./vase < input.txt > output.txt之后用 diff 对比 output.txt 和期望输出。如果没有差异,再提交到OJ也不迟。这个习惯最大的好处是,它把“我感觉没问题”变成了“机器告诉我没问题”,能节省大量复查时间。
4.3 提交前必查的三件事
结合我自己的经验,提交前值得快速过三关。第一关是输入处理:是否真的处理了多组数据,是否在 n == 0 时退出,而不是把 0 当作合法输入参与计算。第二关是输出格式:每个结果是否都换行了,末尾是否有多余的输出。第三关是数据类型:n / 2 这种整数除法会不会因为类型转换出错。
这三关检查起来花不了一分钟,却能挡住大部分非算法层面的错误。特别是“汽水瓶”这种基础题,很多人算法想得明明白白,最后反而因为输入输出格式吃了罚时,那就太冤了。
5. 延伸思考:从汽水瓶到一类换购问题
5.1 换瓶规则改成 k 个换 1 个
做完这道题之后,不妨自己给自己加一道变体:如果不是 3 个空瓶换 1 瓶,而是 k 个空瓶换 1 瓶,并且允许在剩下 k - 1 个空瓶时借 1 瓶,答案是什么?
用同样的“净消耗”思路来分析:每喝到 1 瓶,需要付出 k 个空瓶,收回 1 个空瓶,净消耗 k - 1 个空瓶。所以答案仍然可以写成 n / (k - 1) 的整数部分,前提是 n 足够大,且题目允许最后一轮借瓶。
这个扩展非常有意思,因为它揭示了这类“换购问题”的共同结构:只要兑换动作满足“付出 k 个、返回 1 个”的线性关系,最终能换到的数量就只取决于“净消耗速率”,和中间过程的具体分拆方式无关。理解到这一层,就不需要每种规则都重新推一遍模拟解法了。
5.2 个人刷题心得
回到东华OJ这道25题本身。我个人的体会是,基础题并不等于“简单到不用思考”。这道题最好的练习方式,是先自己写一版模拟解法,再尝试从模拟过程中抽象出数学规律,最后在代码里用一行 n / 2 替换掉整个 while 循环。
这种“先模拟、后优化”的路径,才是刷题最扎实的节奏。如果一上来就背住“汽水瓶答案是除以2”,那你只是记住了一个孤立结论;如果你能从“两个空瓶换一瓶”的借瓶规则出发,推导出“每瓶净消耗2个空瓶”,以后再遇到类似问题,就会习惯性地去找更深层的数学结构。这也是基础题最大的价值所在。