数列、递推、递归,这三个词放在一起,很多人会下意识地觉得“这是数学内容”,等进入编程后又发现“递归是算法题里绕不开的坎”。数学课讲数列,算法课讲递归,数据结构讲递推,最后落到考试和面试里,常常变成“背公式”和“背模板”。结果就是:等差数列、等比数列能算,斐波那契能默写,但一旦要求自己设计一个递归函数,或者在一个真实场景里选择用递推还是用递归,又立刻卡住。
我印象里有一本已经绝版的老书,书名就叫《数列・递推・递归》。它不是那种厚到能砸核桃的教材,也不堆砌偏题怪题,却把这三个概念重新串成了一条完整的思维链。当年我从“会背斐波那契通项公式”到“真的能自己写出递归函数”,靠的就是把这本书里的练习踏踏实实做了一遍。现在回头看,大多数人对“递推”和“递归”的困惑,不是智力问题,而是从来没有人告诉他:数列是现象,递推是规则,递归是计算。三者不是三个孤立知识点,而是同一个问题在不同层面的表达。
这篇文章不准备讲“数列、递推、递归有什么定义”,因为那是百科。我想说的是,当你把这三件事串起来看之后,那些曾经让你头疼的题目,很多会变成一个顺理成章的推导过程。
1. 先分清三件事:数列是现象,递推是规则,递归是计算
1.1 数列:我们看到的是一串数字,要找的是数字背后的“生成方式”
数列给人的最初印象就是“一串有顺序的数”,比如 1, 1, 2, 3, 5, 8, 13…… 但如果只给这一串数字,任何人都能编出无数个规律,让第 7 项等于任意值。数学上讨论数列,本质上是讨论“第 n 项和前面的项之间存在什么关系”,以及“是否有一个明确的规则来产生后续每一项”。
很多初学者对“数列”的理解停留在“求第 n 项”上,这是远远不够的。你看到 1, 2, 4, 8, 16……,当然能想到指数增长;看到 1, 4, 9, 16, 25……,能想到平方。可一旦出现 1, 1, 2, 3, 5, 8 这种“第三项等于前两项之和”的数列,就开始有人套通项公式、套特征方程,却忘了它最自然的生成方式就是递推。
在后续写程序时,我们真正关心的不是“下一项是多少”,而是“能不能用某种顺序,从已知项推导出未知项”。这时,数列就从“一串数”变成了一种“状态序列”。
1.2 递推:用已知推未知,方向是从小到大
递推的本质很简单:给定初始条件,然后按照固定规则,一步接一步向后算。
比如斐波那契数列,用递推式写就是:
- f(0)=0
- f(1)=1
- f(n)=f(n-1)+f(n-2),当 n≥2
这就是热搜词里常见的“第三项等于前两项之和的递推公式”。它描述了当前项与前面项之间的关系,但这种描述本身不会告诉你怎么得到 f(10)。需要你从 f(0)、f(1) 开始,一路加到 f(2),然后再得到 f(3),这样正向推进。
用代码表达,就是最常见的循环:
def fib_iterative(n): if n == 0: return 0 if n == 1: return 1 a, b = 0, 1 for _ in range(2, n + 1): a, b = b, a + b return b这个函数叫作“递推实现”。它符合人类的自然思路:既然我知道前两项,那就算第三项;知道第三项,就算第四项。这种从边界出发、逐步向上算的计算方向,正着走,叫递推。
递推的优点很直接:时间复杂度低,不占用额外调用栈,不容易爆栈。缺点是你必须先找到“从小到大的计算顺序”。有些问题你能写出递推关系,但很难直接改成这种正方向循环,因为可能要先算出一大堆中间状态,这会让你觉得“递推也得会建模”。
1.3 递归:把大问题拆成小问题,方向是从大到小
递归的表达方式则相反。它不急着“从第 0 项算到第 n 项”,而是假设 f(n-1)、f(n-2) 都已经算好了,然后用同一个规则把 f(n) 计算出来。
表达式仍然是:
- f(0)=0
- f(1)=1
- f(n)=f(n-1)+f(n-2)
但计算方向变成了从 n 开始向下拆。
def fib_recursive(n): if n == 0: return 0 if n == 1: return 1 return fib_recursive(n - 1) + fib_recursive(n - 2)递归实现看起来“更数学”,也更贴近递推式的表达,因为它只需要定义清楚两件事:
- 基础情形(n=0、n=1 时直接返回)。
- 递推关系(当前问题转化为两个更小问题)。
但计算机并不知道怎么自动帮你算 f(n-1),它只是把一个新的、参数更小的 fib 调用压到调用栈上,继续拆,直到拆到基础情形,再把结果一层层带回来。
这里有一个关键点:同一个递推式,用正着算的循环是做递推,用函数调用自己来做是递归。二者背后是同一个数学规则,差别在于“由谁维护中间状态”,以及“计算是自底向上还是自顶向下”。很多人学递归时觉得玄幻,是因为没有意识到递归函数里其实藏着一个“反向的递推过程”:从大问题拆到最小问题,再把最小问题的结果一路回传,合成大问题的解。
2. 老书真正厉害的地方,是把“递推、递归、递推”练成了肌肉记忆
2.1 用斐波那契数列反复训练“翻译能力”
《数列・递推・递归》这本老书里,反复出现的并不是那些奇技淫巧,而是一个个简单但能说明方法的数列。斐波那契数列是其中最典型的模型,因为它有三种并存的表达:通项公式、递推式、递归调用。
书中的做法是先让你从数列现象里提炼递推式:
观察:1, 1, 2, 3, 5, 8, 13 规律:第三项等于前两项之和 边界:第一项=1,第二项=1(或者第0项=0,第1项=1) 递推式:F(n)=F(n-1)+F(n-2)然后再提醒你去数一数:如果用递归去求 F(6),C 语言教科书里会写多少层调用?每次调用分别计算了什么?有没有重复算同一个 F(k)?
我第一次在书里画递归调用树时,才恍然大悟:原来递归不是“不用管顺序”,而是把顺序交给调用栈。系统会从上到下拆到底,再从底向上返回。所谓“递推”和“递归”,不过是同一个递推式沿两条相反方向走路。书里不断让你把递归代码改成迭代代码,再把迭代代码改回递归代码,就是为了建立这种双向翻译的肌肉记忆。
2.2 用“整数转字符串”讲清楚递归调用时到底发生了什么
热搜词里有一条“递归法将一个整数n转换成字符串”。这是一个很经典的问题:输入整数 n(例如 1234),把它变成字符串 "1234",并且要求用递归实现。
朴实一点的写法是每次取出最低位数字,把剩下的高位继续递归,最后再拼接:
def int_to_str(n): if n < 0: return "-" + int_to_str(-n) if n < 10: return str(n) return int_to_str(n // 10) + str(n % 10)调用int_to_str(1234)时,会发生:
- 不是题目要求的 1234 < 10,所以计算
int_to_str(123); - 进一步计算
int_to_str(12); - 再计算
int_to_str(1); - 此时 1 < 10,返回
"1"; - 上一层拿到
"1"后拼上"2",得到"12"; - 再上一层拼上
"3",得到"123"; - 最外层拼上
"4",得到"1234"。
这个例子非常适合理解“递归调用点之后的代码什么时候执行”。n // 10是“深入”的过程,str(n % 10)是“回溯返回”时执行的拼接过程。很多人把递归想成“一下子出结果”,其实递归是“一路压栈到底,再一路弹出拼接”。老书用这种数学味很浓的题目,让你在一个极小的场景里看清楚函数调用栈的作用。
2.3 从数学递推到程序递归,目录遍历同样是一棵树
老书虽然来自纸笔年代,但它讨论的递归思想放到今天依然管用:只要一个问题能分解成若干个“更小的同类问题”,就可以用递归来处理。文件系统目录遍历就是一个典型场景。
我们想写一个函数,递归地列出某个目录下的所有文件。这个问题里,“目录里还有子目录”,子目录又可能包含子目录。用递归来描述特别自然:
import os def list_all_files(path): for name in os.listdir(path): full_path = os.path.join(path, name) if os.path.isdir(full_path): list_all_files(full_path) else: print(full_path)这里的“当前目录任务”会转化成“遍历每个子目录的任务”,也就是把一个大目录拆成若干个子目录问题,直到遇到文件后不再继续递归。现实中很多版本控制工具,配置“忽略目录”时也有递归规则。例如 SVN 里设置 ignore 时,如果表达式没有正确处理递归行为,那么某个目录下的深层子目录可能依然会被扫描。理解递归,不仅是为了刷题,更是为了读懂这些工具的默认行为。
2.4 老书的结构不是“知识清单”,而是“思维链训练”
现在很多资料把数列、递推、递归拆成三个独立栏目:数列题归数学,递推归动态规划,递归归算法。读者在每个栏目里都能做对题,但一遇到综合问题就不知道从何下手。
老书的编排方式完全不同。它先给出数列,让你提炼递推式;然后让你把递推式翻译成程序;最后再讨论什么时候该用递归,什么时候该转成递推。它不是为了介绍“递归”这个语法,而是为了训练一种能力:看到一个规模为 n 的问题时,先寻找它和规模为 n-1、n-2 的问题之间的关系,再决定用正向递推还是递归分解。
这种训练带给人的价值,比“记住斐波那契怎么写”要大得多。因为动态规划、分治、回溯、深度优先搜索,本质上都是这套思维在不同问题上的变形。你提前在数列、递推、递归这个最小模型上练熟了,后面遇到复杂算法就不会觉得每个都是新知识。
3. 从看懂到写出:围绕递推式做四步刻意练习
3.1 第一步:遇到任何问题,先写出递推式和边界
不要一上来就写代码,先在纸上回答三个问题:
- 如果把问题规模记为 f(n),那么 f(n) 依赖哪些更小的子问题?
- 这些子问题之间是“相加”“取最大”“取最小”还是“拼接”?
- 最小的子问题(边界)是什么?
例如“求第 n 个斐波那契数”,可以回答:
f(n) = f(n-1) + f(n-2) f(0) = 0, f(1) = 1再例如“把整数 n 转换成字符串”,可以回答:
to_string(n) = to_string(n // 10) + 最低位数字的字符串 to_string(0) = "0" 正负号单独处理这一步看起来简单,却直接决定后面代码能否写对。很多递归写不出来,不是因为不会写函数,而是因为连问题规模怎么缩小都没想清楚。
3.2 第二步:先用循环把递推式“正着算”一遍
我强烈建议先写递推版本,也就是循环版本。原因很简单:正着算时,你必须自己维护“前一项”“后一项”的顺序,这能逼你理解递推式到底在算什么。
以整数转字符串为例,它的递推(迭代)版本可以这样写:
def int_to_str_iterative(n): if n == 0: return "0" sign = "" if n < 0: sign = "-" n = -n digits = [] while n > 0: digits.append(str(n % 10)) n //= 10 digits.reverse() return sign + "".join(digits)这个版本从低位向高位取数字,再反转顺序。它虽然简单,但已经包含了“取余”“整除”“顺序拼接”这些关键动作。先把这个版本跑通,再去写递归版,你会更容易理解递归到底帮你省掉了哪些手工维护步骤。
3.3 第三步:把循环改写为递归,注意调用点之后的代码顺序
从循环到递归,相当于把“自己手动管理的一个临时变量列表”换成“由调用栈自动保存的状态”。在整数转字符串中,我们用n // 10缩小问题,用str(n % 10)产生当前位的字符。关键是“拼接顺序”要选对。
如果写成:
return str(n % 10) + int_to_str(n // 10)就会得到反过来的字符串。因为递归调用在右侧,会在得到子结果前先输出当前位,然后才去处理高位。这个细节看起来很小,却能一次性测试出你是否理解函数调用栈的执行顺序。
正确的顺序应该是:
return int_to_str(n // 10) + str(n % 10)先递归缩小规模,等子问题返回后,再把当前位的字符拼在后面。所以第三步一定要做的刻意练习是:给递归函数里的每个 return 都标注“这里是在向下深入时执行,还是在回溯返回时执行”。
3.4 第四步:用记忆化或迭代处理性能问题,再对比不同写法的差别
递归版本不是免费的。当斐波那契数列的递归暴露出大量重复计算时,书里会自然引入一个优化技巧:用一个数组保存已经算过的结果。
def fib_memo(n, memo=None): if memo is None: memo = {} if n in memo: return memo[n] if n == 0: return 0 if n == 1: return 1 memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo) return memo[n]这就是“自顶向下 + 记忆化”,也叫带缓存的递归。它保留了递归的直观,又避免了重复计算。但要注意,记忆化并不等于能无限递归,因为 Python 默认递归深度有限制,如果 n 特别大,依然可能触发 RecursionError。这时候就需要彻底改写成从底部向上的迭代版本。
这四步练下来,你会逐渐形成一种判断:什么时候递归最方便、什么时候递推最可靠。不是所有递归都要改成递推,也不是所有递推都能容易地改成递归,关键是找到适合当前场景和语言限制的写法。
4. 真正吃透递归,第一步不是背语法,而是学会画递归调用树
4.1 递归调用树是什么?为什么要画?
任何递归函数,都可以用一棵“树”来表示。树的根节点是原始函数调用,每个节点的子节点是它递归出来的子调用。
比如fib(5)的递归调用树,根节点是 fib(5),下面分成 fib(4) 和 fib(3)。fib(4) 又继续分成 fib(3) 和 fib(2) 等等。如果画完全,你会看到很多节点是重复的:fib(3) 出现在 fib(5) 的左分支和右分支,被计算了不止一次。
画递归调用树有两个作用。一是直观显示递归深度和总调用次数,帮你预估时间和空间成本;二是帮你定位边界条件放得对不对。很多递归代码只是“看起来正确”,但只有画出树,你才会发现有些分支根本没有往终点走,永远停不下来。
4.2 递归树是理解“动态规划”和“回溯搜索”的共同基础
搜索算法里的深度优先搜索(DFS),本质上就是在一棵搜索树里递归地选择下一步。约束满足问题(CSP)例如数独、八皇后等,经常用递归回溯来尝试每一种可能。每一个递归函数调用代表“当前在某个状态”,如果状态不满足约束,就回退到上一层,尝试另一种选择。
这时候你只需要把“递推式”换成“决策规则”:
- 当前问题变成“在某一步做选择”。
- 递推关系变成“做完选择后进入更小的子问题”。
- 边界条件变成“选择已经完成,输出或记录结果”。
把递归调用树画出来,你会立刻看到搜索空间有多大,也能看出剪枝应该发生在哪一层。这比单靠背模板理解得快得多。
4.3 判断一个递归对不对,只看两个标准
写递归时可以反复检查:
- 每次调用是否让问题规模变小?如果调用参数没有变小,递归就可能无限循环。
- 是否最终能到达基准情形?如果问题缩小到边界时没有对应的 return,程序就会报错或返回错误结果。
这两个标准听起来很基础,但很多隐蔽的 bug 都源于其中之一。比如有人写递归处理链表时,传进去的node.next可能为空,却忘了判断node is None,结果在基准情形上少了一个分支。画调用树时,这个问题会暴露得很清楚:某个叶节点不是正常结束,而是一个未知的空节点。
5. 新手最容易踩的三个坑,以及我的排错顺序
5.1 坑一:找到了递推式,却没有覆盖所有边界条件
递推式往往只描述“一般情况”,边界通常单独说明。递归函数里的基准情形必须覆盖所有“直接返回”的输入。
举个例子,把整数 n 转换成字符串时,需要处理 n = 0、n > 0、n < 0 三种情况。如果只写:
if n < 10: return str(n)当 n=0 时是没问题的,但当 n 是负数时,n<10 成立,会返回 "-1" 而不是 "-1"? 这个例子不太精确。总之,负数没有特殊处理会导致错误。
处理负数的常见方式是:先判断符号,然后对绝对值递归。但这里也有一个隐藏坑:绝对值-n在大多数语言里对INT_MIN可能会溢出。所以在真正写生产代码时,还得先用更大的整数类型,或者单独处理边界。这就是“边界条件不仅要有,还要覆盖输入范围的极端值”。
5.2 坑二:递归和递推混在一起,导致“能跑但很慢”或“跑着跑着爆栈”
有的初学者会把递归和递推当成可以随手替换的写法:递归写不出来就靠循环硬改,循环写不好就用递归试试。但二者对资源的消耗完全不同。
递归每次调用都会占用一层函数调用栈。Python 默认递归深度通常是一千左右(实际可配置,但不宜盲目调大)。如果题目明确说 n 可能达到百万级别,那么无论递归写得多清晰,都应该考虑递推。反之,如果问题天然是一棵树,例如目录遍历、二叉树遍历,用递归会非常自然,强行改成递推反而要自己维护一个栈,不一定比递归简单。
我的判断标准是:
- 递归深度和 n 同量级,且 n 可能很大:优先用递推或自己维护栈。
- 问题本身是递归结构(树、嵌套括号、表达式):优先用递归。
- 自顶向下思考更简单,但担心重复计算:先用记忆化,再考虑要不要转成迭代。
5.3 坑三:不理解字符串拼接/列表拼接的时机,导致结果顺序颠倒
递归里最常见的一类错误,是“不知道当前层的代码在子调用返回前还是后运行”。
以整数转字符串为例,如果写成:
def to_str_wrong(n): if n < 10: return str(n) return str(n % 10) + to_str_wrong(n // 10)对 1234 调用,会先取到 4,再深入处理 123,最终得到 "4321"。这就是因为把当前位的处理放在递归子调用之前了。递归调用点之后写的代码,要在子调用返回后才会执行;调用点之前写的代码,则会在深入之前执行。理解这一点比背“递归先递后归”重要得多,因为不同的拼接场景需要你把操作放在不同的位置。
5.4 一套排错链路:递归出问题,按这个顺序查
如果写了一个递归函数,结果报错、死循环或输出异常,我一般会按下面顺序排查:
- 看现象:是无限递归(栈溢出/RecursionError),还是结果错误,还是运行太慢?
- 看输入边界:把输入切成最小规模,例如 0、1、负数、空列表、单节点,逐个测试。
- 看基准情形:检查递归出口是否覆盖了所有能直接返回的输入。是否少了
n == 0,是否忘了node is None,是否正确处理了空字符串。 - 看递归参数:每次递归调用传入的参数是否“变得更小”,方向是否最终会走到基准情形。
- 看调用点位置:需要先深入后处理,还是先处理后深入。检查拼接/累加/插入代码写在递归调用之前还是之后。
- 看资源消耗:如果只是慢,画出递归树,检查是否存在大量重复分支;如果会爆栈,尝试改成记忆化或迭代。
这套链路对几乎所有递归题都适用。按顺序走一遍,通常不需要靠“猜”来定位问题。
6. 为什么一本绝版老书,今天仍然值得认真读一遍
6.1 它帮你建立了“从数学到程序”的翻译感
现在的资料太多,一个问题往往有三四种解法视频、七八篇题解,反而让人失去自己推导的耐心。《数列・递推・递归》这种老书的好处是节奏很慢,它会从“观察一串数”开始,一步步带你写出递推式,再翻译成程序。这个慢过程,恰恰是现代学习者最缺的。
我并不是说要把老书当成唯一教材,而是建议用它做“精读素材”。因为它每一章都围绕递推和递归展开,不需要你在一堆章节里找主线。你只需按照书里的例题顺序,自己先推演,再上机验证,就能把“观察规律 -> 写递推式 -> 用递归或递推求解”这条链练成习惯。
6.2 阅读老书时,你应该做什么,而不是摘录什么
现在读一本绝版教材,不建议直接从头到尾抄概念。我试过的有效方式是:
- 拿到一个数列题,先遮住答案,自己在纸上写出初始项和递推式。
- 再把递推式分别写成递归版和迭代版。
- 执行递归版时,手动画出前几层调用树。
- 最后对照书中的讲解,看自己遗漏了什么边界条件或优化方向。
这相当于把一本旧书当成了练习册,而不是参考书。书里某个问题可能只给了一个方法,但你可以主动尝试至少两种实现,并比较它们的差异。做这件事的价值,比读十篇“递归心法”更大。
6.3 真正值得长期记住的,是“递推思维”本身
数列、递推、递归这三个词,在计算机领域里无处不在。写动态规划时,你要寻找状态转移方程,本质是递推;写树的遍历时,你要让函数调用自身,本质是递归;写搜索算法时,你要处理分支、回溯和剪枝,本质是在递归调用树上做优化。这三者从来不是割裂的考点,而是同一种建模能力的不同表现形式。
现在再回到开头的问题:“为什么我学不会数列、递推、递归?” 答案往往是:因为一直在学具体技巧,而没有去理解那条从现象到规则、再到计算的路径。如果你愿意花一个下午,把斐波那契数列从“观察规律”推到“迭代代码”,再推到“递归代码”,最后画一遍调用树,你会发现很多原本散落的算法知识开始自动归位。
如果手头能找到那本绝版老书,建议慢慢读;如果找不到,也可以用任何一本结构清晰、例题简单的旧教材替代,甚至可以用开源算法书配合在线练习。但无论用什么材料,都请记住那个最朴素的学习循环:先观察,再写递推式,然后写出一个能跑的递归或迭代版本,最后画出它的执行过程。这个循环跑得越多次,你对“数列・递推・递归”的理解,就越接近一个真正内化的工程师,而不是一个靠记忆答题的应试者。