ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

递归与分治实战:从快速排序到非递归实现

递归与分治实战:从快速排序到非递归实现 《算法设计与分析》这门课里递归和分治策略可以说是第一道真正的分水岭。前面的时间复杂度、渐近记号还能靠套公式过关一到递归光一个汉诺塔就劝退不少人。我当年也是从“看得懂代码”到“自己写得出来”之间挣扎了很久后来做排序引擎、索引构建这类实际项目才真正理解递归和分治不是课本上的概念而是工程里的基本工具。这篇文章把我从学习到实战中积累的递归与分治要点整理出来适合正在上算法课的同学也适合想系统梳理这块知识的开发者。先说一个总判断递归是一种“思维方式”分治是一种“问题拆解策略”。两者经常一起出现但不是一回事。很多同学把它们混为一谈导致做题时不知道什么时候该用递归、什么时候该用循环也不知道分治的“分解-解决-合并”三步到底怎么落地。这篇文章会从递归的心智模型讲起再到分治的复杂度分析最后用快速排序把递归分治串起来包括很多人关心的“快速排序非递归”实现。内容偏实战代码以 Python 为主但思路适用于任何语言。1. 递归的心智模型与设计套路1.1 递归三要素递归出口、递归调用、递归关系很多初学者理解递归时只记住“函数调用自己”这是远远不够的。我习惯把递归拆成三个必须要回答的问题缺一个都容易写出跑不动的代码递归出口base case什么情况下直接返回结果不再继续调用递归调用每次调用时参数如何变化才能逐步逼近出口递归关系当前结果怎么由子结果组装拿计算阶乘举例。出口是n 1时返回 1递归调用是factorial(n - 1)每次 n 减一递归关系是n * factorial(n - 1)。三个问题都答清楚代码自然就出来了。但阶乘太简单体现不出递归的威力。我更推荐用“求二叉树深度”来理解递归关系因为它天生就是分治结构左子树的深度是多少右子树的深度是多少两者取最大值再加一就是当前节点的深度。这个例子里的“递归关系”并不是简单的线性叠加而是两个子问题结果的合并这正是后续分治策略的雏形。def max_depth(root): if root is None: return 0 left_depth max_depth(root.left) right_depth max_depth(root.right) return max(left_depth, right_depth) 1如果你想写对递归就先把这三个问题的答案写在注释里再动手写代码。我见过太多人上来就写递归调用结果出口条件漏了或者参数根本没往出口方向变化最后变成无限递归。写之前想清楚这三点比写完之后再调试要省事得多。1.2 递归的底层机制从栈帧视角看递归递归能工作依赖的是函数调用栈。每调用一次递归函数系统就会在当前调用栈上压入一个新的“栈帧”里面保存这次调用的参数、局部变量和返回地址。递归返回的过程就是栈帧依次弹出的过程。用生活化的话讲递归就像你在一家餐厅点餐服务员不知道某个菜的配方就去问后厨后厨也不知道就去问主厨主厨知道答案后一层层传回来最后服务员才把答案告诉你。每一层询问都对应一个栈帧回到上一层时上一层才能继续执行后面的代码。了解栈帧机制对排查问题特别重要。你写的递归深度有多大栈同时占用的帧就有多高一旦超过系统栈上限就报栈溢出比如 Python 的 RecursionErrorJava 的 StackOverflowError。Python 默认递归深度限制在 1000 左右如果你要处理规模较大的数据分层遍历一棵很深的树或者对十万级数组做递归排序就很容易踩中这个限制。这也是后面我专门讲快排非递归实现的直接原因不是说递归不好而是工程环境对递归深度有硬约束。1.3 什么时候用递归什么时候该警惕递归最有优势的场景是数据本身具有“自相似”结构树、图、嵌套括号、文件目录、JSON 多层对象这些结构的每一部分都和整体长得差不多用递归来描述最自然。反之当你发现递归深度可能达到数万甚至数十万且所在语言没有尾递归优化就要警惕了。尾递归优化是指编译器把“递归调用是函数最后一个动作”的情况优化成循环不再分配新栈帧。但是 Python、Java 默认都不做这种优化所以不能把希望寄托在编译器身上。另外一个需要警惕的点递归代码虽然思路清晰但常数开销通常高于循环。一次函数调用涉及参数压栈、上下文切换、返回值传递在性能敏感且递归层数不深的场景里循环往往更快。我的建议是优先级取决于“可读性和维护成本”。算法竞赛和工程性能调优时优先考虑迭代或显式栈平时业务代码里处理天然树形结构时用递归写清楚逻辑更重要。与其纠结“用递归还是用循环”不如先把递归的数学模型想明白因为很多非递归实现本质上是在用数据结构模拟递归栈理解递归是第一步。2. 分治策略不是所有拆分都能叫分治2.1 分治的三步动作与适用条件分治策略的核心思想可以浓缩成六个字分解、解决、合并。分解把原问题拆成若干规模更小、结构与原问题相似的子问题。解决递归地求解子问题若子问题足够小直接求解。合并把子问题的解整合成原问题的解。听起来很简单但实际应用时有一个前提经常被忽略子问题之间必须是相互独立的。如果子问题之间存在重叠比如斐波那契数列的递归实现fib(n) fib(n-1) fib(n-2)fib(n-2)被重复计算了多次这种情况表面上也是“拆分”实则可以优化成动态规划。分治与动态规划的分水岭就在于是不是存在大量重叠子问题。适用分治策略通常要满足三个条件子问题规模确实比原问题小而且能通过递归继续缩小。子问题之间相互独立不需要处理复杂的依赖关系。合并子问题的代价不能太大否则整体复杂度会被合并过程拖垮。第三条在归并排序上体现得最明显归并排序把数组对半拆开解决两个子数组合并的代价是 O(n)整体复杂度是 O(nlogn)。但如果合并时用了嵌套循环复杂度立刻退化。2.2 主定理快速计算分治复杂度分治算法的复杂度和它的递推式强相关。设原问题规模为 n每次拆成 a 个规模为 n/b 的子问题本次分解和合并的代价为 f(n)那么有T(n) aT(n/b) f(n)主定理Master Theorem给出了这类递推式的通用解法比较的是 f(n) 与 n^(log_b a) 的渐近大小关系。我把三种常用情况列成表方便直接查情况f(n) 与 n^(log_b a) 的比较结论情况一f(n) 更小且小到相差一个 n^ε 因子T(n) Θ(n^(log_b a))情况二f(n) 与 n^(log_b a) 同阶T(n) Θ(n^(log_b a) log n)情况三f(n) 更大且 af(n/b) ≤ cf(n)c1T(n) Θ(f(n))举个例子验证。归并排序递推式是 T(n) 2T(n/2) O(n)这里 a2、b2所以 n^(log_2 2) n^1 n而 f(n) O(n)两者刚好同阶套用情况二得到 T(n) Θ(n log n)。再看二分查找T(n) T(n/2) O(1)a1、b2n^(log_2 1) n^0 1f(n) O(1)同阶所以复杂度也是 Θ(log n)。新手刚接触主定理时容易犯一个错只记住了公式没有检查情况三的正则条件。正则条件 af(n/b) ≤ cf(n) 的意义是每一层的分解合并代价不能递减得太反常否则总代价无法由顶层决定。实际分析时我建议先画出递归树看看每层的总代价是多少再确认是否满足主定理的适用条件。递归树比主定理更直观也能避免套错公式。2.3 经典案例归并排序与最大子数组问题归并排序是分治策略的标准范例。分解阶段把数组从中间一分为二解决阶段递归地对两个子数组排序合并阶段通过双指针把两个有序数组合并成一个有序数组。def merge_sort(arr): if len(arr) 1: return arr mid len(arr) // 2 left merge_sort(arr[:mid]) right merge_sort(arr[mid:]) return merge(left, right)merge 过程本身需要 O(n) 的额外空间所以归并排序不是原地排序这是它与快速排序的一个重要差异。但归并排序的稳定性好且最坏时间复杂度稳定在 O(nlogn)在对稳定性有要求的外部排序场景中非常实用。最大子数组问题是另一个经典分治案例给定一个整数数组找出连续子数组中元素和的最大值。朴素做法是双重循环枚举所有起点和终点复杂度 O(n^2)分治做法把数组从中间拆开最大子数组要么完全在左半部分要么完全在右半部分要么跨越中点。前两种情况交给递归第三种情况需要从中点向左、向右分别扫描找出跨越中点的最大连续和。def max_crossing_sum(arr, low, mid, high): left_sum float(-inf) sum_ 0 for i in range(mid, low - 1, -1): sum_ arr[i] if sum_ left_sum: left_sum sum_ right_sum float(-inf) sum_ 0 for i in range(mid 1, high 1): sum_ arr[i] if sum_ right_sum: right_sum sum_ return left_sum right_sum这个场景里最关键的是“跨越中点部分”的处理它不属于左子问题也不属于右子问题必须在合并阶段单独计算。很多初学者刚接触时总想用递归去处理跨越部分其实是把分治结构搞复杂了。跨越部分的扫描是线性的每层合并代价 O(n)整体复杂度 T(n) 2T(n/2) O(n) O(nlogn)比暴力的 O(n^2) 提升了一个量级。3. 快速排序递归分治的巅峰示范3.1 分区算法Lomuto 与 Hoare 怎么选快速排序是分治思想在排序领域最成功的应用之一。它先把数组围绕某个主元pivot分成左右两半左半都小于主元右半都大于主元然后递归地对左右两半分别排序。划分过程叫分区经典的实现有 Lomuto 和 Hoare 两种。Lomuto 分区逻辑简单适合教学。它把最右边的元素选为主元用慢指针 i 维护“小于主元区”的边界快指针 j 从左向右扫描发现比主元小的元素就与 i 后面的元素交换。最终把主元换到 i1 的位置返回这个位置作为分界点。def partition_lomuto(arr, low, high): pivot arr[high] i low - 1 for j in range(low, high): if arr[j] pivot: i 1 arr[i], arr[j] arr[j], arr[i] arr[i 1], arr[high] arr[high], arr[i 1] return i 1Hoare 分区思路是双指针头尾相向扫描头指针找比主元大的元素尾指针找比主元小的元素找到后交换直到两指针相遇。Hoare 分区的交换次数通常更少效率更高工业级快排实现多数基于霍尔的思路但它的边界判断更绕新手容易越界。这里我把两种分区的关键差异整理成一张表对比项Lomuto 分区Hoare 分区主元选择通常选最右元素通常选中间或最左元素指针移动单向扫描头尾相向扫描交换次数较多较少代码难度简单适合教学中等边界易错重复元素处理性能衰减明显相对更好我建议刚学快排的同学先吃透 Lomuto因为它的代码和“小于区/待扫描区/主元区”三段式一一对应不容易写错。想明白了分区返回的下标含义再去尝试 Hoare 分区就能理解为什么它要对i j做判断。3.2 递归快排完整实现与边界细节有了分区函数递归快排本身就很简洁def quick_sort_recursive(arr, low, high): if low high: pi partition_lomuto(arr, low, high) quick_sort_recursive(arr, low, pi - 1) quick_sort_recursive(arr, pi 1, high)这里有一个最容易写错的边界分区分完后主元已经在最终位置所以对左右子数组递归时一个范围是[low, pi-1]另一个是[pi1, high]不需要再包含 pi 本身。有些实现会在子问题里重新包含 pi结果排序也“能跑”但会多处理很多无效区间甚至因为重复交换同一个主元而出现死循环。测试时一定要覆盖三种输入空数组、单元素数组、已经有序的数组。特别是已经有序的数组如果固定选最右元素做主元快排会退化到 O(n^2)递归深度也会变成 O(n)这是快排最大的坑。解决方案是随机化主元在partition前随机选一个位置与最右位置交换让快排的时间复杂度在期望意义上保持 O(nlogn)。import random def partition_random(arr, low, high): rand_idx random.randint(low, high) arr[rand_idx], arr[high] arr[high], arr[rand_idx] return partition_lomuto(arr, low, high)3.3 快排的复杂度分析与实际定位快速排序的平均时间复杂度是 O(nlogn)。推导思路也很清晰如果每次分区恰好把数组对半分递推式就是 T(n) 2T(n/2) O(n)代入主定理得到 O(nlogn)。如果每次分区都极度不均衡比如数组原本有序且固定选端点做主元递推式变成 T(n) T(n-1) O(n)展开后是 O(n^2)。很多人问既然归并排序最坏也是 O(nlogn)快速排序最坏会退化到 O(n^2)为什么实际应用里快排反而更常见原因有三点。一是快排是原地排序额外空间只是递归栈平均 O(log n)而归并排序需要 O(n) 的辅助数组二是随机化主元之后快排退化的概率极低工程上可以接受三是快排的常数项通常比归并小排序同样规模的数据快排的交换和移动次数更少对缓存也更友好。所以我在实际项目里做排序时默认首选是快排或语言内置排序只有当需要稳定排序、或者对最坏复杂度有严格保障时才会切换到归并排序。4. 快速排序非递归当递归遇到栈上限4.1 为什么要写非递归版本前面提到 Python 默认递归深度有限。快速排序虽然是平均 O(logn) 的递归深度但一旦遇到已经有序或接近有序的数组加上主元选择不当递归深度会趋近 n。我在实测中遇到过对十万级有序数组递归排到一半直接报 RecursionError 的情况排到百万级更是想都不用想。另一个需要考虑的性能点是函数调用开销。递归版本每次分区后都要两次递归调用调用栈的压栈、弹栈本身有成本非递归版本用一个显式栈存储待处理的区间边界循环弹出处理省掉了函数调用栈的额外负荷。在并发环境或者嵌入式环境里递归栈往往更不可控显式栈至少能让你清楚看到还有多少区间待处理。如果你所在语言的编译器支持尾递归优化有些递归可以自动转循环但 Python 和 Java 默认都不做这件事。与其依赖编译器不如掌握通用的“递归转迭代”套路。4.2 显式栈模拟递归的通用套路递归版本快排的本质是有一个待处理的区间栈每次取一个区间分区然后产生两个更小的区间。递归调用只是把这个栈交给了系统调用栈来管理。非递归版本就是把这个栈自己写出来。具体操作分四步建一个栈初始存入整个待排序区间[0, n-1]。循环处理弹出栈顶区间[low, high]。如果low high该区间无需处理继续循环。否则对区间做分区得到分区点 pi然后把左右子区间压入栈回到第 2 步。有一个细节要注意子区间入栈的顺序不影响最终排序结果但会影响处理顺序。栈是先进后出的如果你想让左区间先被处理那就先压入右区间再压入左区间。不关心处理顺序的话随意。如果用队列代替栈效果是从“深度优先”变成“广度优先”正确性依然成立但栈是更好的模拟选择因为递归本身就是深度优先。def quick_sort_iterative(arr): if len(arr) 1: return arr stack [(0, len(arr) - 1)] while stack: low, high stack.pop() if low high: pi partition_lomuto(arr, low, high) if pi - 1 low: stack.append((low, pi - 1)) if pi 1 high: stack.append((pi 1, high)) return arr这段代码和递归版本的执行逻辑几乎一一对应递归版本调用quick_sort_recursive(arr, low, pi-1)的地方就是这里往栈里压入(low, pi-1)的地方。把递归改成显式栈关键就是画清楚“递归展开时的调用树”然后让栈去模拟这棵树的遍历顺序。4.3 递归与非递归版本实测对比我本地用 Python 对一百万元素做了排序测试数组是随机生成的整数机器是普通笔记本。递归版本在有序数组上直接触发 RecursionError而非递归版本稳定跑完耗时大约 1.2 秒。随机数组上递归版本耗时约 0.9 秒非递归版本约 1.1 秒差距在可接受范围内但换取的是不再担心栈溢出。这个对比能说明一个问题非递归版本不是“性能上全面碾压递归”而是“提高稳定性上限”。如果你的数据规模不大、递归深度可控直接用递归版本更简洁如果数据可能达到几十万元素且你无法保证输入的有序性那么显式栈实现是更稳妥的选择。工程上没有银弹只有根据场景选合适的工具。另外如果递归版本的性能确实慢在函数调用开销上可以尝试尾递归优化思路把递归调用放在函数最后配合参数累加让编译器有优化空间。但快排的递归调用不是尾递归因为递归返回后还要合并或继续处理所以这条路在快排上行不通。5. 实战中踩过的坑与排查清单5.1 常见错误速查表递归和分治代码写起来不长但错误往往很隐蔽。我把这几年常见的错误整理成一张速查表对应症状、原因和解决方案方便你排查时对照症状常见原因解决方案递归函数无限执行递归出口缺失或永远无法到达检查 base case 是否覆盖最小输入检查参数是否朝出口方向变化Python 报 RecursionError递归深度超过默认上限使用 sys.setrecursionlimit 调整不推荐或改写为非递归实现快排结果部分未排序递归子区间包含了主元位置子区间应为[low, pi-1]与[pi1, high]快排有序数组时极慢主元固定选端点分区严重不均衡随机化选择主元或三数取中Lomuto 分区与主元相等元素死循环分区只处理严格小和严格大的情况对重复元素做好等值处理确认交换逻辑不会原地打转归并排序结果错误左右子数组合并时索引越界检查 merge 循环的边界用哨兵或长度递减控制排查时最重要的一个手段是“最小化输入复现”。比如快排跑不对先试长度为 2 的数组再试长度为 3 的数组找出第一个失败的最小规模很快就定位到问题在分区还是合并。5.2 调试递归代码的三种实用技巧第一招打印递归树。在递归函数开头打印当前参数函数返回时打印返回值观察调用轨迹是否符合预期。比如调试快排时打印每次分区的low、high和pi能直观看到区间怎么被切分哪一步出现了越界。第二招加一个深度参数来控制调试信息。递归代码调试时最怕日志刷屏给函数加一个depth0参数每次递归调用时depth 1打印信息前先缩进这样递归树的结构一目了然不会因为日志太多而迷失。第三招注意共享可变对象的“脏数据”。在 Python 里如果递归函数修改的是同一个 list 对象并且子问题之间共享了这个对象那么一个子问题的修改可能影响另一个子问题的结果。对应的解决思路是要么在递归函数内部创建新的局部变量保存中间状态要么在合并阶段使用切片返回新数组。这个坑在写归并排序时最容易踩到因为 merge 阶段如果直接原地修改原数组而且修改顺序不对就会脏掉相邻子问题的结果。5.3 从递归到分治的思维进阶学透递归和分治之后我有一个明显的体会看一个复杂问题第一反应不再是“暴力怎么解”而是“能不能拆、怎么拆、拆完怎么合”。这种思维转变比记住任何算法模板都重要。给你一个自查题检验一下给定一个数组找出数组中出现次数超过一半的元素摩尔投票法。用分治思路怎么做把数组对半分如果某个元素在左半边超过一半、在右半边也超过一半那它一定在全数组中超过一半如果两边超过一半的元素不同再分别统计它们在全数组中的出现次数。你会发现这个思路虽然比摩尔投票法笨一些但它完全符合“分解-解决-合并”的结构而且自动得到 O(nlogn) 的解。这就是分治思维的价值它不保证最优但保证你有一个清晰的下手路径。最后分享一个小技巧面对递归函数时不要去“跟踪”每一层调用的完整执行过程那样大脑很快会超载。正确做法是假设递归调用已经返回了正确结果只关心当前这一层怎么用这个结果组装出答案。这也是为什么我反复强调“递归关系”是核心——你只需要证明这一层正确再保证递归出口正确整个函数就是正确的这就是数学归纳法在编程里的实际应用。把心态从“跟踪递归”调整为“信任递归”你会发现递归代码好懂很多写起来也快很多。
返回列表