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

数组去重,从双重循环到一行 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 !== NaNindexOf(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,导致类型不同的值被错误去重 。另外 nullundefinedNaN 也会被转成字符串,产生意外结果。

这个缺点在 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 比较算法,可以正确区分 NaNNaNNaN 被视为相等,只会保留一个)。
局限性 :对于对象数组,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²) 级别:双重循环、indexOffilter + indexOf
  • O(n log n) 级别:排序 + 相邻比较
  • O(n) 级别:对象哈希(空间换时间)、Set

十、实际工作中怎么选?

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

javascript

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

写在最后

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

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

相关推荐
小许同学记录成长12 小时前
gr-analog 模拟信号模块完整源码分析
python·算法·信号处理
计算机安禾12 小时前
【算法分析与设计】第8篇:贪心策略的理论基础与拟阵模型
算法
西索ovo12 小时前
揭开神秘面纱!JS 代码执行前竟暗藏玄机
javascript
手写码匠12 小时前
手写 MoE(混合专家模型):从零实现大模型的稀疏激活架构
人工智能·深度学习·算法·aigc
Black蜡笔小新12 小时前
自动化AI算法训练服务器/企业AI算力工作站DLTM赋能产业智能数字化升级
人工智能·算法·自动化
许彰午12 小时前
32 个 Vue 组件的设计取舍
前端·javascript·vue.js
Asize13 小时前
JavaScript 对象通关指南:从字面量到原型链,一篇文章踩遍所有坑
前端·javascript
玖釉-13 小时前
「接雨水」问题的算法建模与双指针优化分析
c++·windows·算法
计算机安禾13 小时前
【算法设计与分析】第7篇:01背包问题的动态规划建模与空间优化
算法