Skip to content

✨ 数组去重 👌

要点速览

  • 基本类型优先用 Set,一行搞定,时间/空间均为 O(n),且能正确处理 NaN
  • 对象数组用 Map 按字段去重(如 id),避免对象键强制字符串化的陷阱。
  • 多字段与自定义比较可抽象为 unique(arr, { key: [...], compare }),提升工程可扩展性。
  • 慎用深度去重:JSON.stringify 受属性顺序影响;需要先归一化键顺序。
  • 明确需求维度:数据类型、顺序要求、对比深度与性能指标,先界定再落实现。

快速上手

最常用的两段代码,覆盖 80% 场景:

js
// 基本类型去重:数字、字符串、Symbol、NaN 等
const uniqueBasic = (arr) => [...new Set(arr)];

// 对象数组按字段去重:如按 id
const uniqueBy = (arr, key) => {
  const map = new Map();
  return arr.filter((item) => !map.has(item[key]) && map.set(item[key], true));
};

// 示例
uniqueBasic([1, 2, 2, NaN, NaN]); // => [1, 2, NaN]
uniqueBy(
  [
    { id: 1, name: "a" },
    { id: 1, name: "b" },
    { id: 2, name: "c" },
  ],
  "id"
); // => [{ id:1, name:"a" }, { id:2, name:"c" }]

边界与复杂度

  • Set/Map 方案时间/空间复杂度均为 O(n);双重循环是 O(n^2),仅适用于小数组或兼容性受限环境。
  • 稳定性:上面两种实现均“保留首个出现的元素”,保持原始顺序稳定。

问题拆解维度

在解决数组去重问题前,需从以下 4 个核心维度明确需求:

  1. 数据类型:区分基本类型(数字、字符串、Symbol、NaN)与引用类型(对象数组)
  2. 顺序要求:是否需要保留数组元素的原始顺序
  3. 对比深度:对象数组去重是按特定字段(如 id)对比,还是全属性深度对比
  4. 性能指标:评估不同方案的时间复杂度(O(n)、O(n²))与空间复杂度(O(n))

核心去重方法详解

基本类型去重

ES6 Set 方法(推荐)

  • 原理:利用 Set“天然不存储重复值”的特性,结合展开运算符实现转换
  • 优势
    • 简洁高效,一行代码实现
    • 自动处理 NaNSet 内部将 NaN 视为与自身相等,解决NaN !== NaN的语言特性问题)
    • 时间/空间复杂度均为 O(n),适配多数业务场景
  • 适用场景:无引用类型的纯基本类型数组

示例实现:

javascript
// 基本类型去重(保序、可处理 NaN)
function uniqueBasic(arr) {
  return [...new Set(arr)];
}

console.log(uniqueBasic([1, 2, 2, NaN, NaN, "a"])); // [1, 2, NaN, 'a']

传统方法(无 ES6 环境)

方法实现逻辑复杂度缺陷
双重循环+indexOf外层遍历原数组,内层用indexOf检查元素是否在新数组中,无则 push时间 O(n²)、空间 O(n)性能差,不适用于大数组,且无法处理 NaN
对象哈希表优化创建空对象,以数组元素为键存储,避免重复键值时间 O(n)、空间 O(n)对象键会强制转为字符串,导致1"1"被判定为重复

对应代码实现:

javascript
// 1) 双重循环 + indexOf(不处理 NaN)
function uniqueIndexOf(arr) {
  const res = [];
  for (let i = 0; i < arr.length; i++) {
    if (res.indexOf(arr[i]) === -1) {
      res.push(arr[i]);
    }
  }
  return res;
}
console.log(uniqueIndexOf([1, 2, 2, NaN, NaN])); // [1, 2, NaN, NaN]
javascript
// 2) includes 版本(可处理 NaN,ES7+)
function uniqueIncludes(arr) {
  const res = [];
  for (const x of arr) {
    if (!res.includes(x)) res.push(x);
  }
  return res;
}
console.log(uniqueIncludes([1, 2, 2, NaN, NaN])); // [1, 2, NaN]
javascript
// 3) 对象哈希表优化(存在键字符串化陷阱:1 与 "1" 被视为同一键)
function uniqueByObject(arr) {
  const seen = Object.create(null); // 空对象,无原型链,避免继承方法
  const res = [];
  for (const x of arr) {
    if (!seen[key]) {
      seen[key] = true;
      res.push(x);
    }
  }
  return res;
}
// [1, '1', 2, '2'](示例中恰好保留,但键混淆风险高)
console.log(uniqueByObject([1, "1", 2, "2"]));

对象数组去重

按特定字段去重(如 id)

  • 核心工具:ES6 Map(键支持任意类型,无强制类型转换)

  • 实现步骤

    1. 创建空 Map 实例
    2. filter遍历数组,以对象的目标字段(如item.id)为 Map 的键
    3. Map 中无该键,则存入 Map 并保留当前元素
  • 代码示例

    按单字段去重(保序,保留首个出现的项)

    javascript
    function uniqueByKey(arr, key) {
      const map = new Map();
      return arr.filter((item) => {
        const k = item[key];
        if (!map.has(k)) {
          map.set(k, true);
          return true;
        }
        return false;
      });
    }
    // 使用示例
    const objArr = [
      { id: 1, name: "a" },
      { id: 1, name: "b" },
      { id: 2, name: "c" },
    ];
    console.log(uniqueByKey(objArr, "id"));
    // => [{ id:1, name:"a" }, { id:2, name:"c" }]

    多字段联合去重(如 id + lang)

    javascript
    function uniqueByKeys(arr, keys) {
      const map = new Map();
      return arr.filter((item) => {
        const keyStr = keys.map((k) => item[k]).join("\u0001");
        return !map.has(keyStr) && map.set(keyStr, true);
      });
    }
    
    console.log(
      uniqueByKeys(
        [
          { id: 1, lang: "en" },
          { id: 1, lang: "zh" },
          { id: 1, lang: "en" },
        ],
        ["id", "lang"]
      )
    );
    // => [{ id:1, lang:"en" }, { id:1, lang:"zh" }]
  • 优势:时间复杂度 O(n),无类型转换问题,适配多数对象去重场景

全对象深度去重(慎用)

  • 原理:通过JSON.stringify()将对象转为字符串,再用 Set 对字符串去重
  • 缺陷:对象属性顺序会影响 JSON 字符串结果(如{a:1,b:2}{b:2,a:1}会被判定为不同)
  • 适用场景:对象属性顺序固定的特殊场景

示例实现(属性顺序归一化后再序列化):

javascript
// 将对象按键名排序后再序列化(仅处理浅层键;深层可递归)
function stableStringify(obj) {
  const keys = Object.keys(obj).sort();
  const normalized = {};
  for (const k of keys) normalized[k] = obj[k];
  return JSON.stringify(normalized);
}

function uniqueDeep(arr) {
  const seen = new Set();
  const res = [];
  for (const item of arr) {
    const s = stableStringify(item);
    if (!seen.has(s)) {
      seen.add(s);
      res.push(item);
    }
  }
  return res;
}

// 示例:属性顺序不同但内容相同的对象被视为重复
console.log(
  uniqueDeep([
    { a: 1, b: 2 },
    { b: 2, a: 1 }, // 与上面等价
    { a: 1, b: 3 },
  ])
);

注意

注意:若对象存在嵌套结构且需要深度去重,应在 stableStringify 中对值为对象的属性递归处理;但深度比较的成本较高,优先按业务字段(如 id)去重更高效。

常见陷阱与解决方案

常见误区

  • 用对象作哈希表:键被强制字符串化,1"1" 被视为同一键。
  • 想用 indexOf 去重:对 NaN 失效;应使用 Setincludes
  • 深度去重直接 JSON.stringify:属性顺序不同会被当作不同对象;需先排序键。
陷阱场景问题原因解决方案
NaN 重复保留indexOf底层用===判断,而NaN === NaN为 false,导致indexOf(NaN)永远返回-1indexOf替换为 ES7 的includes(底层对 NaN 做特殊处理)
数字与字符串混淆对象键会强制转为字符串,导致1"1"被视为同一键优先使用 Map 而非对象做哈希表,或在存储时标记类型(如key = typeof item + item
对象属性顺序干扰JSON.stringify()会按属性定义顺序生成字符串若需全对象去重,先统一对象属性顺序(如按键名排序)再序列化

终极版 unique 函数演进

基础版(仅处理基本类型)

javascript
function unique(arr) {
  return [...new Set(arr)];
}

进阶版(支持对象按字段去重)

javascript
function unique(arr, key) {
  if (!key) return [...new Set(arr)];
  const map = new Map();
  return arr.filter((item) => {
    const k = item[key];
    if (!map.has(k)) {
      map.set(k, true);
      return true;
    }
    return false;
  });
}

工程版(支持多场景扩展)

通过 options 参数实现灵活配置:

javascript
/**
 * 通用去重函数,支持多场景配置
 * @param {Array} arr - 待去重数组
 * @param {Object} options - 配置项
 * @param {string|Array|Function} [options.key] - 去重键(字符串/数组/函数)
 * @param {Function} [options.compare] - 自定义比较函数(返回 true 表示相等)
 * @returns {Array} - 去重后的数组
 */
function unique(arr, options = {}) {
  const { key, compare } = options;
  // 1) 自定义比较函数(复杂等价判断),保序
  if (typeof compare === "function") {
    const result = [];
    for (const item of arr) {
      if (!result.some((res) => compare(res, item))) result.push(item);
    }
    return result;
  }
  // 2) 多字段联合去重:key 为字段数组,保序
  if (Array.isArray(key)) {
    const map = new Map();
    return arr.filter((item) => {
      const k = key.map((k) => item[k]).join("#");
      return !map.has(k) && map.set(k, true);
    });
  }
  // 3) 通过函数生成键:更灵活,保序
  if (typeof key === "function") {
    const map = new Map();
    return arr.filter((item) => {
      const k = key(item);
      return !map.has(k) && map.set(k, true);
    });
  }
  // 4) 单字段去重:key 为字符串,保序
  if (typeof key === "string") {
    const map = new Map();
    return arr.filter(
      (item) => !map.has(item[key]) && map.set(item[key], true)
    );
  }
  // 5) 基本类型去重(数字/字符串/Symbol/NaN),保序
  return [...new Set(arr)];
}

面试答题核心要点

  1. 优先方案:基本类型用 Set(提及其处理 NaN 的优势),对象数组按字段用 Map
  2. 避坑意识:指出indexOf处理 NaN 的缺陷、对象键类型转换问题
  3. 性能对比:双重循环 O(n²) vs 哈希表 O(n),说明优化思路
  4. 工程思维:展示函数的扩展性(支持多字段、自定义比较),体现代码复用能力

小结与后续

  • 先明确需求维度(类型、顺序、深度、复杂度),再选方案;避免为“少数特殊场景”牺牲主流程的可读性与性能。
  • 业务中落地优先:基本类型用 Set,对象数组按字段用 Map;多字段/定制比较抽象为 options,集中维护。