阶段 0 · 递归思维 · 第 1 章普及组 J

递归入门:函数怎么调用自己

这一章只干一件事 —— 让你敢相信一个还没写完的函数。

例题:求一个整数的数位和建议用时:90 分钟
这一章和后面的章节不太一样

后面每一章都是「暴力很慢 → 想出关键一步 → 正解很快」。 这一章不比快慢 —— 它要解决的是一个更靠前的问题:你为什么读得懂递归、却写不出递归。

所以这里故意挑了一道用循环三行就能搞定的题。因为循环版你百分之百写得对, 它就能当成标准答案,让你验证自己写的递归到底对不对。先建立信任,再谈技巧。

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循环写法(你已经会的那种)

loop.cpp循环版
// 数位和 —— 循环写法(你已经会的那种)
//
// 输入:一个非负整数 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

没什么好说的,这个你闭着眼睛都能写。但请记住它 —— 第 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递归写法

rec.cpp递归版
// 数位和 —— 递归写法
//
// 和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

就两行。对照上面那张表看:if (n == 0) return 0; 是边界, return n % 10 + digitSum(n / 10); 是递推关系,函数名和注释说明了职责。

8看见它是怎么跑的

递归最大的障碍是「看不见」。所以我们在进入和返回的地方各加一行打印,用缩进表示层数。

trace.cpp过程演示
先点运行,然后对着输出,把注意力放在缩进的变化上:先一路变深,再一路变浅。
// 数位和 —— 带打印的递归,用来「看见」调用栈
//
// 这份代码答案和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑完之后,请特别注意输出的后半段:

   1 + 0 = 1
   2 + 1 = 3
   3 + 3 = 6
   4 + 6 = 10
   5 + 10 = 15

答案 15 不是在「递下去」的时候算出来的,而是在「归回来」的路上一层层攒出来的。 递的时候每一层都只是把问题往下抛,什么都没算;真正的计算发生在返回途中。

✓ 这个调试技巧要记住

在递归函数的开头和结尾各打印一行,用一个全局 depth 变量控制缩进。 以后写 DFS、写记忆化搜索卡住了,这一招永远有效 —— 它能把「看不见的执行过程」变成一张能读的表。

9单步看调用栈

打印还是太快了。下面这个动画把每一步拆开,你可以停在任何一帧反复看。

digitSum 的调用栈
第 1 / 19 步
调用栈(最上面 = 正在执行的那一层)
digitSum(12345) 等着 5 + digitSum(1234) 的结果
当前栈深
1
调用 digitSum(12345)。它自己算不出来,得先知道 digitSum(1234) 是多少 —— 于是继续往下递。

看的时候盯住两件事:

  1. 「递」的阶段:栈越堆越高,每一层都卡在半路,等着下面的结果 —— 没有任何一层算完了。
  2. 「归」的阶段:栈一层层塌下去,每弹出一层就产出一个值,交给上一层。
⚠ 递归深度是有代价的

栈的高度就是递归深度。每一层都要占用真实的内存(保存参数、局部变量、返回地址), 栈用完了程序就直接崩溃 —— 而且它不会告诉你「是递归太深了」,只给你一个段错误。

「多深算深」没有一个通用答案,它是个除法:栈有多大 ÷ 一层占多少字节。 两个数都随系统、编译器、函数里有多少局部变量而变 (这台 Linux 上 ulimit -s 给的是 8192 KB = 8 MB;Windows 上要小得多,常见是 1 MB 那个量级)。 所以别背数字,记这个除法;真要知道自己这台机器的上限,就自己量一次。 ★ 第 30 章第 11 步有一张实测的表,就是这个除法的样子:同一份递归 DFS,图一大就段错误, 表里写清了它压到多少层、每帧多少字节、栈是怎么用完的。

数位和最多递归 10 层左右(生成器最多造 10 位数),完全没问题。 但第 13 章的 DFS 在网格上,最坏情况下层数就是格子数(一条蛇形通路能把整片格子串成一条链), 到第 30 章那张表上它就真的崩了。

10★ 对拍:证明你写的递归是对的

现在到了这个站点最有价值的部分。

你已经有一份肯定正确的循环版。那就让它当标准答案:随机造一堆数字, 两份程序各跑一遍,比对结果。只要有一轮对不上,就把那组数据摆出来。

★ 正确的用法

不要直接点「开始对拍」验证我给的代码 —— 那当然是对的,没有意义。

把「递归版」那一栏里的代码整个删掉,换成你自己默写的那份,然后再点开始。 这才是它存在的理由:你不用问任何人,就能知道自己写对没有。

对拍器
把「递归版」标签页里的代码换成你自己默写的,再点「开始对拍」。生成器会随机造 300 个不同位数的数字,两份程序各跑一遍比对结果。
// 数位和 —— 递归写法
//
// 和 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自测

自测清单0 / 6
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 2 章会用汉诺塔把「信任那个还没写完的函数」这件事再练一遍 —— 汉诺塔的递归只有三行,但几乎没人能靠「在脑子里模拟执行」想明白它。 它会逼着你必须用「使用者视角」,是检验这一章有没有真学会的试金石。