尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PHP数组并集、交集、差集:内置函数与实战避坑指南

PHP数组并集、交集、差集:内置函数与实战避坑指南 很多年前我刚接触 PHP 的时候总觉得数组操作不就是增删改查嘛后来真到业务里跑起来才发现并集、交集、差集这三种操作几乎天天都在用。权限合并需要并集白名单过滤需要交集数据对账需要差集如果只是翻文档查函数很容易记住 API 却用错场景甚至被 PHP 那些隐蔽的类型比较规则坑得怀疑人生。这篇文章不打算罗列文档我想把三种集合操作各自的原理、内置函数之间的细微差别、常见业务场景怎么写、以及我实际踩过的坑都掰开讲清楚。不管你是刚入行的新手还是写过几年 PHP 的老手只要你的代码里出现过数组比对、数组合并、数据过滤这篇文章都值得你花几分钟看完。1. 三个运算到底是什么1.1 从集合思想说起数组不只是列表如果你学过高中数学里的集合并集、交集、差集的概念其实非常简单。A ∪ B是把两个集合的元素合在一起重复的只保留一份A ∩ B是取两个集合都有的部分A - B是保留 A 里有但 B 里没有的部分。PHP 数组本质上就是一个有序映射既能当列表用也能当集合用所以这三种数学运算在 PHP 里都有非常直接的应用场景。但需要注意PHP 数组有两种长相一种是索引数组键是 0、1、2、3 这种自然数另一种是关联数组键是字符串。这两种长相在做并集、交集、差集时语义会完全不同。举个例子array_merge对索引数组是“追加”对字符串键的关联数组却是“后者覆盖前者”如果不清楚这个区别写出来的代码会莫名其妙地丢数据。这也是为什么我强烈建议你在动手之前先确认自己手里的数组到底是哪种结构。1.2 业务里最常见的三个场景并集最典型的场景是权限合并。比如一个用户拥有多个角色每个角色有一批权限 ID最终这个用户能访问的权限集合就是所有角色权限的并集。交集最常见的场景是标签匹配用户身上的标签和某篇文章要求的标签做交集判断这篇文章是否推荐给他。差集则多用于对账、同步、去重比如本地库里有一批商品 ID接口返回了一批线上商品 ID两边一比较就能算出哪些需要新增、哪些需要下架。这些场景看起来不同本质都是“多个数组之间的集合运算”。在 PHP 里面对这些小需求很多人第一反应是写 foreach 循环加 in_array 判断但内置函数的性能通常比手写循环好代码也更简洁。当然内置函数也有它们自己的脾气接下来我会逐个讲清楚。2. 内置函数是主力先把它们摸透2.1 并集array_merge 和运算符的爱恨纠葛实现并集最常用的函数是array_merge但它并不是严格意义上的“数学并集”因为它在合并重复元素时不够智能。往下看$arr1 [1, 2, 3]; $arr2 [3, 4, 5]; // 结果: [1, 2, 3, 3, 4, 5] $result array_merge($arr1, $arr2);看到没数字 3 出现了两次。如果业务要求去重你得自己再包一层array_unique$result array_unique(array_merge($arr1, $arr2)); // 结果: [1, 2, 3, 4, 5]这里 index 数组经过array_merge后索引会被重新从 0 编排这样后续array_unique去重时又能得到一个干净的索引数组。但如果是关联数组array_unique去重后会保留第一次出现的键顺序不会有问题但万一键名是你依赖的东西就要格外小心。再说另一个做并集的方式运算符。它和array_merge的行为差异非常大$arr1 [a 1, b 2]; $arr2 [a 100, c 3]; // array_merge 结果: [a 100, b 2, c 3] $merged array_merge($arr1, $arr2); // 运算符结果: [a 1, b 2, c 3] $plus $arr1 $arr2;核心区别在于array_merge遇到同名字符串键时右边的值会覆盖左边的值而运算符永远保留左边的值右边的重复键会被直接忽略。如果两个数组都有关键业务字段用错一个就可能把配置覆盖掉。我见过真实线上事故A 系统的默认配置被调用方用合并结果调用方传进来的配置根本无法覆盖默认值所有人都被那个隐藏 bug 坑了一整晚。现在回想起来其实就是没搞清楚运算符语义。如果你在合并索引数组时希望去重一种更直观的写法是$result array_values(array_unique(array_merge($arr1, $arr2)));array_values的作用是把键重新变成 0、1、2 这种索引。别小看这一步有些框架对数据结构要求严格比如必须从 0 顺序编号不加array_values很容易埋雷。2.2 交集array_intersect 不能只看值交集函数里最基础的是array_intersect它比较的是数组的值$arr1 [a, b, c]; $arr2 [b, c, d]; // 结果: [1 b, 2 c] $result array_intersect($arr1, $arr2);注意结果保留的是第一个数组的键所以返回的是[1 b, 2 c]而不是从 0 开始。如果你需要重新索引可以再包一层array_values。很多人在对接接口时喜欢直接拿返回数组当 JSON 输出结果输出出来带了一堆奇怪的键名其实根源就在这里。如果你希望同时比较键和值可以使用array_intersect_assoc$arr1 [a 1, b 2]; $arr2 [a 1, b 3]; // 结果: [a 1] $result array_intersect_assoc($arr1, $arr2);array_intersect_assoc只有在键名和值都相同的情况下才会保留适合做精确匹配。还有一类场景是只比较键、不关心值这时应该用array_intersect_key$arr1 [name 张三, age 20]; $arr2 [name 李四, job 工程师]; // 结果: [name 张三] $result array_intersect_key($arr1, $arr2);只比较键的应用场景很广比如两个接口返回的字段列表完全不一样只需要拿到共同的字段名。注意这三种函数的比较规则都是把值转成字符串再比较所以1和1会被认为相等0 和0也一样。这个特性大部分时候是好事但有的时候会给你带来诡异的 bug后面我会专门聊。2.3 差集array_diff 的键名陷阱差集对应的基础函数是array_diff它同样比较值并返回“第一个数组里有、其他数组里没有”的部分$arr1 [1, 2, 3, 4]; $arr2 [3, 4, 5]; // 结果: [0 1, 1 2] $result array_diff($arr1, $arr2);和array_intersect类似返回的数组保留第一个数组的键。如果只需要值列表请记得array_values。如果既要比较键也要比较值用array_diff_assoc如果只想比较键名用array_diff_key$arr1 [name 张三, age 20, city 北京]; $arr2 [age 21, country 中国]; // 只比较键结果: [name 张三, city 北京] $result array_diff_key($arr1, $arr2);我最常用array_diff_key的场景是“找出数组中多余字段”比如用户提交了一批参数但表里只允许其中某几个字段存在这时把提交字段和允许字段做array_diff_key就能筛出非法参数。2.4 一张表看懂全家福为了方便你以后直接翻我把常用函数整理成一个速查表函数名比较维度场景返回键名array_merge值追加/键覆盖合并数组数字键重建字符串键保留运算符键名去重左优先合并且保留左侧键左侧键为准array_intersect值取共同值保留第一个数组键array_intersect_assoc键值精确交集保留第一个数组键array_intersect_key键取共同键保留第一个数组键array_diff值取出差集保留第一个数组键array_diff_assoc键值精确差集保留第一个数组键array_diff_key键键差集保留第一个数组键这张表看起来简单实际开发中一开始没记住写代码就容易写错。比如你本意是拿两个关联数组做交集结果用了array_intersect那比较的就是值而不是键逻辑完全跑偏。所以我的习惯是在写代码前先花十秒钟判断我现在比较的是键、值还是键加值清楚了再选函数。3. 关联数组和多维数组的进阶玩法3.1 保留键名的并集操作上一节提到的array_merge在处理字符串键的关联数组时会产生右覆盖左的行为。有时候这不是我们想要的效果比如从数据库查出来两批配置希望把两批配置合并重复键保留任何一个都可以但绝不能让数据丢。这时运算符或者手动array_replace可能更合适$config1 [debug false, cache 300]; $config2 [debug true, timeout 10]; // 保留 $config1 的同名键 $result $config1 $config2; // 如果你想保留 $config2 的同名键应该这样 $result $config2 $config1;这种写法在合并应用配置、插件参数时非常实用。array_replace的语义则和array_merge基本一致区别在于它可以接受多个数组从前往后依次覆盖而且不会对数字键重新索引。如果你希望合并后数字键保持原样array_replace会比array_merge更安全。另外还有一种场景是合并多个列表数据每个列表里可能有重复 ID需要合并后再去重。我的习惯是先array_merge后array_unique但如果数据量非常大我更推荐用“翻转键”的思路也就是把值放到键里自然去重。下面这段代码经常出现在我的工具函数里$allIds array_keys(array_flip($arr1) array_flip($arr2));array_flip会把原来的值变成键键变成值重复的值自然会覆盖。然后array_keys再把键取回来。不过要提醒你array_flip只适用于字符串和整型值浮点数、布尔值、数组值都会报警告使用时务必确认数据格式统一。3.2 多维数组需要绕的弯内置函数本身就是做一维数组比对的遇到多维数组直接套用基本都会报错或者行为诡异。最常见的场景是二维数组比如从数据库查出来的多条记录你要按某个字段做交集或差集。网上不少人推荐用array_map先提取字段再交给array_diff处理实践下来这个思路简洁且高效$local [ [id 1, name 苹果], [id 3, name 香蕉], [id 5, name 橙子], ]; $remoteIds [1, 3, 7]; $localIds array_column($local, id); // 差异 ID: [5, 7] $needDelete array_diff($localIds, $remoteIds); $needAdd array_diff($remoteIds, $localIds);利用array_column把二维数组拉平成一维再去比较是处理这类需求最优雅的思路。如果你需要比较二维数组的完整行比如判断两条记录是否完全一致可以先把每行序列化为字符串再比较$local [[id 1, name 苹果], [id 2, name 香蕉]]; $remote [[id 1, name 苹果], [id 3, name 西瓜]]; $localStr array_map(json_encode, $local); $remoteStr array_map(json_encode, $remote); $diff array_diff($localStr, $remoteStr); // 注意比较的是 JSON 字符串最好用 array_values 重新索引这种方式做同步对账很好用但注意 JSON 序列化之后每行顺序会影响结果稳妥一点可以先把数组按固定规则排序再序列化。另外如果字段里包含浮点数JSON 序列化后的精度可能会影响一致性判断条件允许时建议用数据库或专门的对比工具。3.3 自定义比较函数兜底一切内置函数比的是值的字符串形式遇到对象、数组、以及需要“按某个业务规则相等”的场景就无能为力了。好 PHP 还提供了一组支持自定义回调的函数比如array_uintersect、array_udiff、array_uintersect_assoc、array_udiff_assoc。举个例子你有一个用户对象数组和一个允许访问的用户 ID 数组想筛选出被允许访问的用户对象$users [ (object)[id 1, name 张三], (object)[id 2, name 李四], (object)[id 3, name 王五], ]; $allowedIds [2, 3]; $result array_uintersect($users, $allowedIds, function ($user, $id) { return $user-id $id; });这里用到了太空船运算符它返回 0 表示相等-1 或 1 表示大小关系。自定义回调的好处是你能完全掌控“什么叫相等”代价是回调会被调用多次数据量大时性能会下降。所以我的建议是能用array_column或array_flip解决的问题尽量不要上自定义回调只有比较逻辑绑定在对象业务规则上时才考虑它。4. 三个实战案例直接抄作业4.1 用户角色的权限 ID 并集假设系统里一个用户有多个角色每个角色的权限 ID 存在perms字段里$userRoles [ [role admin, perms [1, 2, 3, 100]], [role editor, perms [2, 3, 4, 5]], [role viewer, perms [5, 6]], ]; $permIds []; foreach ($userRoles as $role) { $permIds[] $role[perms]; } // 合并所有权限并去重 // 结果: [1, 2, 3, 100, 4, 5, 6] $finalPerms array_values(array_unique(array_merge(...$permIds)));这里用了解包运算符...把多维数组合并成array_merge的参数。注意 PHP 5.6 以上才能这么写老项目如果还在用 5.5只能老老实实用call_user_func_array(array_merge, $permIds)。合并之后顺手array_unique去重再把键重排这样最终权限 ID 列表就会非常干净。可能有朋友会问如果 perm IDs 是字符串怎么办比如权限编号是perm_read、perm_write思路完全一样array_unique对字符串做去重也没问题但要注意字符串0和数字0会被 PHP 的宽松比较判定为相同这种边界场景很少发生一旦发生却很难排查。4.2 对账数据差集对比做支付对账或者商品同步时经常要比较两批订单号。下面是简化版场景本地订单号数组 vs 渠道回调订单号数组需要找出“仅在本地有”和“仅在渠道有”的订单$localOrders [1001, 1002, 1003, 1004]; $channelOrders [1002, 1004, 1005]; $onlyLocal array_values(array_diff($localOrders, $channelOrders)); $onlyChannel array_values(array_diff($channelOrders, $localOrders)); // $onlyLocal [1001, 1003] // $onlyChannel [1005]这里要特别小心$localOrders里是 int$channelOrders里是 string。PHP 的array_diff会把值转成字符串再比较所以1002和1002会被视为相同结果才符合预期。如果你拿到源头数据里既有整数又有带前导零的字符串比如01002和1002被当成同一个单号就会出现严重对账错误。处理对账逻辑时我建议先把两边的数据统一格式用array_map(strval, ...)或者先去掉前导零再做差集。从实现细节看array_diff内部会对每个数组的值建立哈希映射所以它的时间复杂度大致是 O(n m)比自己写双重循环高效得多。数据量在百万级别时内置函数的优势会非常明显。4.3 推荐标签的交集运算内容平台做推荐时经常要看用户标签和候选内容标签的重合度。如果标签是一维数组直接用array_intersect$userTags [php, laravel, 数据库]; $articleTags [php, JavaScript, 算法]; $commonTags array_values(array_intersect($userTags, $articleTags)); // 结果: [php] if (count($commonTags) 0) { // 至少一个标签匹配可以进入候选池 }当面返回的是字符串数组而且你想知道“有哪些标签匹配了”array_intersect再array_values就很合适。如果你想判断“是否完全匹配”可以用array_diff判断差集是否为空$isFullMatch count(array_diff($expectedTags, $userTags)) 0;这个写法比“先比较长度再比较交集”简单得多而且能天然规避重复元素带来的干扰因为array_diff和array_intersect都不在意数组里同一值出现多少次只看有没有。5. 躲开我踩过的坑5.1 类型宽松比较造成的“幽灵数据”PHP 数组比较函数的底层大多数用(string) $value这种字符串化比较。这意味着0、0、false、、null这些值在宽松比较时会有让人意想不到的相等关系。我举一个真实例子$a [0, 0, , false, null]; $b [0, 0]; $result array_intersect($a, $b);看到结果后很多人会吃惊这可能会把0、0、false、null都认为是相同的因为它们在字符串化后存在某种程度的宽松等价关系。遇到这类包含空值、布尔值的数组我强烈建议先做数据清洗$a array_filter($a, function ($item) { return $item ! $item ! null $item ! false; });或者更简单提前统一类型$a array_map(strval, $a); $b array_map(strval, $b);这样能避免一大批说不清道不明的“幽灵数据”进入结果。我自己的经验是凡是数组里同时存在数字、字符串和布尔值的场景一律先规范化再比较绝不要依赖 PHP 的宽松比较冥冥之中的“默契”。5.2 array_merge 把数字键重新洗牌array_merge遇到字符串键会保留键名但遇到数字键会从 0 开始重新索引这经常引起误会。看下面这个例子$arr1 [2 b, 5 e]; $arr2 [0 a, 3 d]; // 结果: [0 b, 1 e, 2 a, 3 d] $merged array_merge($arr1, $arr2);原来的键2、5、0、3全都丢了值被重新编号成 0 到 3。如果你依赖键名做别的业务判断这就是灾难。有些人会改用运算符// 结果: [2 b, 5 e, 0 a, 3 d] $plus $arr1 $arr2;这里键被保留了而且左侧优先。另一个不那么常用但值得记住的写法是array_replace它不会重排数字键而是用后面的数组覆盖前面的同名键。按需选择就好但千万别想当然。5.3 大数组性能的隐形炸弹在数据量只有几百、几千时不管你是用in_array循环还是array_intersect区别都不明显。但数据量一旦到十万、百万级别性能差异会立刻被放大。以前我做过一个需求用本地商品表里 50 万条 ID去过滤接口返回的 20 万条 ID只保留两边同时存在的。一开始我用 foreach 加in_array连着翻车的场景我记忆犹新——脚本跑了快一分钟才出来线上直接被拖死。后来改成用array_flip把 20 万条 ID 翻成键再逐一遍历 50 万条数据判断键是否存在整个计算降到 0.3 秒以下$remoteMap array_flip($remoteIds); $exists []; foreach ($localIds as $id) { if (isset($remoteMap[$id])) { $exists[] $id; } }核心原理很简单PHP 数组底层是哈希表isset判断键是否存在是 O(1) 的操作而in_array在底层是线性遍历最坏情况是 O(n)。如果两边都是大数组array_flipisset几乎是碾压级优化。很多人忽略这个细节等到服务报警才着急所以我在任何需要做大量数组匹配的代码里第一反应都是“能不能把比较的一方翻转成键”。5.4 空数组和二义性结果空数组参与运算的安全性问题也值得一提。array_diff($a, $b)在$a为空时返回空数组这很直观。但array_intersect($a, $b)在$a为空时同样返回空数组可能有人会误以为“至少能把$b里公共的部分找出来”其实不会。这些函数以第一个数组为准第一个数组为空结果永远为空。还有一种情况是回调函数返回 0 和返回 false 的区别。在usort、array_udiff等回调里返回0表示相等返回1表示前大返回-1表示前小。有些人习惯在回调里写return $a $b布尔值会被自动转成 0 或 1这会导致“不相等”也可能被当成“前大”结果乱套。正确的写法是用太空船运算符或者显式写return $a $b不要偷懒。6. 最后分享一点个人经验写了这么多年 PHP我自己最大的体会是集合运算这件事函数名真的不重要重要的是先搞清楚两点——你手里的数组是索引数组还是关联数组你要比较的到底是键还是值。只要这两点想明白了再决定用array_merge还是用array_intersect还是array_intersect_key用array_diff还是array_diff_assoc基本就不会跑偏。另外还有一个建议凡是涉及数组集合运算的业务代码一定要写单元测试尤其是把类型边界情况列出来。比如0、0、false、null混在数组里的用例很多线上 bug 就是这些边界值导致的。哪怕只是简单的assertSame([1, 2], array_values(array_diff(...)))也能帮你省下半夜查日志的时间。编程很多时候靠的并不是多高深的技术而是这些被重复踩过、又被认真总结出来的小细节。希望这篇文章里的经验能帮你少踩几个坑。
返回列表