一道数组去重题,我从 O(n²) 写到 O(n),面试官终于满意了

3 阅读8分钟

一道数组去重题,我从 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 替代内层循环       │
│ filterO(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,而是看清了一条从"能跑"到"跑得快"再到"写得漂亮"的成长路径:

层次一:能跑就行

方案一、二、三都属于这个层次。你知道怎么解决问题,代码也能工作,但你没有思考过"数据量变大以后会怎样"

层次二:关注性能

方案四、五是这个层次的代表。你开始想:能不能不遍历两遍?能不能用空间换时间?这个思维转变,是从"初级"到"中级"的关键跨越。

层次三:简洁且优雅

方案六是这个层次。你知道最合适的工具是什么,你能用一行代码精准表达意图。与此同时,你完全理解这一行代码的背后原理——如果有人问,你能从方案一讲到方案五。


总结三句话

  1. 算法优化的核心就两条路——要么把查找从 O(n) 降到 O(1)(Hash/Set),要么先排序让相同的挨在一起(sort + 相邻比较)。

  2. 空间换时间是经典取舍——没有银弹,O(n) 的时间一定有 O(n) 的额外空间做代价。

  3. 面试写 Set 一行代码是结果,能讲出前五种方案才是功力。代码写给别人看,思路留给面试官品。


下次有人让你写数组去重,别急着写 new Set。从双重循环开始娓娓道来,一步步优化到一行代码——这时候你给他的印象就完全不一样了。