带过信息学竞赛启蒙阶段的孩子,大概率在各类等级认证的三级真题里见过这道"小猫分鱼"。题目本身很短,读起来像个脑筋急转弯:几只小猫轮流分一堆鱼,每次多出几条扔掉,拿走一份,问最初至少有多少条鱼。很多孩子第一反应是"正着模拟",从初始鱼数开始一遍遍试,结果样例过了,数据一复杂就卡住。这道题真正考的不是模拟,而是对递推关系的理解,以及"倒过来想"的枚举能力。
这篇文章我会从题目翻译、算法推导讲到三种可落地的C++写法,再把我带学生时踩过的坑一并列出来。无论你是准备三级考试的学生,还是帮孩子备赛的家长/老师,按这条线走一遍,以后再遇到"分鱼""分桃""分椰子"这类题,思路都是通的。
1. 题面到底在说什么:把"小猫分鱼"翻译成数学语言
1.1 我按真题复原出的主版本
我先按最常见的版本把题面说清楚:有 n 只小猫,按顺序来分一堆鱼。每只小猫到达后,先把当前剩下的鱼分成 n 份,要求每份数量相同;如果分完还剩 k 条,就把这 k 条丢掉,然后拿走属于自己的一份。依次操作,直到 n 只小猫都分完。问题是:最初至少要准备多少条鱼,才能让整个过程顺利走完?
这里"顺利走完"有一个隐含前提:每次分鱼时,每份至少得是 1 条,否则"拿走一份"没有意义。个别版本还会额外要求最后剩余 0 条或某个固定数,这类差异我放到后面讲。你只要抓住核心动作:当前鱼数减去 k 之后,必须能被 n 整除,取出其中的 1/n,剩余 (n-1)/n。
举个例子。如果 n=2、k=1,第一次操作前有 x 条鱼,则第一次操作后剩:
(x - 1) / 2
因为小猫拿走了一份,剩下另一份。第二次操作同样处理。这个模型看着简单,但它是一个标准的递推链。
1.2 不同平台上的变体,先别急着背代码
在真正动手写代码之前,我要提醒你:同一个"小猫分鱼",在不同OJ上输入的参数名和约束可能不太一样。
有的版本输入 n 和 k,n 代表小猫数量,k 代表每次多出的条数;有的版本还会多给一个参数 m,表示把鱼分成 m 份而不是 n 份;还有的版本会约定最后一只小猫分完后必须正好剩下 0 条。这些差异不会改变核心算法,只会改变公式里的分母和枚举终点的判断方式。所以拿到题第一步不是急着写循环,而是把题面里的参数一一对应到公式里,确定好谁是"份数",谁是"剩余"。
我见过不少学生因为默认"分成n份"把 m 版本做错,实际上只要把代码里的 n 全部替换成 m 就行。这种参数细节,恰恰是考场最容易丢分的地方。
1.3 提炼出逆推公式
设第 i 只小猫操作之后剩下的鱼数为 x_i,操作之前为 x_{i-1}。根据规则:
每次操作前,先从 x_{i-1} 中丢掉 k 条,剩下的 x_{i-1} - k 必须能分成 n 份。小猫拿走一份,剩余 n-1 份,于是:
x_i = (x_{i-1} - k) * (n - 1) / n
这一条公式是整道题的地基。正着推是从 x_0 求 x_n,倒着推就是从 x_i 反求 x_{i-1}:
x_{i-1} = x_i * n / (n - 1) + k
但前提是 x_i * n 必须能被 n - 1 整除,否则反推出来的数不是整数,说明当前这个"最后剩余"不可能由合法操作产生。
这个公式简单到可以口算,但它揭示了一个关键点:整个过程是一个确定性的递推,要么从前往后,要么从后往前,不存在多条路径。既然正着找不到起点,那就倒着从终点出发找起点。
2. 为什么别正着模拟:枚举方向才是这道题的钥匙
2.1 正推的灾难:你不知道答案有多大
初学者最自然的想法是:从 1 条开始枚举初始鱼数,然后模拟 n 只小猫依次操作,看是否每一步都满足"减 k 后能被 n 整除"。这个思路不能说错,但它有两个致命问题。
第一,初始鱼数的上界无法估计。经典的水手分椰子问题里,5个人分椰子,最小答案是 3121;如果你把人数加到 6、7,答案会爆炸式增长。从 1 开始暴力枚举初始值,遇到大参数时,可能枚举到几千万甚至上亿都找不到答案,这还不算每次枚举都要模拟 n 步。
第二,正推时一旦某一步不满足整除条件,这次尝试立即失败,但你并不知道初始值应该往哪个方向调整。这就好比在黑夜里走路,每走几步撞一次墙,撞完还得退回原点重新走。
所以正推模拟虽然好写,但基本过不了大数据。
2.2 倒推:把"未知起点"换成"已知终点"
倒推的思路正好绕开了这两个问题。我们不知道 x_0,但我们可以枚举最后一次操作之后的剩余量 x_n。然后利用逆推公式,一步一步算出 x_{n-1}、x_{n-2}……直到 x_0。
这个枚举变量选得非常巧。因为最后剩多少鱼,完全取决于最后一次操作前"每份分了多少条"。而每份至少 1 条,所以 x_n 是从很小就能开始的。我们只需要从小到大枚举最后剩余的每条鱼数,找到第一个能让整条链全部整除的值,它就是最小解。
这样做的好处是:枚举范围小、方向明确,而且一旦找到就立刻可以输出,不用继续往后搜。复杂度大约是 O(枚举次数 × n),对于三级题目的数据范围来说非常安全。
2.3 一个直观的生活类比
你可以把倒推想象成还原一个被切过的蛋糕:正着做是切一刀、拿一块、再切一刀、再拿一块;反着做就是从最后剩下的那一小块开始,每次都按"原来剩余量 × 份数 / (份数-1) + 多余量"把蛋糕拼回去。只要每一步都能整除,就能拼出一块完整的、数量最小的蛋糕。
这个类比在给小学生讲题时特别管用。孩子一旦理解了"反着拼回去",就再也不会想去正着乱试了。
3. 三种能拿分的写法:从暴力到剪枝
3.1 写法A:枚举最后剩余量,逆推验证
这是最容易写对、也最好讲解的版本。外层枚举 x_n,从 0 开始往上加;内层用逆推公式往前推 n 步,每步检查是否整除。
#include <iostream> using namespace std; int main() { long long n, k; cin >> n >> k; for (long long rest = 0; ; rest++) { long long cur = rest; // 从最后剩余量开始往回推 bool ok = true; for (int i = 1; i <= n; i++) { if ((cur * n) % (n - 1) != 0) { ok = false; break; } cur = cur * n / (n - 1) + k; // 检查分鱼前是否真的能分成 n 份,每份至少 1 条 if ((cur - k) / n < 1) { ok = false; break; } } if (ok) { cout << cur << endl; return 0; } } return 0; }注意循环里的检查顺序:先用cur * n是否整除n - 1来判断这一步逆推是否合法,再更新 cur。cur更新后是"当前这一步操作之前的鱼数",我们要把"操作前是否能分成 n 份且每份至少 1 条"也检查掉。
我拿 n=2、k=1 验证一下这个代码的逻辑。rest=0 时,逆推一步得到 cur=1,但 (1-1)/2=0,不够 1 份,失败;rest=1 时,第一次逆推 cur=3,可以分,第二次逆推 cur=7,可以分,输出 7。手动验证:7 条鱼,第一只丢 1 条,剩 6 条分 2 份,拿走 3 条,剩 3 条;第二只丢 1 条,剩 2 条分 2 份,拿走 1 条,剩 1 条,整个过程合法。
3.2 写法B:枚举最后一堆的"每份条数",更自然也更高效
上面的写法虽然正确,但枚举rest时会有大量无效尝试,因为rest并不是任意的整数,它必须是 n-1 的倍数——毕竟最后一次操作后剩下的是 n-1 份。
更自然的枚举变量是"最后一次分鱼时每份有多少条"。设最后一堆每份为 a 条,那么最后一次操作前鱼数是 n*a + k,操作后剩余 (n-1)*a。往前倒推时,上一只小猫操作后的剩余量必须等于这次操作前的鱼数,于是有:
先推导相邻关系:第 i 只与第 i-1 只小猫之间满足
(n - 1) * a_{i-1} = n * a_i + k
所以:
a_{i-1} = (n * a_i + k) / (n - 1)
也就是说,从最后一堆的 a_n 开始,倒推 n-1 次就能得到 a_1,初始鱼数就是 n * a_1 + k。每一步都要求整除。
#include <iostream> using namespace std; int main() { long long n, k; cin >> n >> k; for (long long a = 1; ; a++) { long long cur = a; bool ok = true; // 从最后一只小猫逆推到第一只,总共 n-1 次 for (int i = 1; i < n; i++) { if ((n * cur + k) % (n - 1) != 0) { ok = false; break; } cur = (n * cur + k) / (n - 1); } if (ok) { cout << n * cur + k << endl; return 0; } } return 0; }以 n=3、k=1 为例。a=1,先算倒数第二次: (31+1)/2=2,可行;再算第一次: (32+1)/2=3.5,不行。a=2,第一次逆推 (32+1)/2=3.5,不行。a=3,第一次逆推 (33+1)/2=5,第二次 (35+1)/2=8,都可行,输出初始鱼数 38+1=25。手动验证完全正确。
这个写法比写法A少一层无效枚举,而且天然保证了"每份至少1条"。我建议考场优先写这一版。
3.3 写法C:同余剪枝,让枚举再快一个量级
如果你学过分治和同余,会发现整除条件还可以继续优化。条件
(n * cur + k) % (n - 1) == 0
因为 n 除以 n-1 的余数是 1,所以 n * cur 除以 n-1 的余数就等于 cur 除以 n-1 的余数。于是条件变成:
(cur + k) % (n - 1) == 0
也就是说,每一步的 cur 都必须落在同一个模 (n-1) 的剩余类里。这意味着枚举 a 时,根本没必要 a++ 一个接一个试,可以直接让 a 从"满足 a 模 n-1 等于 -k 的正整数"开始,每次步长加 n-1。
#include <iostream> using namespace std; int main() { long long n, k; cin >> n >> k; long long mod = n - 1; long long start = (mod - k % mod) % mod; if (start == 0) start = mod; // 保证从正数开始 for (long long a = start; ; a += mod) { long long cur = a; bool ok = true; for (int i = 1; i < n; i++) { if ((n * cur + k) % mod != 0) { ok = false; break; } cur = (n * cur + k) / mod; } if (ok) { cout << n * cur + k << endl; return 0; } } return 0; }这个优化在 n 较大时效果明显,但理解成本也高一些。对于三级以及入门阶段的比赛,写法B已经足够;同余剪枝可以作为进阶内容学,不要因为写不出来而焦虑。
3.4 三种写法怎么选
| 写法 | 枚举变量 | 复杂度特点 | 代码量 | 推荐场景 |
|---|---|---|---|---|
| A | 最后剩余量 rest | 枚举量大但思路直观 | 短 | 教学讲解、快速验证 |
| B | 最后每份条数 a | 枚举量小、逻辑自然 | 中 | 考场首选 |
| C | 同余类起点 | 枚举量最小 | 中 | n 很大或追求效率 |
我个人带学生的建议是:先把 A 写熟,验证自己对逆推公式的理解;然后升级到 B,作为竞赛默认写法;C 留到学完同余之后回头再看。
4. 考场避坑:整除、溢出与边界样例
4.1 逆推顺序:先判断整除,再更新数值
这道题最经典的翻车点,就是有人把逆推写成:
cur = cur * n / (n - 1) + k;然后用 if 判断结果是否为整数。这种做法非常危险:C++ 的整数除法直接把小数部分截断,你根本看不出原来的数能不能整除。正确顺序是先取模判断,再执行除法更新。
if (cur % (n - 1) != 0) { // 失败 } cur = cur * n / (n - 1) + k;一句话:先验后再动手。这个习惯不止适用于这道题,凡是递推里带除法的地方都要这样写。
4.2 数值范围:long long 不是可选项
很多学生看到 n、k 很小就随手开 int,结果一交就 WA。这道题中间结果很容易超出 int 范围,尤其是逆推多步之后。
以水手分椰子为例,5 个人分时答案是 3121,int 还能接受;但如果人数到 7、8,答案会指数膨胀。代码中还会出现 cur * n 这种乘法,极端情况下会先于答案溢出。因此我建议比赛时统一使用 long long,想都不用想。
如果你遇到更大的数据,连 long long 都不稳,可以用 __int128 做中间运算,最后再输出。不过这超出三级范围了,了解即可。
4.3 样例全对但 WA 的典型原因
我总结过学生在这道题上最常见的 5 个错误:
第一,循环次数写错。要处理 n 只小猫,从最后一只倒推到第一只需要 n-1 次逆推,有人写成了 n 次,答案整体偏大。
第二,输出对象搞混。最后输出的应该是初始鱼数 x_0,有人输出成了最后一堆每份条数 a,或者输出了中间某一步的剩余量。
第三,忽略了"每份至少 1 条"。如果题目没有明说,但操作语义上有这个要求,那么枚举到的很小值会让某次操作前鱼数不足 n 条,这时即使整除也会产生不合法的 0 份。
第四,k 的含义看反。有些题面说"多出来 k 条",有些说"剩下 k 条给小猫自己处理",描述不同但本质一致;一旦把 k 当成每次从 n 份里额外扣掉的数量,公式就全歪了。
第五,没有从最小枚举值开始找。写法A里 rest 从 0 开始,写法B里 a 从 1 开始,都是为了保证输出最小值。如果贪快从一个大数开始,那么你输出的可能不是最小解。
4.4 自己造测试数据的技巧
不依赖样例,是竞赛选手的基本素养。针对这道题,我建议你手算两个已知场景放进代码里验证。
已知 n=2、k=1 时,最小答案是 7;n=3、k=1 时,最小答案是 25。这两个数据跑对了,代码的框架基本没问题。
如果还想测边界,可以试 n=2、k=0 的情况。此时只有 2 条鱼就能完成:第一只把 2 条分成 2 份,拿走 1 条,剩 1 条;第二只把 1 条分成 2 份?不行,每份不足 1 条。所以答案不是 2,需要继续枚举。最后能找到 6 条:第一只拿走 3 条剩 3,第二只拿走?不对,6条,分2份每份3,拿走3,剩3;第二只3条分2份每份1.5不行。那答案是多少?你正好可以拿这个场景练手,跑出来再和手推对照。
这类边界数据是最容易被题目样例漏掉的,提前准备能省下考场上大量调试时间。
5. 从这道题延伸出去:小猫分鱼、水手分椰子、猴子分桃
5.1 它们其实是同一道题
"小猫分鱼"在经典竞赛里还有两个亲戚:"水手分椰子"和"猴子分桃"。故事不同,数学内核完全一样:每次把总数减去一个固定数,然后取走 1/n,剩余 (n-1)/n,连续操作 n 次,求最小初始值。
如果你做过其中一道,其他两道基本就是改个变量名的事。所以学这类题,重在看模型而非背题目。以后拿到任何"每次丢掉 x 个,取走 1/y,重复 z 次"的描述,都可以直接套本文的逆推框架。
5.2 条件一变,代码怎么跟着变
最常见的变体是增加一个参数 m,表示不是分成 n 份而是分成 m 份。这时候你只需要把公式里所有 n 换成 m,k 的含义不变即可。千万不要因为小猫数量是 n 就把份数也写死成 n——题目很可能故意这样设置陷阱。
还有一种变体是"最后剩余多少条已经指定"。比如要求最后一只小猫分完后恰好剩 r0 条,那么写法A的 rest 从 r0 开始枚举,只不过第一步就要判断这个 r0 让前面的链条是否合法。如果你用的是写法B,就需要额外把 r0 转换成最后一堆每份条数 a = r0 / (n-1),并判断 r0 是否能被 n-1 整除。
总之记住:枚举变量永远选那些可以从小往大、有限增长的整数,通常不是初始值,而是最后一步的某一个小量。
5.3 启蒙阶段怎么讲孩子最容易懂
给小学生讲这道题,我不建议直接上逆推公式,而是先用动手模拟建立直观。
拿 7 条鱼、2 只小猫、每次丢 1 条的场景,让孩子实际摆一摆。先正着走一遍规则,然后问他:如果只告诉你最后剩 1 条,能不能反推出最开始的条数?孩子试几次就会总结出"每次要往回乘 2 再加 1"。这时候把公式写出来,他一下子就理解 n/(n-1) 哪来的了。
之后再引导孩子发现"没有必要枚举第一次,可以枚举最后一次每份的条数",这比灌公式有效得多。等他把 B 写法吃透,再告诉他还有同余优化,这样既不打击信心,也留了进阶空间。
最后再分享一个实操习惯:我在带学生刷题时,要求他们写代码前先在草稿纸上把一个具体小样例的手推过程写完整,包括每一步的剩余量。代码写完,先用自己的小样例自测,再提交。这套流程看起来很慢,但能省掉大量无意义的提交试错。小猫分鱼这道题,只要把逆推公式吃透,再注意整除判断和 long long,基本就是稳拿分了。