文章

数组去重的多种实现方式深度解析

数组去重的多种实现方式深度解析

一句话概括

数组去重是一场关于”相等”的博弈——你能不能判断两个元素是同一个东西,决定了你的方案能对付基本类型、NaN、还是对象。[...new Set(arr)] 是最快最干净的起手式,但要搞定对象数组或自定义比较,你需要自己上场。

核心知识点

1. Set 去重——面试开场的”标准答案”

1
2
3
4
5
6
7
8
9
const arr = [1, 2, 2, 3, NaN, NaN, '1', '1', -0, 0];

const unique = [...new Set(arr)];
console.log(unique);
// [1, 2, 3, NaN, '1', 0]
// ★ NaN 被合并了(Set 内部用 SameValueZero,认为 NaN 等于 NaN)
// ★ '1' 和 1 是两个不同类型,各自保留
// ★ -0 和 0 被当作同一个值
// 时间 O(n),空间 O(n),一行搞定

2. indexOf 与 includes——两个”查重官”的微妙分歧

1
2
3
4
5
6
7
8
9
const data = [1, 2, 1, NaN];

// indexOf:用 === 比较,NaN === NaN 是 false → NaN 被误杀
const byIndexOf = data.filter((v, i) => data.indexOf(v) === i);
console.log(byIndexOf); // [1, 2]  ← NaN 丢了!

// includes:用 SameValueZero,认得出 NaN
const byIncludes = data.filter((v, i) => !data.slice(0, i).includes(v));
console.log(byIncludes); // [1, 2, NaN]

⚠️ 两种方案都是 O(n²),10 万条数据下 indexOf 方案可能跑到 3 秒以上,面试一定要能说出这个性能代价。

3. Map 做标记——混合类型的”安全网”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const mixed = [1, '1', null, undefined, NaN, { id: 1 }, { id: 1 }];

const uniqueMap = arr => {
  const seen = new Map();
  return arr.filter(item => {
    if (seen.has(item)) return false;
    seen.set(item, true);
    return true;
  });
};

console.log(uniqueMap(mixed));
// [1, '1', null, undefined, NaN, {id:1}, {id:1}]
// ★ 1 和 '1' 各自保留(类型不同,Map 严格区分)
// ★ NaN 正确处理
// ★ 但两个 {id:1} 都留下了——Map 比较的是引用,不是内容

4. 对象数组去重——按”身份证”说话

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const users = [
  { id: 3, name: '王五' },
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 1, name: '张三三' }, // id 重复,但 name 变了
];

const uniqueBy = (arr, key) => {
  const seen = new Set();
  return arr.filter(item =>
    seen.has(item[key]) ? false : (seen.add(item[key]), true)
  );
};

console.log(uniqueBy(users, 'id'));
// [{id:3,name:'王五'}, {id:1,name:'张三'}, {id:2,name:'李四'}]
// 保留首次出现,后面的同名 id 丢弃

5. 手写一个”万能”去重——让调用者定义什么是”相等”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const uniqueWith = (arr, isEqual) =>
  arr.filter((item, idx) =>
    arr.findIndex(other => isEqual(item, other)) === idx
  );

// 按 id 相等
const byId = uniqueWith(users, (a, b) => a.id === b.id);

// 按内容深比较(简单场景)
const byContent = uniqueWith(
  [{ a: 1 }, { a: 1 }, { a: 2 }],
  (a, b) => JSON.stringify(a) === JSON.stringify(b)
);
console.log(byContent); // [{a:1}, {a:2}]

这是 O(n²),但胜在灵活度拉满。面试官看到你能从一行 Set 一路写到自定义比较器,印象分直接拉高。

其实你每天都在用

📋 列表页兜底去重——后端分页偶尔返回重叠数据,前端拿到数据第一件事就是按 id 去个重,比祈祷后端修 Bug 快得多。

🔍 搜索联想词合并——多个搜索源(历史记录 + 热门推荐 + 联想词)合并到一起,用 Set 把重复的建议词干掉,用户看到的列表干干净净。

📊 图表 ECharts 数据清洗——接口返回的 xAxis 数据有时带重复日期,直接扔给图表会出两条一样的柱子,先 [...new Set(dates)] 洗一遍。

🏷️ 标签/分类去重展示——一篇文章多个标签,多篇文章的标签拼在一起展示”全部标签”时,Set 一行代码搞定,不用自己写双重循环嵌套。

📝 表单多选防重复——用户在下拉多选里手快点了两次同一个选项,提交前 new Set(selectedValues) 兜底,比去拦截 click 事件更稳。

常见误解

❌ 误区一:”Set 能去重对象数组”

1
2
const arr = [{ id: 1 }, { id: 1 }];
console.log(new Set(arr).size); // 2,不是 1!

Set 比较的是 引用地址,两个 {id:1} 是不同的堆对象,所以 Set 认为它们不一样。对象数组去重必须按 key 或自定义比较。

❌ 误区二:”indexOf 和 includes 去重效果一样”

1
2
const arr = [NaN, NaN];
console.log(arr.filter((v, i) => arr.indexOf(v) === i)); // [] —— NaN 全灭

indexOf 用的是 ===,而 NaN === NaNfalse,所以永远找不到 NaN 的”第一次出现”。includes 用的是 SameValueZero,能正确处理。这个坑是面试官最爱挖的。

❌ 误区三:”用对象属性做去重又快又好”

1
2
3
4
5
6
const obj = {};
[1, '1', null, undefined, {}].forEach(v => { obj[v] = true; });
console.log(Object.keys(obj));
// ['1', 'null', 'undefined', '[object Object]']
// 数字 1 和字符串 '1' 被压成同一个 key!
// 两个不同的对象被压成同一个 '[object Object]'!

对象的 key 永远是字符串(或 Symbol),任何非字符串值都会触发隐式 toString(),这会导致类型信息全部丢失。要安全地做映射,用 Map

❌ 误区四:”去重一定会保留第一个出现的元素”

Setfilter + indexOf 确实保留首次出现。但如果你用的是 reduce 从右往左构建、或者先 reverse() 再去重,结果顺序可能正好相反。面试时如果被问到”如果要保留最后一次出现呢”,能答出来才是加分项:

1
const keepLast = arr => [...new Set([...arr].reverse())].reverse();

一句话总结

Set 是起点,Map 是底线,自定义比较器是你和面试官拉开差距的终点——数组去重的关键从来不是”怎么去掉”,而是”你凭什么说它们一样”。

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量