递归关系:算法复杂度分析的推演核心

发布时间:2026/10/13 4:39:40
递归关系:算法复杂度分析的推演核心 很多人学算法最先接触的是排序、查找、链表这些具体知识点代码能背下来几套但一碰到“分析复杂度”就露馅。我见过不止一个能默写快排的人被问到“为什么平均是O(n log n)”时只能挤出“因为书上这么写”。这个问题的根源多半是没弄明白递归关系。算法效率这件事本质上就是在回答“问题规模变大时计算量怎么涨”而递归关系就是描述这种增长规律的数学语言。无论你是准备面试、做算法竞赛还是纯粹想把代码写得心里有底递归关系都是一道绕不过去的门槛。这篇文章就用实际案例把递归关系的基本形式讲透看完你至少能自己推演出归并排序、二分查找这类经典算法的复杂度而不是靠死记硬背。1. 为什么算法效率的根基是递归关系1.1 一段面试对话引出的问题以前当面试官时我经常出这样一道题写一个函数求数组最大值。绝大多数人都能写出线性扫描的版本复杂度O(n)。然后我追加一句能不能用递归写写完后再问一句这个递归版本的时间复杂度是多少能答上来的人大概一半不到。有意思的是这部分人里很多不是因为数学底子好而是因为他们真的理解“递归关系”这个工具。他们看到递归代码会条件反射地写出T(n) T(n-1) 1然后解出O(n)。这道题本身不难但它揭示了一个普遍现状很多人把递归当成一种“写法”而不是一种“分析模型”。实际上递归不仅是一种代码组织方式更是一种描述计算量的数学框架。当你写出一个递归函数时它的时间复杂度就已经被一个隐藏的递归关系锁死了——分析算法的效率本质就是解这个递归关系。1.2 递归关系描述“规模自相似”的方程正式一点说递归关系recurrence relation是一个方程它把问题规模为n时的某种量通常是时间开销T(n)表达成更小规模问题对应量的函数。举个例子最简单的情形T(n) T(n - 1) 1T(1) 1这个方程表示解决规模n的问题需要先解决规模n-1的问题再做1单位的额外操作。展开之后是什么T(n) T(n-1) 1 T(n-2) 2 ... T(1) (n-1) n。所以T(n) O(n)。这里的关键是“自相似”大问题的开销由小问题的开销加上自己的额外开销构成。这跟递归代码的结构是严格对应的。看这段求和代码int sum(int n) { if (n 1) return 1; return sum(n - 1) n; }函数体内做了三件事判断边界、递归调用、加法。其中递归调用本身消耗的就是T(n-1)加法和判断是常数时间。于是就有了上面的递推关系。反过来你给出任何一个递推关系基本都能想象出一个对应结构的递归程序。生活化一点想递归关系就像记录家庭开支这个月的总支出 上个月的总支出 这个月的新增支出。上个月的总支出又取决于上上个月层层往回推直到推到某个起始月份。算法的递归关系也是这个逻辑只不过它关注的是“计算量”而不是“钱”。1.3 递归关系怎么影响复杂度递归关系之所以重要是因为它直接决定了复杂度的增长量级。同样是“每次只减小一个规模”的递归差一个常数项结果可能差一个量级T(n) T(n-1) 1复杂度O(n)T(n) T(n-1) n复杂度O(n²)前者相当于每次只做1单位额外工作后者相当于每层都要做n单位额外工作。层数都是n层但总量截然不同。这就是为什么“展开递归关系”能让你真正看清算法的效率而不是停留在“这代码是递归的所以很慢”这种模糊判断上。我个人的体会是分析递归算法复杂度时最忌讳的就是凭感觉。递归关系是精确的数学对象你只要把T(n)写对后续解方程的过程可以机械执行。绝大多数人出错不是解方程的能力不行而是第一步T(n)就写错了——这个坑后面专门讲。2. 递归关系的三类基本形式2.1 线性递减型每步缩小一个固定量第一类最常见的形式是T(n) a·T(n-b) f(n)其中a是递归调用次数b是每次缩小的规模f(n)是额外开销。这类递归对应的代码结构是每次递归调用传入的规模n-b而且这种调用出现了a次。最基础的情况是a1、b1也就是每次只调一次规模减1。典型例子就是上面说的递归求和、递归求阶乘。这种形式展开后是一个等差数列复杂度很容易判断看f(n)的量级。如果f(n)是常数T(n) O(n)如果f(n)是线性T(n) O(n²)如果f(n)是二次T(n) O(n³)规律就是比f(n)高一次。这个结论可以用展开法快速验证T(n) T(n-1) n展开n次后得到n (n-1) ... 1这是等差数列求和结果是n(n1)/2即O(n²)。a1的情况就危险了比如T(n) 2T(n-1) 1这意味着每一层的问题会分裂成两个子问题而且每个子问题规模只减1。展开后是2^n级别的增长。这就是为什么有些递归代码跑起来卡死——表面上看“每层只减1”实际上是指数爆炸。2.2 分治型问题一分为多第二类是分治型T(n) a·T(n/b) f(n)。这里的a是子问题个数b是规模缩小倍数。归并排序是标准例子T(n) 2T(n/2) O(n)。二分查找是另一例T(n) T(n/2) O(1)。这种形式比线性递减型常见得多因为“分而治之”是算法设计里的核心思想。把一个大问题拆成若干个规模相当的子问题各自解决后合并结果几乎所有高效的排序、查找、矩阵运算算法都是这个套路。分治型的复杂度变化比线性递减型更微妙。同样是拆成两个子问题合并开销不同结果可能完全不同T(n) 2T(n/2) 1结果是O(n)T(n) 2T(n/2) n结果是O(n log n)T(n) 2T(n/2) n²结果是O(n²)这三种情况分别对应什么实际场景第一种像是把一个数组分成两半每半做同样操作但合并只需常数时间第二种是归并排序合并需要线性时间第三种是某些低效的分治算法合并阶段做了O(n²)的工作。为什么结果差异这么大关键在于随n增大递归树每一层的总工作量是在增加、不变还是减少。第一种情况下每层总工作量是124...n收敛到O(n)第三种情况下每层总工作量是n² 2·(n/2)² 4·(n/4)²等比增长第一层就主导了整体第二种情况每层工作量都是n层数log n层所以是n log n。2.3 常数项与多项式项的微妙差别递归关系里这个f(n)也就是“额外开销”往往决定了算法的命运。它的含义是在递归调用的开销之外当前这一层要做多少额外工作。这个额外工作可能是合并两个有序数组、可能是扫描一遍数据找中间点、也可能只是比较几个数。f(n)是常数、是对数、是线性、是n log n都会让最终复杂度落在不同的量级。我见过的初学者最容易犯的一个错误就是忽略这个f(n)的量级以为分治递归都是O(n log n)。这是错的前面已经给过反例T(n) 2T(n/2) n² 的结果是O(n²)不是O(n log n)。还有一个细节容易被忽略f(n)里的常数系数在渐进分析里不重要但f(n)的形式多项式、对数、指数至关重要。T(n) 2T(n/2) 10n和T(n) 2T(n/2) 0.1n²前者是O(n log n)后者是O(n²)。系数再小也改变不了n²的增长速度碾压n log n的事实。3. 三种求解方法展开法、递归树、主定理3.1 展开法最笨但最可靠展开法也叫迭代法就是把递归关系一层一层展开直到边界条件然后把所有项加起来。这个方法不需要任何技巧只需要耐心但它能让你从直观上理解复杂度为什么是这个量级。以T(n) T(n-1) nT(1) 1为例T(n) T(n-1) n T(n-2) (n-1) n T(n-3) (n-2) (n-1) n ... T(1) 2 3 ... n 1 2 3 ... n n(n1)/2结果就是O(n²)。整个过程没有任何跳跃每一层替换都是严格等价的。这种“笨办法”其实最适合用来验证自己对复杂度的直觉对不对。分治型也可以用展开法。以T(n) 2T(n/2) n为例T(n) 2T(n/2) n 2[2T(n/4) n/2] n 4T(n/4) n n 4[2T(n/8) n/4] n n 8T(n/8) n n n展开k层后子问题规模是n/2ᵏ前面系数是2ᵏ每层都会多出n的额外开销。当n/2ᵏ 1时停止k log₂n。此时T(n) n·T(1) n·log₂n n n log₂n也就是O(n log n)。展开法的实操心得展开到第2、3层时要停下来观察规律。如果每层的额外开销加出来是一个模式等差数列、等比数列、常数列后面就可以直接写求和公式不用真的展开到底。3.2 递归树法把复杂度画出来递归树法是展开法的图形化版本。把递归关系画成一棵树根节点代表原问题开销记为f(n)子节点代表递归调用每个节点的开销对应各自规模的f值从根到叶子的每条路径深度是递归层数所有节点的开销之和就是总复杂度。以T(n) 3T(n/4) n²为例根节点开销n²第一层有3个子节点每个开销(n/4)² n²/16这一层总开销 3n²/16第二层有9个子节点每个开销(n/16)² n²/256这一层总开销 9n²/256每一层总开销都在等比缩小公比是3/16小于1等比数列求和首项n²公比3/16总和收敛到n²/(1 - 3/16) 16n²/13所以复杂度是O(n²)。这个例子说明当递归树的每层总开销递减时复杂度由根节点主导。反过来T(n) 2T(n/2) n的递归树每层总开销都是n层数log₂n总开销n log₂n。再比如T(n) 4T(n/2) n第一层总开销n第二层总开销4·(n/2)2n每层翻倍等比增长最终由叶子层主导。叶子数量是4^(log₂n) n²所以复杂度是O(n²)。递归树法最大的价值在于“眼见为实”。我曾经让一个完全看不懂主定理的同学画了一张T(n) 2T(n/2) n的递归树画完他立刻明白了为什么是n log n——每层的工作量一目了然。如果你一开始理解不了主定理建议先用递归树把几个经典例子画一遍建立直觉后再用主定理提速。提示画递归树时重点看“每层总开销的变化方向”。递增复杂度由叶子层主导递减复杂度由根节点主导不变复杂度是层数乘以每层开销。3.3 主定理分治递推的“快速查询表”主定理Master Theorem是求解形如T(n) aT(n/b) f(n)的递推关系的标准工具其中a ≥ 1b 1f(n)是渐近正函数。它把复杂度分成三种情况核心是比较f(n)与n^(log_b a)的增长速度。先解释n^(log_b a)是怎么回事。在递归树里从根到叶子要经过log_b n层叶子节点数量是a^(log_b n) n^(log_b a)。这个值代表“叶子层的总容量”它的大小直接决定复杂度下限。你可以把它理解成两个选手叶子层的容量n^(log_b a)和每层额外开销f(n)谁增长得快谁就主导总复杂度。三种情况如下情况条件复杂度一f(n) O(n^(log_b a - ε))ε 0T(n) Θ(n^(log_b a))二f(n) Θ(n^(log_b a))T(n) Θ(n^(log_b a) log n)三f(n) Ω(n^(log_b a ε))ε 0且af(n/b) ≤ cf(n)c 1T(n) Θ(f(n))用实际例子对照T(n) 2T(n/2) na2, b2, n^(log₂2) n。f(n) n正好等于n^(log_b a)属于情况二答案是Θ(n log n)。T(n) 2T(n/2) 1f(n) 1 O(n^(1-ε))ε取0.5就成立属于情况一答案是Θ(n)。T(n) 2T(n/2) n²f(n) n² Ω(n^(1ε))属于情况三答案是Θ(n²)。主定理的实操要点先算出n^(log_b a)再和f(n)比增长量级。如果f(n)明显慢答案看n^(log_b a)如果f(n)一样快答案乘log n如果f(n)明显快答案直接是f(n)。记住“谁大听谁的一样大就乘log”。注意主定理不是万能的。f(n)和n^(log_b a)之间存在“多项式差距”是情况一和情况三的前提。如果f(n) n/log n这类夹在中间的情况主定理失效需要另想办法。4. 经典算法案例拆解4.1 归并排序T(n) 2T(n/2) O(n)归并排序是分治思想的代表作。它的流程是把数组一分为二递归排序左右两半然后合并两个有序数组。合并两个长度为n/2的数组需要最多n-1次比较所以合并阶段是O(n)。递归关系就写出来了T(n) 2T(n/2) nT(1) 1用递归树看每层分成2个子问题每个规模减半每层做的合并总开销都是n第一层合并两个n/2规模第二层合并四个n/4规模以此类推。树的深度是log₂n层所以总复杂度是n n log₂n Θ(n log n)。有一个很常见的疑问为什么归并排序一定是n log n不能更快从递归关系的角度回答任何基于比较的排序算法都可以建模为二叉决策树树的高度至少是log₂(n!)根据斯特林公式log₂(n!) ≈ n log₂n。归并排序的递归关系恰好达到了这个下界。这就是递归关系不仅能描述算法还能证明算法最优性的一个例子。实操中归并排序的空间复杂度O(n)经常被忽略。每次合并都会申请临时数组递归深度log n不会造成栈溢出但累计分配的空间是O(n)。我们在工程里优化归并排序时通常先分配一次全局临时数组避免递归过程中反复malloc这个优化和递归关系无关但能显著提升性能。4.2 二分查找T(n) T(n/2) O(1)二分查找的递归关系是T(n) T(n/2) 1边界条件T(1) 1。每次比较后问题规模减半只需要常数时间的额外操作。展开T(n) T(n/2) 1 T(n/4) 2 ... T(1) log₂n所以复杂度是Θ(log n)。n从1到10亿log₂n大约是30意味着最多比较30次就能找到目标。这就是为什么说二分查找在大数据量下依然飞快。这个例子揭示了一个重要结论分治递归的复杂度并不总是n log n。二分查找是“一个子问题常数合并”的分治复杂度是对数级归并排序是“两个子问题线性合并”复杂度是n log n。关键在于a子问题个数和f(n)额外开销的组合。实战中二分查找最容易被坑的不是复杂度分析而是边界条件。写递推公式容易写对循环里的middle更新逻辑难。但理解了递归关系可以反过来推导循环写法每次要保证问题严格减半避免死循环。比如left mid 1对应的是排除掉mid位置right mid对应的是保留mid位置这个取舍其实就是递推关系中“减少规模”的具体实现。4.3 斐波那契从O(2^n)到O(n)斐波那契数列的朴素递归是理解“指数爆炸”的最佳教材。代码很简单int fib(int n) { if (n 1) return n; return fib(n - 1) fib(n - 2); }递归关系是T(n) T(n-1) T(n-2) 1。这个递推关系不像分治型那样有一个明确的b因为两个子问题规模不是等比例缩小的一个减1一个减2。但可以证明T(n)是指数级的直观上看递归树的叶子数量大约等于斐波那契数F(n)本身而F(n) Θ(φⁿ)φ约等于1.618。所以朴素的递归斐波那契跑n50就已经非常慢了计算量大约是1.6的50次方约10的10次方量级。这就是我第一次实际跑这个代码时的感受——n45左右要等好几秒n50几乎等到让人怀疑电脑坏了。怎么优化观察递归树会发现fib(n-1)和fib(n-2)的计算存在大量重叠子问题。用一个数组缓存已经计算过的值每个n只算一次递归关系变成T(n) T(n-1) 1因为计算fib(n)时fib(n-1)已经递归算过了fib(n-2)直接从数组里取不再递归。复杂度瞬间从指数级降到O(n)。这就是动态规划里“记忆化搜索”的核心思路。递归关系在这里的作用不是求解复杂度而是帮助你发现重复计算的结构——当你在递归树里看到同一个节点反复出现时就该上记忆化了。5. 工程实战中的递归效率问题5.1 栈溢出与递归深度计算理论分析完复杂度回到实际工程第一个遇到的坑就是递归深度。递归调用依赖调用栈每个栈帧都占用内存。栈空间通常是几十MB级每一层递归大概消耗几百字节到几KB取决于局部变量数量和函数参数所以递归深度达到几万层就可能栈溢出。从递归关系可以提前预估递归深度。T(n) 2T(n/2) n的深度是log₂nT(n) T(n-1) n的深度是n。对一个n100000的数组做递归求和深度10万层用默认栈大小基本必崩。这不是复杂度问题——复杂度只是时间量级深度决定的是空间占用二者要分开看。我处理过的一个线上问题是某模块用递归实现树的遍历树深度到几千层时偶发崩溃。排查后发现是递归深度随输入数据分布变化最坏情况达到上万层。解决办法是把递归改成显式栈迭代。显式栈虽然代码丑一点但堆空间够大可控性高得多。还有一点容易被忽略递归深度和复杂度不一定同量级。T(n) T(n/2) T(n/2) n深度是log₂n复杂度是n log n但T(n) 2T(n-1) 1深度是n复杂度是2ⁿ。所以光看深度不能判断复杂度光看复杂度也不能判断深度两者要分开算。5.2 重复子问题记忆化的正确姿势判断一个递归是不是存在大量重复子问题最直接的办法是画递归树看同一个规模的节点出现了多少次。斐波那契的递归树里fib(n-2)出现多次fib(n-3)出现更多次越往下重复越严重。这类递归优化空间极大。记忆化的思路是第一次计算结果时存入缓存后续遇到相同规模的子问题直接取缓存。我用一个哈希表或者数组做缓存键是问题规模如果状态维度多就用元组或者编码后的字符串。关键点是缓存命中后不再进入递归这样每个子问题只计算一次。但记忆化不是银弹。它的额外开销包括缓存查找、更新以及缓存本身的存储成本。如果子问题重叠量不大或者状态空间太大导致缓存内存爆炸记忆化反而拖慢速度。判断标准很简单递归树中相同节点出现的次数是否足够多。斐波那契里每个子问题被重复调用指数次记忆化收益巨大但归并排序的两个子问题规模不同、没有重叠记忆化毫无意义。实际操作中我先用递归树数一遍重复节点的个数。如果重复数量级在指数级直接上记忆化如果几乎不重复就考虑是不是要换迭代实现或者干脆保持递归。5.3 递归转迭代的时机与方法递归代码可读性好但性能受限于函数调用开销和栈深度。当递归深度不可控、或者对性能有极致要求时需要转成迭代。最简单的转换场景是尾递归。尾递归指的是递归调用是函数体的最后一个操作这种情况下可以用循环直接改写。例如int factorial(int n, int acc) { if (n 1) return acc; return factorial(n - 1, n * acc); }这个函数改成循环很直接用一个while循环反复更新n和acc直到终止条件。尾递归在编译器层面其实已经优化成循环了但如果语言/编译环境不保证尾递归优化手动改写就是必要的。非尾递归的转换稍微麻烦一点。比如树的深度优先遍历天然是递归结构转迭代需要手动维护一个栈把“剩余要做的事”压进栈。这在代码上确实比递归丑但避免了栈溢出风险。我的经验是如果递归深度预计超过1万层直接考虑转迭代如果不超过几千层且性能不敏感保留递归反而更好维护。递归转迭代还有一个中间方案把递归函数改成循环加缓存自底向上的动态规划。斐波那契从递归记忆化改成循环数组本质上就是消除了递归调用的开销同时保留子问题缓存的好处。这种改写通常最简单因为递推关系本身就是从底往上算的。6. 常见错误与排查技巧6.1 求解递推式最容易踩的三个坑第一个坑是T(n)写错。常见情况是没算清递归调用次数。比如树的中序遍历递归左子树一次、右子树一次T(n) T(k) T(n-1-k) 1其中k是左子树节点数。很多人直接写成T(n) 2T(n/2) 1这是错的——除非是平衡树否则左右子树规模不相等。正确的做法是先分析问题结构再写递推关系而不是套模板。第二个坑是忽略常数项的影响。在主定理里f(n) n和f(n) 0.001n²增长量级不同结果完全不同。但在写递推关系时有人会机械地把所有额外开销都写成O(1)或O(n)没有仔细分析合并阶段到底做了什么。归并排序是O(n)合并如果实现里用了嵌套循环做合并实际是O(n²)递推关系就应该写成T(n) 2T(n/2) n²分析结果完全不同。第三个坑是忘记边界条件。T(1)或T(0)的值会影响展开法的最终表达式。比如T(n) 2T(n/2) n如果T(1) 0和T(1) 1展开结果相差一个常数项渐进复杂度不变但精确值不同。在做算法题要求精确计算时边界条件必须写清楚。6.2 快速估算复杂度的经验法则不需要每次都用主定理几个经验法则可以秒判常见递归复杂度每次规模减半、额外开销常数O(log n)每次规模减半、拆两个子问题、额外开销线性O(n log n)每次规模减1、额外开销常数O(n)每次规模减1、额外开销线性O(n²)每次规模减半、拆两个子问题、额外开销常数O(n)这些法则本质是主定理的直观版本记住了可以快速脑算但最好还是理解背后的展开逻辑不然遇到变种容易翻车。还有一个特别有用的估算技巧递归树的叶子数。叶子数代表“递归真正算到最底层时做了多少次基本操作”。叶子数通常就是n^(log_b a)它决定了复杂度的下限。比如T(n) 4T(n/2) n叶子数是4^(log₂n) n²所以复杂度至少O(n²)再加上每层额外开销结果就是O(n²)。这个技巧在分析带多项式额外开销的分治递归时特别高效。6.3 面试和笔试中的递推分析套路面试中考察递归关系往往不会直接让你解一个递推式而是让你分析某段递归代码的复杂度。我这里有一套固定流程第一步找出递归调用的参数变化确认子问题规模。看每次递归传入的参数是n-1还是n/2还是别的形式。第二步数清递归调用的次数。函数体里出现了几次递归调用分别是什么参数如果有条件分支要考虑最坏情况。第三步分析每层额外工作。代码里除了递归调用之外循环、排序、合并等操作各是什么复杂度取最坏的那个当f(n)。第四步写出递推关系用主定理或展开法求解。这套流程我反复用了很多年基本没有失手过。以二分查找的递归版为例按流程走参数变化是n→n/2调用次数1次额外工作是常数比较所以T(n) T(n/2) 1解出O(log n)。还有一类陷阱题递归函数里有两个递归调用但参数不是n/2而是n-1和n-2斐波那契型。这时代入主定理会失败因为b不存在子问题规模不是等比例缩小。需要画递归树看叶子数或者用特征方程法但一般面试只需要你判断出是指数级就够了。我个人在实际面试中出这道题时真正想考察的不是你会不会解递推式而是你有没有建立“从代码到递推关系”的映射能力。能把代码转换成递推式并把递推式解开说明你对递归的理解不是停留在语法层面而是深入到了计算模型层面。这也是这篇文章最想传递的东西算法效率不是背出来的是推出来的。你只要熟练掌握递归关系的基本形式配合展开法、递归树、主定理这三板斧绝大多数递归算法的复杂度都能自己算出来而且算过一遍之后很难忘记。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询