资讯动态

PHP分治算法实战:二分查找、归并排序与快速排序的工程落地

发布时间:2026/10/9 14:33:34 来源:尧图企业网站定制
说到分治算法我先想起菜市场里一个熟练的猪肉师傅。一整扇猪肉摆在那他不会拿刀从中间乱砍而是先找准关节顺着肌理下刀把大块分成几个部位再把每个部位切成该有的样子。这个画面恰好和“分治”的路数一致——一个大问题按自然边界拆成小问题小问题解决了大问题也就解决了。标题里的“庖丁解牛”说的就是这个意思。我见过不少PHP同事业务写得很溜但一提到算法就肝颤觉得分治、递归这些词跟日常开发隔着一条鸿沟。实际上你在PHP里做的很多事——在有序用户列表里快速定位一个用户、对大数组排序、递归遍历分类树、把多台服务器返回的数据聚合起来——底层都绕不开这个思想。问题在于PHP这门语言有自己的小脾气数组按值传递、递归有深度限制、写时复制机制会在不知不觉中把你的内存吃光。单纯把算法书上的伪代码搬到PHP里十有八九会踩坑。下面我就想把这些坑一个个摊开从一个二分查找开始一直写到归并、快排和工程落地聊聊我踩过的、别人也大概率会踩的那些地方。1. 分治算法到底在解决什么问题——剥掉“高深”的外衣1.1 为什么“分解、解决、合并”三个动作就够了分治算法的英文是 Divide and Conquer翻译过来就是分而治之。它的实现套路异常固定只有三步分解Divide把原问题切成若干个规模更小的子问题解决Conquer递归地解决这些子问题当子问题小到可以直接解决时这就是递归出口也叫 base case合并Combine把子问题的结果拼成原问题的结果。拿整理仓库来类比。一个库房堆了几百箱货你不会让一个人从头翻到尾。你可能会先把仓库按货架分为 A 区、B 区、C 区再让不同的人负责每个区每个人又可以把一个货架分成上下两层来整理最后各区负责人把库位清单汇总整个仓库就整理完了。放在计算机里这就是一次标准的分解、解决、合并。很多递归函数看起来像分治其实并不是。比如递归遍历一个嵌套数组每个子任务只是原任务的浅层副本并没有“把问题规模减半”或者“拆成多个独立子任务”这种是树形递归不一定需要合并步骤。而分治的命门在最后一步如果合并结果比直接算还慢那分治就是竹篮打水。1.2 什么样的场景真正适合用分治真正适合分治的问题通常有三个特征。第一问题可以分解成若干个和原问题同类型的子问题只是规模更小。第二子问题之间彼此独立可以各干各的不需要共享中间状态。第三合并子问题结果的成本要足够低明显低于直接暴力计算的成本。举个例子。假设你在做一个订单系统卡在月末统计 100 万条订单的金额。不考虑数据库聚合的情况下一种常见做法是把订单按 ID 区间切成 10 块每块 1 万条分别算出金额再把 10 个结果相加——这就是典型的分治。每块内部还是加总只是规模变小块与块之间没有依赖可以串行也可以并行最后一步加法近乎免费。反过来如果一个子问题的解会影响到另一个子问题的解比如最短路径、背包问题这类需要全局信息的东西分治就不能直接套那些往往需要动态规划或者别的策略。分治不是万能的但它一旦适用就能把问题的规模从“一整个巨大任务”降维成“若干个可控的小任务”。1.3 复杂度差异为什么值得学循环嵌套的暴力解法通常有 O(n²) 或更高的复杂度。用分治思想设计的算法比如二分查找把规模每次减半时间复杂度是 O(log n)归并排序是 O(n log n)。当 n 从 1000 涨到 100 万O(n²) 是万亿级操作O(n log n) 只是约 2000 万次差距几乎是天壤之别。你在本地跑小数组时感觉不出区别一旦数据量上来服务器卡到报表超时你才会想起这一课。这不是让你去背诵主定理而是要记住遇到大数据量的“重复性处理”先停下来想想能不能拆。分治不是考试专用知识点它本身就是你在工程中控制复杂度的第一件武器。2. 从零手写第一个分治代码二分查找的实现与边界哲学2.1 递归版二分查找先写对边界条件二分查找是分治里最接近“庖丁解牛”的一个例子每次找中间点判断目标在左半边还是右半边然后只递归处理那半边。子问题和原问题同构只是区间缩小了一半合并动作几乎不存在因为找到就是找到了没找到就是没找到。function binarySearchRecursive(array $arr, int $target, int $low, int $high): int { if ($low $high) { return -1; } $mid $low intdiv($high - $low, 2); if ($arr[$mid] $target) { return $mid; } if ($arr[$mid] $target) { return binarySearchRecursive($arr, $target, $mid 1, $high); } return binarySearchRecursive($arr, $target, $low, $mid - 1); }递归出口用的是$low $high而不是$low $high。为什么如果当前区间只剩下一个元素也就是$low $high我们仍然需要判断这个元素是不是目标值不能直接退出。只有当区间里没有任何元素时才说明目标不存在。这里我特意用$low intdiv($high - $low, 2)而不是intdiv($low $high, 2)。很多教科书会直接用(low high) / 2是因为在 C 或 Java 里两个很大的 int 相加可能溢出。PHP 的整数相加如果超过 PHP_INT_MAX 会自动转成 float精度受损后拿到的 mid 就可能出错。虽然以 PHP 数组的实际规模很难触发但把这种防溢出的写法养成习惯以后转到其他语言也受益。调用示例很简单$ids [2, 5, 8, 12, 16, 23, 38, 56]; $index binarySearchRecursive($ids, 23, 0, count($ids) - 1); // 返回 52.2 迭代版与递归版的取舍递归版代码简洁容易证明正确性但每次递归都会压一个函数调用栈工程上我更倾向迭代版。二分查找的递归深度虽然只有 log2(n)普通数据量下没什么压力但能用迭代表达就尽量迭代这是 PHP 项目里一个值得长期坚持的习惯。function binarySearchIterative(array $arr, int $target): int { $low 0; $high count($arr) - 1; while ($low $high) { $mid $low intdiv($high - $low, 2); if ($arr[$mid] $target) { return $mid; } if ($arr[$mid] $target) { $low $mid 1; } else { $high $mid - 1; } } return -1; }注意循环条件是$low $high和递归版的$low $high是严格对应的。时间复杂度上递归版和迭代版都是 O(log n)空间上递归版因为调用栈是 O(log n)迭代版是 O(1)。所以迭代版在性能和内存占用上都有优势。2.3 二分查找在 PHP 工程里的三个注意点第一个注意点数组必须有序。PHP 的 sort 默认按升序排列但会重置索引。如果用的是关联数组并保留键名需要用 asort。二分查找要求的是连续整数下标否则$arr[$mid]取到的元素和预期的顺序对不上。第二个注意点二分查找更适合静态数据或低频变化的有序集合。如果数组在频繁插入和删除每次维护有序的代价很高不如换成数据库索引或者更合适的数据结构。第三个注意点手写二分不一定总是比内置函数快。PHP 的in_array、array_search是在 C 层做线性扫描常数项非常小。我在一个 2 万条数据的有序数组上循环查找 3000 次用in_array跑了 2 秒多换成二分查找不到 0.1 秒量级差距确实明显。但如果只是查一次两次几千条数据的小数组直接in_array反而更省事。工程选择永远是看场景规模而不是看算法听起来高级不高。3. 排序战场上的分治双雄归并排序与快速排序怎么选3.1 归并排序先拆到最小再两两合并归并排序完全按“分治”流程设计把数组对半切递归排序两个子数组再把两个有序数组合并成一个有序数组。递归出口是数组长度小于等于 1。代码写出来非常工整function mergeSort(array $arr): array { $n count($arr); if ($n 1) { return $arr; } $mid intdiv($n, 2); $left mergeSort(array_slice($arr, 0, $mid)); $right mergeSort(array_slice($arr, $mid)); return mergeSortedArrays($left, $right); } function mergeSortedArrays(array $left, array $right): array { $merged []; $i 0; $j 0; $leftCount count($left); $rightCount count($right); while ($i $leftCount $j $rightCount) { if ($left[$i] $right[$j]) { $merged[] $left[$i]; $i; } else { $merged[] $right[$j]; $j; } } while ($i $leftCount) { $merged[] $left[$i]; $i; } while ($j $rightCount) { $merged[] $right[$j]; $j; } return $merged; }mergeSortedArrays是两个有序列表合并的标准写法两个指针从头开始谁小谁进结果数组。因为左右两边各自有序所以比较次数最多是 m n - 1不会做无效重复比较。稳定性是归并排序最值钱的特性。当两个元素的值相等时左边子数组的元素会在右边子数组的元素之前进入结果数组所以相同值不会交错乱序。这在排序带有“先后插入时间”的数据时很重要比如按金额排序但又希望相同金额的订单仍按时间排列。不过上面这个写法直接用array_slice每一层递归都会做真实拷贝内存开销相当大。这个问题我会在下一节专门展开。3.2 快速排序原地分区的艺术快速排序和归并排序共享“递归切割”的骨架但思路恰恰相反归并是“先递归再合并”处理顺序从叶子向上走快排是“先分区再递归”每一步先把一个基准元素放到最终位置让左边都比它小、右边都比它大然后递归处理左右两部分。由于分区是原地进行的不需要额外的大块合并空间所以内存友好。function quickSort(array $arr, int $low, int $high): void { if ($low $high) { return; } $pivotIndex partition($arr, $low, $high); quickSort($arr, $low, $pivotIndex - 1); quickSort($arr, $pivotIndex 1, $high); } function partition(array $arr, int $low, int $high): int { $pivot $arr[$high]; $i $low - 1; for ($j $low; $j $high; $j) { if ($arr[$j] $pivot) { $i; [$arr[$i], $arr[$j]] [$arr[$j], $arr[$i]]; } } [$arr[$i 1], $arr[$high]] [$arr[$high], $arr[$i 1]]; return $i 1; }partition 是快排的精华。这里选最后一个元素作为基准 pivot。$i 指向“小于等于基准的最后一个元素的位置”$j 负责从左到右扫描。每当发现一个小于等于基准的元素就把 $i 往前推一步并把这个元素交换到已经处理好的分区尾部。扫描结束后所有小于等于基准的元素都在 $i 之前$i 1 就是基准应该待的位置把它和$arr[$high]互换基准就位。注意函数签名里的array $arr。在递归中数组必须通过引用传递否则每层递归都会复制整个数组内存会直接爆掉。这个点我在下一节会做一次真实复现。基准选择是快排最容易翻车的地方。固定取最后一个元素时如果数组本身已经有序或者接近有序每次划分都只有一边有数据时间复杂度退化成 O(n²)递归深度变成 O(n)。一个常用补救是“三数取中”从首、中、尾三个位置取中间值做基准可以显著降低退化概率但不能完全消除。顺带提一句稳定性分区扫描时等于基准的元素可能被交换到其他位置所以相同值的相对顺序无法保证这让快排天然不稳定。如果业务要求稳定排序归并是更稳的方案。3.3 一张表看懂归并与快排的取舍对比维度归并排序快速排序平均时间复杂度O(n log n)O(n log n)最坏时间复杂度O(n log n)O(n²)容易退化额外空间O(n)需要辅助合并数组O(log n)主要是递归栈稳定性稳定不稳定典型工程定位数据量可控、稳定优先内存敏感、原地处理在 PHP 里实际排数据首选永远是内置的 sort 和 usort。它们由 C 实现底层还会根据数据规模自动切换排序策略比任何 PHP 手写算法都快。那为什么还要学归并和快排因为它们是理解分治的最佳切片归并教你不取巧地拆和合快排教你聪明的原位分区。排序本身在业务里已经不需要你造轮子但这两套思维的适用范围远不止排序。4. PHP 值复制语义下的分治性能陷阱不引用的递归都是内存炸弹4.1 PHP 的写时复制机制与数组切片陷阱PHP 的函数参数默认是按值传递。很多开发者会以为数组做参数传进函数时整个数组被拷贝了一份其实不对。PHP 内部用的是引用计数加写时复制Copy On Write机制函数刚收到数组参数时只是新建了一个符号表项多个变量共享同一个 zval 容器并没有真正拷贝物理内存。只有当某个位置试图修改这个数组时引擎才会为这份副本分配独立内存。这个机制带来了一个非常隐蔽的坑在分治递归里为了把数组切成两半我们通常会用到array_slice或array_merge这类“看起来人畜无害”的函数它们会立刻触发真实的物理拷贝。归并排序里每一层递归都要 slice 两次、merge 一次整个过程中内存峰值会远高于数组的原始大小。对于几千个元素的数组你可能无感放到几十万、上百万个元素内存就摁不住了。4.2 一次真实的内存耗尽事故有次我在本地对 100 万个随机整数做手写归并排序跑到一半 PHP 直接抛出Fatal error: Allowed memory size of 134217728 bytes exhausted。134217728 字节就是 128MB这是 PHP 默认的 memory_limit。单纯给数组本身100 万个整数在 64 位系统上大约要几十 MB但array_slice和 merge 的连环复制直接把峰值干到了几倍。我当时的第一个反应是“不会是死循环吧”后来加了memory_get_peak_usage(true)一测才发现是分治实现的空间开销失控了。如果是普通业务场景128MB 可能还好但如果你跑在 Docker 容器里容器内存限额只有 256MB或者同时跑着若干个 worker 进程这个内存峰值很容易成为线上事故的导火索。4.3 怎样写出内存友好的分治代码优化方向其实很明确能用索引拆问题就不要切数组能原地交换就不要新建数组能传引用就不要传值。二分查找我们直接用 $low 和 $high 区间数组从始至终只有一份不需要拷贝所以它天然内存友好。快速排序必须用引用$arr并原地交换元素不建子数组排序过程除了函数调用栈之外额外只占常数级内存。归并排序若一定要保留“切分”语义可以在合并时才新建结果数组或单独维护一个临时缓存数组但 PHP 不像 C 和 Java 有真正可自动释放的底层数组纯手写归并的内存优化空间有限这时候更务实的做法是如果只是排序功能直接交给内置 sort。还有一个开发经验可以分享在开发阶段把 memory_limit 临时调低一点比如php -d memory_limit64M test.php故意让自己更早撞见内存问题比上线后在 512MB 的容器里爆掉再去排查从容得多。4.4 PHP 8 的这些陷阱有变化吗PHP 8 带来了 JIT、完善了类型系统、让内置排序稳定但值复制和写时复制的模型没有本质改变。JIT 可以让整数运算、循环这类 CPU 密集代码跑得更快但数组切片、array_merge这些 C 层操作的成本仍然存在递归调用的栈帧分配也没有消失。所以不要指望升级 PHP 版本就能解决所有性能问题。我在 PHP 8 上验证过同样的归并排序实现内存峰值和 PHP 7.4 差不多只是耗时略微下降。瓶颈一直在数据复制上而不在 CPU 指令执行上。5. 递归深度失控PHP 崩溃的常见模式与兜底方案5.1 递归多深才会出事先说结论分治算法给不同子任务带来的递归深度差异很大。二分查找和归并排序每次递归把规模减半递归深度是 O(log n)即便 n 是 10 亿深度也才 30 层左右完全安全。快速排序就不一样了最坏情况下每次只切掉一个元素递归深度是 O(n)对一个 10 万元素的退化数组它会一路递归几万层。PHP 没有像 Python 那样内置一个严格的递归层数上限但有两道隐形门槛。第一道是开发环境的 Xdebug 限制装 Xdebug 后默认有 max_nesting_level 配置默认值在不同版本里通常是 256 或 512一旦超过直接抛 Maximum function nesting level reached 报错这不是代码错误而是配置限制。第二道门槛是运行时栈空间和 memory_limit每层递归都要创建新的执行上下文、保存局部变量和返回地址层数上去之后内存上限会先扛不住如果配置不当甚至可能让 PHP 进程直接段错误。实际排错的观察顺序是这样的先看有没有报 Maximum function nesting level有就说明 Xdebug 的限制被触发没有的话再看错误日志里是Fatal error: Allowed memory size还是 Segmentation fault。前者是 PHP 层内存告警后者是 C 层栈溢出。两种情况处理思路不同前者优先优化内存使用后者必须降低递归深度或者改成迭代。5.2 快速排序退化时的迭代解法既然快排最怕递归深一个常见的工程解法是把递归栈改成显式维护的栈。递归调用本质上是系统帮你维护了一个函数调用栈迭代版只是把这层控制权拿回自己手里栈里存的是待处理的区间而不是栈帧每个区间被弹出处理后再压入新的子区间。这样即使遇到有序数组的退化输入也只是栈里多存几个区间不会把 PHP 的调用栈撑爆。function quickSortIterative(array $arr): void { $stack [[0, count($arr) - 1]]; while ($stack ! []) { [$low, $high] array_pop($stack); if ($low $high) { continue; } $pivotIndex partition($arr, $low, $high); if ($pivotIndex - 1 $low) { $stack[] [$low, $pivotIndex - 1]; } if ($pivotIndex 1 $high) { $stack[] [$pivotIndex 1, $high]; } } }这里的 partition 函数和递归版本完全一样可以直接复用。迭代版本相较递归版本的性能差异不大真正价值是让最坏情况从“进程崩溃”降级成“可控的内存占用”。用在生产环境时即使数据再恶心最多也只是慢不会挂。5.3 先估算递归深度再决定要不要递归动手写递归之前先估算一下这个算法最坏情况的递归深度。如果深度和输入规模同阶也就是 O(n)在大数据上要小心如果深度是对数阶 O(log n)基本可以放心写。提供两个自测方法。第一种是在递归函数入口用静态计数器计数每进入一层加一函数返回前减一峰值就是最大深度外面再配一个 echo 观察。第二种更简单在递归参数里带一个$depth出现异常时把深度写进日志。出问题时优化方向是先把二叉递归尾递归化再把尾递归改循环最后再考虑整体用显式栈。一步一步来不要一上来就重写成一个完全不同的算法。开发环境下如果你确认代码正确、只是被 Xdebug 拦了可以临时用php -d xdebug.max_nesting_level10000 test.php做最小范围测试。但不要把调高限制当上线方案线上环境不应该依赖无限递归。6. 分治思想在真实 PHP 项目中的落地场景该用的时候用不该用别硬用6.1 四个适合分治的实际场景第一个场景是大文件日志分块统计。日志文件动辄几十 GB不可能一次加载进内存。常见做法是读取时按行数分成若干块每块单独在子进程中统计关键词频次或错误数最后汇总。这正是分治而且天然适合并行化把块分给多个进程处理再合并结果。第二个场景是 IP 归属查询。把 IP 转换成整数后放入有序数组用二分查找定位某个 IP 段。实际场景里CDN 或风控系统常常需要在几万条 IP 规则里快速判断一个请求是否需要拦截二分查找是这里最常见的落点迭代版代码性能好又不会出深度问题。第三个场景是批量接口数据聚合。假设一个页面要展示用户近 30 天的数据但数据源是按天分片的接口。你可以先请求第 1 到 15 天的数据、第 16 到 30 天的数据两个子任务独立完成后做一个简单的合并、排序、去重。如果接口数量很大还能把子任务丢到 Swoole TaskWorker 或消息队列的多个 worker 里去并行。第四个场景是树形结构的递归处理。商品分类、组织架构、评论楼的嵌套回复本质上都可以看作分治先处理每个子树得到结果再把结果拼到当前节点。递归处理分类树是 PHP 开发里最常见的分治应用之一但要注意分类树的层数就是递归深度太大的树同样会踩到前面说的深度坑。看这些例子的共同点每个子问题都跟原问题同构、子问题之间没有共享可变状态、最后合并成本极低。符合这三条才值得用分治。6.2 内置函数与手写分治的边界在哪里很多 PHP 开发学会排序算法后的第一反应是“我以后是不是应该自己写排序”。答案是不要。PHP 内置的 sort、usort、array_search、array_column 这些函数在 C 层面做了大量优化会根据数据规模自动选择不同的策略稳定性和性能都碾压 PHP 层的手写实现。工业代码里读和写同样重要自己写的算法多了反而引入 bug 的风险更高。手写分治的真正价值有三块一是面试和晋升题里的基本功自证二是当你需要定制排序规则、分区逻辑等内置函数做不到的事情时你能在理解原理的基础上改造三是当数据规模超出单机内存、需要把问题拆到多个进程或机器上时分治的框架能帮你想清楚分布式任务的切分和汇总方案。我在代码评审中最常见的问题是有人为了“用上算法”而在小数组上硬套二分或者快排。这种过度设计十有八九会把代码搞复杂。一个小数组几百个元素以内用内置array_search线性扫描实际速度往往并不慢因为 PHP 层循环的开销远大于 C 层线性扫描的常数项。先量出问题的规模再决定要不要上算法。6.3 从“会用”到“会设计”的经验心得这一节没有代码我纯分享一些踩坑之后才总结出来的做法。刚开始学分治时我总喜欢直接写递归函数写完再调试。后来发现更高效的方法是第一步先在白纸上把问题拆解的递归树画出来只画前两层加最底层的 base case能串起来再写代码。第二步先写合并函数或分区函数这些是最容易出错也最好单测的部分然后再写递归主体的壳把它们串起来。第三步测试时一定要覆盖边界空数组、单元素数组、已经有序的数组、全部元素相同的数组、超大数据。一个典型的例子二分查找的递归出口写法是一念之差$low $high和$low $high在特殊情况下行为完全不同归并的合并函数如果少处理一边剩余的尾部元素数据就会静默丢失。这些边界问题不靠肉眼盯能解决而是靠刻意构造极端输入来逼出来。经验逐步积累后你会发现分治真正统一的场景化思考方式大任务能不能拆成同构小任务小任务的结果如何合并中间是否有依赖三个问题都有答案算法就会自己浮现出来而不是靠背模板。最后分享一个我调试这类代码时的小习惯在 CLI 下把 memory_limit 临时调低比如php -d memory_limit64M test.php这样内存问题会在本地更早暴露而不是等部署到容器里才炸。再加上一套覆盖空集、单元素、全相同元素、超大数组的测试基本能把递归代码的常见坑都扫一遍。算法这事儿写得多了就会明白最难的不是理解复杂度公式而是让代码在真实语言环境里可靠运行。

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

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

免费获取报价 →
↑