PHP数组并集交集差集:array_merge、array_intersect、array_diff函数详解
写PHP整天跟数组打交道的人迟早会遇到这么一类需求两个数组要合并成一个两个数组要找出共有的部分两个数组要挑出A有B没有的元素。这些就是数组的并集、交集、差集运算。很多人第一反应是for循环套if代码写出来又臭又长性能还拉胯。其实PHP内置函数早就把这些活儿干好了只是不少朋友对array_merge、array_intersect、array_diff这一族函数的细节区别没吃透用起来总是差点意思。这篇东西就是干这个的。我会把这几个函数的参数语义、返回值细节、关联数组和数字索引数组的差异、多维数组怎么处理以及大数组场景下的性能对比全部拆开揉碎讲清楚。不管你是刚接触PHP的新手还是写了好几年业务的老手只要平时跟数组打交道这篇都能让你少踩几个坑。1. 理解PHP数组的集合属性为什么并交集差集是刚需1.1 PHP数组不是简单数组很多从Java、C转过来的朋友一开始特别不适应PHP数组。严格来说PHP的数组是一个有序映射它既能当列表用也能当字典用甚至能同时混合数字键和字符串键。这个设计在别的语言里基本见不到。正因为这个特性PHP数组的“相等”“包含”“合并”就比别的语言复杂。两个数组做并集运算时你要问自己三个问题是保留左边的值还是右边的值数字键是重新编号还是保留原键嵌套数组是浅合并还是深合并这三个问题不搞清楚用array_merge就可能出现莫名其妙的键覆盖。我举个例子。假设有两个配置数组?php $default [timeout 30, retry 3, debug false]; $custom [timeout 60, log_path /tmp/app.log];业务上想把自定义配置覆盖默认配置这里用array_merge还是运算符结果完全不同。这个细节我会在并集一节详细说但这里先记住一个判断原则PHP数组由键值对构成集合运算的本质是键和值的匹配逻辑。1.2 集合运算的三种维度值、键、键值对数组的并集、交集、差集表面上看是一回事底层其实有三种比较维度按值比较只看元素的值不管键是什么。典型函数是array_intersect、array_diff以及它们的递归版本。按键比较只看键名不看值。典型函数是array_intersect_key、array_diff_key。按键值对同时比较键相等而且值也相等才算匹配。典型函数是array_intersect_assoc、array_diff_assoc。为什么要分这么细因为业务场景差异极大。你统计两个用户列表的共同ID那是按键比你比较两份配置的差异那是按值比你要判断两个数组是不是完全一样那就得键值对同时比。很多人用错了维度导致线上bug还查半天找不出原因。按我实际经验来说按值比较的使用频率最高但按键比较反而最容易出错因为数组的字符串键和数字键混在一起时PHP的键比较还涉及类型转换这个坑我后面专门讲。1.3 一个生活化类比把数组想象成两个班级的花名册。并集就是两个班的全部学生合并在一起交集就是同时出现在两个班名单上的学生差集就是只在A班不在B班的名单。这里的“学生”可以按学号唯一标识相当于键也可以按姓名判断相当于值如果你要求学号和姓名都一致才算同一个人那就是键值对同时比较。这个类比能帮你快速判断业务场景该用哪个函数。判断两个订单列表是否包含重复商品商品ID是天然主键按值比较就行判断两个目录树结构是否一致必须递归比较整个路径那就得用递归版本。2. 并集运算array_merge、运算符与array_union2.1 array_merge最常用的合并工具array_merge是PHP里最常用的数组合并函数它把一个或多个数组的元素合并起来后面的值覆盖前面的值对字符串键而言。?php $a [name 张三, age 25]; $b [name 李四, city 北京]; $result array_merge($a, $b); // [name 李四, age 25, city 北京]这里的关键行为有两个第一字符串键时后面的覆盖前面的。这符合“后写覆盖”的直觉。第二数字键时不会覆盖而是重新编号追加。哪怕两个数组都有键0合并后也会变成0、1、2这样连续的数字索引原来的键0、键1统统丢失。?php $a [apple, banana]; $b [orange, grape]; $result array_merge($a, $b); // [0 apple, 1 banana, 2 orange, 3 grape]如果你在做数据拼接这个行为是合理的但如果你的数字键本身有业务含义比如ID列表想保留原始ID作为键那array_merge就不合适了必须用运算符。2.2 运算符左键优先的并集运算符是PHP数组并集的另一个实现但它的语义和array_merge完全不同?php $a [name 张三, age 25]; $b [name 李四, city 北京]; $result $a $b; // [name 张三, age 25, city 北京]看到区别了吗同样两个数组运算符的结果是左边的name保留了右边的name被丢弃。也就是说运算符是“左优先”如果键在左边已经存在右边的不会覆盖它如果键不存在于左边右边的才被追加进来。对于数字键也是一样的规则?php $a [0 apple, 1 banana]; $b [0 orange, 2 grape]; $result $a $b; // [0 apple, 1 banana, 2 grape]这个特性在什么场景特别有用合并配置参数时你想让默认配置不被用户配置覆盖而是让默认值填充缺失项这时候用$a $b$a为默认配置$b为用户配置就对了。反过来想让用户配置覆盖默认配置用array_merge($default, $custom)。2.3 数组并集的选择决策表场景推荐方式原因用户配置覆盖默认配置array_merge($default, $custom)后面覆盖前面的字符串键默认配置补充用户缺失参数$default $custom左优先不覆盖已有键拼接数字索引数组array_merge自动重新编号输出一致保留数字键业务意义$a $b不会重排数字索引合并多层嵌套配置array_merge_recursive递归合并不丢子树需要自定义合并规则array_replace / array_replace_recursive只覆盖同键不追加新键时用array_merge_recursive和array_replace_recursive这俩函数平时用得少但遇到嵌套数组合并时是真救命的。举个例子?php $g1 [user [name 张三, tags [vip]]]; $g2 [user [age 30, tags [new]]]; $r1 array_merge_recursive($g1, $g2); // user下会变成 name、age、tags[vip,new] $r2 array_replace_recursive($g1, $g2); // user下会变成 name、age但tags会被整个替换成[new]前者是递归合并值后者是把同键的值替换掉。配置合并、权限合并、选项合并这类场景这两个函数比array_merge好用得多。2.4 自己实现array_union何时需要有朋友可能会问那到底有没有原生的array_union函数很遗憾PHP标准库没有这个函数。array_merge array_unique 可以模拟值并集但要注意array_unique对二维数组无效你得先序列化或hash。真正常见的自定义并集需求是两个关联数组按指定键合并比如按用户ID合并两个列表?php function union_by_key(array $a, array $b, string $key): array { $map []; foreach ($a as $item) { $map[$item[$key]] $item; } foreach ($b as $item) { $map[$item[$key]] $item; // 重复键后者覆盖 } return array_values($map); }这种函数虽然不到10行但比任何内置函数都贴合业务。所谓“懂数组”不是背函数名而是知道内置函数的边界在哪然后用自定义逻辑补上边界。3. 交集与差集array_intersect家族与array_diff家族3.1 按值比较的array_intersect与array_diff交集和差集最基础的用法如下?php $a [apple, banana, orange]; $b [banana, grape, apple]; $intersect array_intersect($a, $b); // [apple, banana]注意顺序和键都按第一个数组来 $diff array_diff($a, $b); // [orange]只保留第一个数组独有的元素这两个函数有一个非常容易被忽略的点它们保留的是第一个数组的键。如果你传入的是关联数组返回值的键来自第一个数组而不是重新编号。?php $a [fruit1 apple, fruit2 banana]; $b [banana, fruit1 apple]; $intersect array_intersect($a, $b); // [fruit1 apple]这个特性既是优点也是陷阱。优点是你知道结果来自哪个原始元素陷阱是如果你期望返回连续数字索引拿到的是一个键不连续的数组直接用于遍历没问题用于array_merge或json_encode就可能产生意外结构。3.2 按值比较时PHP的“相等”标准是什么array_intersect的相等比较用的是宽松比较不是严格比较。这意味着字符串1和数字1会被视为相等?php $a [1, 2, 3]; $b [1, two, 3]; $result array_intersect($a, $b); // [1, 3] 或者 [01, 23]大多数业务场景下这没什么问题但做精确定匹配时得留意。比如你比较订单ID订单ID是字符串1001另一个数组里有数字1001宽松比较下它们相等看似没问题反过来如果一个是1001abc一个是1001那宽松比较就为false结果就偏了。老项目里常见这种坑一个数组里存的ID是前端传过来的字符串另一个是数据库里取出来的整数交并集算出来结果总是比预期多或少。解决办法是统一格式化后再比较或者用下面的严格比较回调版本。3.3 按键比较的array_intersect_key与array_diff_key按键比较的场景大多出现在配置、映射表、对象列表这类数据里。?php $a [id 1, name 张三, age 30]; $b [name 李四, age 20, city 北京]; $intersect_key array_intersect_key($a, $b); // [name 张三, age 30] 键是共有的值来自第一个数组 $diff_key array_diff_key($a, $b); // [id 1] 键只存在于第一个数组让我说一个真实业务两张表导出Excel列名不一样但有几列是相同的你想只导出共有列。用array_intersect_key一下两个表头数组共有列就出来了配合循环取值代码量能砍一大半。按键比较还有一种常见用法从一个数组里排除另一个数组指定的键。比如用户提交了表单字段你想剔除掉id、created_at之类的字段?php $input [name 张三, id 5, age 30, created_at 2024-01-01]; $forbidden [id, created_at]; $filtered array_diff_key($input, array_flip($forbidden)); // [name 张三, age 30]这里array_flip把禁止列表转成以字段名为键的数组再配合array_diff_key一行代码搞定字段剔除。这种组合技巧写业务代码时特别飘逸而且性能也不差。3.4 严格匹配的array_intersect_assoc与array_diff_assoc当你要比较的维度从“键相等”或“值相等”升级为“键和值都相等”就要用assoc系列?php $a [id 1, name 张三]; $b [id 1, name 张三]; $result array_intersect_assoc($a, $b); // [name 张三]因为id一个是int一个是string宽松比较下键值对不等注意这里比较也是宽松的这里有个细节array_intersect_assoc虽然要求键相等、值相等但值比较用的还是宽松比较。如果要做全严格匹配PHP官方推荐用array_intersect关联比较比如?php $a [id 1, name 张三]; $b [id 1, name 张三]; $result array_intersect_assoc($a, $b);上面这段代码其实还是会匹配id因为1 1为true。想严格区分int和string得自己实现回调版本。这个细节我下一节讲array_uintersect时展开。差异场景也是同理?php $a [id 1, name 张三]; $b [id 1, city 北京]; $diff array_diff_assoc($a, $b); // [name 张三]因为id在键上相等、值上宽松相等所以不认为有差异对于写数据比对脚本、做数据校验、做同步检查的场景diff_assoc非常实用但一定要意识到它的值是宽松比较。4. 自定义比较array_uintersect、array_udiff与多维数组4.1 回调函数撑起的自定义比较标准库提供的函数全用宽松比较但业务里经常需要自定义规则。比如两个数组都存对象对象类都有一个id属性你想按id比较或者你比较日期字符串想先把日期解析成时间戳再比。这类需求全靠array_uintersect、array_udiff、array_uintersect_assoc、array_udiff_assoc。?php class Item { public $id; public $name; public function __construct($id, $name) { $this-id $id; $this-name $name; } } $a [new Item(1, A), new Item(2, B), new Item(3, C)]; $b [new Item(2, B), new Item(3, C), new Item(4, D)]; $result array_uintersect($a, $b, function($x, $y) { return $x-id $y-id; }); // 返回包含Item(2,B)和Item(3,C)的数组语法上注意参数顺序array_uintersect的第一个数组决定返回的元素后面的数组用于参与比较。回调返回0表示相等正数表示第一个比第二个大负数表示小。用太空船运算符最省事。4.2 严格类型比较怎么弄如果你想让数组比较从宽松变成严格没有现成的内置函数但可以用array_uintersect配合严格比较?php $a [id 1, name 张三]; $b [id 1, name 张三]; $result array_uintersect_assoc($a, $b, function($x, $y) { return $x $y ? 0 : ($x $y ? -1 : 1); }); // 空数组因为1 ! 1严格比较的回调要小心如果两个值类型不同你不能只返回非0还要保证比较的一致性。用strcmp或者三元表达式都可以推荐写成上面那种形式逻辑清晰不会出现排序算法因比较结果不对称而乱掉的问题。4.3 多维数组的递归处理二维数组、多维数组做差集或交集直接用array_diff会得到非常奇怪的结果。因为PHP比较数组时把数组当成一个整体做比较两个内层数组完全相同才相等稍有不同就算“不同”。这时候有两个方案方案一先对每个内层元素做hash再比较hash。?php function array_recursive_diff($a1, $a2) { $hash1 array_map(serialize, $a1); $hash2 array_map(serialize, $a2); $diff array_diff($hash1, $hash2); return array_map(unserialize, $diff); }原理很简单serialize能把一个数组转成字符串字符串之间比较是精确的然后diff完再unserialize还原。这个办法在数据行比对、表格数据同步时非常好用。方案二用array_udiff自定义递归比较函数。?php function array_compare_recursive($a, $b) { if ($a $b) return 0; if (is_array($a) is_array($b)) { return array_diff($a, $b) [] ? 0 : -1; } return $a $b ? -1 : 1; } $diff array_udiff($arr1, $arr2, array_compare_recursive);这个方案的自定义函数必须在所有情况下都返回一致的比较结果不少新手在这里翻车。相比之下serialize方案更稳因为字符串比较结果是确定的。4.4 多维数组按键交集的实用技巧多维数组做按键运算时可以配合array_column提取内层键值?php $users1 [[id 1, name 张三], [id 2, name 李四]]; $users2 [[id 2, name 李四], [id 3, name 王五]]; $ids1 array_column($users1, id); $ids2 array_column($users2, id); $commonIds array_intersect($ids1, $ids2); // [1 2] $commonUsers array_values(array_filter($users1, function($u) use ($commonIds) { return in_array($u[id], $commonIds); }));这样先提取ID列表做交集再回原数组过滤逻辑清晰比直接套多维比较函数更容易读。数组操作的优雅不一定是函数用得花哨而是把复杂问题拆成简单步骤串起来。5. 实际项目中的避坑经验与性能参考5.1 数组比较的键保留陷阱好多人在循环里用array_intersect处理数据拿到结果直接foreach没问题但拿到结果后想再合并一次问题就来了?php $a [x apple, y banana]; $b [x apple]; $intersect array_intersect($a, $b); $merged array_merge($intersect, [z orange]); // [x apple, z orange]表面看没问题但如果你对关联数组用合并?php $merged2 [z orange] $intersect; // [z orange, x apple]键顺序反了。键顺序在某些场景下会引发json_encode输出顺序的变化不是bug但可能造成签名校验失败、缓存key不一致这类诡异问题。我的建议是在需要保持输出顺序稳定时所有数组合并统一用array_merge所有数组填充统一用别混着用。5.2 内存与性能大数组场景怎么省数组操作在大数据量下性能差距能到几倍甚至几十倍。比如你要过滤一个10万行的数据表怎么用差集最合理我实测过几种方案给个参考PHP 8.1数组元素为简单字符串10万元素方案耗时约内存foreach in_array平均1.2s高in_array每次遍历全数组array_diff平均20ms中array_flip isset平均5ms中array_diff_key平均15ms中结论很明显能用内置diff/intersect别手写循环需要频繁判断元素是否存在先用array_flip翻转成哈希表再查速度提升几十倍。?php $bigList range(1, 100000); $needle [256, 512, 1024]; $flipped array_flip($bigList); foreach ($needle as $n) { if (isset($flipped[$n])) { // 存在 } }这是处理大数组时的核心思想把“值查找”转成“键查找”。PHP数组作为映射表键查找是O(1)值查找是O(n)。不管你是做交集差集还是单纯判断存在先翻转再查永远是最优解。5.3 关联数组的差集array_diff为什么对“配置键”无能为力array_diff是按值比较如果你要找出$a配置里有多少键不在$b配置里用array_diff就错了得用array_diff_key。这类错误我见过太多次?php $configA [timeout 30, retry 3]; $configB [timeout 60, log true]; $diff array_diff($configA, $configB); // [timeout 30] // 因为30 ! 60但retry3在B里不存在所以结果是timeout不对retry3也不在B的值列表里所以应该返回[retry3,timeout30]只是键顺序是原顺序实际上面这个例子array_diff返回的是[retry3, timeout30]因为两个值在B的值列表里都找不到。看起来不算错但如果你想知道“哪些配置键被移除了”这个答案明显不直观。array_diff_key才是正解?php $removed array_diff_key($configA, $configB); // [retry 3]这就是前面说的维度问题。使用diff/intersect前先想明白你要比较的是值、键还是键值对选错函数结果就是事故。5.4 常见坑速查表问题表现可能原因解决办法array_merge后数字键被重排数字键默认重新编号用运算符保留原始键运算符不自带覆盖效果左优先语义换array_merge两个字符串1和整数1被认为相等宽松比较用array_uintersect自定义严格比较多维数组diff结果异常值比较对数组是整体比较先serialize再diff或递归自定义比较配置键差异查不出来用成了array_diff而不是array_diff_key换按键比较函数结果数组键不连续diff/intersect保留第一数组键array_values重新索引合并嵌套配置丢子树array_merge浅合并用array_merge_recursive或array_replace_recursive大数组in_array性能爆炸值查找O(n)array_flip后isset键查找O(1)5.5 一个综合案例计算两个用户列表的增删改把前面所有知识点串起来看一个完整的业务场景。假设你有一个定时同步脚本要从远程API拉取用户列表和本地数据库用户列表做比对找出新增、删除、变更的用户。?php $localUsers [ 1 [name 张三, age 30], 2 [name 李四, age 25], 3 [name 王五, age 40], ]; $remoteUsers [ 2 [name 李四, age 26], // 年龄变了 3 [name 王五, age 40], 4 [name 赵六, age 35], // 新增 ]; // 要删除的本地有远程没有 $toDelete array_diff_key($localUsers, $remoteUsers); // [1 [name张三,age30]] // 要新增的远程有本地没有 $toInsert array_diff_key($remoteUsers, $localUsers); // [4 [name赵六,age35]] // 需要检查变更的共同ID $commonIds array_intersect_key($localUsers, $remoteUsers); // [2...,3...] // 逐个比较共同用户内容不同则更新 $toUpdate []; foreach ($commonIds as $id $localUser) { if ($localUser ! $remoteUsers[$id]) { // 这里用 ! 而不是 !因为年龄是数字远程可能是字符串 $toUpdate[$id] $remoteUsers[$id]; } }这样一个增删改同步逻辑用array_diff_key和array_intersect_key就解决了代码清晰性能也好。如果把数组按ID转成以ID为键的形式整个同步脚本读起来就像自然语言一样顺畅。6. 实用扩展配合array_map、array_filter玩出花6.1 交集差集不是终点过滤转换才是日常很多时候你拿到的原始数组不能直接用需要先转换再运算。array_map负责转换array_filter负责过滤两者配合diff系列能解决一大票数据清洗问题。比如你要找出两份CSV导出数据中邮箱重复的人?php $file1 [[email atest.com, name A], [email btest.com, name B]]; $file2 [[email btest.com, name B], [email ctest.com, name C]]; $emails1 array_column($file1, email); $emails2 array_column($file2, email); $duplicateEmails array_intersect($emails1, $emails2); $duplicateUsers array_values(array_filter($file1, function($row) use ($duplicateEmails) { return in_array($row[email], $duplicateEmails); }));array_column本身就是从多维数组提取一列搭配intersect等于把“按某个字段找共有项”这件事做成了模板。以后任何类似需求都可以套这个模式。6.2 差集加条件只算状态为active的差集有时候你不想全量比想带过滤条件比较。直接diff达不到效果先filter再diff才是正解?php $localActive array_filter($localUsers, fn($u) $u[status] active); $remoteActive array_filter($remoteUsers, fn($u) $u[status] active); $missingActive array_diff_key($localActive, $remoteActive);这种组合方式非常实用因为它把集合运算拆成了“数据准备”和“集合运算”两步每一步都可以独立调试。如果结果不对先看filter出来的数据再看diff的结果问题定位比在一大段循环代码里找快得多。6.3 去重场景与并集差集的联动数组去重有人用array_unique有人先用flip再key。array_unique的问题是它也是宽松比较而且对多维数组无效。典型场景合并多个数组去掉重复项保留最后出现的值。?php $arrays [ [id 1, name A], [id 2, name B], [id 1, name A2], ]; $mergedById []; foreach ($arrays as $item) { $mergedById[$item[id]] $item; // 以ID为主键覆盖 } $final array_values($mergedById);这就是用“键覆盖”实现按指定字段去重比array_unique更精确。如果你对比一下array_unique的宽松比较和这个方案的严格键映射你会发现后者在业务场景里可靠得多。6.4 大数据集合运算的预处理策略处理集合运算前先做归一化能省掉后面一大半麻烦。归一化包含三件事统一键类型ID转字符串就全转字符串转整数就全转整数统一编码两个源数据可能一个UTF-8一个GBK直接比较等于灾难统一空值处理null、空字符串、false要不要算等价我写过一个数据同步脚本当时就是因为一个源是null字符串另一个源是PHP的nullarray_diff结果永远不对。后来统一把空值转成特定占位符问题解决。做集合运算前花一分钟检查数据形态比浪费半小时排查结果诡异要划算得多。7. 函数速查与最后的经验之谈7.1 常用函数速查表函数作用比较维度键保留备注array_merge合并数组键覆盖/数字键追加数字键重编号最常用$a $b合并数组左优先保留全部键适合默认配置array_merge_recursive递归合并同键值合并为数组键保留适合嵌套配置array_replace_recursive递归替换同键值替换键保留适合覆盖深层配置array_intersect交集值保留第一个数组的键宽松比较array_intersect_key交集键保留第一个数组的键配置键比较首选array_intersect_assoc交集键值保留第一个数组的键宽松比较array_uintersect交集自定义回调保留第一个数组的键适合对象/严格比较array_diff差集值保留第一个数组的键宽松比较array_diff_key差集键保留第一个数组的键删除字段/删除用户首选array_diff_assoc差集键值保留第一个数组的键数据比对首选array_udiff差集自定义回调保留第一个数组的键适合多维/对象我把这张表贴在这里不是因为函数多难记而是因为这十二个函数的“比较维度”和“键保留规则”才是真正的分水岭。搞清楚这两列用错的概率至少降80%。7.2 我踩过最深的一个坑有一次写订单导出功能本地订单列表和远程订单列表做差异比对。本地订单键是数据库自增ID远程订单键是字符串订单号。我直接用array_intersect_key去比对结果一个都没匹配上因为1001和1001在PHP数组键中不是同一个键。后来我统一把两边都转成字符串键再比问题瞬间消失。这让我意识到数组集合运算的很多问题不在函数本身而在数据形态。两种系统的数据键的类型、格式、编码不完全一致任何集合运算前都得做数据归一化。7.3 最后一个小技巧如果你经常要在两个大数组里找共有元素并且元素是字符串或数字可以用一个双flip技巧比array_intersect更快?php $a [apple, banana, orange]; $b [banana, orange, grape]; $flippedB array_flip($b); $intersect array_filter($a, function($item) use ($flippedB) { return isset($flippedB[$item]); });原理是isset查键是O(1)array_filter遍历第一个数组一遍总复杂度O(n)。array_intersect内部实现其实也做了哈希优化但你自己控制整个流程在需要附带其他过滤条件时更灵活。数据量到几十万级别时这个写法肉眼可见地比默认写法快。这也是为什么我一直强调真正吃透PHP数组不是把它当数据结构而是把它当底层哈希映射来看待。