数组去重,从双重循环到一行 Set,我经历了什么?

18 阅读8分钟

数组去重,从双重循环到一行 Set,我经历了什么?

面试官:说说数组去重有几种写法?
我:呃……[...new Set(arr)]
面试官:就这?

没错,Set 确实是最简单的方案,但如果你只知道这一种,那可能错过了一整个成长过程。今天我把数组去重的 六种姿势 从头撸到尾,顺便聊聊注释怎么写、API 怎么用、时间/空间复杂度怎么算,以及为什么面试官喜欢问这道题。


一、题目长这样

javascript

输入:[1, 2, 3, 2, 5]
输出:[1, 2, 3, 5]

很基础吧?但不同的写法背后,体现的是不同的思考深度。


二、先聊聊注释 —— 你真的会写吗?

很多人觉得代码写出来就能跑,注释是多余的。但现实是:

  • 代码的开发者和使用者不是同一个人,你写的函数三个月后别人(甚至你自己)可能完全忘记它是干嘛的。
  • 注释是代码的一部分,它不占用运行时间,却能在维护时救你一命。
  • 好的注释能提高代码的可读性,尤其是参数、返回值、作者、日期这些元信息。

来看一个规范的函数注释长什么样:

javascript

/**
 * @func 数组去重
 * @param {Array} arr 数组
 * @return {Array} 去重后的数组
 * @author djz
 * @date 2026-05-25
 */
function unique(arr) {
    // ...
}

这不仅仅是形式主义。当你用 IDE 或者文档生成工具时,这些注释可以直接变成 API 文档。而且,一个函数只做一件事(单一职责),复杂功能要封装入口要做参数校验 —— 注释能让这些设计意图更清晰。

后面所有的去重函数,我都会加上参数校验和注释,养成好习惯。


三、方法一:双重循环 —— 最朴素的理解

javascript

/**
 * 数组去重(双重循环版)
 * @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

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 !== NaNindexOf(NaN) 永远返回 -1,导致 NaN 被重复保留)。


五、方法三:filter —— 声明式的优雅

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

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

// 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,导致类型不同的值被错误去重。另外 nullundefinedNaN 也会被转成字符串,产生意外结果。

这个缺点在 ES6 的 Map 中得到完美解决,Map 的 key 可以是任意类型,并且不会隐式转换。


八、方法六:Set —— 真正的降维打击

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 比较算法,可以正确区分 NaNNaN 和 NaN 被视为相等,只会保留一个)。
局限性:对于对象数组,Set 去重是基于引用,而不是基于对象内容。如果你有两个内容相同但引用不同的对象,Set 会认为它们不同而保留两个。这时候需要自己写比较逻辑,通常配合 Map 使用。


九、横向对比总结

方法时间复杂度空间复杂度是否保持原顺序特殊值处理(NaN等)代码量
双重循环O(n²)O(n)保持❌ NaN 会重复
indexOfO(n²)O(n)保持❌ NaN 会重复
filter + indexOfO(n²)O(n)保持❌ NaN 会重复
排序后相邻比较O(n log n)O(n)改变⚠️ 取决于排序
对象哈希(对象字面量)O(n)O(n)保持❌ 类型会被转字符串
SetO(n)O(n)保持✅ 完美支持一行

时间复杂度补充说明

  • O(n²) 级别:双重循环、indexOffilter + indexOf
  • O(n log n) 级别:排序 + 相邻比较
  • O(n) 级别:对象哈希(空间换时间)、Set

十、实际工作中怎么选?

  1. 现代浏览器 / Node.js 环境无脑用 Set。一行代码,性能好,无副作用。
  2. 需要兼容 IE 或老环境:可以用 filter + indexOf,或者自己封装一个 Set 的 polyfill。
  3. 数组非常大(几十万以上)且顺序无所谓:排序法也是不错的选择,而且内存占用相对可控。
  4. 数组里包含对象,且希望按引用去重Set 天然支持,因为对象引用是唯一的。
  5. 数组里包含对象,希望按某个属性值去重:用 Map 配合 reduce 或 filter

javascript

// 按 id 去重的常见写法
const uniqueByKey = (arr, key) => [...new Map(arr.map(item => [item[key], item])).values()];
  1. 需要保持顺序且处理 NaNSet 是你的朋友。

写在最后

从双重循环到 Set,不只是代码行数的减少,更是对数据结构和算法理解的升级。每一行代码背后,都有时间和空间的权衡。我希望这篇文章能帮你彻底搞懂数组去重,也顺便把注释习惯、API 用法、复杂度分析这些基本功一起带上。

对了,你平时写注释吗?还是说 —— 代码即注释?评论区聊聊 👇