文章

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

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

一句话概括

深入探讨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内部使用哈希表实现,具有以下特点:

  1. 哈希冲突处理:当哈希值冲突时,使用链地址法解决
  2. 动态扩容:当元素数量超过容量时,会扩容并重新哈希
  3. 查找效率:平均时间复杂度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);

总结与扩展

最佳实践总结

  1. 基本类型数组:优先使用Set方法,简洁高效
  2. 对象数组:使用Map或自定义键值,确保正确性
  3. 大数据量:避免O(n²)算法,选择O(n)方法
  4. 特殊值处理:注意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) | 可排序数据 |

扩展思考

  1. 内存优化:对于超大数组,可以考虑分批处理
  2. 并行处理:使用Web Workers进行并行去重
  3. 流式处理:对于流式数据,实现增量去重
  4. 去重策略:保留第一个、最后一个或自定义策略

数组去重看似简单,但要写出高效、健壮的去重函数,需要深入理解JavaScript的数据类型、内存管理和算法优化。在实际项目中,应根据具体场景选择合适的去重方法,平衡性能、可读性和兼容性。

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