1. 先把这个老问题翻译成代码能理解的条件
B3836这个编号,在GESP二级的题库里挺出名,本质就是一道活了一千五百多年的老题——百鸡问题。每次带学生备战GESP二级,我都会拿它当枚举思想的入门例题。这道题表面上是个数学题,实际上考的是你有没有能力把文字条件拆成循环和if。很多孩子一看"公鸡母鸡小鸡"就慌了,其实读完题你会发现,需要写的代码不超过三十行。
先把原题的意思捋一遍。题目出自中国古代数学著作《张邱建算经》,原话是"鸡翁一,值钱五;鸡母一,值钱三;鸡雏三,值钱一。凡百钱买鸡百只,问翁、母、雏各几何。"GESP二级的B3836就是把这个故事数字化:公鸡每只5文钱,母鸡每只3文钱,小鸡3只共1文钱。现在手里有100文钱,要刚好买到100只鸡,问公鸡、母鸡、小鸡分别多少只。按公鸡数量从小到大输出所有满足条件的方案,如果没有方案,输出None。
这道题没有输入,直接输出结果。我习惯让学生先别碰电脑,拿笔把答案算出来,然后再去想代码怎么写。因为只有你先知道答案长什么样,后面写程序验证起来才有底。
1.1 把题目条件翻译成数学表达式
设公鸡为a只,母鸡为b只,小鸡为c只,条件其实就两条:
- 数量条件:a + b + c = 100
- 价格条件:5a + 3b + c / 3 = 100
价格条件里为什么是c/3而不是别的?因为三只小鸡才一文钱,所以c只小鸡的总价就是c除以3。但这里藏着一个很容易被忽略的前提:鸡只能整只买,不可能出现"买半只小鸡"的情况。所以c必须是3的倍数,也就是c % 3 == 0,否则这一组数据根本不成立。这个条件在翻译成代码时一定要写出来,很多人的第一版程序就是漏了它,导致把不该输出的方案也输出了。
还有一个隐含要求是:a、b、c都是非负整数。虽然题目没直接说"可以一只公鸡都不买",但数学上买0只是合法的。后面我们会看到,这个"0"恰恰是最容易漏掉的一类答案。
1.2 为什么GESP二级会拿它当压轴
GESP二级的考纲很明确:顺序结构、分支结构、循环结构。不考数组、不考字符串处理、不考函数递归。在这么小的知识范围内,能出的有区分度的题目其实不多,百鸡问题就是其中最典型的一类:不需要任何高级数据结构,只要你会写for循环和if判断,再有一点"把所有可能性都试一遍"的枚举意识,就能做出来。
我把这道题定位成"枚举思想的试金石"。它考的不是你会不会某个语法,而是拿到一个条件之后,能不能想到用多层循环把解空间完整地扫一遍,再用if把符合条件的方案筛出来。这个思路在二级阶段能救命,在后面三级、四级的模拟题里更是常客。很多学生有个误区,觉得这种题要先解数学方程,解出来再输出答案。但对信息学竞赛来说,枚举才是更通用的解法,因为万一题目把100改成127,方程可能就不好解了,而枚举程序只要改一个数还能跑。
1.3 输出结果到底长什么样
无输入,直接打印四行:
0 25 75 4 18 78 8 11 81 12 4 84这是我让学生先手算出来的结果,每组三个数依次是公鸡、母鸡、小鸡。我经常提醒他们注意第一行,公鸡数量是0。很多孩子潜意识里觉得"买鸡"至少得买一只公鸡,结果把这一组漏了,最后只输出三行,白白丢分。题目要求按公鸡数量从小到大输出,0、4、8、12正好是升序,待会写程序的时候只要让公鸡的循环从小到大走,输出顺序就天然满足。
2. 最稳妥的思路:从三重循环到二重循环的降维
现在进入正题。我见过太多初学者一上来就试图用数学技巧直接推导,结果把自己绕晕。信息学竞赛里最忌讳的就是"想太多,写太少"。考试时间有限,先把一个能跑出正确答案的版本写出来,再考虑优化,这才是正确节奏。
2.1 三重循环:先跑通再谈优化
最直观的做法是让a、b、c都从0枚举到100,把所有组合都试一遍,把满足条件的打印出来。代码长这样:
#include <cstdio> int main() { for (int a = 0; a <= 100; ++a) { for (int b = 0; b <= 100; ++b) { for (int c = 0; c <= 100; ++c) { if (a + b + c != 100) continue; if (c % 3 != 0) continue; if (5 * a + 3 * b + c / 3 != 100) continue; printf("%d %d %d\n", a, b, c); } } } return 0; }这段代码总共循环了101乘101乘101次,约103万次。听起来挺大,但对现代评测机来说就是几十毫秒的事。GESP二级的评测环境跑这段程序毫无压力,哪怕你完全不优化,也能拿满分。所以我的建议是:如果你在考场上脑子一时转不过弯,写三重循环保底完全没问题,至少比推导错数学公式导致全盘皆输要强得多。
这里有一点要注意:判断条件为什么用continue而不是用if包住整个输出?两种写法效果一样,但我更推荐用continue,因为它能让"不满足条件就跳过"的思路更清晰,代码少了嵌套,不容易把大括号配对搞错。对刚开始学循环嵌套的孩子来说,这个习惯能省掉很多排查错误的时间。
2.2 循环边界的学问:不是越大越安全
上面代码里三个循环都写的是a <= 100,其实这个范围可以缩小得更多。公鸡5文一只,100文最多买20只,所以a最大就是20;母鸡3文一只,最多买33只,所以b最大是33;小鸡不管多便宜,总数不能超过100,所以c最大是100。把上限改小之后,循环次数变成21乘34乘101,大概7.2万次,比原来快了一个数量级。
但我要提醒一句:缩小循环范围的前提是你对条件确实理解透了,否则很容易出错。有些人图省事把公鸡循环写成a < 20,这就漏掉了a=20这一层。虽然a=20时价格已经花光100文,不可能还有母鸡和小鸡,这组解不存在,但万一题目数据变了,这种写法就有可能漏解。稳妥起见,边界一律用<=,别在这种地方赌运气。
还有一个更隐蔽的边界问题:a虽然最多20,但当a取到20时,b其实只能是0。如果你不把b的上限改成100-a,而是直接让b从0枚举到33,程序也不会出错,因为c=100-a-b会自动变成负数,后面的判断会把它筛掉。这个"c可能为负"的情况一定要处理,否则会出现幽灵方案。
2.3 用总数关系砍掉一层循环
三重循环的思路简单,但不够优雅。既然题目已经给了a+b+c=100这个硬条件,那c就没必要再枚举了,直接用c=100-a-b算出来就行。这就是"方程约束消元"的思想。
#include <cstdio> int main() { for (int a = 0; a <= 20; ++a) { for (int b = 0; b <= 33; ++b) { int c = 100 - a - b; if (c < 0 || c % 3 != 0) continue; if (5 * a + 3 * b + c / 3 == 100) { printf("%d %d %d\n", a, b, c); } } } return 0; }这段代码里有两个细节值得停下来想一想。第一,为什么先判断c < 0?因为如果a和b加起来已经超过100,c就变成负数了,负数不可能是鸡的数量,直接跳过。第二,c % 3 != 0的判断为什么放在价格判断前面?因为如果c不是3的倍数,c/3在整数除法里会丢掉余数,可能导致价格判断产生错误结果。所以先把c的合法性检查完,再去做价格比较。
这个二重循环版本基本就是我推荐的考场最终版了。它不但代码短,逻辑也清楚,更重要的是体现了"利用约束减少枚举维度"的思维方式。别小看这一层优化,它把循环次数从103万次降到了不到700次,整个程序肉眼可见地"嗖"一下出结果。
3. 用数学把枚举条件再压缩:推导出7a+4b=100
如果你对枚举已经玩得比较熟练,可以再往前走一步,从数学上把解空间直接压缩到一维。这一步不是GESP二级的必须要求,但能帮你彻底理解这道题的本质,也是为后面学二分、学搜索剪枝打地基。
3.1 消元推导:从两个等式到7a+4b=100
把前面那组方程搬过来:
- a + b + c = 100
- 5a + 3b + c/3 = 100
第二个方程两边同时乘以3,得到:15a + 9b + c = 300。然后用这个式子减去第一个方程,c就被消掉了:(15a - a) + (9b - b) + (c - c) = 300 - 100,也就是14a + 8b = 200。两边同时除以2,就得到非常漂亮的结果:
7a + 4b = 100
这个式子意味着什么?b根本不需要循环枚举,它由a唯一决定:b = (100 - 7a) / 4。只要枚举a,b就算出来了,c再用100-a-b算出来。
3.2 a为什么必须是4的倍数
既然b = (100 - 7a) / 4,那么b要是整数,100 - 7a就必须能被4整除。怎么看这个条件?我们只用看模4的余数:100除以4余0,7除以4余3。所以100 - 7a能不能被4整除,等价于0 - 3a ≡ 0 (mod 4),也就是3a必须是4的倍数。3和4互质,所以a必须是4的倍数。
同时b不能为负,也就是100 - 7a >= 0,所以a最多到14。在这个范围内是4的倍数的数有:0、4、8、12。就这四个。
逐个代入:
- a=0,b=(100-0)/4=25,c=100-0-25=75
- a=4,b=(100-28)/4=18,c=100-4-18=78
- a=8,b=(100-56)/4=11,c=100-8-11=81
- a=12,b=(100-84)/4=4,c=100-12-4=84
于是四组解全部出来了。整个过程不需要计算机,一张草稿纸就能写完。这也是为什么很多老选手看到这道题会心一笑,因为它的数学本质非常干净。
3.3 三种实现的取舍
说了三种做法,到底用哪种?我整理了一张表:
| 方案 | 循环次数 | 代码难度 | 出错风险 | 推荐场景 |
|---|---|---|---|---|
| 三重循环 | 约103万次 | 最低 | 低 | 刚学枚举、考场上求稳 |
| 二重循环 | 约700次 | 低 | 低 | 考场首选,效率与清晰度兼顾 |
| 数学推导+一重循环 | 约14次 | 中 | 较高 | 数学功底好,用来验算或炫技 |
我的建议很明确:二级考试用二重循环。三重循环虽然也能过,但代码稍长,容易在三个循环的边界上出纰漏;数学推导虽然优雅,但万一你推错一步,整道题就完了。二重循环正好卡在"够简单"和"够快"的平衡点上,就算放在GESP三级、四级的场景里也完全够用。
4. 考场实战:代码这样写才不会被扣分
题目本身不难,但每年GESP考完,还是有人在这道题上丢分。丢分点基本集中在几个非常具体的地方,我一个个说。
4.1 浮点数判等:看似很对,其实在赌运气
我见过不少学生把价格条件写成这样:
if (5 * a + 3 * b + c / 3.0 == 100.0)看起来挺严谨,价格精确到小数,再用浮点数判等。但这个写法在原理上就站不住脚。计算机里的浮点数没法精确表示1/3这种无限循环小数,c/3.0的二进制结果是个近似值。在某些取值下,这个近似值恰好让整个表达式算出来等于100.0,在另一些取值下可能就差那么一点点,导致本该成立的方案被错误地过滤掉。
整数问题就不要引入浮点数,这是竞赛里的铁律。正确做法是先把c % 3 != 0的情况排除掉,然后放心大胆地用c / 3做整数除法参与运算。整数除法不会丢精度,而且c能被3整除时,c / 3就是准确的。
4.2 空行、空格和None的大小写
输出格式是很多人不注意的隐形扣分点。题目要求每行三个整数,用空格分隔,行末换行。有些孩子习惯在输出语句里多加一个空格,变成"a b c ",或者用逗号分隔,最后全被评测机判成格式错误。GESP的评测对格式要求比较严格,宁可少写代码,也别在多出来的空格上栽跟头。
再看无解分支。题目明确说了,如果不存在满足条件的方案,输出None。注意是"None",不是"none",不是"NONE",也不是"No Solution",更不是"无解"。这种地方就是送命题,答案对了,字符串大小写错了照样零分。虽然B3836这道题有解,用不到这个分支,但你不能因为用不到就不写。万一出题人把条件一改,或者考场上遇到变体题,这个分支就是你的保命符。
4.3 考场自查:把答案代回去算一遍
程序写完别急着交,花十秒钟做一次自查。把输出的第一组数据拿回来验证:0只公鸡花0文,25只母鸡花75文,75只小鸡花25文,总数25+75=100只,总价75+25=100文,完全吻合。再抽查最后一组:12只公鸡花60文,4只母鸡花12文,84只小鸡花28文,总数100只,总价100文。能自查出答案是合理的,这题基本就稳了。
我还习惯在代码里临时加一句输出方案总数的语句,确认一共打印了4行。如果只有3行,那一定是漏了某个边界情况,比如公鸡0只的那组。检查完再把这句临时输出删掉,别让它影响到正常输出。
5. 完整代码与这套思路往后怎么用
最后给出一个可以直接提交的完整版本。我用C++17语法,头文件只包含cstdio,用printf输出,全程整数运算。
5.1 最终提交版代码
#include <cstdio> int main() { bool found = false; for (int a = 0; a <= 20; ++a) { for (int b = 0; b <= 33; ++b) { int c = 100 - a - b; if (c < 0 || c % 3 != 0) continue; if (5 * a + 3 * b + c / 3 == 100) { printf("%d %d %d\n", a, b, c); found = true; } } } if (!found) puts("None"); return 0; }这段代码里我把上一个版本漏掉的found标记补上了。它的作用就是记录"到底有没有找到过方案"。如果整个循环跑完一次都没进入过输出分支,说明无解,这时才输出None。没有这个标记的话,无解情况就不知道什么时候该输出None了。这个模式在后续很多"输出所有方案,无解时输出XXX"的题目里都会用到,建议直接记下来。
5.2 题目改成输入n怎么办
有些变体题会把100改成读入的n,表示"用n文钱买n只鸡"或者"用100文钱买n只鸡"。处理思路完全不变,只要把代码里的100全部换掉,把循环边界也按n调整即可。比如公鸡最多n/5只,母鸡最多n/3只,而c的合法范围依赖n。我曾经让学生做过一个练习:把这道题的100改成127,让他们重新跑程序,结果四组解变成了别的组合,不少人这才真正理解"枚举是跟着条件走,而不是背答案"。
5.3 这套"枚举→消元→剪枝"的思路,后面还要用很久
很多人搜GESP七级、八级的备考资料,总觉得要学什么高深算法。实际上,不管四级考的排序和二分,还是五级的递归回溯,底层都是枚举。二分本质上是利用单调性加速枚举范围;回溯本质上是带剪枝的深度优先枚举。你在百鸡问题里学会的"先用最朴素循环跑通,再用等式约束减维度,最后用数学条件剪枝",这条优化链会在之后所有算法题里反复出现。
我个人的看法:这道GESP二级的百鸡问题,值得你花一下午把它玩透。从三重循环写到二重循环,再亲手推一遍7a+4b=100,你收获的不只是这一题的满分,而是一套处理约束条件的思维习惯。考场上真遇到它,直接二重循环稳扎稳打打完收工,剩下的时间留给别的题,比什么都强。