前端知库

怎样实现数组去重和数组扁平化?

去重最简单的是 [...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 和对象的结果:

JavaScript
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:

JavaScript
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 和函数属性会被丢掉。有明确的唯一字段时,按字段去重更可靠。