PHP实现分治算法:从归并排序到最大子数组
发布时间:2026/10/3 10:31:57 作者:尧图编辑部 阅读量:1,286

你要是翻过几本算法书或者准备过面试应该对“分治算法”这个说法不陌生。一句话概括就是把一个大问题拆成几个独立的小问题小问题解决了再合并回答案。道理听着不复杂可真到了用 PHP 手写的时候很多人会卡在递归边界、数组切片、内存开销这些细节上最后跑出来的结果要么不对要么直接内存溢出。这篇文章我就用 PHP 把分治算法彻底拆开讲一遍从核心思想到可运行的代码再到调试经验和性能陷阱希望能让你看完之后遇到新问题也能自己动手拆解。我默认读者至少会写基本的 PHP 函数和数组操作不懂递归也没关系下面会用大量例子把递归讲清楚。如果你正在学算法、准备面试或者工作中突然要处理“排序、查找、最大子数组”这类逻辑这篇内容应该能直接帮到你。1. 分治算法的解题骨架拆、解、合三类问题一眼识别1.1 分治三步走为什么它能降低思考难度分治算法的核心动作只有三个我习惯叫它“拆、解、合”。“拆”是把原问题切成若干个规模更小的子问题要求这些子问题互相独立也就是说解决A的时候不用关心B“解”是递归地去解决这些子问题直到子问题小到可以直接给出答案“合”则是把子问题的解按照一定规则组合回原问题的答案。我常用一个生活化的类比来理解它整理一个乱糟糟的大房间。你不会站在门口发呆而是先划分区域比如书桌归书桌、衣柜归衣柜这是“拆”每个区域单独收拾这是“解”最后把各区域清理出来的垃圾集中丢掉把有用的东西归位这是“合”。关键是每个区域的整理方法一样只是范围变小了这就是递归思想的来源。很多人觉得分治难其实难的不是“拆”和“解”而是“合”。归并排序里子数组都排好序后怎么把两个有序数组合并成一个有序数组这个合并逻辑才是整个算法最需要动脑的地方。后面第2节我会专门讲这个。从复杂度角度看分治之所以高效是因为它把“规模为 n 的问题”变成了“若干个规模为 n/2 的问题”再通过一次线性时间的合并操作把结果拼起来。以归并排序为例递推公式是 T(n) 2T(n/2) O(n)解得时间复杂度 O(n log n)这比冒泡排序的 O(n²) 要快得多在数据量达到几万以上时差距非常明显。1.2 哪些场景适合分治哪些场景别硬套不是所有问题都适合用分治。我总结了三个判断条件你可以直接拿来当检查清单第一问题可以分解成结构相同、规模更小的子问题。排序、查找、求最大值最小值这类问题天然满足。比如给一万个数字排序你可以拆成两组五千个分别排序再合并结果。第二子问题的解能组合出原问题的解。这一点最容易被忽略。比如求整个数组的最大值你拆成左半边最大值和右半边最大值合起来取 max 就行。但如果问题是求数组中连续一段的最大和最大子数组问题光取左右两边的最大和是合不出来的必须额外考虑“跨越中间分界线的子数组”这个我在第3节会详细演示。第三子问题之间不能有大量重叠。如果子问题重叠严重你再用分治递归就白白重复计算很多次这时候应该用动态规划把中间结果存起来。斐波那契数列就是典型例子用递归分治思路写 n50 能跑到天荒地老用动态规划数组秒出结果。不符合这些条件的问题强行套分治只会让代码更复杂、性能更差。我见过有人用分治去求一个只有十几个元素的数组最大值还要写递归和合并逻辑最后效果远不如一个简单的 for 循环。分治的价值在“大规模”在“可并行”小规模问题用最直接的办法就好。2. 用 PHP 手写分治从归并排序理解“合”的代价2.1 一个干净的归并排序实现我最早系统性地写分治就是从归并排序开始的。它结构清楚适合用来建立对分治的直觉。下面这段代码是我在 PHP 8.2 环境下调试过的版本用了引用传参避免频繁复制整个数组性能上会更友好。function mergeSort(array $arr, int $left, int $right): void { if ($left $right) { return; } $mid intdiv($left $right, 2); mergeSort($arr, $left, $mid); mergeSort($arr, $mid 1, $right); merge($arr, $left, $mid, $right); } function merge(array $arr, int $left, int $mid, int $right): void { $temp []; $i $left; $j $mid 1; while ($i $mid $j $right) { if ($arr[$i] $arr[$j]) { $temp[] $arr[$i]; $i; } else { $temp[] $arr[$j]; $j; } } while ($i $mid) { $temp[] $arr[$i]; $i; } while ($j $right) { $temp[] $arr[$j]; $j; } foreach ($temp as $k $value) { $arr[$left $k] $value; } }调用方式很简单$arr [38, 27, 43, 3, 9, 82, 10]; mergeSort($arr, 0, count($arr) - 1); print_r($arr);这里有两个关键点需要展开说。第一点intdiv($left $right, 2)就是($left $right) 1它的作用是取中间下标避免溢出。在 PHP 里整数溢出问题不像 C 语言那么致命但在 JavaScript 这类语言里$left $right在数组特别大时可能超过安全整数范围所以养成用intdiv写中点的习惯是好事。第二点merge函数里为什么要用临时数组而不是直接在原数组上交换因为归并排序的核心保证是“两个子数组各自有序”合并时需要同时从两个数组的头部开始比较谁小就先放入结果。这个过程中原数组的下标会被临时覆盖直接原地交换会破坏还未比较的元素位置。用临时数组存下合并结果最后再一次性拷回原数组对应区间是既简洁又安全的做法。我试过用array_slice来实现归并代码看起来更短但它每一次递归都会切片复制出两个新数组空间开销成倍增加在数据量大的时候很容易内存告急。实际工程里还是推荐下标 引用的写法虽然初次看稍显复杂但跑起来稳得多。2.2 二分查找、快速幂分治在 PHP 里的两个轻量级变体归并排序是“分治 合并”的典型但分治不一定要有复杂的合并。二分查找就是极其轻量的分治每次把查找区间砍半子问题更小但不需要合并因为在哪个区间继续找是直接确定的答案要么在左、要么在右。function binarySearch(array $arr, int $target): int { $left 0; $right count($arr) - 1; while ($left $right) { $mid intdiv($left $right, 2); if ($arr[$mid] $target) { return $mid; } if ($arr[$mid] $target) { $left $mid 1; } else { $right $mid - 1; } } return -1; }注意这里我用的是不是。很多人写二分查找死循环问题就出在边界条件上。当left right时区间里还剩最后一个元素若用会漏掉这最后一步判断导致返回错误的结果。另一个常见错误是更新边界时写成$left $mid这样在只剩两个元素的时候mid会一直落在左边区间永远不会缩小直接死循环。$left $mid 1和$right $mid - 1这两个加一减一是保证循环必然终止的关键。再看看快速幂。计算2^10你可以循环乘十次但计算2^1000000循环就不划算了。分治思想在这里表现为指数减半function fastPow(int $base, int $exp): int { if ($exp 0) { return 1; } $half fastPow($base, intdiv($exp, 2)); if ($exp % 2 0) { return $half * $half; } return $half * $half * $base; }这个函数的时间复杂度是 O(log n)比循环乘法快得多。从分治角度看它完美展示了“解”和“合”的配合递归计算base^(exp/2)再根据 exp 的奇偶性决定是否多乘一个 base。这里最容易踩的坑是 PHP 整数溢出$half * $half在底数稍大时会超出 PHP 整数范围变成浮点数进而丢失精度。如果需要处理超大整数应该使用 BCMath 扩展的bcmul、bcpowmod等函数我在第4节会再提。3. 实操记录用分治解决“最大子数组”问题3.1 题目拆解与 PHP 实现上面两个例子属于“合并简单”或“无需合并”的分治但真实世界里最考验人的恰恰是那些“合并逻辑绕脑子”的问题。最大子数组问题就是最好的练习素材。问题描述是给定一个可能包含负数的整数数组找出一个连续子数组使得它的元素之和最大返回这个最大和。比如[-2, 1, -3, 4, -1, 2, 1, -5, 4]中最大子数组是[4, -1, 2, 1]和是 6。用遍历法当然能做但时间复杂度 O(n²)数据一多就扛不住。分治解法能把复杂度压到 O(n log n)。思路是这样的把数组从中点切成左右两半那么最大子数组只有三种可能完全在左半边、完全在右半边、或者跨越中点。前两种可以直接递归求解第三种需要单独处理。关键是第三种情况怎么算从mid开始向左扩展记录途经元素的最大累加和从mid 1开始向右扩展同样记录最大累加和两个最大累加和相加就是跨越中点的最大子数组和。直接上代码function maxSubArray(array $arr, int $left, int $right): int { if ($left $right) { return $arr[$left]; } $mid intdiv($left $right, 2); $leftMax maxSubArray($arr, $left, $mid); $rightMax maxSubArray($arr, $mid 1, $right); $crossMax crossMax($arr, $left, $mid, $right); return max($leftMax, $rightMax, $crossMax); } function crossMax(array $arr, int $left, int $mid, int $right): int { $leftSum PHP_INT_MIN; $sum 0; for ($i $mid; $i $left; $i--) { $sum $arr[$i]; if ($sum $leftSum) { $leftSum $sum; } } $rightSum PHP_INT_MIN; $sum 0; for ($j $mid 1; $j $right; $j) { $sum $arr[$j]; if ($sum $rightSum) { $rightSum $sum; } } return $leftSum $rightSum; }测试代码$arr [-2, 1, -3, 4, -1, 2, 1, -5, 4]; echo maxSubArray($arr, 0, count($arr) - 1), PHP_EOL; // 输出 63.2 参数选择与边界细节为什么交叉子数组不能漏最开始我写这个题犯过的错误就是只比较左子数组最大值和右子数组最大值忽略了跨中点的最大子数组。因为最大子数组完全可能有一部分在左、一部分在右比如数组[5, -20, 10, 20]左半边的最大子数组是[5]右半边的最大子数组是[10, 20]但全局最大是跨中点的[10, 20]它其实包含右半边起始点10和右半边的20。等一下这个例子里跨中点的最大和应该是5 (-20) 10 20 15而右半边是30所以全局最大在右半边。我换个例子[8, -10, 5, 6]左半边最大是8右半边最大是1156跨中点是8 (-10) 5 6 9全局最大还是右半边的11。这说明跨中点不是总会胜出但你不算它就可能在某个测试用例上得到错误答案。我找了一个更直观的数组[-3, 4, 2, -1]左半边最大是4右半边最大是2跨中点是4 2 6全局最大是 6。如果不计算跨中点结果就是 4直接出错。所以无论跨中点看起来是否“可能亏”都要老老实实算一遍用三个候选值取max算法才完备。再补充一个细节crossMax里向左扩展时$leftSum初始值必须设置成PHP_INT_MIN而不是 0。因为当数组里全是负数时$sum会不断累加成负数如果你初始为 0$sum $leftSum永远不成立最后返回 0但实际最大子数组应该是最大的那个负数比如[-5, -2, -3]正确答案是 -2。把初始值设为PHP_INT_MIN第一轮循环就会把$sum写入$leftSum问题就解决了。这个“全负数”边界是我在真实测试里踩过最典型的坑建议你把它记在笔记里以后遇到任何“求最值”的算法题都要先问自己一句“初始值设成 0 会不会在全是负数时出错”4. PHP 性能陷阱与调试排查实录4.1 递归层数、数组拷贝、函数调用开销分治算法在 PHP 里跑得慢很多时候不是算法本身的问题而是语言层面的几个隐藏陷阱。第一个是递归深度。PHP 默认的递归深度限制在xdebug.max_nesting_level或memory_limit之外并没有一个硬性的独立配置但深层递归会迅速消耗栈内存尤其是 Xdebug 开启时递归层级一多会直接报Maximum function nesting level reached。所以如果你在本地开着 Xdebug 跑归并排序大数据集遇到这个报错不要慌要么改配置提高限制要么用迭代法重写。第二个是数组拷贝。PHP 的数组是写时复制也就是说你直接$copy $arr并不会立刻复制但一旦修改元素就会触发真正的内存复制。如果在递归里频繁使用array_slice生成子数组那不仅时间开销暴增内存也会快速膨胀。1 万个元素的数组你可能没感觉但 100 万个元素每个递归层都 slice 一次内存可能直接爆掉。解决办法就是我在归并排序里演示过的“原数组 下标区间”方式全程只操作一个数组用$left、$right圈定范围。第三个是函数调用开销。PHP 的函数调用本身是有成本的递归越多、函数拆得越碎性能损失越明显。分治算法的复杂度分析默认函数调用是 O(1)但 PHP 的函数调用显然不是免费的。数据量小的时候无感数据量大的时候你可能会发现 PHP 的归并排序比同规模 C 语言版本慢不少这是语言特性决定的。工程上如果真要在 PHP 里大规模排序直接调用内置sort即可内置函数用 C 实现比你在 PHP 层手写同策略算法要快得多。手写分治的价值更多在于理解算法、处理复杂业务逻辑而不是和内置排序竞争性能。我用一张表格总结一下常见取舍问题错误做法推荐做法取中点($left $right) / 2intdiv($left $right, 2)递归传数组直接传值用引用$arr或下标区间拆分子数组array_slice多次复制原数组上维护左右边界递归溢出盲目调大限制先确认递归层数和业务是否合理超大数乘法普通*BCMath 的bcmul4.2 常见报错与排查速查表写分治算法时我遇到最多的几个报错和解决问题如下报错/现象原因解决办法Undefined offset递归边界算错访问了不存在的数组下标打印每次递归的$left、$right、$mid重点查merge里的下标更新死循环/超时$left $mid导致区间不缩小改成$left $mid 1$right $mid - 1Maximum function nesting level递归层数超过 Xdebug 限制临时关闭 Xdebug 或调整xdebug.max_nesting_level结果全是 0PHP_INT_MIN初始值没用好检查是否初始化为 0全负数场景会返错内存不足array_slice频繁复制大数组改用下标区间传递减少临时数组精度丢失普通乘法溢出转为浮点用 BCMath 扩展处理大整数我在调试分治代码时会用一个很笨但有效的方法在递归入口加一行echo打印当前处理的区间和关键变量。数据规模小的时候人脑跟踪整个递归流程完全没问题。等确认逻辑正确了再把echo删掉跑大数据测试。这个技巧看着土但比肉眼读代码快得多。另外如果你用 VSCode 写 PHP并配置了 Xdebug那调试递归会轻松很多。在递归函数的第一行打断点每次进入递归都会停在断点你可以在“调用堆栈”面板看到完整的递归链路哪个分支出错一目了然。没有断点调试条件的话error_log也是个好选择把关键中间结果写入日志再慢慢回看。还有一个容易被忽略的问题PHP 的intdiv在 PHP 7 才引入如果你还在老版本 PHP 5 上跑代码会直接报“未定义函数”。如果遇到这种兼容性问题把intdiv($a, $b)替换成(int)($a / $b)就能解决。不过现在主流都到 PHP 8 了热词里都有“php 8.3下载”我会建议你至少用 PHP 8.0 以上的环境来学习和测试不仅性能更好强类型语法支持也更完善。5. 从分治联想到的工程思维写这篇文章的时候我脑子里不停浮现的不只是排序和查找还有工作中真实遇到的“拆解问题”场景。比如分析一个大型报表任务你不能一次性把几十个维度的数据全部处理完工程量太大、边界太杂但你可以先按业务模块拆成独立子任务每个子任务内部再递归细化最后汇总结果。这个思路和分治算法几乎一模一样。我在实际项目里就做过一个批量数据清洗程序每天要处理几十万行Excel导入的数据。我把流程拆成“读取拆分—逐组清洗—合并输出”三个阶段每个阶段内部再用分而治之的思路去处理不同规则。每一步都比整体问题简单得多代码也能分给不同同事去完善最后再焊接到一起。这种思维方式比单纯记住某个算法更重要。不过也要提醒一句分治不总是银弹。如果一个问题有大量重叠子问题动态规划才是正解如果数据量很小直接暴力破解反而更清晰。判断用哪种算法本质上是在“理解成本”和“运行效率”之间做权衡这需要你多看、多写、多对比。另外分治算法天然适合并行处理。因为在“拆”这一步每个子问题之间是独立的你可以开多个进程或线程分别计算最后在“合”这一步汇总。PHP 的pcntl_fork或者消息队列分发给多个 Worker 就是这个思路的工程化。我项目里有一个耗时统计脚本就是把数据按日期分段扔给多个进程同时跑最后再合并结果耗时从半小时压缩到五分钟左右效果立竿见影。6. 写在最后的一点个人体会分治算法是我认为“最像人思考方式”的算法之一。它不是神来之笔而是把复杂的未知问题一步步变成多个简单的已知问题最后再拼回答案。就像庖丁解牛看着一头整牛眼里却已经是骨骼、关节、经络的清晰结构下刀自然游刃有余。PHP 语言本身语法直观数组操作灵活用来练习和实现分治算法刚刚好。我个人经验里学这个算法的顺序是先把归并排序手写三遍直到不用看任何参考也能流畅写出再做最大子数组问题体会“合并逻辑”的巧妙之处最后用二分查找和快速幂来加深“边界条件”的理解。这个过程走下来再遇到其他分治类问题你脑子里自然会想“怎么拆、怎么解、怎么合”而不是到处搜索现成代码。最后再分享一个小技巧如果你写完一个分治函数总是不放心可以用一个随机数生成器生成一堆小规模数组再拿一个暴力循环版本的结果做对照多跑几百轮。只要小规模数据全部通过你的算法逻辑基本就是对的剩下的只是性能调优问题。这个做法我每次都会用可以说是最省心的验证手段了。