数组去重,从双重循环到一行 Set,我经历了什么?
面试官:说说数组去重有几种写法?
我:呃......
[...new Set(arr)]?面试官:就这?
没错,Set 确实是最简单的方案,但如果你只知道这一种,那可能错过了一整个成长过程。今天我把数组去重的 六种姿势 从头撸到尾,顺便聊聊注释怎么写、API 怎么用、时间/空间复杂度怎么算,以及为什么面试官喜欢问这道题。
一、题目长这样
javascript
css
输入:[1, 2, 3, 2, 5]
输出:[1, 2, 3, 5]
很基础吧?但不同的写法背后,体现的是不同的思考深度。
二、先聊聊注释 ------ 你真的会写吗?
很多人觉得代码写出来就能跑,注释是多余的。但现实是:
- 代码的开发者和使用者不是同一个人,你写的函数三个月后别人(甚至你自己)可能完全忘记它是干嘛的。
- 注释是代码的一部分,它不占用运行时间,却能在维护时救你一命。
- 好的注释能提高代码的可读性,尤其是参数、返回值、作者、日期这些元信息。
来看一个规范的函数注释长什么样:
javascript
php
/**
* @func 数组去重
* @param {Array} arr 数组
* @return {Array} 去重后的数组
* @author djz
* @date 2026-05-25
*/
function unique(arr) {
// ...
}
这不仅仅是形式主义。当你用 IDE 或者文档生成工具时,这些注释可以直接变成 API 文档。而且,一个函数只做一件事 (单一职责),复杂功能要封装 ,入口要做参数校验 ------ 注释能让这些设计意图更清晰。
后面所有的去重函数,我都会加上参数校验和注释,养成好习惯。
三、方法一:双重循环 ------ 最朴素的理解
javascript
ini
/**
* 数组去重(双重循环版)
* @param {Array} arr
* @returns {Array}
*/
function unique(arr) {
// 健壮性校验:不是数组就返回空数组
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
let res = [arr[0]];
for (let i = 1; i < arr.length; i++) {
let flag = true; // 标记是否重复
for (let j = 0; j < res.length; j++) {
// 使用 === 恒等比较,值相等且类型相等
if (arr[i] === res[j]) {
flag = false;
break;
}
}
if (flag) {
res.push(arr[i]);
}
}
return res;
}
思路 :拿原数组的每一项,去已经存了结果的新数组里逐个比对,没出现过就加进去。
时间复杂度 :O(n²) ------ 两重循环,数据量大了会明显变慢。
空间复杂度:O(n) ------ 需要一个新数组存放结果。
这里用到了 Array.isArray ------ 这是一个静态方法 ,不需要实例化数组就能调用。养成好习惯:所有对外暴露的函数,都要做参数类型校验。
四、方法二:indexOf ------ 用 API 偷个懒
javascript
ini
function unique(arr) {
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
const res = [];
for (let i = 0; i < arr.length; i++) {
// indexOf 返回元素在数组中第一次出现的位置,-1 表示不存在
if (res.indexOf(arr[i]) === -1) {
res.push(arr[i]);
}
}
return res;
}
思路 :不再自己写内层循环,而是用数组的 indexOf 方法判断当前元素在 res 中是否存在。
复杂度 :仍然是 O(n²) ------ 因为 indexOf 内部也是一次遍历。但代码量减少了一点。
注意 :indexOf 使用严格相等比较,所以 NaN 的问题依然存在(NaN !== NaN,indexOf(NaN) 永远返回 -1,导致 NaN 被重复保留)。
五、方法三:filter ------ 声明式的优雅
javascript
javascript
function unique(arr) {
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
// filter 过滤:返回新数组,元素是那些满足条件的原数组项
return arr.filter(function(item, index) {
// 当前元素在原数组中第一次出现的位置 === 当前索引
// 满足这个条件的就是首次出现,保留
return arr.indexOf(item) === index;
});
}
思路 :filter 本身会遍历数组,对每个元素执行回调,回调返回 true 就保留,false 就过滤掉。这里利用 arr.indexOf(item) 返回第一次出现的索引,如果和当前索引相等,说明是第一次遇到,应该保留。
复杂度 :还是 O(n²) ------ filter 遍历 O(n),内部 indexOf 又遍历 O(n)。
优点 :代码简洁,可读性高,适合中小数组。
缺点:本质上没解决性能问题。
这里多说一句:filter 是数组的一个常用实例方法,掌握它可以写出更函数式的代码。
六、方法四:排序后相邻比较 ------ 换个思路降复杂度
javascript
ini
function unique(arr) {
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
// 先排序
arr = arr.sort();
let res = [arr[0]];
for (let i = 1; i < arr.length; i++) {
// 相邻比较,不一样才加入
if (arr[i] !== arr[i - 1]) {
res.push(arr[i]);
}
}
return res;
}
思路 :先对整个数组排序(默认升序),重复元素就会挨在一起。然后遍历一次,只保留那些和前一元素不同的项。
时间复杂度 :排序 O(n log n) + 遍历 O(n) = O(n log n) ,比 O(n²) 快很多。
空间复杂度 :O(n)(新数组),如果允许原地修改可以更低。
重要代价 :会改变原数组的顺序 。排序后元素的位置和原来不一样了。如果你的业务要求保持原顺序(比如用户列表按时间排序),这个方法就不能用。
另一个坑 :默认排序是把元素转成字符串,所以 [1, 5, 10] 会变成 [1, 10, 5]。稳妥起见,数字数组要传比较函数:arr.sort((a,b) => a - b)。
七、方法五:对象哈希 ------ 空间换时间
javascript
ini
// O(n) 遍历一次
// 空间换时间
function unique(arr) {
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
let res = [];
let obj = {}; // 用对象模拟 HashMap
for (let i = 0; i < arr.length; i++) {
// 用当前元素的值作为 key,检查是否已存在
if (!obj[arr[i]]) {
res.push(arr[i]);
obj[arr[i]] = 1;
} else {
obj[arr[i]]++;
}
}
return res;
}
思路 :利用对象的属性存取是 O(1) 的特性,记录每个值是否出现过。一次遍历,每个元素 O(1) 判断,总体 O(n)。
复杂度 :O(n),是目前几种方法里最快的。
代价 :多了一个对象 obj,占用了额外内存 ------ 这就是典型的空间换时间 。
严重缺点 :对象的 key 只能是字符串(或 Symbol)。数字 1 和字符串 '1' 会被当作同一个 key,导致类型不同的值被错误去重 。另外 null、undefined、NaN 也会被转成字符串,产生意外结果。
这个缺点在 ES6 的
Map中得到完美解决,Map的 key 可以是任意类型,并且不会隐式转换。
八、方法六:Set ------ 真正的降维打击
javascript
javascript
// Set 是 ES6 新增的数据结构,内部实现类似 HashMap,O(1) 查重
// Set 中的元素不重复
function unique(arr) {
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
// 将数组转为 Set,再展开回数组
return [...new Set(arr)];
}
思路 :Set 是一种不重复的值的集合。把一个数组传给 new Set(arr),自动就完成了去重。再用扩展运算符 ... 把它变回数组。
时间复杂度 :O(n) ------ Set 内部基于哈希结构,插入和查找都是 O(1)。
空间复杂度 :O(n)。
额外优点 :Set 使用 SameValueZero 比较算法,可以正确区分 NaN(NaN 和 NaN 被视为相等,只会保留一个)。
局限性 :对于对象数组,Set 去重是基于引用,而不是基于对象内容。如果你有两个内容相同但引用不同的对象,Set 会认为它们不同而保留两个。这时候需要自己写比较逻辑,通常配合 Map 使用。
九、横向对比总结
| 方法 | 时间复杂度 | 空间复杂度 | 是否保持原顺序 | 特殊值处理(NaN等) | 代码量 |
|---|---|---|---|---|---|
| 双重循环 | O(n²) | O(n) | 保持 | ❌ NaN 会重复 | 多 |
| indexOf | O(n²) | O(n) | 保持 | ❌ NaN 会重复 | 中 |
| filter + indexOf | O(n²) | O(n) | 保持 | ❌ NaN 会重复 | 少 |
| 排序后相邻比较 | O(n log n) | O(n) | 改变 | ⚠️ 取决于排序 | 中 |
| 对象哈希(对象字面量) | O(n) | O(n) | 保持 | ❌ 类型会被转字符串 | 中 |
| Set | O(n) | O(n) | 保持 | ✅ 完美支持 | 一行 |
时间复杂度补充说明:
- O(n²) 级别:双重循环、
indexOf、filter + indexOf - O(n log n) 级别:排序 + 相邻比较
- O(n) 级别:对象哈希(空间换时间)、
Set
十、实际工作中怎么选?
- 现代浏览器 / Node.js 环境 :无脑用
Set。一行代码,性能好,无副作用。 - 需要兼容 IE 或老环境 :可以用
filter + indexOf,或者自己封装一个Set的 polyfill。 - 数组非常大(几十万以上)且顺序无所谓:排序法也是不错的选择,而且内存占用相对可控。
- 数组里包含对象,且希望按引用去重 :
Set天然支持,因为对象引用是唯一的。 - 数组里包含对象,希望按某个属性值去重 :用
Map配合reduce或filter。
javascript
javascript
// 按 id 去重的常见写法
const uniqueByKey = (arr, key) => [...new Map(arr.map(item => [item[key], item])).values()];
- 需要保持顺序且处理
NaN:Set是你的朋友。
写在最后
从双重循环到 Set,不只是代码行数的减少,更是对数据结构和算法理解的升级。每一行代码背后,都有时间和空间的权衡。我希望这篇文章能帮你彻底搞懂数组去重,也顺便把注释习惯、API 用法、复杂度分析这些基本功一起带上。
对了,你平时写注释吗?还是说 ------ 代码即注释?评论区聊聊 👇