这个题目看着眼熟吧?猴子吃桃问题——但凡在学校里学过 Java、C 语言,或者刷过一点算法题的人,八成都在书里或者卷子上见过它。题目本身不长:猴子第一天摘了若干个桃子,当场吃了一半觉得不过瘾,又多吃了一个;第二天早上又把剩下的桃子吃掉一半,再加一个;之后每天早上都重复这个操作。等到了第 10 天早上,猴子准备吃桃子的时候一看——只剩 1 个了。题目问:第一天到底摘了多少个桃子?
很多人第一反应是拿个变量顺着天数往下算,结果发现算到一半就卡住了,因为顺着推根本不知道第一天摘了多少。实际上这题的破局点只有一个:逆推。从第 10 天剩下的 1 个桃子出发,反推第 9 天、第 8 天,一直到第 1 天。很多初学者栽在这里,不是因为不会写 Java,而是没把“递推关系”反过来想清楚。这篇文章我打算把这题掰开揉碎讲一遍,包括数学建模、Java 的循环解法、递归解法、正向验证,以及一些容易踩的坑。既照顾刚接触 Java 的同学,也聊点对刷题和面试有帮助的思路。
1. 题目到底怎么读:先建模,而不是先写代码
1.1 一个经典的“逆推型”递推问题
先把题目完整地复述一遍,因为很多网上流传的版本缺了最后一句。完整条件是这样的:猴子第一天摘下若干个桃子,当即吃了一半,觉得不够,又多吃了一个。第二天早上,它又把剩下的桃子吃掉一半,再多加一个。以后每天早上的吃法都和前一天的规则一样:吃前一天剩下的一半,再多吃一个。直到第 10 天早上,猴子想吃桃子时,发现只剩下 1 个桃子。每天都不吃坏桃子,也没有别的动物偷吃的前提下,问猴子第一天一共摘了多少个桃子。
这道题在数学上属于典型的递推问题,而且是从“末状态”向“初状态”反推的类型。它不像斐波那契数列那样正着推顺手,也不像排序那样去模拟一个过程,它需要你先把第 n 天和第 n+1 天的关系写出来,再决定用循环还是递归。
从第 n 天到第 n+1 天的变化规律是:吃了“当天总数的一半再多一个”。用公式表达就是:
第 n+1 天早上剩下的桃子数 = 第 n 天早上剩下的桃子数 - (第 n 天早上剩下的桃子数 / 2 + 1)
把这个式子整理一下:
第 n+1 天的剩余量 = 第 n 天的剩余量 ÷ 2 - 1
这个式子看着很清楚,但有个问题:我们是知道最后一天,反过来求第一天。所以更常用的是它的逆向形式。把上面的式子做代数变换:
第 n 天的剩余量 = (第 n+1 天的剩余量 + 1) × 2
什么意思呢?就是如果你知道某一天早上剩下的桃子数,那么前一天的桃子数一定是“这一天的剩余量加 1 之后乘以 2”。为什么是这个关系?因为猴子每天吃“一半加一个”,剩下的恰好就是“原来的一半减一个”。那么原来的一半就等于“剩下的加一个”,原来的总数自然就是“(剩下的 + 1)× 2”。
这个关系是整个题目的核心,不管用循环、递归还是公式法,都要反复用到它。写代码之前,先在草稿纸上把这个公式写明白。
1.2 可验证的初始条件和最终条件
先把已知条件列出来:
- 第 10 天早上剩余的桃子数 = 1。
- 第 9 天早上剩余的桃子数 = (1 + 1) × 2 = 4。
- 第 8 天早上剩余的桃子数 = (4 + 1) × 2 = 10。
- 第 7 天早上剩余的桃子数 = (10 + 1) × 2 = 22。
- 第 6 天早上剩余的桃子数 = (22 + 1) × 2 = 46。
- 第 5 天早上剩余的桃子数 = (46 + 1) × 2 = 94。
- 第 4 天早上剩余的桃子数 = (94 + 1) × 2 = 190。
- 第 3 天早上剩余的桃子数 = (190 + 1) × 2 = 382。
- 第 2 天早上剩余的桃子数 = (382 + 1) × 2 = 766。
- 第 1 天早上剩余的桃子数 = (766 + 1) × 2 = 1534。
所以第一天摘了 1534 个桃子。注意,1534 是“第 1 天早上”的总数,不是“第一天吃剩下的”。它指的是猴子摘下来、还没有开始吃时的完整数量,恰好也是猴子在第一天早上看到的桃子数。这里有个容易混淆的地方:很多同学第一步就加错,会把第 10 天的 1 直接代入公式,算出来第 9 天是 4,这个没问题;但有人会把第 10 天当成“吃了之后剩 1”,于是把第 9 天当成 1,这就大错特错了。
正向验证一遍:第 1 天,1534 ÷ 2 + 1 = 768,剩下 1534 - 768 = 766;第 2 天,766 ÷ 2 + 1 = 384,剩下 382;第 3 天,382 ÷ 2 + 1 = 192,剩下 190;第 4 天,190 ÷ 2 + 1 = 96,剩下 94;第 5 天,94 ÷ 2 + 1 = 48,剩下 46;第 6 天,46 ÷ 2 + 1 = 24,剩下 22;第 7 天,22 ÷ 2 + 1 = 12,剩下 10;第 8 天,10 ÷ 2 + 1 = 6,剩下 4;第 9 天,4 ÷ 2 + 1 = 3,剩下 1。第 10 天早上正好剩 1 个。数字对得上,说明模型没问题。
2. 用 Java 实现的几种思路:循环、递归和公式法
2.1 最憨但最稳的写法:倒序循环
既然已经明确了逆向递推公式,那就直接用一个循环从第 10 天倒着推回第 1 天。这种写法不烧脑,适合所有阶段的开发者。
public class MonkeyPeachLoop { public static void main(String[] args) { int peach = 1; // 第10天早上的剩余数量 for (int day = 9; day >= 1; day--) { peach = (peach + 1) * 2; System.out.println("第" + day + "天早上的桃子数 = " + peach); } System.out.println("第一天摘的桃子数 = " + peach); } }输出结果:
第9天早上的桃子数 = 4 第8天早上的桃子数 = 10 第7天早上的桃子数 = 22 第6天早上的桃子数 = 46 第5天早上的桃子数 = 94 第4天早上的桃子数 = 190 第3天早上的桃子数 = 382 第2天早上的桃子数 = 766 第1天早上的桃子数 = 1534 第一天摘的桃子数 = 1534这里最关键的地方是循环变量的边界。peach初始化为 1,代表第 10 天早上的剩余量。循环从day = 9开始,用第 10 天的数据反推第 9 天;一直做到day = 1,就反推出了第 1 天的数量。循环结束之后peach里存的就是第一天的总数。
这段代码会在控制台打印每一天的中间结果,方便核对。如果不想打印那么多,去掉System.out.println那一行即可。
从性能上看,这个循环只执行 9 次,时间复杂度是 O(n),n 是天数;空间复杂度是 O(1),只有一个整型变量。这种解法在实际开发里是首选,因为它逻辑清晰、不依赖方法调用栈、不会栈溢出,调试也简单。
2.2 面试官喜欢的写法:递归
递归在数据结构与算法里占有特殊地位。很多人觉得递归难,其实递归就做两件事:找到递推关系,找准终止条件。拿猴子吃桃来说,递推关系已经有了,终止条件就是第 10 天剩 1 个。
如果定义f(day)表示“第 day 天早上还没吃之前的桃子数”,则有:
f(10) = 1
f(n) = (f(n + 1) + 1) × 2
对应的 Java 代码:
public class MonkeyPeachRecursion { public static int f(int day) { if (day == 10) { return 1; } return (f(day + 1) + 1) * 2; } public static void main(String[] args) { int result = f(1); System.out.println("第一天摘的桃子数 = " + result); } }这个写法里,调用f(1)之后会发生连环调用:f(1)调用f(2),f(2)调用f(3)……一直调用到f(10),然后f(10)返回 1,再一层层往回带值,最后算出f(1) = 1534。
很多初学者会写错终止条件,常见错误是写成if (day == 1) return 1;。这就完全反了,因为题目说的是第 10 天剩 1 个,不是第 1 天剩 1 个。这样一写,递归会一路算到f(10)甚至更远,结果全乱。
另一个常见错误是递推公式写反,写成return 2 * f(day + 1) - 1。这个式子看着稍微有点像,算出来结果差之千里。正确写法必须是(f(day + 1) + 1) * 2,这是由“吃一半加一个”反推得到的,不要凭感觉乱改。
递归的优点是代码短、逻辑与数学定义一致、可读性强。缺点是如果天数很多,会占用大量 Java 虚拟机栈空间,导致StackOverflowError。本题目只有 10 天,递归深度很浅,完全没问题。但如果把 10 改成 10000,那递归基本上必挂,循环解法却毫发无损。所以我在实际工作中倾向于用循环打底,递归负责呈现思路。
2.3 逼格更高的写法:数学公式直接求解
用数学方法把递推公式展开,可以直接写出通项公式。设第 n 天早上剩余的桃子数为 T(n),已知:
T(n+1) = T(n) / 2 - 1
做代数变换,两边同时加 2:
T(n+1) + 2 = (T(n) + 2) / 2
所以T(n) + 2构成一个公比为 1/2 的等比数列。于是:
T(n) + 2 = (T(10) + 2) / 2^(10-n)
T(10) = 1,则:
T(n) = 3 / 2^(10-n) - 2
当 n = 1 时:
T(1) = 3 / 2^9 - 2 = 3 × 512 - 2 = 1534
如果你愿意,一行代码也能算:
public class MonkeyPeachFormula { public static void main(String[] args) { int day = 10; int result = (int) (3 * Math.pow(2, day - 1) - 2); System.out.println("第一天摘的桃子数 = " + result); } }Math.pow(2, 9)等于 512,乘 3 减 2 得 1534。公式法的最大优势是时间复杂度 O(1),不管天数多少,一次就算出来。但它也有局限:需要对递推式做比较熟练的数学变换。如果只是解这道题,循环和递归已经完全够用。公式法更适合作为一种“额外视角”去理解题目的本质,也能帮你更清晰地看到为什么是 1534 而不是别的数。
3. 正向验算、工具函数和边界问题
3.1 用正向模拟验证逆向推出来的结果
代码写完了,数字也输出 1534 了,但心里总有点不踏实。怎么确认这个答案是对的?最直接的办法是写一段正向模拟:假设第一天有 1534 个桃子,按题目规则每天吃“一半加一个”,连续吃 9 天,看看第 10 天早上是不是剩 1 个。
public class MonkeyPeachVerify { public static void main(String[] args) { int peach = 1534; for (int day = 1; day <= 9; day++) { int eaten = peach / 2 + 1; peach = peach - eaten; System.out.println("第" + day + "天吃了" + eaten + "个,剩余" + peach + "个"); } System.out.println("第10天早上剩余:" + peach); } }输出:
第1天吃了768个,剩余766个 第2天吃了384个,剩余382个 第3天吃了192个,剩余190个 第4天吃了96个,剩余94个 第5天吃了48个,剩余46个 第6天吃了24个,剩余22个 第7天吃了12个,剩余10个 第8天吃了6个,剩余4个 第9天吃了3个,剩余1个 第10天早上剩余:1这个验证过程相当重要。首先,它能在做题时帮你排除“公式方向写反”“循环边界搞错”“递归终止条件写错”这类问题。其次,它也是一种很通用的编码习惯——任何逆推出来的结果,都值得用正向逻辑再走一遍。在真实业务开发里,这种思想对应的就是“用另一条路径交叉验证计算结果”,排查脏数据时特别好用。
3.2 数据类型的隐患:int 够用吗?
猴子吃桃问题里,第一天摘了 1534 个,int 完全够用。但如果你把题目的参数改一改,比如第 20 天早上剩 1 个,那第一天摘的桃子就会变成 3×2^19 - 2,也就是 1572862,int 还能扛得住。可一旦变成第 30 天剩 1 个,结果就是 3×2^29 - 2,约 16 亿,已经接近 int 的上限。如果变成第 31 天,结果直接超过 21 亿,int 就溢出了。
所以写代码时不要默认 int 永远安全。建议根据参数范围选择类型,必要时用long:
public class MonkeyPeachLong { public static void main(String[] args) { long peach = 1L; for (int day = 29; day >= 1; day--) { peach = (peach + 1L) * 2L; } System.out.println("第1天桃子数 = " + peach); } }第 31 天用 long 也能算出 6442450942,仍然在范围内。要是天数再往上加,就得考虑用BigInteger了。虽然这个题目本身不会触发这种极端场景,但作为程序员,心里要有一根弦:所有递推乘法都要警惕溢出。尤其是面试时,如果面试官追加一句“如果第 100 天呢”,你能立刻答出“需要用 BigInteger 或者用字符串处理”,会显得经验很足。
如果在循环里加上打印语句,还能直观地看到“第 n 天剩余量”增长的速度有多快——第 1 天到第 10 天是指数增长,翻倍速度惊人。这也是为什么猴子摘桃这类问题被用来讲解递归和递推时特别合适的缘故,数字增长趋势能验证公式,还能加深对指数爆炸的直观感受。
3.3 方法抽取:把“逆推逻辑”和“主流程”分开
在实际工程里,不建议把所有逻辑都堆在main方法里。哪怕只是一个小算法题,也值得把核心逻辑抽成一个方法。这样便于单元测试、便于复用,也更接近真实开发的写法。下面这段代码把循环逆推封装成一个静态方法:
public class MonkeyPeachUtils { /** * 计算第1天早上的桃子总数 * * @param lastDay 最后观察的那一天,例如第10天 * @param lastRemain 最后观察那天早上的剩余桃子数,例如1 * @return 第1天早上的桃子数 */ public static long calcFirstDayPeach(int lastDay, long lastRemain) { long peach = lastRemain; for (int day = lastDay - 1; day >= 1; day--) { peach = (peach + 1) * 2; } return peach; } public static void main(String[] args) { long result = calcFirstDayPeach(10, 1); System.out.println("第一天摘的桃子数 = " + result); } }写成方法之后,你还可以加一些防御性参数校验:lastDay必须大于等于 2,lastRemain必须大于等于 0。如果以后题目改成“第 15 天早上剩 3 个”,直接传参进去就行,不用改内部逻辑。这是编程习惯的问题,但很多人就是在这个小细节上拉开差距的。
4. 常见错误和踩坑实录,帮你提前排雷
4.1 错误一:把“第 10 天剩 1 个”当成“第 10 天吃了剩 1 个”
这种错误不止一次出现在初学者身上。把题意理解成“猴子在第 10 天吃完之后还剩下 1 个”,然后会把第 10 天当成一个被吃过的状态,导致反推时多推一步,算出来结果变成了 3070。其实题目意思是“第 10 天早上,还没吃的时候,就剩下 1 个了”。所以第 10 天是观察终点,不需要对它再执行一次“吃一半加一个”的规则。
怎么避免?最简单的办法是读题之后,先用手算验证几个数。根据“第 9 天早上 4 个、第 10 天早上 1 个”这个关系,你会发现 4 ÷ 2 - 1 = 1,正好符合题意。如果按“第 10 天吃完剩 1 个”理解,那第 10 天早上的原始数量应该满足“x ÷ 2 - 1 = 1”,x = 4,这会让第 9 天变成另一个值,和已知递推明显矛盾。多做一步手算,比直接写代码更容易发现问题。
4.2 错误二:循环边界写错
循环写法里,常见错误是把day从 9 循环到 0,或者从 10 循环到 1。这两种都会导致计算结果偏差。正确思路是:peach代表“当前已知道的那一天的剩余量”,每执行一次循环体,就反推前一天。所以第一次进循环计算出来的是第 9 天,最后一次进循环计算出来的必须是第 1 天。循环变量应该从lastDay - 1开始,到 1 结束。
如果你不确定边界,建议打印每一天的中间值,然后用第 2 节里的正向模拟去验证。代码能跑通不代表结果对,输出结果和手算结果一致才算真的对。
4.3 错误三:递归方向写反
递归写法里,f(day)依赖f(day + 1),而不是f(day - 1)。因为题目给的是末状态,反推初状态。一部分同学受“从第 1 天开始推”的思维惯性影响,会写出:
public static int f(int day) { if (day == 10) { return 1; } return f(day - 1) / 2 - 1; }这个写法问题很大:当day = 10时虽然返回 1,但f(1)会调用f(0)、f(-1)……根本没完没了,最终栈溢出。正确的递归必须保证子问题朝“已经知道答案的边界”靠近。本题的边界是第 10 天,所以参数应该增大而不是减小。
注意:递归递归,关键就在“递”和“归”两个方向。末状态已知就朝末状态递归,初状态已知就朝初状态递归。写之前先想清楚哪一头是不需要计算的。
4.4 错误四:直接使用short或byte
如果拿byte peach = 1来写,循环第 3 次就爆了,因为(10 + 1) * 2已经等于 22,还是安全的,可一旦到第 6、7 次就超过 127,byte 溢出变成负数。同理short也不太安全。虽然这个题用 int 就够,但把数据类型意识培养起来,以后处理其他算法题会有好处。
那我会怎么做?默认使用int,并且在乘法之后回顾一下数字的量级。如果题目参数可能调整,或者天数本身是用户输入,那我直接上long。代码多写一个L后缀,不丢人,反而显得严谨。
5. 变体训练:从一道经典题理解递推思维
5.1 换个问法:问第 n 天剩余量
如果题目变成“已知第一天摘 1534 个,求第 n 天早上剩余多少个”,这就是正向推导问题了。递推公式:
T(n+1) = T(n) / 2 - 1
循环可以正着写:
int peach = 1534; for (int day = 1; day <= 9; day++) { peach = peach / 2 - 1; System.out.println("第" + (day + 1) + "天早上剩余 = " + peach); }这个变体很简单,但能帮你理解正推和逆推的本质区别:正推是“已知起点求过程”,逆推是“已知终点求起点”。平时刷题时,判断题目类型是正推还是逆推,比急着找公式更重要。
5.2 换成“每次吃三分之一加两个”
再进一步,把条件改成“猴子每天吃剩下桃子的三分之一再多吃两个,到第 10 天早上剩 1 个”。那递推关系变成:
第 n+1 天剩余 = 第 n 天剩余 × (1 - 1/3) - 2 = 第 n 天剩余 × 2/3 - 2
反推公式:
第 n 天剩余 = (第 n+1 天剩余 + 2) × 3 / 2
这个变体考的不是死记硬背,而是你能不能灵活地从“吃了多少”推出“原来有多少”。如果你只是把这道题当作“背答案”来刷,遇到这种微调就会露怯。
我建议每个准备面试的朋友,把这种递推题的通用模板记牢:
- 设 f(n) 表示第 n 天早上的数量;
- 根据“吃法”建立 f(n) 和 f(n+1) 的关系式;
- 确认哪一天是边界;
- 用循环或递归实现;
- 正向验算。
这套模板几乎能覆盖所有“猴子吃桃类”问题。题目怎么变,骨架都不变。
5.3 在 LeetCode 风格场景里的定位
严格来说,猴子吃桃不是一道高频算法面试题,考链表、二叉树、动态规划的频率远高于它。但它在初学者阶段出现的频率很高,因为它是理解“递归”和“递推”的最佳入门素材之一。它比阶乘多了一点弯弯绕,比斐波那契多了一点逆推思想,又没有复杂的数据结构和边界条件,非常适合用来检验一个初学者能不能把数学语言翻译成程序语言。
面试时如果真遇到这道题,建议的做法是:先说思路,“我打算从第 10 天逆推到第 1 天”,然后写循环版本,再补充递归版本,最后提一下时间复杂度和溢出风险。一套组合拳打下来,基本不会扣分。
6. 我在实际处理这类题时的一些写码习惯
最后聊点我的个人习惯。每次写这种递推题,我一般不会只写一种解法。先把循环版本写出来跑一遍,再把递归版本写出来跑一遍,最后必然补一个正向验证函数。不要嫌麻烦,正向验证代码往往只有几行,但对“确认思路没跑偏”特别有用。就像写业务代码时,我会对关键计算函数做一层交叉校验,用两种不同方式算出同一个指标,确保结果一致再入库。这种习惯放在算法题里,就是验算。
另外,如果是教学场景,我非常推荐在代码里保留打印语句。看到第 9 天是 4、第 8 天是 10、第 7 天是 22……这些数呈指数增长的时候,对“递推公式”的理解会一下子深很多。如果一股脑只输出最终结果,虽然简洁,但对初学者来说少了很多直观感受。
还有一个容易被忽略的点:变量命名。很多人写算法题喜欢用x、n、p这样的名字,这不是不行,但代码可读性会差一些。我倾向于用peach、remain、eaten这类能表达含义的名字。哪怕题目只写 20 行,一个好名字也能帮你在调试时少浪费几分钟。尤其在团队协作里,同事看你的代码,不会盯着你的数学推导过程看,只会看变量名和函数名是否表意清晰。
如果你打算在博客里复现这道题,我建议配一个“天数-剩余数”的表格,或者保留中间输出。比如列出从第 1 天到第 10 天的剩余桃子数:1534、766、382、190、94、46、22、10、4、1。这张表本身就是递推关系的最好证明。你会发现倒着看是“乘以 2 再加 2”,正着看是“除以 2 再减 1”,两个方向都在验证同一套逻辑。
猴子吃桃这道题本身不难,但它是一块很好的试金石。能独立把它写对,说明你对递归、循环、递推公式和边界条件已经有了基本感觉。在这基础上再去学动态规划、回溯算法,底层思维是相通的。下次再遇到类似题目,别急着开 IDE,先在草稿纸上把“第 n 天和第 n+1 天的关系”写出来。写清楚了,代码基本就出来一半了。