一道数组去重题,我从 O(n²) 写到 O(n),面试官终于满意了
六种写法,从青铜到王者,一次讲透 JS 数组去重 🚀
面试中有一道题,十个前端九个会被问到——数组去重。
题目本身很简单:给你一个数组 [1, 2, 2, 3, 5],把重复的干掉,返回 [1, 2, 3, 5]。
看似简单,但你能写出几种解法?每种解法的时间复杂度是多少?能不能一步步优化?
今天这篇文章,我把自己学习这道题的过程完整梳理了一遍——六个 JS 文件,六种写法,从 O(n²) 暴力循环一直优化到 O(n) 的一行代码。
写代码之前,先聊三个好习惯
在写去重逻辑之前,有三个编程基本功值得先说清楚。它们跟算法无关,但决定了你的代码是"能跑就行"还是"专业水准"。
习惯一:注释是代码的一部分
你有没有过这种经历——三个月后打开自己写的代码,完全忘了当时在想什么?
代码的读者和使用者,往往不是写代码的那个人。注释不是"额外工作",它是代码的一部分。
/**
* @func 数组去重
* @param {Array} arr 待去重数组
* @return {Array} 去重后的数组
* @author zj
* @date 2026-05-25
*/
function unique(arr) {
// ...
}
记住一句话:好的注释解释 WHY,代码本身说明 HOW。
习惯二:一个函数只做一件事
- 校验参数 → 专心校验
- 去重逻辑 → 专心去重
- 格式化输出 → 专心格式化
复杂功能拆成小函数,而不是揉成一坨 200 行的大函数。
习惯三:永远不要信任输入
用户传进来的可能根本不是数组。第一行就做好防御:
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
Array.isArray() 是 ES5 的静态方法,挂在构造函数上,不需要实例化就能用。写代码的"健壮性",就体现在这些细节里。
六种去重方案,一种一种升级
铺垫完了,下面进入正题。我会按时间复杂度从高到低,逐一拆解六种写法。
方案一:双重 for 循环(暴力枚举)
时间复杂度:O(n²)
这是最直觉的写法,也是大多数人的第一反应:
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;
}
思路很简单:遍历原数组,拿每个元素去结果数组里找,找不到就加进去。
这里的 === 是全等运算符——值和类型都要相等。JS 是弱类型语言,1 == "1" 返回 true(隐式类型转换),但 1 === "1" 返回 false。写代码时尽量用 ===,减少意外。
评价:
| 维度 | 说明 |
|---|---|
| 优点 | 不用任何 API,面试手写最稳,体现基本功 |
| 缺点 | O(n²),1 万元素的数组就是 1 亿次比较,卡成 PPT |
| 适用场景 | 面试时先写出这个,证明自己会写代码 |
方案二:indexOf — 看不见的循环
时间复杂度:O(n²)
方案一的内层 for 循环本质上是在"查找是否已存在"。JS 提供了 indexOf 方法来做这件事:
function unique(arr) {
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
const res = [];
for (let i = 0; i < arr.length; i++) {
if (res.indexOf(arr[i]) === -1) { // 结果里还没有这个元素
res.push(arr[i]);
}
}
return res;
}
indexOf(item) 返回元素在数组中第一次出现的下标,找不到就返回 -1。
代码比方案一简洁了很多,但注意——时间复杂度并没有变。indexOf 底层还是遍历,只是把"看得见的循环"藏进了 API 里,本质还是 O(n²)。
记住:API 不会降低复杂度,只是让代码更好看。
方案三:filter — 一行代码的 O(n²)
时间复杂度:O(n²)
追求简洁的话,filter 配合 indexOf 可以压缩到一行:
function unique(arr) {
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
return arr.filter(function (item, index) {
return arr.indexOf(item) === index;
});
}
这个逻辑需要稍微"拐个弯"理解:
arr.indexOf(item)返回item第一次出现在原数组中的位置index是当前遍历到的位置- 如果两者相等 → 说明当前这个
item就是它第一次出现,保留 - 如果不等 → 说明前面已经有一个相同的了,过滤掉
filter 回调返回 true 就保留元素,返回 false 就过滤掉。
常见错误写法(注意避坑):
// ❌ 错误:不存在 Array.filter.call 这种写法
Array.filter.call(function (item, index) { ... });
// ✅ 正确:filter 是实例方法,要么用 prototype 要么直接用实例
Array.prototype.filter.call(arr, function (item, index) {
return arr.indexOf(item) === index;
});
// 最简单:
arr.filter(function (item, index) {
return arr.indexOf(item) === index;
});
方案四:排序 + 相邻比较 — 第一次真正优化
时间复杂度:O(n log n)
前面三个方案都是 O(n²),数据量一上来就扛不住。怎么真正降低复杂度?
思路:先把数组排序,这样相同的元素就会排在一起,然后只需要比较相邻元素就够了!
function unique(arr) {
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
arr = arr.sort(); // 先排序 O(n log n)
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²) 快一个数量级,对 1 万元素差距巨大 |
| 缺点 | sort() 会修改原数组(副作用);去重后结果是排序过的,不是你原来的顺序 |
算法优化的经典思路:排序 → 相邻比较,把"跟所有元素比"变成"跟旁边的比"。
方案五:对象字面量 / Hash Map — O(n) 达成
时间复杂度:O(n)
还能再快吗?能!空间换时间。
我们不要在内层做任何查找了。引入一个对象(Hash Map),每次遇到一个新元素就在对象里"打个标记",下次再遇到直接 O(1) 查到。
function unique(arr) {
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
let res = [];
let obj = {}; // 哈希表
for (let i = 0; i < arr.length; i++) {
if (!obj[arr[i]]) { // 之前没存过这个 key?
res.push(arr[i]);
obj[arr[i]] = 1; // 打个标记,值随便写
}
}
return res;
}
为什么早期 JS 用对象而不是 Map?
因为早期的 JavaScript 就是用来做点表单交互、弹个窗的,没有 HashMap 这种数据结构。程序员用对象字面量来模拟——对象属性名的查找就是 O(1) 的哈希查找。
注意 obj[arr[i]] 和 obj.name 的区别:
.name→ key 是写死的字面量[arr[i]]→ key 是动态变量
但这个方案有个坑:
let obj = {};
obj['toString'] = 1; // 把原型上的 toString 方法覆盖了!
obj[1] === obj['1']; // true —— 对象的 key 一律转成字符串
更安全的写法:
let obj = Object.create(null); // 创建无原型对象,彻底干净
// 或者直接用 ES6 的 Map
let map = new Map();
方案六:Set — 终极一行代码
时间复杂度:O(n)
ES6 带来了 Set——一个天生不可重复的数据容器:
function unique(arr) {
if (!Array.isArray(arr)) {
console.log('type error');
return [];
}
return [...new Set(arr)]; // 就这一行
}
Set 内部使用类似 HashMap 的机制保证 key 唯一,插入和查找都是 O(1)。... 扩展运算符把 Set 展开,再用 [] 包成数组。
| 维度 | 说明 |
|---|---|
| 优点 | 一行代码,语义极其清晰,时间复杂度 O(n) |
| 缺点 | 需要 ES6 环境,非常老的浏览器需要 polyfill |
面试时如果你直接写这一行,面试官八成会追问:"你了解 Set 内部是怎么实现的吗?"——这时候你就把方案五(Hash Map)的原理讲一遍,面试官会很满意。
六种方案全景对比
┌─────────────────┬────────────┬──────────┬───────────┬──────────────────────┐
│ 方案 │ 时间复杂度 │ 空间复杂度 │ 代码量 │ 核心思路 │
├─────────────────┼────────────┼──────────┼───────────┼──────────────────────┤
│ 双重 for 循环 │ O(n²) │ O(n) │ ~12 行 │ 暴力枚举 │
│ indexOf │ O(n²) │ O(n) │ ~7 行 │ API 替代内层循环 │
│ filter │ O(n²) │ O(n) │ ~3 行 │ 声明式编程 │
│ 排序 + 相邻比较 │ O(n log n) │ O(n) │ ~8 行 │ 排序后只需比较邻居 │
│ 对象 / Hash Map │ O(n) │ O(n) │ ~8 行 │ 空间换时间 │
│ Set (ES6) │ O(n) │ O(n) │ ✨1 行 │ 原生数据结构 │
└─────────────────┴────────────┴──────────┴───────────┴──────────────────────┘
三个层次的成长路径
学完这六种写法,我最有感触的不是记住 API,而是看清了一条从"能跑"到"跑得快"再到"写得漂亮"的成长路径:
层次一:能跑就行
方案一、二、三都属于这个层次。你知道怎么解决问题,代码也能工作,但你没有思考过"数据量变大以后会怎样"。
层次二:关注性能
方案四、五是这个层次的代表。你开始想:能不能不遍历两遍?能不能用空间换时间?这个思维转变,是从"初级"到"中级"的关键跨越。
层次三:简洁且优雅
方案六是这个层次。你知道最合适的工具是什么,你能用一行代码精准表达意图。与此同时,你完全理解这一行代码的背后原理——如果有人问,你能从方案一讲到方案五。
总结三句话
-
算法优化的核心就两条路——要么把查找从 O(n) 降到 O(1)(Hash/Set),要么先排序让相同的挨在一起(sort + 相邻比较)。
-
空间换时间是经典取舍——没有银弹,O(n) 的时间一定有 O(n) 的额外空间做代价。
-
面试写 Set 一行代码是结果,能讲出前五种方案才是功力。代码写给别人看,思路留给面试官品。
下次有人让你写数组去重,别急着写 new Set。从双重循环开始娓娓道来,一步步优化到一行代码——这时候你给他的印象就完全不一样了。