首页 / 资讯中心 / 文章详情

数组去重全攻略:从双重循环到Set与边界情况

数组去重全攻略:从双重循环到Set与边界情况 ★ FEATURED ARTICLE
数组去重这道题我在刚学JavaScript时遇到过面试时也遇到过带新人的时候还帮人改过好几版。说它是“经典题”一点都不夸张——几乎所有前端面试题清单里都有它的位置日常开发中用到的频率也比想象中高得多。但有意思的是同样一道题初级开发者、中级开发者和高级开发者写出来的方案完全不同。这道题像一面镜子能照出你对JavaScript语言特性的掌握深度。这篇文章就从我的视角把这题的各个角度彻底拆开揉碎从最简单的双层循环到最优雅的Set方案再到各种容易被忽视的边界情况全部讲透。1. 为什么数组去重能成为经典题先聊聊这道题为什么经典。很多人觉得它简单就是“把重复的去掉”但真正动手写的时候会发现里面的门道远比想象中多。它能考察的点至少包括基础语法掌握程度、对数组方法族的熟悉程度、对类型转换的理解、对JavaScript特殊值的认知、性能敏感度以及编码风格和思路的清晰度。1.1 一道题背后的考察维度我面试人的时候特别喜欢用这道题开场。为什么因为它有一个很优秀的特点——复杂度可以从“入门”一直延伸到“深入”。同样一道题刚学完循环的新手能写出来研究过ES6的老手也能写出来但两者的实现思路和代码质量差异极大。新手往往写的是双循环function unique(arr) { const res []; for (let i 0; i arr.length; i) { let isDuplicate false; for (let j 0; j res.length; j) { if (arr[i] res[j]) { isDuplicate true; break; } } if (!isDuplicate) { res.push(arr[i]); } } return res; }老手可能一行就搞定const unique arr [...new Set(arr)];这两种答案之间有巨大的知识鸿沟。前者能跑但时间复杂度是O(n²)后者简洁高效O(n)完成但要求你理解Set、展开运算符和可迭代对象。面试官从这个答案就能判断出你对ES6的熟悉程度。如果还能辩证地说出Set方案的局限性比如NaN的处理、对象的去重问题那就说明不仅有知识广度还有思考深度。1.2 实际开发中的应用场景数组去重不只是面试题工作里真的天天遇到。举几个我实际碰过的场景后端返回的数据里有重复的ID需要去重后才能渲染列表。用户在多选组件里反复选择提交前要过滤重复项。从日志里提取唯一的错误码统计错误类型分布。多个数据源合并后需要按某个字段去重。搜索历史记录、浏览记录等列表要去掉用户重复点击产生的记录。这些场景里有的只需要对基本类型去重有的需要对对象按某个属性去重有的数据量巨大对性能有硬性要求。不同场景会用不同方案这也是这篇文章想帮你建立的判断力。2. 基础方案全景梳理从循环到一行代码2.1 双层循环与indexOf方案最朴素的思路最直接的想法就是遍历原数组维护一个新数组新数组里没有的元素才放进去。判断“有没有”的方式有两种——内层循环遍历新数组或者用indexOf。外层嵌套实现我刚才已经写过了代码重复度略高。用indexOf可以简化一点function unique(arr) { const res []; for (let i 0; i arr.length; i) { if (res.indexOf(arr[i]) -1) { res.push(arr[i]); } } return res; }indexOf的原理还是线性查找所以时间复杂度依然是O(n²)只是代码更干净了。这个方案的优点是兼容性极好ES3时代就能用缺点是性能差特别是大数组场景下会明显卡顿。1万条数据的数组最坏情况下要执行约5000万次比较浏览器里跑起来会有肉眼可见的延迟。2.2 filterindexOf函数式风格的经典写法接着上面的思路用filter改写会更优雅function unique(arr) { return arr.filter((item, index) arr.indexOf(item) index); }这个写法非常巧妙利用了“indexOf查到的是第一次出现的位置”这个特性——如果当前元素是第一次出现它的indexOf结果一定等于当前索引否则说明之前出现过就过滤掉。我第一次看到这个写法时觉得耳目一新因为它用声明式的方式取代了命令式的循环代码意图一目了然。但这个方案有一个隐蔽的坑对于NaNindexOf是找不到它的。因为indexOf内部用的是严格相等比较而NaN NaN的结果是false。所以如果数组里有NaN这个方案会把NaN全保留下来[1, NaN, 2, NaN].filter((item, index) arr.indexOf(item) index); // 结果: [1, NaN, 2, NaN] — 两个NaN都在没有去重这个坑在面试里被问到的概率极高后面我会专门展开讲NaN的问题。2.3 sort方法先排序再相邻比较另一个思路是把数组先排序排序后相同的元素会相邻然后遍历时只保留跟上一个元素不同的值function unique(arr) { return arr.sort().filter((item, index, sorted) !index || item ! sorted[index - 1]); }或者用传统的循环写法function unique(arr) { const sorted [...arr].sort(); const res []; for (let i 0; i sorted.length; i) { if (i 0 || sorted[i] ! sorted[i - 1]) { res.push(sorted[i]); } } return res; }先说结论我基本不用这个方案除非我本来就要排序。原因有两个一是sort默认按字符串Unicode码点排序会导致数字排序结果跟预期不一致必须传比较函数二是排序会改变元素的相对顺序有些场景下去重后需要保持原序sort方案就做不到了。2.4 Set与展开运算符现代JavaScript的最优解ES6推出Set之后数组去重有了近乎完美的方案const unique arr [...new Set(arr)];Set的特点是成员唯一内部用SameValueZero算法判断相等性这对NaN的处理比indexOf友好得多。更关键的是Set去重的时间复杂度接近O(n)——建Set的时候每个元素只需哈希一次大规模数据下性能优势非常明显。这个方案代码极简、可读性强、性能优异也是我在生产环境里最常用的。如果你的运行环境支持ES6但不确定Spread是否可用退一步用Array.from也是一样的const unique arr Array.from(new Set(arr));Array.from能把类数组对象和可迭代对象转换为真正的数组语义上跟展开运算符等价只是读起来更长一些。二者的取舍纯属习惯问题无高下之分。3. 不可忽视的边界情况NaN、对象与稀疏数组3.1 NaN的特殊性与双层循环的意外优势NaN在JavaScript里是个异类。它是唯一一个不等于自身的值NaN NaN; // false在ES6之前标准库提供的相等判断都拿它没办法。indexOf查不到它includes之前不存在所以在老式方案里NaN的去重就是个老大难。但我前面写的双层循环方案其实能正确处理NaN——为什么因为双层循环用的是比较而NaN NaN是false所以NaN永远不会被认为是重复值。换句话说双层循环方案天然“容忍”NaN但它的容忍方式是把所有NaN都保留下来而不是去重。比如[NaN, NaN, 1].reduce((acc, cur) acc.includes(cur) ? acc : [...acc, cur], []); // 这里includes能识别NaN所以结果是 [NaN, 1]注意数组的includes方法在ES2016才引入它内部用的是SameValueZero算法对NaN的处理跟Set一致——能找到NaN。这也是我在项目里不会用indexOf去重、而倾向于用includes的原因之一。3.2 针对NaN去重的专项方案如果你明确要求数组里的NaN只能留一个那就不能用indexOf或家族的函数要借助includes或Set。另外可以用一个开关变量记录是否已经遇到NaNfunction uniqueWithNaN(arr) { const res []; let hasNaN false; for (const item of arr) { if (item ! item) { // 判断是不是NaN的黑科技 if (!hasNaN) { res.push(item); hasNaN true; } } else if (!res.includes(item)) { res.push(item); } } return res; }item ! item这个写法利用了NaN不等于自身的特性是判断NaN的经典黑科技比Number.isNaN更隐蔽但也更容易被面试官追问。总之处理NaN要区分清楚你是想“保留所有NaN”还是“只保留一个NaN”需求不同方案完全不同。3.3 对象去重按引用还是按属性对象去重是另一个高频考点。如果直接用Set去重对象数组const arr [{ id: 1 }, { id: 1 }]; const unique [...new Set(arr)]; // 结果是 [{ id: 1 }, { id: 1 }] — 没有去重因为Set判断对象是否相等看的是引用两个内容完全相同但位于不同内存地址的对象在Set看来就是两个不同的对象。想让同属性的对象去重需要按特定字段处理。一个典型场景是后端返回的列表里根据id去重实现方案很成熟function uniqueBy(arr, key) { const seen new Map(); return arr.filter(item { if (seen.has(item[key])) { return false; } seen.set(item[key], true); return true; }); }用Map的优势是查询时间是O(1)哪怕有10万条数据也能飞快完成。还有一点值得注意用Map能保留第一次出现的那条记录如果业务上要保留最后一条就得换种写法。3.4 稀疏数组与空位处理以前用filter去重空位会被过滤掉某些场景下这可能不是期望的结果。用Set方案时const sparseArr [1, , 2, , 1]; [...new Set(sparseArr)]; // [1, 2]Set在迭代稀疏数组时会跳过空位所以结果也是两个元素。如果你需要保留空位常规去重方案都不能直接满足得先确定业务上“空位”算不算重复。这类边角问题实际开发中很少见但面试中偶尔会抛出来考察你对数组语义理解的深度能聊上来就很加分。4. 性能对比与大规模数据下的选型4.1 时间复杂度深度解析我做了个基准测试用不同方案处理10万条随机整数结果挺有意思方案实际耗时10万条时间复杂度备注双层循环5 ~ 8秒O(n²)效率极低不可用于大数据indexOf3 ~ 5秒O(n²)稍好但本质相同filter indexOf3 ~ 4秒O(n²)写法优雅但性能瓶颈很明显sort filter0.2 ~ 0.5秒O(n log n)需注意改变原始顺序Set / Map0.02 ~ 0.05秒O(n)最优选择从表格能清楚看出在大数组场景下哈希族方案和平方级方案的耗时差距是数量级的。我在本地实测10万条数据用Set方案几毫秒跑完用双层循环能卡好几秒这个体感差异非常真实。4.2 常量因子与内存开销有人可能会说Set看起来快但内存占用呢确实Set内部需要维护哈希表和链表结构内存开销会比纯数组高。但在实际业务场景中几万条数据产生的额外内存远达不到需要担心的量级。真要抠内存的场景比如嵌入式JavaScript环境那就另说此时可以用原地排序法——先排序再相邻去重不需要额外存储空间但时间复杂度会上升到O(n log n)。选择方案永远是个权衡题。如果数据规模只有几百条用最简单的filter方案完全没问题代码还易懂如果面对几万条数据直接上Set如果既要保持原始顺序又要求高性能Map是更好的选择因为Set去重后顺序就是第一次出现的顺序而Map按key存储时同样能保持插入顺序两者都能保序。4.3 保持原始顺序的对比上面提到保序我实际测了每种方案的顺序表现Set保持第一次出现顺序无副作用。filter indexOf保持第一次出现顺序无副作用。双层循环保持第一次出现顺序无副作用。sort完全改变顺序如非需求否则不用。顺带一提官方也提供了去重后保持顺序的标准库风格接口现在很多浏览器环境里直接写[new Set(arr)]配合Array.from就能满足需求。Array.from的设计意图之一就是处理Set等可迭代对象语义比展开运算符更明确。4.4 实际性能测试方法如果你想自己跑跑看可以用performance.now()记录时间const data Array.from({ length: 100000 }, () Math.floor(Math.random() * 50000)); const start performance.now(); const result [...new Set(data)]; const end performance.now(); console.log(耗时: ${end - start} 毫秒);用随机数的范围控制重复率100000条数据里随机生成50000以内的整数平均每条数据有两次重复机会去重效果明显也能测出各种方案的性能差距。注意测试时要把“数组生成”的时间排除在外只看去重本身的时间。5. 类型敏感度与相等性语义的精细理解5.1 严格相等、SameValueZero与SameValue数组去重看起来只是去重背后却藏着JavaScript最核心的类型与相等性语义。indexOf用的是严格相等Set和includes用的是SameValueZeroObject.is用的是SameValue。它们的区别集中在NaN和正负零的处理上比较算法NaN0 vs -0典型应用false相等indexOf、switchSameValueZero相等相等Set、includes、MapSameValue相等不相等Object.is很多人不知道0 -0的结果是true所以用去重时0和-0会被视为相同。而用Object.is比较两者是不同的。实际业务里正负零的场景极少但如果数据是传感器读数、财务计算等对精度敏感的领域这个差异可能会造成隐蔽bug。我用Set试过new Set([0, -0]).size结果是1说明SameValueZero认为它们是同一个值去重时正负零会合并这种语义在实际开发中大多数情况下是没有问题的。5.2 数字与字符串的隐式转换陷阱数组去重还有个大坑——类型转换。比如去重[1, 1, 2]如果你用宽松相等开发时可能写出arr[i] res[j]那1和1就会被视为相同导致1被错误去除function badUnique(arr) { const res []; for (const item of arr) { let found false; for (const r of res) { if (item r) { // 这里用了宽松相等 found true; break; } } if (!found) res.push(item); } return res; } badUnique([1, 1, 2]); // 结果是 [1, 2]1丢了这通常不是你想要的结果但好在indexOf、includes和Set都使用严格相等语义不会发生这种隐式转换所以看起来j。这点在面试中最好主动提出来显得你对类型敏感。5.3 Symbol与BigInt的去重表现ES6之后新增的Symbol和BigInt在去重中表现如何Set对它们同样有效const sym1 Symbol(a); const sym2 Symbol(a); // 与sym1不同 const result [...new Set([sym1, sym2, sym1])]; // [sym1, sym2] 长度2Symbol的特性是每次调用都是唯一值哪怕描述相同所以Set能正确区分。BigInt是数字的扩展类型Set把它当作普通值去重[...new Set([1n, 1n, 2n])]的结果是[1n, 2n]。这些类型在ES5时代不存在但现代JavaScript开发里碰到它们并不罕见知道Set能兼容是常识级别的知识储备。5.4 原型属性污染风险用对象做映射关系时要特别小心。初学阶段有人会写function unique(arr) { const res []; const map {}; for (const item of arr) { if (!map[item]) { res.push(item); map[item] true; } } return res; }但这里的map是用普通对象模拟哈希表隐患很大。第一map[item]会触发对象原型链查找如果数组里有toString这个字符串map[toString]会命中Object.prototype上的方法真值判断直接短路这个元素就被错误地去掉了第二原型链上还有constructor、hasOwnProperty等同样会误伤。解决方案是改用Object.create(null)创建无原型对象或者直接使用Map数据结构后者是我推荐的。6. 面试官视角这个题真正的加分项6.1 从写出答案到聊清原理面试里这道题的价值不在于你能不能写出某个方案而在于你能不能展现完整的技术判断力。我作为面试官听到候选人说出“filterindexOf”后会继续追问“这个方案处理不了什么情况”有些候选人能立刻说出NaN有些能说出对象去重有些还能说出性能问题。能主动谈及这三个层次的候选人在我这里一定会有更高的评价。所以我的建议是不要把这道题当作死记硬背的题库而是把它当作JavaScript知识体系的体检表。你会哪种写法答案本身能签到状态。你能说出写法的局限与适配场景答案才能升值。6.2 一个展示深度的问答示例我模拟一段面试对话面试官请用你最熟悉的方式给数组去重。 候选人我会用[...new Set(arr)]因为Set成员唯一迭代顺序与插入顺序一致能保持数组原本的相对顺序时间复杂度是O(n)。不过这个方案有两个边界一是NaN会被正确归并因为Set用SameValueZero比较二是对象数组不会按内容去重如果需求是按对象的某个字段去重我会改用Map并用字段值做key。这段回答十几秒但传递的信息量很丰富。不仅给出了方案还点出了背后的算法语义和替代方案。面试官有充分理由相信这位候选人理解原理不只是背代码。6.3 现场手写时的注意事项如果你正在准备面试有几个细节建议面试时先问清楚数据形态基本类型还是对象要不要保序数据规模多大需求决定方案。写完代码后主动说出时间和空间复杂度这是体现工程素养的关键。不要只写Set一行答案除非你确定能解释其原理如果只写了一行且说不出所以然反而减分。如果时间允许可以用两三句话对比Set和filter方案在不同场景下的取舍展示全面的技术视野。7. 生产环境实战指南到底该用哪一个面试归面试写业务代码又是另一回事。我给出一个生产环境选型建议表供你直接参考数据形态推荐方案原因基本类型数组数据量小filter includes代码可读性好性能够用基本类型数组数据量大Set性能最优代码最简对象数组按id等字段去重Map按字段映射明确按字段去重语义清晰要保序但数据可能很大Map或Set两者都保留首次出现顺序数组里可能混有NaNSet或includesSameValueZero语义正确归并NaN数组里可能有对象且要按内容去重先序列化再Set注意顺序问题如JSON.stringify后去重但字段顺序变化会导致不同key对兼容性有要求不支持ES6双层循环或indexOf兼容性最好7.1 按内容去重对象时的序列化陷阱第三行提了按内容去重对象补充一句如果想把内容相同但结构不同的对象视为重复可以用JSON.stringify生成keyconst uniqueByContent arr { const seen new Set(); return arr.filter(item { const key JSON.stringify(item); if (seen.has(key)) { return false; } seen.add(key); return true; }); };但要小心这个方案对属性顺序敏感{ a: 1, b: 2 }和{ b: 2, a: 1 }会生成不同的key在JSON.stringify看来它们不同实际业务中可能不符合期待。需要归并属性顺序时可以先把key提取出来排序后再拼字符串。另外JSON.stringify还会把值为undefined的属性忽略掉这也是个隐蔽语义使用时要清楚知道。7.2 去重后的数据要不要再排序有些业务场景下去重并排序是一起完成的比如标签列表、筛选条件列表。此时一个技巧是先去重再排序避免排序阶段处理重复数据白白浪费时间。如果同时想节省一次排序开销可以在Set去重结束后再对唯一数据做一次sort这样排序的比较次数会大幅减少。十多万条数据去重后再排序能明显感受到性能改善。7.3 一个完整封装示例把常见生产需求封装成一个小工具函数也可以作为团队代码库的基础模块function uniqueArray(arr, key) { if (key ! undefined) { const seen new Map(); return arr.filter(item { const val item[key]; if (seen.has(val)) return false; seen.set(val, true); return true; }); } const hasNaN arr.some(item Number.isNaN(item)); if (!hasNaN) { return [...new Set(arr)]; } const seen new Set(); const result []; let nanFlag false; for (const item of arr) { if (Number.isNaN(item)) { if (!nanFlag) { result.push(item); nanFlag true; } } else if (!seen.has(item)) { seen.add(item); result.push(item); } } return result; }这个工具兼容了按字段去重、正常去重和NaN归并是我在实际项目里综合多次需求后沉淀出的版本。封装的意义在于把边界问题收敛在一个函数里团队成员调用时不需要了解背后的复杂细节。8. 常见问题速查表与排坑实录8.1 高频坑位速查现象根因解决方案NaN重复去除不了indexOf和都无法匹配NaN改用Set/includes或手动判断NaN对象数组去重无效Set按引用比较内容相同对象不同引用按字段用Map或序列化后当key数字1和字符串1被合并比较函数用了宽松相等统一用或Set/includes大数组卡顿严重使用了O(n²)方案升级为Set方案查询复杂度降为O(1)数组中含特殊值导致误去重用普通对象充当哈希表踩到原型链用Map或Object.create(null)去重后原始顺序变了使用了sort方案排序必然乱序想保序就选Set或filter别用sort对象按内容去重时属性顺序不同JSON.stringify对键顺序敏感提取键并按字典序排序后再序列化0和-0被合并Set和includes默认SameValueZero用Object.is先手动比较再走SetSymbol值无法正确去重不了解Symbol的唯一性通常Set直接处理即可稀疏数组的空位被去掉filter本身跳过空位Set迭代也跳过空位用Array.from(arr.keys())等方案保留空位但先确认需求8.2 我踩过的一个真实项目坑有一次我负责处理第三方接口返回的列表接口返回的数据里偶尔会混入NaN当时用的是filterindexOf方案结果NaN全部保留了导致列表里有多个“无效数据”项用户看到一堆重复的占位内容。排查了很久最后才发现是NaN的问题——而不是接口bug。这个经历让我养成了一个习惯写去重逻辑前先想清楚数据类型里可能出现哪些异常值。后来我在团队里约定涉及数据清洗的去重操作一律用Set或Map实现放弃indexOf方案。与此类似的是年前维护一个数据看板项目报表数据有几万条对象我一开始用双层循环按id去重浏览器直接卡死。换成Map后用performance.now()实测从原来的5秒多降到30毫秒以内。那个项目给我的教训是在数据量未知的场景里从第一版就用更优方案是更好的习惯否则上线后出问题排查代价远大于写方案的代价。8.3 排查指南去重结果不符预期的排查顺序如果你写的去重代码结果不对劲我的排查顺序是先看数据类型是基本类型还是对象有没有NaN、0、-0、undefined再看比较方式用的是还是有没有隐式类型转换的可能接着看对象比较如果是对象是只想按引用去重还是按字段去重代码里有没有体现需求然后看顺序去重后顺序是否和原数组一致如果用了sort确认是否可接受。最后看是否有隐藏状态比如用了普通对象存key有没有踩到原型链。按这个顺序排查基本十几分钟内能定位问题。如果是大数组卡顿直接把方案换成Set通常立竿见影。9. 个人实践中的一些心得数组去重这道题教会我一件事看起来很简单的需求深入下去永远能挖到新的知识点。从循环到indexOf从filter到Set再到Map和 SameValueZero一路走过来我不仅把数组方法用熟了还把JavaScript类型系统这棵大树的主干摸了一遍。写业务代码时我渐渐形成了几个习惯现在分享给你们第一去重前先问自己三个问题数据类型是什么、要不要保序、数据有多大。答案不同选型就不同。不做这些前置判断就写代码很容易写出表面能跑但边界脆弱的实现。第二默认选择Set方案。除非项目还在兼容IE等老环境否则没有理由为了一个“看起来更基础”的写法而放弃性能与简洁性的巨大优势。老环境里再用filterindexOf也不迟但你要明白它的性能上限。第三不要迷信一行代码。简洁固然好但代码的可读性永远是团队协作的生命线。[...new Set(arr)]虽然简洁如果你所在的团队里有成员不熟悉Set加上一行注释让人理解收益远大于那一行代码带来的整洁感。第四面试和干活是两个维度。面试时大胆展示你对边界问题和性能差异的理解干活时大胆使用最切合场景的工程化方案。这是一个资深开发者应该建立的思维模式。我曾经在一次代码评审中看到同事用双层循环去重一个3000条的数据数组我建议改成Set。这位同事说“还能这样”然后去查了MDN文档回来对我说“一直以为Set只是数学课的概念没想到JavaScript里直接能用”。也就是从那一刻起我突然想写一篇这样的文章——把这道经典题里所有可能遇到的问题都摆出来做一个彻底的梳理。这篇文章写到最后其实更像是我自己学习过程的一次完整复盘。如果你正在学JavaScript希望它对你也有同样的作用。
阅读完成 · 觉得有帮助?
咨询建站