资讯动态

分治算法实战:从归并排序到最大子数组,避开四大陷阱

发布时间:2026/9/12 8:04:37 来源:尧图企业网站定制
大事化小小事化了这句话谁都会说可真到了代码层面很多人一写分治算法就卡壳——知道要拆不知道怎么拆拆完之后不知道怎么合合的时候边界条件一错直接把自己绕晕。这个系列前面几篇已经把递归、复杂度这些地基打过了这篇就来专门解决分治这件事它不是一句口号而是一套有章法的拆解流程。我会用归并排序、最大子数组两个经典案例把分治的骨架拆开再带你用递归树和主定理把复杂度看透最后把我实际写代码时踩过的四个坑原原本本列出来希望能帮你省下几个晚上的排查时间。1. 分治算法的底层逻辑三步走框架和一条不能踩的红线1.1 分解、解决、合并三步走框架分治算法的标准套路归纳起来就是三个动作分解Divide、解决Conquer、合并Combine。听起来像废话但很多人只记住了分解把后两步不当回事导致写出来的代码看起来像个分治实际上只是把一个函数递归掉连怎么收尾都不知道。打个比方。你面前有两百本打乱顺序的书要按编号排好最笨的办法是一本一本地插到正确位置这就是插入排序的思路数据量一大就完蛋。分治的做法是这样的把两百本书分成两堆每堆一百本再往下分直到每堆只剩一本书——一本书天然就是有序的。然后从最小单元开始两两合并合并的过程就是一手拿一本书哪本编号小就先放进新书架。这个比喻要记在心里因为分治算法的代码结构几乎是这个过程的直接翻译。翻译成伪代码就是def solve(问题): if 问题规模足够小: 直接求解并返回 把问题拆成若干个子问题 result 组合所有子问题的解 return result记住递归终止条件是问题小到可以直接解决而不是问题变成空。1.2 什么样的子问题才值得分下去这是分治算法最重要的一条红线拆出来的子问题必须相互独立。如果一个子问题的结果会影响到另一个子问题的计算那你就不是在分治你是在给自己制造混乱。怎么理解独立用现实场景说你让两个朋友帮你整理书你告诉A整理左边一百本告诉B整理右边一百本这没有问题。但如果你把同一本书的封面拆给A、内页拆给B那A和B的工作就互相纠缠根本没法合并。算法里的串联和并联也是这个区别子问题之间最好能并联处理彼此不依赖合并时才能拿来即用。判断一个问题能不能分治我习惯自问三个问题子问题和原问题是不是同一类问题如果不是你写的就不是分治。子问题之间会不会重复计算相同内容如果会你可能更适合动态规划而不是分治。合并子问题结果的成本能不能接受合并太贵的话分治整体收益会被吃掉。这三个问题是最重要的筛选器。接下来我们先从最经典的归并排序看起把分和治这两个字彻底看透。2. 从零手写归并排序把分和治彻底拆开看2.1 归并排序的四行分治骨架归并排序是分治思想最标准的样本代码骨架简单到让人怀疑但你把它吃透之后分治套路基本就懂了一半。先看分治的骨架def merge_sort(nums): if len(nums) 1: return nums mid len(nums) // 2 left merge_sort(nums[:mid]) right merge_sort(nums[mid:]) return merge(left, right)就这么四行逻辑。第一部分是终止条件第二部分是分解第三部分是递归解决第四部分是合并。当你刚开始写分治的时候请先在纸上把这个骨架画出来再动指头。但这里有一个值得注意的工程细节上面的写法每次递归都用nums[:mid]这种切片它会产生新的子数组副本。在算法题和小数据量场景下无所谓但在真实项目中一个十万级的数组就会产生大量的临时列表内存和时间都有浪费。更靠谱的做法是用索引下标传递范围避免反复复制def merge_sort(nums, left, right): if left right: return mid (left right) // 2 merge_sort(nums, left, mid) merge_sort(nums, mid 1, right) merge(nums, left, mid, right)我见过不少人在工程代码里用了切片版本数据量一大就暴露出性能问题最后还得回头改造。所以算法简写可以用切片生产环境尽量用索引区间。这也算是我用真实代价换来的一个经验。2.2 合并函数为什么是性能的胜负手分治的骨架谁都能背真正拉开差距的是合并这一步。归并排序的合并是把两个已经有序的子数组拼成一个更大的有序数组正确做法是双指针同时扫描把较小的那个依次放入临时数组最后把剩余部分接上def merge(nums, left, mid, right): temp [] i, j left, mid 1 while i mid and j right: if nums[i] nums[j]: temp.append(nums[i]) i 1 else: temp.append(nums[j]) j 1 if i mid: temp.extend(nums[i:mid 1]) if j right: temp.extend(nums[j:right 1]) nums[left:right 1] temp这里有几个很容易犯的错我先提前说后面踩坑章节还会细讲。第一i和j的初始点容易写错右半部分的起点是mid 1不是mid。第二while i mid and j right循环结束之后必然有一边还有剩余元素这时候直接接上就行不用再比较大小了因为它们本身已经是排好序的。第三合并是稳定排序的关键if nums[i] nums[j]用了相等时保留左边元素这样相同值的相对顺序不会变。单次合并的时间复杂度是 O(n)n 是当前子数组的元素总数整个归并排序的时间复杂度是 O(n log n)这个我们会在第4节用递归树详细推。你只要记住一句话归并排序的分是免费的真正的工作量全在治。3. 最大子数组分治最容易被忽略的跨中点洞察3.1 暴力解法到分治解法的思路跃迁如果归并排序让你看到了分的威力最大子数组问题就是让你见识合的深度。问题是这样给定一个数组里面可能有正数有负数找出一个连续的子数组让它的元素和最大。比如数组[-2,1,-3,4,-1,2,1,-5,4]最大子数组是[4,-1,2,1]总和是 6。暴力做法是枚举所有起点和终点两层循环累加复杂度 O(n^2)。我第一次用暴力法写这个题的时候觉得已经挺顺了直到被面试官追问能不能优化才认认真真去研究分治解法。分治的思路是把这个数组从中间劈成两半那么最大子数组只可能出现在三个位置——完全在左半部分、完全在右半部分、或者跨越中点。前两种情况直接递归解决就行第三种情况才是分治这题的精髓它不属于左边单独的问题也不属于右边单独的问题而是横跨在两个子问题的边界上。3.2 跨中点的扫描函数分治的精髓所在跨越中点的最大子数组怎么找它不是简单地从mid往左找一段最大再从mid 1往右找一段最大然后把两段拼起来就行——注意必须是从 mid 开始向左连续延伸以及从 mid 1 开始向右连续延伸然后相加。为什么要规定从中间出发因为跨中点意味着这段子数组必须包含nums[mid]和nums[mid 1]这两个相邻元素所以向两边延伸时不能跳过中间任意一个元素。如果你左边选了[0..mid-1]而没选nums[mid]那结果就不算跨越中点了。这个理解一旦偏差代码就全错了。看代码def max_subarray(nums, left, right): if left right: return nums[left] mid (left right) // 2 left_max max_subarray(nums, left, mid) right_max max_subarray(nums, mid 1, right) cross_max max_crossing(nums, left, mid, right) return max(left_max, right_max, cross_max) def max_crossing(nums, left, mid, right): left_sum float(-inf) current 0 for i in range(mid, left - 1, -1): current nums[i] left_sum max(left_sum, current) right_sum float(-inf) current 0 for i in range(mid 1, right 1): current nums[i] right_sum max(right_sum, current) return left_sum right_sum这个解法的时间复杂度是 O(n log n)。当然最大子数组问题存在更优的 Kadane 算法只要 O(n)但分治版本的思考方式——答案不在左边就在右边否则它就横跨中线——在之后的区间问题里会反复出现比如计网里的最大带宽区间、数据分析里的最大增长区间都是类似的模型。所以它不是一道可以跳过的题。4. 复杂度为什么是对数级的递归树和主定理4.1 画递归树比硬记公式更可靠很多人在刚开始接触分治的时候最难接受的就是为什么这个算法的复杂度是 O(n log n)。这其实就是把递归展开后数工作量的问题你可以用递归树来直观理解。拿归并排序举例假设原始数组长度是 n。第一层我们把问题分成两个规模约 n/2 的子问题每个子问题的合并操作都要遍历一遍当前子数组的元素所以第一层总工作量大约是 n。第二层有 4 个规模约 n/4 的子问题每个合并工作量 n/44 个加起来还是 n。第三层同理仍然是 n。每一层的工作量都是 n树一共往下分了 log₂n 层总工作量就是 n × log₂n。这里有个反直觉的点每一层的工作量几乎相同而不是越往下越小。很多人凭直觉觉得越分越小花的时间应该越来越少但别忘了子问题数量也在翻倍一层摊下来总量是稳定的。理解了这个你就不会在复杂度分析上犯迷糊。4.2 主定理的三种情形套用自查如果每个递归题都画树确实麻烦。更体系化的方法是主定理Master Theorem。它的标准形式是T(n) aT(n/b) f(n)其中 a 是子问题的个数n/b 是每个子问题的规模f(n) 是分解和合并的额外开销。主定理比较的是 f(n) 和 n^(log_b a) 谁增长得更快情形条件复杂度情形1f(n) 增长慢于 n^(log_b a)O(n^(log_b a))情形2f(n) 和 n^(log_b a) 同阶O(n^(log_b a) log n)情形3f(n) 增长快于 n^(log_b a)O(f(n))套几个例子你就熟练了二分查找T(n) T(n/2) O(1)a1b2n^(log₂1)n^01f(n)1属于情形2答案是 O(log n)。归并排序T(n) 2T(n/2) O(n)a2b2n^(log₂2)nf(n)n属于情形2答案是 O(n log n)。一个低效的分治T(n) 2T(n/2) O(n²)合并阶段做了一次平方级操作n 与 n² 相比增长更慢属于情形3答案就是 O(n²)。这说明合并步骤设计得好不好直接决定整个算法的天花板。我在实际判断一个分治复杂度时第一反应永远不是背情形而是先画三层递归树感受一下再用主定理验证。画树能帮你理解主定理帮你偷懒两者缺一不可。5. 分治和其他算法思想的边界什么时候该换思路5.1 分治与动态规划一条独立之隔分治和动态规划DP看起来都是把大问题拆小很多初学者分不清其实中间的界线在于子问题是否重叠。分治假设子问题是相互独立、不重叠的。归并排序的左半边和右半边处理的是完全不同的元素互不干扰。动态规划则恰恰相反它面对的场景是子问题高度重叠——同一个子问题会被多个上层问题反复用到比如斐波那契数列def fib(n): if n 1: return n return fib(n - 1) fib(n - 2)这个写法表面上看也有分治的味道把 fib(n) 拆成 fib(n-1) 和 fib(n-2)但fib(n-2)会被fib(n-1)内部再次计算子问题之间大量重叠。直接分治递归会导致指数级的时间复杂度n50 时已经卡到怀疑人生。解决方案就是记忆化把算过的子问题存下来——这其实就从分治滑向了动态规划。我的判断方法很简单画出递归树如果发现同一子树被重复计算就说明子问题不独立该上 DP 而不是硬核分治。5.2 分治与二分查找一字之差思路不同还有一个高频混淆点二分查找和分治到底是什么关系有人会说二分查找也是一种分治严格来讲它更准确的名字是减治。分治的特征是把问题拆成多个子问题所有子问题都要处理然后汇总结果。归并排序和快速排序都是这样两边都要排。二分查找则每轮只进入其中一个子问题另一半被直接丢弃。整个过程中没有合并这一步——因为另一半根本没参与计算。所以从方法论层面你可以说二分是分治的特例但面试时如果被问二分和分治的区别你要能说出分治重在建合并减治重在选方向。快速排序、二叉树遍历这类问题才是分治最典型的应用场景。判断一个算法属于哪一派就看它递归调用之后到底是在拼结果还是在选下一步。6. 实战复盘四个我在分治代码里踩过的坑6.1 边界条件写错导致递归停不下来归并排序最基础的坑就是终止条件。我见过有人写if left right: return这会导致单元素区间left right时仍然继续递归栈直接就爆了。正确写法是if left right: return。排查方法其实很简单写分治递归时先在脑中模拟一个长度为 1 和长度为 2 的最简输入一步步走流程。如果长度为 1 的输入能顺利返回长度为 2 的输入能正确合并你的边界基本就稳了。很多栈溢出问题不是算法思路错了而是最基础的终止条件少等了一个等号。6.2 跨中点函数只扫半边合并结果残缺这个坑我印象特别深。以前写最大子数组时我先写了向左扫的逻辑跑出来结果和暴力解对不上整整排查了两个小时最后发现max_crossing里面右半段的循环起点写成了mid而不是mid 1。这样nums[mid]被算了两遍子数组的和虚高。复盘下来这类错误的核心原因是对跨中点的定义不够清晰。跨越中点的子数组必须由两段构成以mid结尾的左边一段加之以mid 1开头的右边一段。你把这两段切开来看每个循环的职责就清楚了代码也不容易错。6.3 递归深度过深Python直接报RecursionError在真实项目里用递归分治处理大数组Python 默认的递归深度上限是 1000处理一万个元素都困难。我有次在本地环境跑归并排序测试数组一长直接 RecursionError当时第一反应是算法崩了实际上就是递归深度限制。解决办法有三条路一是用sys.setrecursionlimit(10000)临时调高上限二是改成非递归的迭代式归并也就是从底层两两合并开始一层层往上归三是在生产环境用支持大递归或尾递归优化的语言。我的建议是算法题用第一条省事工程代码尽量用第二条因为调高递归上限只是拖延问题深递归在栈空间上依然不优雅。6.4 子问题共享可变状态结果互相污染最后一个坑比较隐蔽。分治递归处理数组时如果合并阶段不小心直接修改了原数组或者使用了某个全局变量来暂存结果那么左右两个递归分支可能互相污染状态导致最终结果明明逻辑正确却数据错乱。我处理这类问题的经验是分治函数尽量保持无副作用输入子数组区间、输出合并结果中间用局部临时变量不改动全局状态。说得直白一点让每个递归调用都活在自己的沙箱里。一旦你在调试时发现两个分支计算出的值串味了第一时间检查是否有共享的可变对象。写分治算法的时候我建议你在动手前先做一次这个自问清单子问题和原问题是同类问题吗子问题之间相互独立吗子问题小到可以直接求解时边界条件写好了吗合并步骤能把所有子问题的解拼回原问题的完整答案吗会不会漏掉跨边界的解递归深度、临时空间、合并成本都能接受吗这套清单帮我避开了很多不必要的调试。分治算法真正难的地方从来不是拆而是对独立性的判断和合并细节的把握。把这层窗户纸捅破了后续再看快速排序、二叉树、最近点对之类的问题思路都会顺畅很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价