后面每一章都是「暴力很慢 → 想出关键一步 → 正解很快」。 这一章不比快慢 —— 它要解决的是一个更靠前的问题:你为什么读得懂递归、却写不出递归。
所以这里故意挑了一道用循环三行就能搞定的题。因为循环版你百分之百写得对, 它就能当成标准答案,让你验证自己写的递归到底对不对。先建立信任,再谈技巧。
1一句话问题
给一个非负整数 n,求它各位数字之和。
输入
12345
输出
15
因为 1 + 2 + 3 + 4 + 5 = 15。
2先用纸笔手算一遍
拿 12345 手算,把每一步写下来:
12345 的数位和 = 5 + (1234 的数位和)
1234 的数位和 = 4 + (123 的数位和)
123 的数位和 = 3 + (12 的数位和)
12 的数位和 = 2 + (1 的数位和)
1 的数位和 = 1 + (0 的数位和)
0 的数位和 = 0 ← 到底了
请务必真的在纸上写一遍。写完盯着这六行看,你会发现一件事:
每一行的形状都一模一样,都是「最后一位 + 剩下那部分的数位和」。 唯一特殊的是最后一行 —— 它不需要问别人,自己就知道答案是 0。
这六行就是递归。不是「像」递归,它就是。剩下的只是把它翻译成 C++。
3循环写法(你已经会的那种)
// 数位和 —— 循环写法(你已经会的那种)//// 输入:一个非负整数 n// 输出:n 的各位数字之和。例如 12345 -> 1+2+3+4+5 = 15//// 思路:反复取出最后一位(n % 10),然后把最后一位砍掉(n / 10),直到 n 变成 0。
#include <bits/stdc++.h>using namespace std;
int main() { long long n; cin >> n;
int sum = 0; while (n > 0) { sum += n % 10; // 取出最后一位 n /= 10; // 砍掉最后一位 }
cout << sum << "\n"; return 0;}点「运行 ▶」看结果
没什么好说的,这个你闭着眼睛都能写。但请记住它 —— 第 10 步我们要拿它当标准答案。
4先确认它是对的
改几个输入试试:0、7、100、999999999。特别是 0,它是最容易被忽略的边界。
确认无误之后,我们就有了一份可信的参照物。这一点很重要: 学新东西的时候,手上有一个「肯定对」的东西,比什么都让人安心。
5那循环有什么问题?
对这道题,循环没有任何问题。诚实地讲,这题用循环就够了。
但请看纸上那六行 —— 它描述的是「12345 的答案依赖 1234 的答案」这样一种关系。
循环没有表达这个关系,它是把过程「压平」了:拿一个变量 sum 一路累加,
你得在脑子里模拟指针怎么移动、sum 怎么变化,才能确认它是对的。
这个差别在这道题上无关紧要。但等到问题变成:
- 「一个
n位数的全排列」—— 你得套n重循环,可n是运行时才知道的(第 3 章) - 「从这个格子出发能走到哪些格子」—— 走到新格子后,又要从新格子继续走(第 13 章)
- 「从 (i,j) 走到底的最大和」—— 依赖「从 (i+1,j) 走到底的最大和」(第 17 章)
这些问题的结构本身就是递归的。硬用循环写,你得自己手动维护一个栈来模拟。 所以学递归不是为了炫技,是因为有一大类问题,不用递归表达就会变得极其别扭。
6★ 关键的一步
写递归时,你要把自己当成这个函数的使用者,而不是执行者。
写 digitSum(n / 10) 这一行的时候,不要去追问「那它进去之后又会怎么跑」——
一旦开始追问,你的脑子就会陷进无限套娃里,然后卡住。这就是绝大多数人学不会递归的地方,
不是笨,是追问的方向错了。
正确的姿势是:digitSum 这个函数我已经定义好了,它的职责是「返回参数的数位和」。
既然如此,digitSum(1234) 当然返回 1234 的数位和。我直接拿来用就行了。
一句话:先说清楚这个函数负责什么,然后就信任它。
这个信任怎么才不会变成空头支票?靠三件事,缺一不可:
| 要素 | 在这道题里 | 不写会怎样 |
|---|---|---|
| 1. 函数的职责 | digitSum(n) 返回 n 的各位数字之和 |
你自己都说不清它干什么,那就不可能写对 |
| 2. 边界(出口) | n == 0 时返回 0 |
永远递不到底,栈溢出崩溃 |
| 3. 递推关系 | n % 10 + digitSum(n / 10) |
无法把问题变小,同样递不到底 |
第 3 条还藏着一个容易被忽略的要求:递推必须朝着边界走。
这里每次 n / 10 都让 n 严格变小,所以迟早会撞上 n == 0。
如果你写成 digitSum(n) 调用 digitSum(n),三要素齐了也照样死。
7递归写法
// 数位和 —— 递归写法//// 和 loop.cpp 解的是同一道题,答案必须完全一样。//// 递归三要素:// 1. 这个函数「负责什么」—— digitSum(n) 返回 n 的各位数字之和。// 想清楚这一句,比想清楚它内部怎么执行重要一百倍。// 2. 边界(递归出口)—— n == 0 时,没有任何数字,和是 0。直接返回,不再往下递。// 3. 递推关系 —— n 的数位和 = 最后一位(n % 10) + 剩下部分的数位和(digitSum(n / 10))。//// 注意第 3 步:我们直接「相信」digitSum(n / 10) 会算对,不去追问它内部怎么跑的。// 这就是递归最难迈过去、也最关键的一步 —— 把自己当成使用者,而不是执行者。
#include <bits/stdc++.h>using namespace std;
// ⚠ 两个新手容易被绊一下的写法,说清楚:// · 参数是 `long long`、返回值是 `int` —— 这不是笔误。// 输入的数可能很大(超过 int 能存的 21 亿),所以参数要用 long long;// 但**数位和**再大也就几十(19 位的数最多 9×19 = 171),int 绰绰有余。// 「输入用大类型、答案用够用的类型」是常态,不用强求两边一致。// · `(int)(n % 10)` 里的括号是**类型转换**:`n % 10` 的类型是 long long,// 转成 int 再和右边的 int 相加。不写这一句也能编译(会自动转),// 写出来是为了让「这里发生了一次类型转换」看得见。// ⇒ C++ 更推荐的写法是 `static_cast<int>(n % 10)`,本书为了短用了 C 风格。int digitSum(long long n) { if (n == 0) return 0; // 出口 return (int)(n % 10) + digitSum(n / 10); // 递推:最后一位 + 剩下的}
int main() { long long n; cin >> n; cout << digitSum(n) << "\n"; return 0;}点「运行 ▶」看结果
就两行。对照上面那张表看:if (n == 0) return 0; 是边界,
return n % 10 + digitSum(n / 10); 是递推关系,函数名和注释说明了职责。
8看见它是怎么跑的
递归最大的障碍是「看不见」。所以我们在进入和返回的地方各加一行打印,用缩进表示层数。
// 数位和 —— 带打印的递归,用来「看见」调用栈//// 这份代码答案和 rec.cpp 一样,只是在进入和返回时各打印一行,// 并用缩进表示当前在第几层。跑一遍,你就能亲眼看到:// - 先一路「递」下去(缩进越来越深),直到撞上出口// - 再一路「归」回来(缩进越来越浅),答案是在回来的路上攒出来的//// 这个「进入打印 / 返回打印」的技巧,以后调试任何递归都能用。
#include <bits/stdc++.h>using namespace std;
int depth = 0;
void indent() { for (int i = 0; i < depth; i++) cout << "| ";}
int digitSum(long long n) { indent(); cout << "-> 进入 digitSum(" << n << ")\n"; depth++;
int result; if (n == 0) { result = 0; indent(); cout << " 撞到出口,直接返回 0\n"; } else { int rest = digitSum(n / 10); // 先把子问题算出来 result = (int)(n % 10) + rest; indent(); cout << " " << n % 10 << " + " << rest << " = " << result << "\n"; }
depth--; indent(); cout << "<- 返回 " << result << " (来自 digitSum(" << n << "))\n"; return result;}
int main() { long long n; cin >> n; cout << "开始计算 " << n << " 的数位和\n\n"; int ans = digitSum(n); cout << "\n最终答案 = " << ans << "\n"; return 0;}点「运行 ▶」看结果
跑完之后,请特别注意输出的后半段:
1 + 0 = 1
2 + 1 = 3
3 + 3 = 6
4 + 6 = 10
5 + 10 = 15
答案 15 不是在「递下去」的时候算出来的,而是在「归回来」的路上一层层攒出来的。 递的时候每一层都只是把问题往下抛,什么都没算;真正的计算发生在返回途中。
在递归函数的开头和结尾各打印一行,用一个全局 depth 变量控制缩进。
以后写 DFS、写记忆化搜索卡住了,这一招永远有效 —— 它能把「看不见的执行过程」变成一张能读的表。
9单步看调用栈
打印还是太快了。下面这个动画把每一步拆开,你可以停在任何一帧反复看。
看的时候盯住两件事:
- 「递」的阶段:栈越堆越高,每一层都卡在半路,等着下面的结果 —— 没有任何一层算完了。
- 「归」的阶段:栈一层层塌下去,每弹出一层就产出一个值,交给上一层。
栈的高度就是递归深度。每一层都要占用真实的内存(保存参数、局部变量、返回地址), 栈用完了程序就直接崩溃 —— 而且它不会告诉你「是递归太深了」,只给你一个段错误。
「多深算深」没有一个通用答案,它是个除法:栈有多大 ÷ 一层占多少字节。
两个数都随系统、编译器、函数里有多少局部变量而变
(这台 Linux 上 ulimit -s 给的是 8192 KB = 8 MB;Windows 上要小得多,常见是 1 MB 那个量级)。
所以别背数字,记这个除法;真要知道自己这台机器的上限,就自己量一次。
★ 第 30 章第 11 步有一张实测的表,就是这个除法的样子:同一份递归 DFS,图一大就段错误,
表里写清了它压到多少层、每帧多少字节、栈是怎么用完的。
数位和最多递归 10 层左右(生成器最多造 10 位数),完全没问题。 但第 13 章的 DFS 在网格上,最坏情况下层数就是格子数(一条蛇形通路能把整片格子串成一条链), 到第 30 章那张表上它就真的崩了。
10★ 对拍:证明你写的递归是对的
现在到了这个站点最有价值的部分。
你已经有一份肯定正确的循环版。那就让它当标准答案:随机造一堆数字, 两份程序各跑一遍,比对结果。只要有一轮对不上,就把那组数据摆出来。
不要直接点「开始对拍」验证我给的代码 —— 那当然是对的,没有意义。
把「递归版」那一栏里的代码整个删掉,换成你自己默写的那份,然后再点开始。 这才是它存在的理由:你不用问任何人,就能知道自己写对没有。
// 数位和 —— 递归写法//// 和 loop.cpp 解的是同一道题,答案必须完全一样。//// 递归三要素:// 1. 这个函数「负责什么」—— digitSum(n) 返回 n 的各位数字之和。// 想清楚这一句,比想清楚它内部怎么执行重要一百倍。// 2. 边界(递归出口)—— n == 0 时,没有任何数字,和是 0。直接返回,不再往下递。// 3. 递推关系 —— n 的数位和 = 最后一位(n % 10) + 剩下部分的数位和(digitSum(n / 10))。//// 注意第 3 步:我们直接「相信」digitSum(n / 10) 会算对,不去追问它内部怎么跑的。// 这就是递归最难迈过去、也最关键的一步 —— 把自己当成使用者,而不是执行者。
#include <bits/stdc++.h>using namespace std;
// ⚠ 两个新手容易被绊一下的写法,说清楚:// · 参数是 `long long`、返回值是 `int` —— 这不是笔误。// 输入的数可能很大(超过 int 能存的 21 亿),所以参数要用 long long;// 但**数位和**再大也就几十(19 位的数最多 9×19 = 171),int 绰绰有余。// 「输入用大类型、答案用够用的类型」是常态,不用强求两边一致。// · `(int)(n % 10)` 里的括号是**类型转换**:`n % 10` 的类型是 long long,// 转成 int 再和右边的 int 相加。不写这一句也能编译(会自动转),// 写出来是为了让「这里发生了一次类型转换」看得见。// ⇒ C++ 更推荐的写法是 `static_cast<int>(n % 10)`,本书为了短用了 C 风格。int digitSum(long long n) { if (n == 0) return 0; // 出口 return (int)(n % 10) + digitSum(n / 10); // 递推:最后一位 + 剩下的}
int main() { long long n; cin >> n; cout << digitSum(n) << "\n"; return 0;}试试故意写错,感受一下它有多灵:
- 把出口改成
if (n == 1) return 1;—— 看它在什么数据上翻车(提示:想想n = 0) - 把递推改成
n / 10 + digitSum(n % 10)(两个操作符写反了) - 干脆把出口整行删掉 —— 页面上会报「超时」。但超时不等于爆栈,见下面这条
上面第三条的原话是「会得到『超时』,那其实是栈溢出」。后半句是错的,而且错在因果上。
本站的运行和对拍都用 -O2 编译(lib/cxx.mjs:67,那里写着它为什么不能动)。
实测把出口删掉之后:
-O2:死循环,不是爆栈。 编译器把这段递归改写成了一条跳回自己的jmp(objdump里那个函数已经没有自调用了),栈一点没长(跑三秒 RSS 纹丝不动), 于是它一直转,直到 3 秒时限到了被杀掉 —— 页面上看到的「超时」是这么来的。-O0/-O1/-Og:这才是真的爆栈,而且是瞬间段错误(连 0.01 秒都不到,退出码 139), 根本轮不到超时。
⇒ 「屏幕上看到的现象」和「真正的原因」是两件事。 以后每次想写「那其实是 XXX」之前,先真跑一遍 —— 这一条就是这么被抓出来的。 (实测环境:g++ 15.2.0,WSL2 内核 6.18.33.2,2026-08-24。换个编译器版本不一定这么优化。)
对拍只能证明「在生成器造得出的数据范围内没问题」。 如果生成器造的数据太温柔,错误代码照样能蒙混过关。
这不是假设 —— 我在准备第 14 章 BFS 时就踩了这个坑: 一份「漏掉了向上这个方向」的错误代码,在小而空旷的迷宫上连过 500 轮, 把墙加密、网格放大之后,第 55 轮就露馅了。
所以:造数据要往极端里造。多造边界(0、1、最大值)、多造特殊形状。 这件事本身就是一项要练的本事,后面每一章都会再练一次。
11自测
- 洛谷 P1028 数的计算解析 → —— NOIP2001。递归入门第一题,先想清楚「函数负责什么」
- 洛谷 P5461 赦免战俘解析 → —— 递归分治,能把递归「画」出来,很直观
- 洛谷 P1255 数楼梯解析 → —— 斐波那契。会 TLE —— 别急着优化,记住这个感觉,第 17 章解决它
- 洛谷 P1044 栈解析 → —— 进阶。想不出来就先跳过,学完第 17 章再回来
第 2 章会用汉诺塔把「信任那个还没写完的函数」这件事再练一遍 —— 汉诺塔的递归只有三行,但几乎没人能靠「在脑子里模拟执行」想明白它。 它会逼着你必须用「使用者视角」,是检验这一章有没有真学会的试金石。