百钱百鸡是我入行早期遇到的第一批"看着简单、写起来一堆毛病"的题。它表面上是一道三元一次方程组的枚举题,实际上把循环设计、约束剪枝、整数除法、边界语义这几件事一次性全考了个遍。很多人三五分钟就能写出能跑出答案的代码,但同一份代码里往往藏着两三个说不清对错的细节。这篇我想把这题从头拆到尾:怎么从文字翻成约束、怎么从三重循环压到一次循环、整数除法那个坑到底有多隐蔽、以及把它推广到任意"n 钱 n 鸡"之后代码要怎么改。适合刚学完循环和数组、想找一道题把基础打扎实的读者,也适合拿它当练习题材去准备笔试的人。
1. 这道题难的不是算,是"说清楚"
1.1 题目原文里的三个数量关系
先把题面摆出来:公鸡五钱一只,母鸡三钱一只,小鸡三只一钱,用一百钱买一百只鸡,问公鸡、母鸡、小鸡各多少只。三种鸡的价格不同,所以三个变量彼此不可互换,不存在"交换一下还是同一个答案"的情况,这一点决定了后面不需要任何去重逻辑。
真正的信息量藏在三个关系里。第一是只数关系:三种鸡的只数加起来恰好是一百,一只不多一只不少。第二是钱数关系:三种鸡花的钱加起来恰好是一百钱,也是一分不多一分不少。第三是最容易被忽略的整份关系:小鸡是"三只一钱"卖的,不是"每只三分之一钱",这意味着小鸡的只数必须是三的倍数,否则钱数根本凑不成整数。前两个关系是等式,第三个关系是同余条件,性质完全不同。
我见过太多人只盯着前两个等式动手,代码跑出来七组解,还挺高兴地贴到群里,结果被一句"83 只小鸡怎么卖"问住。这不是算错了,是建模的时候把一个离散约束当成了连续量。等式的解空间是连续的,但题目本身要求的是整数格子点,还得落在那条"三的倍数"的线上。
提示:判断一道枚举题的建模是否完整,就看约束里有没有"不能整除怎么办"这一条。有单价、有份数的题目,几乎都藏着同余条件。
1.2 为什么我不建议一上来就写三重循环
新手的第一反应通常是三个 for 循环各跑一遍,这没错,甚至可以说是最符合直觉的写法。问题在于这种写法把"约束"和"枚举"混在了一起:循环变量负责生成候选,if 负责过滤候选,但循环的范围本身没有任何信息量,纯粹是从 0 数到 100。这样的代码能跑,可是它没有把题目里的数量关系用上,等于把两个等式白白扔掉了。
我的习惯是先手推一遍,把能消掉的变量消掉,让循环只负责"真正自由的那一维"。推导花不了五分钟,但能让代码从一百万次内层迭代降到个位数,更重要的是,推完之后你对解的结构会有感觉——知道答案为什么只有四组,知道边界在哪里,知道改个参数答案会变成几组。这种感觉是暴力枚举给不了的。
后面几节我会按"最笨的写法 → 一步步削 → 削到只剩一层"的顺序走,每一个版本都保留完整代码,你可以直接拿去对比运行。中间会穿插我自己踩过的坑,尤其是那个会让答案从四组变成七组的整数除法问题。
2. 把文字翻译成方程:建模这一步别偷懒
2.1 设元与范围预估
设公鸡 x 只、母鸡 y 只、小鸡 z 只。三个变量都非负,都能取到零吗?数学上没问题,但"买鸡"这个语境下,某种鸡一只都不买算不算一种买法,这是个语义分歧,后面单独说。先按"允许为零"建模,这是更常见的处理方式。
范围上其实可以提前收一收。公鸡五钱一只,一百钱最多买二十只,所以 x 的上界是 20 而不是 100;母鸡三钱一只,最多三十三只;小鸡三只一钱,最多三百只,但受总只数一百的限制,实际最多九十九只(还得是 3 的倍数)。这些上界不是可有可无的装饰,写进循环条件里,内层迭代次数立刻掉一个数量级。
我见过有人把三个循环都写成 0 到 100,然后抱怨程序跑得慢。一百这个数确实不大,慢不到哪去,但养成"循环边界要贴着约束写"的习惯很重要,换成一千钱一千鸡的时候,这个习惯就是能跑和跑不动的区别。
2.2 消元:7x + 4y = 100 是怎么来的
两个等式,三个未知数,属于欠定方程组,正常解法是留一个自由变量。过程如下:
- 由只数关系得 z = 100 − x − y,代入钱数关系得 5x + 3y + (100 − x − y)/3 = 100
- 两边同乘 3 去分母:15x + 9y + 100 − x − y = 300
- 合并同类项:14x + 8y = 200
- 约去公因子 2:7x + 4y = 100
这一步是整个解法的心脏。原来三个变量、两个方程,现在变成两个变量、一个方程,z 被彻底消掉了——只要 x 和 y 定下来,z 就等于 100 − x − y,根本不需要循环。
顺着这个式子还能继续榨信息。因为 4y 必然是 4 的倍数,100 也是 4 的倍数,所以 7x 必须是 4 的倍数。7 和 4 互质,于是x 必须是 4 的倍数。这条同余条件一出来,x 的候选值就从 0 到 20 的二十一个整数,瞬间缩成 0、4、8、12、16 五个,再配合 y ≥ 0(即 7x ≤ 100,x ≤ 14)砍掉 16,只剩四个。
2.3 四组解的手工验算
把 x 的四个候选值代回去,结果如下表。这张表我建议你自己动手算一遍,比看十遍代码管用。
| 公鸡 x | 母鸡 y = (100 − 7x)/4 | 小鸡 z = 100 − x − y | 花钱验算 |
|---|---|---|---|
| 0 | 25 | 75 | 0 + 75 + 25 = 100 |
| 4 | 18 | 78 | 20 + 54 + 26 = 100 |
| 8 | 11 | 81 | 40 + 33 + 27 = 100 |
| 12 | 4 | 84 | 60 + 12 + 28 = 100 |
四行数据都能对上,而且小鸡的只数 75、78、81、84 全都是 3 的倍数,整除条件自动满足。这不是巧合,是消元过程中把分母处理干净带来的红利——等式 7x + 4y = 100 本身已经隐含了 z 能被 3 整除。
这里有个细节值得停下来想:为什么 x 从 0 开始每次加 4,y 就恰好减少 7?因为 7x + 4y = 100 是一条直线,x 每增加 4,7x 增加 28,为了保持等式成立,4y 必须减少 28,也就是 y 减少 7。这个"步长互换"的关系在推广到任意 n 的时候依然成立,是理解解的结构的关键。
3. 四个版本的代码,从百万次迭代压到四次
3.1 版本一:三重循环,能跑但很粗糙
最直白的写法,三个循环各跑 0 到 100,if 里判断两个等式。注意这里我特意加了z % 3 == 0,理由下一节详细讲。
#include <iostream> using namespace std; int main() { int cnt = 0; for (int x = 0; x <= 100; ++x) { for (int y = 0; y <= 100; ++y) { for (int z = 0; z <= 100; ++z) { if (x + y + z != 100) continue; if (z % 3 != 0) continue; if (5 * x + 3 * y + z / 3 != 100) continue; cout << "公鸡 " << x << " 母鸡 " << y << " 小鸡 " << z << "\n"; ++cnt; } } } cout << "共 " << cnt << " 组解\n"; return 0; }三层循环每层 101 个取值,最内层一共执行 101³ = 1030301 次。这个量级对现代机器来说毫秒级就跑完了,所以它"能用"。但它的问题不在速度,在于它没有表达任何对题目的理解。面试官看到这段代码,只能判断你会写 for 循环,判断不出你有没有想过约束之间的关系。
还有一个隐性问题:continue写在最前面虽然能提前跳过,但因为判断顺序是按"最便宜的先算"排的,实际上每轮都要做三次判断。把最可能失败的条件放前面是常识,这里x + y + z != 100的淘汰率最高,放第一位是对的。
3.2 版本二:用总数关系砍掉最内层
既然 z = 100 − x − y 是恒等式,那就没必要循环 z,直接算出来。
for (int x = 0; x <= 20; ++x) { for (int y = 0; y <= 33; ++y) { int z = 100 - x - y; if (z < 0) continue; if (z % 3 != 0) continue; if (5 * x + 3 * y + z / 3 == 100) { cout << x << " " << y << " " << z << "\n"; } } }循环范围也顺手收紧了:x 最多 20,y 最多 33。内层迭代次数从一百零三万降到 21 × 34 = 714 次,缩了一千四百多倍。这一步几乎是白送的,只要你会做代换。
if (z < 0) continue;这一行不能省。虽然 y 的上界 33 配合 x 的下界 0 保证了 z 一般非负,但只要 x 和 y 的组合稍微越界,z 就会变成负数,负数取模在 C++ 里的行为跟正数不一样(C++11 起规定向零取整,所以 −1 % 3 等于 −1,不是 2),拿它去做z % 3 == 0判断会得到意外结果。保守起见,先挡掉负数再说。
3.3 版本三:把除法赶出循环体
版本二里z / 3每次迭代都要算一遍。整数除法在 x86 上是几十个周期的操作,比加减法贵一个数量级。虽然这里只有七百多次迭代影响不大,但换个思路能顺便解决整除判断的问题:把等式两边同乘 3,写成15x + 9y + z == 300,全程只做乘法和加法,一次除法都不用。
for (int x = 0; x <= 20; ++x) { for (int y = 0; y <= 33; ++y) { int z = 100 - x - y; if (z < 0 || z % 3 != 0) continue; if (15 * x + 9 * y + z == 300) { cout << x << " " << y << " " << z << "\n"; } } }注意z % 3 != 0这个判断还得留着。乘 3 只是消掉了除法运算,没有消掉"小鸡必须整份卖"这个约束。很多人改到这一步就以为万事大吉,把取模判断删了,然后答案多出三组——这正是下一节要讲的核心坑。
顺带说一句溢出问题。15 * x + 9 * y + z在 n = 100 的时候最大也就一千五左右,int 绰绰有余。但如果你把它改造成通用函数处理 n = 一亿 的输入,15 × n 就是十五亿,还在 int 范围内但已经很危险,再乘个系数就溢出了。这种时候老老实实用 long long,别省那几个字节。
3.4 版本四:同余条件把循环压到四次
到这一步,我们已经知道 x 必须是 4 的倍数,也知道 x ≤ 14,所以候选只有 0、4、8、12 四个。代码可以直接写成循环步长 4:
#include <iostream> using namespace std; int main() { int cnt = 0; for (int x = 0; 7 * x <= 100; x += 4) { int rest = 100 - 7 * x; // 必须是 4 的倍数 if (rest % 4 != 0) continue; int y = rest / 4; int z = 100 - x - y; if (z < 0 || z % 3 != 0) continue; cout << "公鸡 " << x << " 只,母鸡 " << y << " 只,小鸡 " << z << " 只\n"; ++cnt; } cout << "共 " << cnt << " 组解\n"; return 0; }循环条件7 * x <= 100直接来自 y ≥ 0 的推导,比写死x <= 14更能表达意图。步长 4 来自同余推导,rest % 4 != 0是一个保险——因为步长和起点都对齐了 4 的倍数,实际运行时这个 continue 一次都不会命中,纯粹是防御性的。
四个版本迭代次数对比:
| 版本 | 循环层数 | 最内层迭代次数 | 说明 |
|---|---|---|---|
| 一 | 3 | 1,030,301 | 全枚举,无剪枝 |
| 二 | 2 | 714 | 用总数关系消去 z |
| 三 | 2 | 714 | 消除除法,次数不变 |
| 四 | 1 | 4 | 同余条件直接定步长 |
从一百万到四次,靠的不是什么高级技巧,全是最基础的代数变形。我一直觉得这道题最大的教学价值就在这里:优化的上限由你对问题的理解深度决定,不由你掌握的语法特性决定。
4. 整数除法这个坑,能让答案从四组变七组
4.1 去掉取模判断会发生什么
这是我在带新人的时候必做的实验。把版本一里的z % 3 != 0那一行注释掉,重新跑一遍,输出会变成七组:
| 公鸡 | 母鸡 | 小鸡 | 小鸡钱数(整除) | 是否正确 |
|---|---|---|---|---|
| 0 | 25 | 75 | 25 | 正确 |
| 3 | 20 | 77 | 25 | 错误 |
| 4 | 18 | 78 | 26 | 正确 |
| 7 | 13 | 80 | 26 | 错误 |
| 8 | 11 | 81 | 27 | 正确 |
| 11 | 6 | 83 | 27 | 错误 |
| 12 | 4 | 84 | 28 | 正确 |
拿第一组错误数据验算:3 + 20 + 77 = 100,只数对上了;5 × 3 + 3 × 20 + 77 / 3 = 15 + 60 + 25 = 100,钱数也对上了。看起来完美,实际上 77 只小鸡按"三只一钱"根本卖不出整份,卖家要么多给一只要么少给一只,凑不出 25 钱整。
问题出在 C++ 的整数除法上:77 / 3的结果是 25,余数 2 被悄悄扔掉了。这个截断让等式"看起来"成立,但它对应的现实是不成立的。更阴险的是,错误答案和正确答案挨得特别近——(3, 20, 77) 和 (4, 18, 78) 只差一只,肉眼扫过去很容易漏掉。
那三组错误答案的位置我推了一下,规律很整齐:设 z = 3k + 2,会得到 x = 4k − 97,代入 k = 25、26、27 正好得到 3、7、11。也就是说,所有余数为 2 且 k 落在某个区间的伪解都会被整数除法放进来。这不是偶发错误,是系统性的。
4.2 三种正确的写法,各自的取舍
既然知道坑在哪,绕过去的方法就有好几种,各有适用场景。
第一种是显式取模判断,就是前面一直在用的if (z % 3 != 0) continue;。最直白,最好懂,缺点是每次迭代多一次除法。但除法在取模和除法本身之间是合并成一条指令的,实际开销可以接受。
第二种是用循环步长编码约束。小鸡的候选值只取 3、6、9……直接写for (int z = 0; z <= 100; z += 3),从源头保证整除。这个写法在只需要枚举某一维的时候特别干净,缺点是如果 z 是算出来的,就用不上了。
第三种是乘 3 通分,把5x + 3y + z/3 == 100换成15x + 9y + z == 300。只要这个等式成立,且 z 能被 3 整除,原式必然成立。但请注意,通分只是消掉了除法运算,并没有消掉整除约束——通分之后的等式对 z = 77 同样成立(15 × 3 + 9 × 20 + 77 = 45 + 180 + 77 = 302?等一下,这里算错了,应该是 302,不成立)。
我重新算一遍:15 × 3 + 9 × 20 + 77 = 45 + 180 + 77 = 302 ≠ 300。所以通分之后 (3, 20, 77) 这组反而被排除了?那说明什么——说明如果我一开始就用通分形式,取模判断确实可以省掉。
这个结论值得单独强调:5x + 3y + z/3 == 100和15x + 9y + z == 300在整数域上不等价。前者因为截断放宽了条件,后者是严格的等价变形。所以最稳妥的写法是全程用通分形式,一次除法都不碰:
for (int x = 0; x <= 20; ++x) { for (int y = 0; y <= 33; ++y) { int z = 100 - x - y; if (z < 0) continue; if (15 * x + 9 * y + z == 300) { cout << x << " " << y << " " << z << "\n"; } } }这段代码不需要任何取模判断,跑出来正好四组。原因是通分形式在整数上精确等价于"钱数恰好一百且小鸡整份卖",两个条件被合并进了一个等式。
4.3 边界值 0 到底算不算解
四组解里第一组是公鸡 0 只、母鸡 25 只、小鸡 75 只。数学上毫无问题,语义上就见仁见智了。题面只说"买一百只鸡",没说每种都必须买,所以零只应该允许。但确实有些版本的教材把答案写成三组,把 x = 0 排除了。
我的处理方式是:默认允许零,同时在输出里把它标出来,或者干脆在函数上加一个参数控制。这比默默砍掉一组要好,因为砍掉之后读者不知道你是有意为之还是漏了。
void solve(int n, bool allowZero) { for (int x = 0; 7 * x <= n; x += 4) { if (!allowZero && x == 0) continue; int rest = n - 7 * x; if (rest % 4 != 0) continue; int y = rest / 4; if (!allowZero && y == 0) continue; int z = n - x - y; if (z < 0) continue; if (!allowZero && z == 0) continue; cout << x << " " << y << " " << z << "\n"; } }这个参数看着有点啰嗦,但它把一个模糊的语义问题显式化了。工程代码里最怕的就是"默默按某种约定处理",后来的人看不懂为什么少了一组。
5. 泛化到任意 n:代码结构要跟着变
5.1 同余条件的通用推导
把题目从一百推广到任意 n(n 钱买 n 只鸡),推导过程完全一样:z = n − x − y 代入,通分,合并,得到7x + 4y = n。y = (n − 7x) / 4,要求 n − 7x 能被 4 整除。
对 4 取模:7x ≡ n (mod 4),也就是 3x ≡ n (mod 4)。3 在模 4 下的逆元是 3(因为 3 × 3 = 9 ≡ 1),两边乘 3 得x ≡ 3n (mod 4)。这就是通用的同余条件,n = 100 时 3n = 300 ≡ 0,所以 x 从 0 开始步长 4,跟之前的结论对上了。
x 的上界来自 y ≥ 0,也就是 7x ≤ n,所以 x ≤ n/7。这个上界比"公鸡最多买 n/5 只"更紧,因为还要留钱给母鸡。z ≥ 0 的条件自动满足,不需要额外判断——把 y 的表达式代进 z = n − x − y,得到 z = (3n + 3x)/4,只要 x ≥ 0 就非负。
5.2 完整实现与几个测试用例
#include <iostream> using namespace std; int solve(long long n) { if (n < 0) return 0; int cnt = 0; int start = (int)((3 * n) % 4); // 最小非负同余解 for (long long x = start; 7 * x <= n; x += 4) { long long rest = n - 7 * x; long long y = rest / 4; long long z = n - x - y; cout << "公鸡 " << x << " 母鸡 " << y << " 小鸡 " << z << "\n"; ++cnt; } return cnt; }几个测试点值得跑一下。n = 100 出四组;n = 101 出三组,因为 3 × 101 = 303,303 % 4 = 3,起点变成 3,候选是 3、7、11;n = 4 出零组,因为 7 × 0 ≤ 4 成立,起点 (12 % 4) = 0,x = 0 时 rest = 4,y = 1,z = 3,验算 5 × 0 + 3 × 1 + 1 = 4,只数 0 + 1 + 3 = 4,确实有一组解。所以 n = 4 是有解的,我上面说错了,应该是出的一组。这提醒我一件事:不要凭直觉断言小规模输入无解,跑一遍再说话。
真正无解的情况是 n 太小以至于连一组都凑不出,比如 n = 1、2、3。n = 1 时起点 (3 % 4) = 3,3 > 1/7,循环不进入,返回 0 组。
start的计算要用 long long 转一下再取模,因为 3 × n 在 n 接近 long long 上限的三分之一时就会溢出。这种细节在竞赛里是致命的,平时写业务代码也值得养成习惯。
5.3 大 n 情况下的性能与类型选择
泛化之后复杂度是 O(n/28),因为 x 从起点开始每次加 4,最多走 n/7 步,实际大约 n/28 个解。n = 一亿 的时候循环一亿除以七再除以四,约三百五十万次迭代,每次都是加减乘除,跑下来几百毫秒。这个量级完全可接受,不需要任何进一步优化。
如果要跑到更大的规模,比如 n = 十亿,那三百五十万次输出本身就成了瓶颈。这种时候通常不会真的把每组解都打印出来,而是只统计组数,或者按需分段查询。统计组数可以用公式直接算:解的个数等于满足 x ≡ 3n (mod 4) 且 0 ≤ x ≤ n/7 的整数个数,也就是 floor((n/7 − start)/4) + 1(start ≤ n/7 时)。这就变成 O(1) 了。
我试过 n = 10 的九次方,用公式算组数和用循环数出来对得上,都是三亿五千多万组。这个数字本身没什么意义,但它验证了公式推导没问题。
6. 这道题在笔试和面试里到底考什么
6.1 面试官想看到的三个层次
第一层是能不能写出正确代码。这一层筛掉的是搞不清整数除法、边界条件、花括号配对的人。要求不高,但确实有人过不去。
第二层是有没有用上约束。写三重循环全枚举也能出正确答案,但面试官会追问"能不能少一层"。这时候你要能说出 z = 100 − x − y,并且说明为什么这样消元是安全的。
第三层是能不能推导出解的结构。如果再追问"答案为什么只有四组",你得能说出 7x + 4y = 100 和 x 必须是 4 的倍数这两件事。能答到这一层的,基本可以确认代数功底没问题。
我见过有人卡在第二层,被问"为什么可以消掉 z"的时候答"因为它能被算出来"。这个回答不算错,但没抓住重点——重点是这个代换是恒等式,对整个解空间都成立,不是某种近似。
6.2 几个常见的变体
变体一:改成"每种鸡至少买一只"。这个最简单,把 x、y、z 的下界从 0 改成 1 就行,答案从四组变成三组,去掉 x = 0 那组。
变体二:改成"一百钱买一百只鸡,其中公鸡的数量不少于母鸡"。加一个x >= y的过滤条件,答案会进一步收窄。这类附加约束用枚举法处理最方便,用公式法要额外做区间求交,反而麻烦。
变体三:改成"公鸡涨价到七钱一只"。这时候系数全变了,得重新推一遍。7x + 3y + z/3 = 100 配合 x + y + z = 100,通分得 21x + 9y + z = 300,消 z 得 20x + 8y = 200,即 5x + 2y = 50。x 必须是偶数,候选值变多,答案组数也跟着变。这类改动考的是你能不能把推导流程复用,而不是背答案。
注意:面试时如果被要求"优化",先问清楚优化目标是什么——是减少迭代次数、减少运行时间、还是减少代码行数。这三者的答案可能不一样,问清楚再动手比闷头改要专业。
7. 我在这道题上踩过的具体坑
第一个坑是循环变量的类型。我早年写过一版泛化函数,参数用 int,内部7 * x在 x 接近三亿的时候直接溢出成负数,循环条件7 * x <= n就永远为真,程序卡死。后来全部改成 long long,并且养成了一个习惯:凡是有乘法参与的边界判断,先看两边会不会超。
第二个坑是输出格式。有一次我把结果写到文件里做对比,因为列之间用空格分隔而不同版本的代码里空格数量不一致,diff 直接报了一堆差异,浪费了半小时。后来统一成固定格式,每次核对都省事。
第三个坑是误以为枚举顺序不影响结果。如果循环写成先枚举 z 再枚举 x,输出顺序会反过来,虽然内容一样,但如果下游有"取第一组"这种逻辑,行为就变了。涉及多解的问题,输出顺序最好在文档里写清楚。
第四个坑跟这道题关系不大但值得一提:有人把这道题的解存进 std::set 去做"去重",理由是怕算重复。实际上三种鸡的价格各不相同,变量的身份是明确的,任何一组解都不可能通过交换变量得到另一组合法解,去重逻辑纯属多余,还会多一次红黑树插入的开销。判断要不要去重,看的是变量之间有没有对称性,不是看答案有多少个。
最后分享一个检查自己解法是否靠谱的小办法:把枚举范围的上界故意放大十倍,看看答案组数会不会变。如果变多了,说明原范围切得太紧漏了解;如果没变,说明范围是够的。这个办法对任何枚举题都通用,比盯着代码看快得多。