数组去重的多种实现方式深度解析
一句话概括
深入探讨JavaScript数组去重的多种实现方式,从基础到进阶,分析不同方法的性能差异和适用场景。
背景
数组去重是前端开发中非常常见的操作,无论是处理API返回的数据、用户输入的列表,还是表单提交前的数据清洗,都离不开数组去重。虽然看似简单,但不同实现方式在性能、兼容性、功能支持等方面存在显著差异。
概念与定义
数组去重是指从一个数组中移除重复元素,确保每个元素只出现一次。去重的核心在于判断两个元素是否”相等”,这涉及到JavaScript中的严格相等(===)和抽象相等(==)运算符,以及对象类型的比较规则。
最小示例
1
2
3
4
// 基础去重方法
const arr = [1, 2, 2, 3, 4, 4, 5, 1];
const uniqueArr = [...new Set(arr)];
console.log(uniqueArr); // [1, 2, 3, 4, 5]
核心知识点拆解
1. Set方法
1
2
3
function uniqueBySet(arr) {
return [...new Set(arr)];
}
优点:
- 代码简洁,一行搞定
- 性能优秀,时间复杂度O(n)
- 内部使用哈希表,查找速度O(1)
缺点:
- 无法正确处理对象数组(Set比较的是引用地址)
- 无法保留原始数组的顺序(ES6 Set保持插入顺序)
- 不支持NaN的去重(NaN !== NaN,但Set认为它们相等)
2. indexOf方法
1
2
3
4
5
function uniqueByIndexOf(arr) {
return arr.filter((item, index) => {
return arr.indexOf(item) === index;
});
}
优点:
- 保留原始数组的顺序
- 兼容性好(ES3+)
缺点:
- 性能较差,时间复杂度O(n²)
- 每次都要遍历数组查找,效率低下
3. 对象映射法
1
2
3
4
5
6
function uniqueByObject(arr) {
const obj = {};
return arr.filter(item => {
return obj.hasOwnProperty(item) ? false : (obj[item] = true);
});
}
优点:
- 性能较好,时间复杂度O(n)
- 保留原始数组顺序
- 可以处理基本类型
缺点:
- 无法处理对象数组
- 有键值冲突风险(如”1”和1会被认为是同一个键)
4. Map方法
1
2
3
4
5
6
function uniqueByMap(arr) {
const map = new Map();
return arr.filter(item => {
return map.has(item) ? false : map.set(item, true);
});
}
优点:
- 性能优秀,时间复杂度O(n)
- 可以处理任意类型的数据
- 保留原始数组顺序
- 没有键值冲突问题
缺点:
- 需要ES6支持
5. reduce方法
1
2
3
4
5
function uniqueByReduce(arr) {
return arr.reduce((unique, item) => {
return unique.includes(item) ? unique : [...unique, item];
}, []);
}
优点:
- 函数式编程风格
- 代码可读性好
缺点:
- 性能较差,includes每次都要遍历
- 时间复杂度O(n²)
6. 排序后去重
1
2
3
4
5
6
function uniqueBySort(arr) {
return arr.slice().sort((a, b) => a - b)
.filter((item, index, array) => {
return index === 0 || item !== array[index - 1];
});
}
优点:
- 可以处理数字数组
- 性能较好,排序O(n log n) + 去重O(n)
缺点:
- 会改变原始数组的顺序
- 只适用于可排序的数据类型
7. 对象数组去重
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function uniqueObjectsByKey(arr, key) {
const seen = new Set();
return arr.filter(item => {
const value = item[key];
return seen.has(value) ? false : seen.add(value);
});
}
// 多属性去重
function uniqueObjectsByKeys(arr, keys) {
const seen = new Set();
return arr.filter(item => {
const identifier = keys.map(key => item[key]).join('|');
return seen.has(identifier) ? false : seen.add(identifier);
});
}
实战案例
案例1:处理API返回的用户列表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// API返回的原始数据
const users = [
{ id: 1, name: '张三', age: 25 },
{ id: 2, name: '李四', age: 30 },
{ id: 1, name: '张三', age: 25 }, // 重复数据
{ id: 3, name: '王五', age: 28 }
];
// 使用Map去重
const uniqueUsers = users.filter((user, index, self) =>
index === self.findIndex(u => u.id === user.id)
);
console.log(uniqueUsers);
// 输出:[{ id: 1, name: '张三', age: 25 }, { id: 2, name: '李四', age: 30 }, { id: 3, name: '王五', age: 28 }]
案例2:处理包含NaN的数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const arr = [1, NaN, 2, NaN, 3, '1', '1', undefined, undefined, null, null];
// Set方法无法正确处理NaN
console.log([...new Set(arr)]); // [1, NaN, 2, 3, '1', undefined, null]
// 自定义去重函数
function uniqueWithNaN(arr) {
const seen = new Set();
const result = [];
for (const item of arr) {
if (typeof item === 'number' && isNaN(item)) {
// 特殊处理NaN
if (!seen.has('NaN')) {
seen.add('NaN');
result.push(item);
}
} else {
const key = item === 0 && 1 / item === -Infinity ? '-0' : item;
if (!seen.has(key)) {
seen.add(key);
result.push(item);
}
}
}
return result;
}
console.log(uniqueWithNaN(arr)); // [1, NaN, 2, 3, '1', undefined, null]
案例3:大数据量处理优化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 生成10万个随机数的数组
function generateLargeArray(size) {
const arr = [];
for (let i = 0; i < size; i++) {
arr.push(Math.floor(Math.random() * 10000));
}
return arr;
}
const largeArray = generateLargeArray(100000);
// 性能测试
console.time('Set方法');
const uniqueBySet1 = [...new Set(largeArray)];
console.timeEnd('Set方法'); // 约5-10ms
console.time('Map方法');
const uniqueByMap1 = largeArray.filter((item, index, self) =>
index === self.findIndex(i => i === item)
);
console.timeEnd('Map方法'); // 约2000-3000ms
console.time('对象映射法');
const uniqueByObject1 = largeArray.filter((item, index, self) => {
const obj = {};
return obj.hasOwnProperty(item) ? false : (obj[item] = true);
});
console.timeEnd('对象映射法'); // 约1000-2000ms
底层原理
Set的内部实现
Set在JavaScript内部使用哈希表实现,具有以下特点:
- 哈希冲突处理:当哈希值冲突时,使用链地址法解决
- 动态扩容:当元素数量超过容量时,会扩容并重新哈希
- 查找效率:平均时间复杂度O(1),最坏情况O(n)
对象属性与Map的对比
| 特性 | 对象属性 | Map | |——|———-|—–| | 键类型 | 只能是字符串/Symbol | 任意类型 | | 键顺序 | 无序 | 插入顺序 | | 性能 | 属性访问较慢 | 查找更快 | | 方法 | 简单属性读写 | 丰富的API |
NaN的特殊处理
在JavaScript中,NaN是一个特殊的值:
1
2
3
4
5
6
7
8
9
10
11
console.log(NaN === NaN); // false
console.log(Object.is(NaN, NaN)); // true
// Set认为NaN相等
const set = new Set([NaN, NaN]);
console.log(set.size); // 1
// 但严格比较不相等
const arr = [NaN, NaN];
const unique = [...new Set(arr)];
console.log(unique.length); // 1
高频面试题解析
题目1:如何实现一个高性能的数组去重函数?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function optimizedUnique(arr) {
// 根据数据类型选择不同的策略
if (arr.length === 0) return [];
// 检查是否是对象数组
const isObjectArray = arr.some(item => item && typeof item === 'object');
if (isObjectArray) {
// 对象数组使用Map
const map = new Map();
return arr.filter(item => {
const key = JSON.stringify(item);
return map.has(key) ? false : map.set(key, true);
});
} else {
// 基本类型使用Set
return [...new Set(arr)];
}
}
题目2:如何处理包含循环引用的对象数组去重?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function uniqueObjectsWithCircularRef(arr) {
const seen = new WeakMap();
const result = [];
function hasCircularRef(obj, seen = new WeakSet()) {
if (obj !== null && typeof obj === 'object') {
if (seen.has(obj)) return true;
seen.add(obj);
for (const key in obj) {
if (hasCircularRef(obj[key], seen)) return true;
}
}
return false;
}
for (const item of arr) {
if (!hasCircularRef(item)) {
const key = JSON.stringify(item);
if (!seen.has(key)) {
seen.set(key, true);
result.push(item);
}
}
}
return result;
}
题目3:如何实现一个支持自定义比较函数的去重?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function uniqueWithComparator(arr, comparator) {
return arr.filter((item, index, self) => {
return index === self.findIndex(current => comparator(item, current));
});
}
// 使用示例
const users = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 1, name: '张三' },
{ id: 3, name: '王五' }
];
// 按ID去重
const uniqueById = uniqueWithComparator(users, (a, b) => a.id === b.id);
console.log(uniqueById);
// 按姓名去重(忽略大小写)
const uniqueByName = uniqueWithComparator(users, (a, b) =>
a.name.toLowerCase() === b.name.toLowerCase()
);
console.log(uniqueByName);
总结与扩展
最佳实践总结
- 基本类型数组:优先使用Set方法,简洁高效
- 对象数组:使用Map或自定义键值,确保正确性
- 大数据量:避免O(n²)算法,选择O(n)方法
- 特殊值处理:注意NaN、-0、null等特殊情况
性能对比
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 | |——|————|————|———-| | Set | O(n) | O(n) | 基本类型,大数据量 | | indexOf | O(n²) | O(1) | 小数组,兼容性好 | | Map | O(n) | O(n) | 任意类型,保留顺序 | | reduce | O(n²) | O(n) | 函数式编程风格 | | 排序去重 | O(n log n) | O(n) | 可排序数据 |
扩展思考
- 内存优化:对于超大数组,可以考虑分批处理
- 并行处理:使用Web Workers进行并行去重
- 流式处理:对于流式数据,实现增量去重
- 去重策略:保留第一个、最后一个或自定义策略
数组去重看似简单,但要写出高效、健壮的去重函数,需要深入理解JavaScript的数据类型、内存管理和算法优化。在实际项目中,应根据具体场景选择合适的去重方法,平衡性能、可读性和兼容性。