怎样实现数组去重和数组扁平化?
去重最简单的是 [...new Set(arr)],它按 SameValueZero 比较,能识别 NaN,但对象只按引用比较;对象数组要按某个字段去重时用 Map。扁平化可以直接用 flat(Infinity),手写时用递归或显式栈,并注意 flat 会跳过稀疏数组的空位。
简答
接口返回的标签列表里有重复项,要去掉重复的,最简单的写法是
[...new Set(arr)]。Set 里每个值只能出现一次,把数组放进去再展开出来,重复项就没了,顺序还保持第一次出现的顺序。Set 判断“相同”用的是 SameValueZero 规则:和
===基本一样,区别是NaN和NaN算相同。所以indexOf写法去不掉的NaN,Set 能去掉。可是对象按引用比较,两个内容一样的{ id: 1 }会被当成不同的值。对象数组要按id去重,就用一个 Map,以id为键记录。扁平化是把
[1, [2, [3]]]变成[1, 2, 3]。现在直接用arr.flat(Infinity),flat()不传参数默认只展开一层。手写时遍历数组,遇到数组就递归展开,不是数组就放进结果;嵌套很深时可以改用显式栈,避免递归层数太多导致栈溢出。要注意,
flat会跳过稀疏数组里的空位,手写版本如果用for循环按下标遍历,会把空位当成undefined放进结果,两者结果不一样。
去重的几种写法
下面对同一个数组试三种写法,重点看 NaN 和对象的结果:
const list = [1, '1', 1, NaN, NaN, { id: 1 }, { id: 1 }];
const bySet = [...new Set(list)];
const byIndexOf = list.filter((item, index) => list.indexOf(item) === index);
console.log(bySet.length); // 5
console.log(byIndexOf.length); // 4
console.log(bySet.includes(NaN)); // true
console.log(byIndexOf.includes(NaN)); // false
function uniqueBy(items, getKey) {
const seen = new Map();
for (const item of items) {
const key = getKey(item);
if (!seen.has(key)) seen.set(key, item);
}
return [...seen.values()];
}
const users = [{ id: 1, name: '甲' }, { id: 2, name: '乙' }, { id: 1, name: '甲2' }];
console.log(uniqueBy(users, (user) => user.id).map((user) => user.name).join()); // 甲,乙
Set 得到 5 项,indexOf 写法只有 4 项,差的就是 NaN:Set 保留了一个,indexOf 写法把两个都丢了。因为 indexOf 用的是 ===,NaN === NaN 是 false,list.indexOf(NaN) 永远返回 -1,跟任何下标都不相等,所以每个 NaN 都被过滤掉了。
两个 { id: 1 } 在两种写法里都保留了下来,因为它们是两个不同的对象。1 和 '1' 也都保留,因为类型不同。按字段去重时,uniqueBy 用 Map 记录每个 id 第一次出现的那一项,后面同 id 的都跳过。想保留最后一次出现的那项,去掉 has 判断,每次都 set 就行。
为什么不用双层循环?双层循环每一项都要和前面所有项比较,是 O(n²)。Set 和 Map 查找平均是常数时间,整体接近 O(n)。
手写扁平化
先看递归版本,可以指定展开几层,行为对齐原生 flat:
function flatten(arr, depth = 1) {
const result = [];
arr.forEach((item) => {
if (Array.isArray(item) && depth > 0) {
result.push(...flatten(item, depth - 1));
} else {
result.push(item);
}
});
return result;
}
function flattenDeep(arr) {
const stack = [...arr];
const result = [];
while (stack.length > 0) {
const item = stack.pop();
if (Array.isArray(item)) {
stack.push(...item);
} else {
result.push(item);
}
}
return result.reverse();
}
const nested = [1, [2, [3, [4]]], 5];
console.log(JSON.stringify(flatten(nested))); // [1,2,[3,[4]],5]
console.log(JSON.stringify(flatten(nested, Infinity))); // [1,2,3,4,5]
console.log(JSON.stringify(flattenDeep(nested))); // [1,2,3,4,5]
console.log(flatten([1, , [2]]).length); // 2
flatten(nested) 为什么只展开了一层?因为 depth 默认是 1,进入下一层时减到 0,再遇到数组就原样放进结果,和 nested.flat() 一样。传 Infinity 时怎么减都大于 0,就会一直展开到底。
flattenDeep 不用递归,而是自己维护一个栈。每次从栈顶取一项,是数组就把它的元素压回栈里,不是就放进结果。因为是从末尾往前取的,结果是倒序的,最后要 reverse 一次。它的好处是嵌套再深也不会因为递归太多层而栈溢出。
最后一行的 [1, , [2]] 中间有一个空位,结果长度是 2,不是 3。因为 forEach 会跳过空位,这和原生 flat 跳过空位的行为一致。如果改成 for (let i = 0; i < arr.length; i++),读到空位得到 undefined,结果就会多一项。
常见追问
arr.toString().split(',') 能扁平化吗?
只有数组里全是数字或简单字符串时能凑合用。它会把所有值变成字符串,数字 1 变成
'1',对象变成'[object Object]',字符串里本身有逗号的还会被拆开,所以不推荐。
Set 去重之后顺序会变吗?
不会。Set 按插入顺序保存值,重复的值不会再插入,所以结果保持每个值第一次出现的顺序。
对象数组能用 JSON.stringify 当键去重吗?
能用但有坑。属性顺序不同的两个对象,比如
{ a: 1, b: 2 }和{ b: 2, a: 1 },序列化出的字符串不同,会被当成两个;undefined和函数属性会被丢掉。有明确的唯一字段时,按字段去重更可靠。