ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

递归进阶:自上而下与自下而上的思维差异与实战对比

递归进阶:自上而下与自下而上的思维差异与实战对比 1. 内容整体设计与思路拆解在算法这条路上递归是一道绕不过去的坎。很多人一开始接触递归记住的只有“函数调用自己”这句废话真正面对问题的时候要么不知道递归出口怎么找要么写出来的代码在 n30 的时候就开始卡死。我自己带过不少初学算法的朋友发现他们卡住的最大原因不是不知道递归语法而是脑子里始终没有建立起“问题拆解方向”的概念。《算法很美》第四部分开篇讲递归直接抛出了两个方向自上而下Top-down和自下而上Bottom-up。我当时第一次听到这两个词第一反应是这不就是递归和迭代的区别吗后来才意识到这个理解太浅了。这两个方向本质上是两种完全不同的思维路径递归只是其中一种方向的天然载体而迭代也不是另一种方向的唯一实现方式。1.1 什么是自上而下自上而下也叫自顶向下指的是从最终目标出发把一个大问题逐层拆解成规模更小的子问题直到子问题小到可以直接给出答案然后再把子问题的结果逐层返回汇总成大问题的解。最典型的就是斐波那契数列的朴素递归写法int fib(int n) { if (n 1) return n; return fib(n - 1) fib(n - 2); }这段代码的执行过程就是从上往下不断调用自己先把 fib(n) 拆成 fib(n-1) 和 fib(n-2)再把 fib(n-1) 拆成 fib(n-2) 和 fib(n-3)……一直拆到 fib(0) 和 fib(1) 这两个已知值为止。整个过程像一棵不断往下生长的树所以叫“自上而下”。这种方式的优势是思维负担小。只要你找到了递归方程代码几乎就是照着方程抄一遍。缺点也很明显如果没有记忆化大量子问题被重复计算复杂度会指数级爆炸递归深度过大时还会栈溢出。1.2 什么是自下而上自下而上也叫自底向上是反过来从最小子问题开始先把最底层的答案算出来再逐步往上推导最终得到大问题的答案。它不一定非要写成迭代形式但绝大多数情况下用迭代实现会让代码更简洁、性能更好。用斐波那契数列举例自下而上的写法是从 fib(0)、fib(1) 出发依次推出 fib(2)、fib(3)……直到 fib(n)int fibDp(int n) { if (n 1) return n; int prev2 0, prev1 1; for (int i 2; i n; i) { int cur prev1 prev2; prev2 prev1; prev1 cur; } return prev1; }这两种方式解决的是同一个问题使用同一个状态转移方程区别只是计算的推进方向。1.3 两种路线的选择标准我经常被问到到底用自上而下还是自下而上我的建议是分情况判断而不是凭感觉选。如果问题本身就有明确的“大问题拆小问题”结构而且你一下子就能写出递归方程那么直接从自上而下入手先写出朴素递归再考虑优化。如果递归写出来后存在大量重叠子问题说明需要记忆化这时候你可以选择保留递归结构加备忘录也可以直接改成自下而上的动态规划。如果递归深度可能非常大比如 n 达到十万甚至百万那么应该优先考虑自下向上的迭代写法避免系统栈溢出。如果问题的子问题顺序不直观难以确定迭代方向自上而下的记忆化搜索反而是更稳妥的选择。这里要特别强调一点自上而下和自下而上不是对立关系而是同一种状态转移思想的两种表达形式。理解了这一点后面学动态规划会顺很多。2. 核心细节解析与实操要点2.1 递归三要素与状态转移判断你递归学没学明白我通常看三件事递归出口、递归方程、子问题的规模是否递减。这三个要素缺一不可。递归出口是最容易被忽视的。很多人写递归时先写递归体后补终止条件结果要么漏掉要么条件给错。正确的做法是先问自己问题最小到什么时候我能直接给出答案这个答案就是出口。递归方程是核心中的核心。它的本质是表达“当前问题的解怎么由更小的子问题构成”。比如斐波那契数列当前项等于前两项之和爬楼梯问题到第 n 阶的方案数等于到第 n-1 阶的方案数加第 n-2 阶的方案数。递归方程找对了代码只是翻译工作。子问题规模递减保证递归能结束。如果递归方程里子问题的规模没有变小那么递归就会无限循环直到栈爆掉。这是我发现初学者最容易犯的问题之一递归方程写得热闹但调用自身时参数没有变化程序卡死都不知道为什么。2.2 递归树与复杂度估算估算递归算法的复杂度最直观的工具是递归树。拿朴素的斐波那契举例fib(n) 调用 fib(n-1) 和 fib(n-2)fib(n-1) 又调用 fib(n-2) 和 fib(n-3)每一层调用的数量大约翻一倍树的高度是 n。所以朴素的斐波那契递归时间复杂度是 O(2^n)。看到指数级复杂度第一反应就应该是“存在重叠子问题”。你可以自己画一画递归树会发现 fib(n-2) 被计算了不止一次而是多次。递归树还能帮我们看清空间复杂度。递归的每一层调用都会在系统栈上占用空间递归树的最大深度就是空间开销所以朴素递归的空间复杂度是 O(n)。加了记忆化之后每个子问题只计算一次递归树退化成一条链时间复杂度降为 O(n)。这个从指数级到线性级的飞跃就是动态规划里“避免重复计算”的直观体现。2.3 备忘录设计与状态存储记忆化搜索说白了就是给递归加缓存。用一个数组或哈希表记录已经算过的子问题结果下次遇到同样的子问题直接取缓存不再重复计算。我写的斐波那契记忆化递归是这样的int fibMemo(int n, vectorint memo) { if (n 1) return n; if (memo[n] ! -1) return memo[n]; memo[n] fibMemo(n - 1, memo) fibMemo(n - 2, memo); return memo[n]; }这里需要注意两个细节第一备忘录的初始值要选好。我经常用 -1 表示“还没算过”因为斐波那契的结果都是非负数-1 不会和合法结果冲突。如果你的问题里 -1 也可能是合法答案那就换一个不可能出现的值比如 INT_MIN或者干脆用一个布尔数组标记是否计算过。第二备忘录的索引范围要提前规划清楚。很多人在递归中直接开一个大小为 n 的数组结果访问 memo[n] 越界。稳妥的做法是开 n1 或者更大一点把索引从 0 开始算好。记忆化搜索和自下而上的动态规划在时间复杂度和空间复杂度上通常是一样的只是推进方向不同。我个人的习惯是正式做题时优先写记忆化搜索因为它更接近人类思考方式不容易出错笔试要求性能时再改成自下而上的迭代版本。3. 实操过程与核心环节实现3.1 用斐波那契串起两种思路斐波那契数列是最经典的教学案例也是我每次复盘这两个概念时必讲的例子。它足够简单简单到能让人把所有注意力集中在方向本身。完整的三层对比是这样的实现方式方向时间复杂度空间复杂度核心特点朴素递归自上而下O(2^n)O(n)代码最直观重复计算严重记忆化递归自上而下O(n)O(n)保留递归结构加缓存迭代递推自下而上O(n)O(1)性能最优代码稍抽象我强烈建议你亲手把三个版本都写一遍然后分别跑 fib(10)、fib(30)、fib(50)感受一下运行时间的差异。我记得自己第一次跑朴素递归 fib(45) 的时候笔记本风扇狂转等了十几秒才出结果改成迭代版本后瞬间就算出来了。这种对比带来的冲击比任何理论解释都管用。3.2 爬楼梯自上而下写递归爬楼梯问题描述很简单每次可以走 1 阶或 2 阶问走到第 n 阶有多少种不同的走法。先不要急着写代码先想状态。走到第 n 阶最后一步可能是从第 n-1 阶跨上来也可能是从第 n-2 阶跨上来。所以走到第 n 阶的方案数 走到第 n-1 阶的方案数 走到第 n-2 阶的方案数。于是状态转移方程就是f(n) f(n-1) f(n-2) f(1) 1 f(2) 2按照自上而下的方式写递归代码几乎直接翻译方程int climbStairs(int n) { if (n 1) return 1; if (n 2) return 2; return climbStairs(n - 1) climbStairs(n - 2); }这个写法在 n 较大时同样会超时加上备忘录或者改成自下而上的迭代即可。我见过很多人在这个题上犯一个边界错误把 f(0) 定义为 1然后递归方程写成 f(n) f(n-1) f(n-2)f(1)1f(2)2 也能跑通。但如果你的思路是“到第 0 阶有一种走法不走”这个定义本身没问题只是比较抽象容易把自己绕晕。我更推荐初学者把边界定义成具体可感知的 f(1)1、f(2)2等对状态理解透彻了再考虑更精简的边界。3.3 归并排序两种方向的实现对比归并排序是另一个能很好体现“自上而下”和“自下而上”差别的经典算法。教材上通常讲的是递归版本也就是自上而下的分治把数组对半切开分别排序再合并。这是理解分治思想的最佳范例。递归版本的伪代码骨架void mergeSort(vectorint arr, int left, int right) { if (left right) return; int mid (left right) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid 1, right); merge(arr, left, mid, right); }但归并排序也可以自下而上做先把数组分成若干个长度为 1 的子数组两两合并成长度为 2 的有序数组再把长度为 2 的数组合并成长度为 4 的有序数组……直到整个数组合并完成。这个过程不需要递归调用只用循环控制合并的步长。迭代版本的核心思路void mergeSortIterative(vectorint arr) { int n arr.size(); for (int len 1; len n; len * 2) { for (int left 0; left n - len; left 2 * len) { int mid left len - 1; int right min(left 2 * len - 1, n - 1); merge(arr, left, mid, right); } } }两种实现的时间复杂度都是 O(n log n)空间复杂度都是 O(n)但代码的思考方式完全不同。自上而下强调的是“如何把大问题拆成小问题”自下而上强调的是“如何从小结果拼出大结果”。我建议你把两个版本都亲手写一遍。写完之后你会对递归的理解上一个台阶因为你会发现同一个算法换一个方向实现逻辑完全不一样但同样的正确。4. 常见问题与排查技巧实录4.1 递归超时与重复计算最常见的问题就是“我的递归代码逻辑明明对的为什么跑不出结果”如果你用 n50 跑朴素递归斐波那契卡住了这不是死循环而是计算量太大。这时你应该画递归树看看有没有重叠子问题。一旦发现了重叠子问题马上就能确定优化方案要么加备忘录要么换成自下而上的递推。我自己的排查顺序是这样的先确认递归出口正确也就是最小的子问题能返回正确结果。再确认递归方程正确也就是当前问题的解确实由这些子问题组成。然后画递归树观察是否有大量重复节点。如果有重复节点加备忘录如果递归深度过深直接改迭代。4.2 栈溢出与迭代改造递归深度超过系统栈限制时程序会直接崩溃。我记得有一次在本地测试一个递归遍历二叉树的问题数据量不大但是在递归到一万层的时候进程直接崩了。之后养成了一个习惯只要预估递归深度可能超过几千就提前考虑迭代写法。比如斐波那契递归写法虽然好理解但在 n 达到十万时即使有记忆化也不能用递归因为递归深度就是 n。这种情况只有自下而上的迭代才能稳稳地跑完。顺带提一句很多动态规划问题都面临同样的问题这也是为什么算法竞赛选手更偏爱递推而不是递归的原因。4.3 边界条件和状态方程错误边界条件写错会导致整体结果错位。最常见的是递归出口返回值给错。比如斐波那契的出口如果你把 fib(1) 直接返回 1 没问题但有些变种题会要求 fib(0)0、fib(1)1弄混之后整个结果链全部偏移。状态转移方程写反方向也经常发生。有的问题是“从前到后”推导比如爬楼梯有的问题是“从后到前”推导比如从终点出发逆推。一旦方向搞反循环边界和初始值全部跟着乱。我的建议是写自下而上版本时先把状态转移方程写在纸上把初始值和循环顺序标清楚再动键盘。4.4 常见问题速查表问题现象可能原因解决方案递归卡死或栈溢出递归出口缺失或子问题规模不递减检查出口条件确认递归参数变化n 稍大就运行超时存在大量重叠子问题画递归树确认加备忘录或改递推结果总是差一点边界值给错或状态转移方程写错从小数据逐项手算对照程序输出备忘录不生效初始值选择不当与合法结果冲突换用特殊值或额外布尔数组标记迭代结果顺序颠倒循环方向与状态依赖方向不一致明确状态依赖关系调整循环顺序这张表是我平时调试递归类问题时最常用的排查思路。大部分问题集中在表里的前两类而后两类问题往往是理解了方向之后依然容易踩的深坑。4.5 一个调试小技巧最后分享一个我实测下来很省事的调试方法在小数据规模下把递归函数的“入参和返回值”全部打印出来。很多人觉得这一步多余但在排查递归问题时它比任何调试器都好用。比如跑 fib(4)你会看到类似这样的调用序列fib(4) 调 fib(3) 和 fib(2)fib(3) 又调 fib(2) 和 fib(1)……把这些调用过程打印出来哪些子问题被重复计算了哪里返回值不对一目了然。等确认逻辑没问题后再把打印代码删掉加上备忘录优化即可。这套方法我用了很多年从初学者阶段一直到工作岗位调试复杂递归逻辑时依然有效。打印出来的调用树就是你的递归树对照着分析问题比肉眼盯代码快得多。
返回列表