JavaScript数组集合运算详解:交集、差集、并集与补集
写前端的谁没在数组上栽过跟头。数组去重、过滤、合并几乎每个项目里都会用到但真到了“两个数组怎么取交集”“差集怎么写”的时候网上搜出来的答案五花八门filter、includes、Set、reduce一顿混用能跑但很多人根本说不清为什么这么写更不知道隐藏了哪些坑。这篇文章就专门把数组的交集、差集、并集、补集讲透。我会把每种运算的多种实现方式都列出来分析各自的优缺点再结合实际场景告诉你该选哪种。对象数组怎么办、NaN怎么处理、性能差异有多大这些常规文档里不写的内容也会一并聊到。适合谁看刚入门的前端新手能从基础实现里学会思路工作一两年的开发者能补上集合运算的理论盲区哪怕是写了好几年业务的也能从后面的避坑章节里找到几处值得注意的细节。1. 先把问题说清楚数组取交集到底在取什么很多人写代码容易陷入“复制粘贴”模式看到别人的写法能跑就直接用了但集合运算背后是有明确数学定义的。先花两分钟搞清楚概念后面写代码才能有底气。1.1 集合运算的基本概念给定两个数组假设A [1, 2, 3, 4]B [3, 4, 5, 6]交集属于 A 同时也属于 B 的元素结果是[3, 4]差集属于 A 但不属于 B 的元素结果是[1, 2]这个方向就是“A 对 B 的差集”反过来是[5, 6]并集属于 A 或属于 B 的所有元素合并去重后是[1, 2, 3, 4, 5, 6]补集在某个“全集”里但不在某个子集里的元素通常需要一个全集概念对称差集只属于其中一个集合、不同时属于两个集合的元素也就是(A 差 B) ∪ (B 差 A)结果是[1, 2, 5, 6]用生活里的事情类比你和同事分别在点外卖软件上收藏了不同的店铺交集就是你们俩都吃过的店差集是你去过但对方没去过的店并集是两边所有店合起来去重对称差集是你们俩各自去过、但从来没一起吃过的那几家。集合运算跟去重的关系非常密切。标准集合论里的“集合”天然不允许重复元素但 JavaScript 的数组是有序列表同一元素可能出现多次。所以做运算时首先要确定一个前提你是把数组当数学意义上的集合用还是当普通列表用这个前提直接决定了实现方式。1.2 为什么JavaScript没有原生提供这些方法你可能会问Array.prototype上为什么没有intersection、union这样的方法毕竟concat、filter、includes都有。主要原因是历史包袱和设计哲学。JavaScript 的数组本身就是个“万能列表”既能当栈用又能当队列用还能放各种混合类型。官方 API 一直走的是“元操作”路线更愿意提供filter、map、reduce这种单一职责的方法让开发者自己去组合。组合出[1, 2, 3].filter(x [2, 3, 4].includes(x))并不复杂不需要为每一种组合专门加一个内置方法。另一个原因是底层没有定义“元素相等”的标准。集合论里元素等价关系很明确但 JS 里有和的区别NaN ! NaN[1] ! [1]对象比较的是引用。如果官方提供了intersection方法它到底该按什么规则判断两个元素“相等”这个问题在标准层面一直扯不清楚。所以干脆把选择权交给开发者你自己决定比较规则这反而更灵活。理解了这一点你就知道为什么网上有那么多“看似相同但细节不同”的写法了。每种写法背后其实都藏着一个对“相等”的判断策略搞清楚这个你就能自己写工具函数了。2. 交集从filter到Set的完整实现交集是实际开发里最高频的需求。常见场景包括判断用户是否有权限、筛选两个条件下都满足的数据、找出公共标签。下面从最直观的写法开始逐步优化。2.1 最直观的写法filter includesconst arr1 [1, 2, 3, 4]; const arr2 [3, 4, 5, 6]; const intersection arr1.filter(item arr2.includes(item)); // [3, 4]这个写法非常直白遍历arr1对每一个元素检查它是否存在于arr2中保留存在的那些。优点一眼就能看懂缺点是性能不理想。includes内部是线性查找假设arr1长度是 marr2长度是 n整体复杂度是O(m × n)。数组小的时候无感但如果你在服务端处理几千条数据或者在前端处理几万条的列表耗时会明显起来。还有一个隐蔽问题includes用的是SameValueZero比较规则NaN在includes里可以被找到这点后面专门讲但普通做不到。所以下面这种写法对NaN的处理是不一致的const intersection arr1.filter(item arr2.indexOf(item) ! -1); // indexOf 用严格相等NaN 永远找不到2.2 进阶写法Set filter把arr2转成Set查找时间就是O(1)整体复杂度降为O(m n)const arr1 [1, 2, 3, 4]; const arr2 [3, 4, 5, 6]; const set2 new Set(arr2); const intersection arr1.filter(item set2.has(item)); // [3, 4]这段代码就是大多数项目里最终采用的版本。Set.has()的性能远高于数组的includes()尤其当其中一个数组很大时差距是数量级的。这里有个细节值得说明Set内部判断元素是否存在时用的是SameValueZero比较可以认为等同于并额外处理了NaN。也就是说用Set.has比includes对NaN更友好。如果你希望结果里不包含重复项可以先用Set给arr1去重再过滤const intersection [...new Set(arr1)].filter(item set2.has(item));记住一个原则交集结果天然不包含重复元素因为元素必须同时出现在两个位置才算数。但如果arr1本身有重复没去重的话结果里也会有重复。看你实际需求决定要不要加这一步。2.3 保留重复项的特殊场景有些场景下交集需要保留重复项。比如你有两份订单 ID 列表同一个 ID 在arr1里出现两次就代表两条订单不能随便去重。这时候可以换一种思路用Map记录arr2中每个元素出现的次数遍历arr1时每匹配一次就消耗一次计数。function intersectionWithDuplicates(arr1, arr2) { const countMap new Map(); for (const item of arr2) { countMap.set(item, (countMap.get(item) || 0) 1); } const result []; for (const item of arr1) { if (countMap.get(item) 0) { result.push(item); countMap.set(item, countMap.get(item) - 1); } } return result; } console.log(intersectionWithDuplicates([1, 1, 2, 3], [1, 1, 1, 2])); // 输出 [1, 1, 2]这个写法相当于把arr2当成一个“限量水库”arr1每取走一个重复元素水库水位就降一格。这样能正确处理两边的重复数量而不是简单地把所有匹配项都塞进结果。实际开发里这种需求不算特别频繁但面试里被问过多次。能写出计数版的交集说明你对集合运算的理解不是表面功夫。3. 差集方向性很重要差集的代码写法跟交集几乎一样只是把!取反。但差集有个交集没有的特点方向性。A对B的差集和B对A的差集是两个不同的结果用错方向会产生隐蔽的逻辑 bug。3.1 基础实现const arr1 [1, 2, 3, 4]; const arr2 [3, 4, 5, 6]; const difference arr1.filter(item !arr2.includes(item)); // [1, 2] const difference2 arr2.filter(item !arr1.includes(item)); // [5, 6]同样可以用Set优化const set2 new Set(arr2); const difference arr1.filter(item !set2.has(item)); // [1, 2]这段代码语义很清楚筛选出“我以为我有、但对方没有”的元素。实际业务里典型的场景是对比新旧两份配置找出被删掉的那部分数据。但要注意很多同事写的差集代码是“碰巧能用”因为他们默认了arr1是大的、完整的集合。如果哪天传参顺序变了差集结果就完全反了。最简单的防御办法是封装函数时把两个参数的方向用命名说清楚// 返回存在于 source 但不存在于 excluded 中的元素 function diff(source, excluded) { const set new Set(excluded); return source.filter(item !set.has(item)); }3.2 对称差集与相对差集如果你业务上需要的是“两边都不一样的东西”那就是对称差集const arr1 [1, 2, 3, 4]; const arr2 [3, 4, 5, 6]; const diff1 arr1.filter(item !arr2.includes(item)); const diff2 arr2.filter(item !arr1.includes(item)); const symmetricDiff [...diff1, ...diff2]; // [1, 2, 5, 6]对称差集的语义是“只属于其中一边的元素”。这个词听着学术但在业务里非常常见比如对比服务端返回的数据和本地缓存的数据找出需要同步的增量或者两份名单对比找出只在一份名单里出现的人。用Set版本可以写得紧凑些const set1 new Set(arr1); const set2 new Set(arr2); const symmetricDiff [ ...arr1.filter(item !set2.has(item)), ...arr2.filter(item !set1.has(item)), ];不过这样写会有一个小问题如果arr1或arr2里本身有重复项结果也会带上重复项。如果想保证结果是“集合”可以先各自去重再求差const symmetricDiff [ ...[...new Set(arr1)].filter(item !set2.has(item)), ...[...new Set(arr2)].filter(item !set1.has(item)), ];3.3 典型应用场景权限的增删对比我做过一个后台系统的权限管理页面角色绑定的权限列表是[1, 2, 3, 4]勾选后提交的是[3, 4, 5]。怎么算出哪些权限要新增、哪些要删除这就是标准的差集和对称差集应用const original [1, 2, 3, 4]; const submitted [3, 4, 5]; const toRemove original.filter(id !submitted.includes(id)); // [1, 2] const toAdd submitted.filter(id !original.includes(id)); // [5]提交给后端时通常需要这两个数组分开展示所以要算两个不同方向的差集而不是只算一个对称差集。对应到界面上删除 1、2新增 5完美匹配用户的操作预期。这个场景还有更复杂的版本如果权限项是对象比如包含权限编码和权限名称怎么比较这就涉及到对象数组的集合运算我会在第 6 节专门展开。4. 并集合并去重一次搞定并集的需求也很常见比如两个接口各返回一页数据合并到一起展示或者多个来源的标签汇总统计。核心操作就一步合并 去重。4.1 Set展开运算符Set天然去重加上展开运算符就是最简洁的并集实现const arr1 [1, 2, 3, 4]; const arr2 [3, 4, 5, 6]; const union [...new Set([...arr1, ...arr2])]; // [1, 2, 3, 4, 5, 6]这段代码能跑而且很短。但要注意如果两个数组都非常大比如各几万条[...arr1, ...arr2]会先创建一个很大的中间数组再转成Set内存瞬时占用可能偏高。可以用循环依次addconst set new Set(arr1); for (const item of arr2) { set.add(item); } const union [...set];效果一样但少了中间数组内存压力更小。日常开发里两种写法差别不明显但在大数组处理上养成“能用add就不必展开”的习惯是好的。4.2 concat Set还有一种风格是concat合并后去重const union [...new Set(arr1.concat(arr2))];用哪个完全看项目里的代码风格。concat是 ES5 时代的老方法语义也直白。不过我个人更推荐展开运算符因为concat在处理不定数量的数组时需要嵌套或者配合reduce展开运算符更灵活const union [...new Set([...arr1, ...arr2, ...arr3])];4.3 去重后的顺序稳定性Set迭代时保持插入顺序所以并集结果的顺序是先出现arr1中的元素再出现arr2中不在arr1里的元素。这在多数场景下正好符合预期。但如果你是先concat再塞进Set顺序就没变还是原来的先后顺序只是去重而已。如果你希望结果按某种业务规则排序比如按时间倒序要记得先排序再返回别指望并集操作自带排序功能。这里提醒一个细节数字和字符串“长得一样”但不会被去重。Set的比较规则是SameValueZero相当于严格相等所以1和1是两个元素。这也是为什么我用[1, 2]和[1, 2]合并时结果会包含四个元素。如果业务上有“1 和 1 视为同一个 ID”的需求你得先把两边的类型统一了再计算。5. 补集与综合应用集合运算全家桶交、差、并讲完后还剩一个补集。补集跟差集的区别在于差集是“A 对 B”的排除补集是“在全集中排除某个子集”。业务里如果预先定义了“所有可能值的全集”补集就很自然地变成了差集。5.1 补集的实现思路假如系统里所有可选权限编码是ALL_PERMS [p1, p2, p3, p4, p5]某个角色拥有[p1, p3]那么该角色还缺哪些权限这就是补集const ALL_PERMS [p1, p2, p3, p4, p5]; const owned [p1, p3]; const missing ALL_PERMS.filter(perm !owned.includes(perm)); // [p2, p4, p5]本质上就是ALL_PERMS对owned的差集。所以你可以复用同一个diff工具函数const missing diff(ALL_PERMS, owned);如果你觉得“补集”和“差集”是两套东西可能在实现时绕远路。我用过的经验是如果你需要“补集”就先检查代码里是否已经定义了“全集”数组。全集存在的话补集就是差集的一种特例不需要额外的方法。5.2 把四种运算封装成一组工具函数项目里只会遇到一次集合运算的可能性很小。建议你直接建一个arraySet.js工具模块把常用运算统一封装成导出的函数后续项目里直接复用。// arraySet.js function toSet(arr) { return new Set(arr); } // 交集同时存在于两个数组中的唯一元素 export function intersection(arr1, arr2) { const set2 new Set(arr2); return [...new Set(arr1)].filter(item set2.has(item)); } // 差集存在于 source、不存在于 excluded 中的元素 export function difference(source, excluded) { const excludedSet new Set(excluded); return [...new Set(source)].filter(item !excludedSet.has(item)); } // 并集合并后去重 export function union(...arrays) { const set new Set(); for (const arr of arrays) { for (const item of arr) { set.add(item); } } return [...set]; } // 对称差集只存在于其中一个数组中的元素 export function symmetricDifference(arr1, arr2) { return union( difference(arr1, arr2), difference(arr2, arr1), ); }如果你们项目用的是 CommonJS就把export换成module.exports内容一样。封装的时候注意两个设计取舍。第一我这组函数的结果都默认去重了因为集合语义就是“不重复”。如果你的场景要保留重复项再写单独的版本不要试图在一个函数里同时满足去重和不去重两种需求API 容易变得混乱。第二参数顺序用“源”和“排除对象”来定义差集并把这个顺序在 JSDoc 注释里写明能显著减少同事用反方向的概率。5.3 实际案例两批标签的差异对比我之前做过一个内容管理后台运营需要对比“文章现有的标签”和“推荐标签库”确定要删掉哪些、新增哪些。每次进入编辑页前端拿到现有的三四个标签同时从接口拉回全部标签库几十个。用刚才的工具函数代码非常清晰const library [前端, 后端, 运维, 算法, 产品]; const articleTags [前端, 产品, 管理]; const toRemove difference(articleTags, library); // [管理] const toAdd difference(library, articleTags); // [后端, 运维, 算法] const allCandidates union(library, articleTags); // 用于下拉提示的完整选项这类“集合运算解决业务问题”的写法比用if循环判断要可靠得多。运营侧的点选操作虽然简单但后端拿到的增删列表如果算错了会给数据同步带来脏数据。用统一封装后的函数至少每个函数只承担一个语义出了问题也容易定位。6. 对象数组的集合运算引用与属性前面聊的数字和字符串都是原始类型直接用Set、includes就能搞定。但业务中最常见的其实是对象数组比如用户列表、订单列表、配置项列表。对象数组的集合运算比原始类型复杂了一个维度怎么判断两个对象“相等”。6.1 什么时候会踩坑先看一个新手极其容易写错的版本const users1 [{ id: 1 }, { id: 2 }]; const users2 [{ id: 2 }, { id: 3 }]; const common users1.filter(u users2.includes(u)); // []结果为空。原因是includes比较对象时用的是引用相等[users2]里的{ id: 2 }和users1里的{ id: 2 }虽然在结构上相同但在内存中是两个独立对象引用不同永远不相等。这个坑非常隐蔽因为数据可能一摸一样但你就是匹配不上。过去我排查过一个线上问题两边接口返回的数据肉眼看着完全一致但前端比对耗时很久且结果不对最后定位到就是引用比较的问题。6.2 按属性求交集业务上对象数组的集合运算几乎总是按某个唯一标识如id、code进行比较。正确姿势是把比较对象的主键提取成Set再用主键去判断。const users1 [{ id: 1, name: 张三 }, { id: 2, name: 李四 }]; const users2 [{ id: 2, name: 李四 }, { id: 3, name: 王五 }]; const idSet2 new Set(users2.map(u u.id)); const commonUsers users1.filter(u idSet2.has(u.id)); // [{ id: 2, name: 李四 }]这段代码里users1的完整对象被保留下来了所以你可以直接把交集结果渲染到页面上不需要再从users2里查找对象。这一点在实际开发里非常实用。类似的差集和并集也可以按主键实现// 差集存在于 users1 但不在 users2 中 const diffUsers users1.filter(u !idSet2.has(u.id)); // 并集按 id 去重合并 const unionUsers [ ...users1, ...users2.filter(u !idSet2.has(u.id)), ];注意并集这里的技巧以users1为基础只追加users2中“主键不在基础集里”的元素。这样做既去重又保留了对象的完整结构比“直接把所有对象塞进 Set 再展开”要靠谱得多因为对象放进 Set 后比较的还是引用不会按id去重。6.3 按多个属性组合判断相等有些业务没有唯一主键需要把两三个字段合在一起才能确定“同一个人”。比如订单明细订单号和商品编码组合起来才唯一。这时可以把多个字段组合成一个字符串const keys1 users1.map(u ${u.orderId}_${u.productCode}); const keys2 new Set(users2.map(u ${u.orderId}_${u.productCode})); const common users1.filter(u keys2.has(${u.orderId}_${u.productCode}));注意组合字符串时最好用不容易冲突的分隔符比如_本身没问题但字段里如果也包含这个字符可能造成误判。更稳妥的做法是使用JSON.stringify([u.orderId, u.productCode])const keyOf u JSON.stringify([u.orderId, u.productCode]); const keys2 new Set(users2.map(keyOf)); const common users1.filter(u keys2.has(keyOf(u)));JSON.stringify数组时天然带上了,和[、]字段内部值不太可能和这个序列化格式恰好撞车误判率大大降低。如果你的比较规则更复杂比如忽略大小写、需要模糊匹配那就不适合把字段塞进Set了老老实实写一个matches(a, b)函数再用双循环或分组策略去匹配。双循环复杂度是O(n²)当数据量大时可以用“按第一个属性粗分桶、在桶内细匹配”的方式优化本质是空间换时间。7. 避坑指南与性能取舍集合运算的代码一般都很短但坑恰恰藏在“短”里面。这一节把常见问题集中整理一遍也给出性能维度的参考。7.1 常见错误速查表错误写法问题所在正确做法arr1.filter(x arr2.indexOf(x) ! -1)indexOf对NaN永远返回-1使用Set.has或includesobjArr1.filter(x objArr2.includes(x))对象按引用比较结构相同也匹配不上先提取主键Set再按主键过滤[...new Set([...arr1, ...arr2])]大数组时产生额外大中间数组循环往Set里addsource.filter(x !excluded.includes(x))大数组时includes是O(n)查找用new Set(excluded)求差集时搞反两个数组的顺序结果完全反了逻辑 bug 难排查封装difference(source, excluded)注释写明方向用比较数组元素数字和字符串会互相匹配类型混乱明确统一类型再比较元素是对象时直接set.add(obj)Set引用去重{id:1}和{id:1}不会合并按主键或序列化键处理表格里列出的每一条都是我在真实代码评审或排查中遇到过的。尤其是前两条几乎每个新人都踩过。7.2 性能实测与选择标准写代码不能只求“能跑”。当数组规模变大后算法复杂度的差别会立刻体现出来。做个简单对比假设两个数组长度都是 10000filterincludes外层 10000 次内层每次最多扫描 10000 项最坏情况约 1 亿次比较浏览器里跑会明显卡顿。filterSet.has把数组转Set是 10000 次操作查找每个元素是O(1)总操作量约 2 万次毫秒级完成。差距是四到五个数量级。所以我的原则很简单只要数组长度可能超过几百就用Set只有明确知道数据量很小才允许用includes走简洁路线。另一个性能考量点是把数组转Set的成本。这个操作本身是O(n)如果你在一个大循环里反复对一个不变的数组做new Set(array)那其实是重复计算。建议在循环开始前把Set定义好// 不推荐每次循环都重新构建 Set for (const item of bigList) { const set new Set(otherList); if (set.has(item)) { ... } } // 推荐把 Set 提升到循环外 const set new Set(otherList); for (const item of bigList) { if (set.has(item)) { ... } }这个小改动看起来微不足道但实际项目里我见过因为这个细节导致页面渲染从 200ms 飙到好几秒的案例。7.3 扩展可配置比较器的通用封装如果你在多个项目里需要处理对象数组集合运算可以考虑做一个支持“比较函数”的通用版本。这样既不丢失灵活性也能复用同一套逻辑。function createSetUtils(compareFn) { // 用 Map 存“键” - 原始元素 const keyOf (item) { for (const [key, original] of map) { if (compareFn(key, item)) return key; } return item; }; const map new Map(); // 这个 sketch 只是为了说明思路实际实现会麻烦不少。 // 更常用的做法是传入 stringifier 而不是 compareFn。 } // 实际项目我更推荐这种传入“取键函数” function createArraySet(keyFn) { const keyOf item keyFn(item); return { intersection(arr1, arr2) { const keys2 new Set(arr2.map(keyOf)); const seen new Set(); return arr1.filter(item { const k keyOf(item); if (keys2.has(k) !seen.has(k)) { seen.add(k); return true; } return false; }); }, difference(source, excluded) { const excludedKeys new Set(excluded.map(keyOf)); return source.filter(item !excludedKeys.has(keyOf(item))); }, union(...arrays) { const seen new Set(); const result []; for (const arr of arrays) { for (const item of arr) { const k keyOf(item); if (!seen.has(k)) { seen.add(k); result.push(item); } } } return result; }, }; }使用方式const userSet createArraySet(u u.id); userSet.intersection(users1, users2); userSet.difference(users1, users2); userSet.union(users1, users2);这套封装的优点是彻底解耦了“元素相等”的定义。数字数组可以用默认的keyFn item item对象数组可以按id、按多字段组合完全看业务怎么传。我没把比较器做得很复杂因为越通用的抽象越容易过度设计。实际项目里一个keyFn已经能覆盖 90% 的场景。如果你真的需要compareFn比如模糊匹配对象间的“相似度”那已经不是集合运算的范畴了建议直接走普通的双层循环加剪枝策略硬套工具函数反而会写出性能很差的代码。写在最后数组的集合运算本身就是几个filter、Set、includes的组合看起来简单但真到了生产环境里类型差异、引用相等、内存占用、方向混淆每一个细节都有可能给你上一课。我个人在项目里坚持了这么几个原则能用Set就尽量用Set能提取主键就绝不直接用对象比较差集函数永远明确参数方向。这套习惯帮助我减少了很多不必要的线上问题。再分享一个小技巧如果你要对接口返回的数据做集合运算可以顺手写几个小单元测试覆盖NaN、空数组、重复项和对象数组这四种情况。一次写好后不管项目怎么迭代都不用再为这些基础操作提心吊胆。