数组去重,就是在数组中查找相同的元素,保留其中一个,去除其他元素的程。

从这句话揭示了数组去重的两个关键因素:

  1. 找到重复项
  2. 去除重复项

本文告诉你在遇到去重问题时该如何思考,并以 JavaScript 为例,进行详细解释。使用 JavaScript 示例主要是因为它环境比较好找,而且直接对象 (Plain Object) 用起来很方便。

找到重复项

找到重复项最关键的算法是判定元素是否相同。判定相同,说起来似乎很简单 —— 用比较运算符就好了嘛!真的这么简单吗?

用 JavaScript 来举个例:

const a = { v: 10 };
const b = { v: 10 };

肉眼观察,这里的 ab 相同吧?但是 JavaScript 不这么认为:

console.log(a == b);    // false
console.log(a === b);   // false

肉眼观察和程序比较使用了不同的判断方法。肉眼观察很直接的采用了字符串比对的方法,而程序压根没管是不是数据相同,只是直接判断它们是不是同一个对象的引用。我们一般会更倾向于使用符合人眼直观判断的方法,所以可能会想到使用 JSON.stringify() 把对象变成字符串来判断:

console.log(JSON.stringify(a) === JSON.stringify(b));   // true

现在如果我们把 ab 略作改变,又该如何?

const a = { v: 10, n: "1" };
const b = { n: "1", v: 10 };

乍一看,ab 不同。用 JSON.stringify() 的结果来比对,也确实不同。但是仔细一看,他们的属性是完全相同的,唯一的区别在于属性的顺序不一样。那么到底顺序应不应该作为一个判断相同的依据呢?

这个问题现在真没法回答。“该如何”取决于我们的目标,也就是业务需求。

从上面的例子我们可以了解:判断相同并不是一个简单的事情,根据不同的业务要求,需要选择不同的判断方法;而不同的判断方法,可能产生不同的判断结果。

接下来先讲讲常见的判断方法。

最直接的:比较运算符

比较运算符主要用于比较基本类型的值,比如字符串、数、布尔等。

普通比较运算符 (==) 在比较不同类型的值时,会先把它们转换为相同类型再来比较;而严格比较运算符 (===) 则更为严格,会直接将类型不同值判定为不同。这些都是基本的 JavaScript 语法知识。现代开发中为了能更好的利用工具,除极少数特殊情况外,都应该使用 === 来进行判断。尤其是在 TypeScript 中,几乎都不会出现 == 了。

JavaScript 中,比较运算符不会比较对象属性,只会比较对象的引用是否相同。如果要比较对象具体信息,需要用到接下来讲到的方法。

完整信息比对

顾名思议,就是对对象或者数组进行完整地比对,将对象的属性,或者数组的元素拿出来一一对比,判断是否完全相同。比如前面示例中的 ab,在不考虑属性顺序的情况下,他们都有相同的属性 vn,而且每个属性的值也相同,所以我们可以判定他们是完全相同的。

这需要通过遍历属性的方式来比较:

function compare(a, b) {
    // 先判断属性个数,如果属性个数不等,那肯定不相同
    const aEntries = Object.entries(a);
    const bEntries = Object.entries(b);
    if (aEntries.length !== bEntries.length) {
        return false;
    }

    // 再遍历逐一判断属性,只要有一个不等,那就整个不相同
    for (const [key, value] of aEntries) {
        if (b[key] !== value) { return false; }
    }

    return true;
}

上例中那个简单的 compare 函数似乎可以达到目的,但是很容易被证伪:

  • 如果 ab 是数组怎么办?
  • 如果 ab 是多层属性结构怎么办?

逻辑更严密的比较方法需要判断类型,不同的类型进行不同的比较;同时,对于多层次的属性结构,通过递归深入比较(注意阅读注释)。

function deepCompare(a, b) {
    // 类型相同,值或引用相同,那肯定相同
    if (a === b) { return true; }

    // 如果 a 或者 b 中有一个是 null 或 undefined,那二者不同,
    // 因此在这个条件下,a 和 b 可能相同的情况已经在前一条分支中过滤掉了。
    // 同时这个分支结合上一条分支,排除掉了 null 和 undefiend 的情况,之后不用判空了。
    if (a === null || b === null || a === undefined || b === undefined) {
        return false;
    }

    const [aType, bType] = [a, b].map(it => typeof (it));
    // 如果 a 和 b 类型不同,那就不同
    if (aType !== bType) { return false; }

    // 我们重点要深入判断的是对象和数组,它们的 typeof 运算结果都是 "object",
    // 其他类型就简单判断。前面已经处理了等值和空值的情况,剩下的就直接返回 false 了
    if (aType !== "object") { return false; }

    if (Array.isArray(a)) {
        // 作为数组进行比较,数组是一个单独的逻辑,
        // 使用 IIFE 封装是为了保证能 return,避免混入后面的逻辑。
        // 所以这里的 IIFE 不是必须的。
        return (() => {
            if (a.length !== b.length) { return false; }
            for (const i in a) {
                if (!deepCompare(a[i], b[i])) {
                    return false;
                }
            }
            return true;
        })();
    }

    // 使用之前的逻辑判断对象,记得把属性值判断那里改成递归判断,
    // 使用 IIFE 封装逻辑
    return (() => {
        // 先判断属性个数,如果属性个数不等,那肯定不相同
        const aEntries = Object.entries(a);
        const bEntries = Object.entries(b);
        if (aEntries.length !== bEntries.length) {
            return false;
        }

        // 再遍历逐一判断属性,只要有一个不等,那就整个不相同
        for (const [key, value] of aEntries) {
            if (!deepCompare(value, b[key])) { return false; }
        }

        return true;
    })();
}

上面的 deepCompare 可以处理大部分直接对象和数组数据的比较,但仍然会有一些特殊的情况处理不了,比如对象中存在循环引用时 deepCompare 会陷入死循环。这个 deepCompare 只是简单介绍了一下完整信息对比思路,在生产环境中可以使用 Lodash 的 _.isEqual() 方法。

关键信息比对

完整信息比对消耗较大。对于某些特定的业务对象来说,可能会有一些标识性的属性用来进行快速判定。比如对于用户信息来说,通常会有用户 ID 能唯一识别用户,所以比对的时候只需要简单的比对用户 ID 就可以了。比如,下面的 u1u2

const u1 = {
    userId: 123,
    name: "James",
};

const u2 = {
    userId: 123,
    phone: "12345678900"
}

虽然 u1u2 有着不同的属性,但是关键信息是相同的,所以可以认定为同一人的信息。只是比对过后,我们可能需要对这两个信息进行一个选择,或者进行合并。这将是后面“去重”要干的事情。

HASH 比对

去重的过程中通常需要对一个对象进行多轮比对,如果不能使用关键信息快速比较,每次都进行完整信息比对可能会非常耗时——尤其是对象层次较深而且数据庞大的时候。这种情况下我们可以考虑 HASH 比对。也就是根据对象的属性,计算出来一个相对唯一的 HASH 值,每次比对时只需要检查 HASH 值是否相同,就会非常快速。

比如对上述示例中的 ab 对象,可以使用这样一个简单的 HASH 算法:

// 算法仅用于示意,并未验证其有效性

const computeHash = (() => {
    /**
     * @param {string?} s
     * @return {number}
     */
    function hashString(s) {
        if (!s) { return 0; }
        let hash = 0x37;
        for (let i = 0; i < s.length; i++) {
            hash = (hash << 4) ^ s.charCodeAt(i);
        }
        return hash;
    }

    /**
     * @param {{v: number, n: string}} obj
     * @return {number}
     */
    return function (obj) {
        const hn = hashString(obj.n);
        const hv = obj.v;
        return Math.pow(hv, 7) ^ hn;
    }
})();

computeHash 函数也使用 IIFE 进行了封装,主要是想把 hashString() 作为一个内部算法保护起来,不被外部直接调用。

然后,可以分别计算 ab 的 Hash 值用于比较

const [aHash, bHash] = [a, b].map(it => computeHash(it));
console.log(aHash, bHash);

console.log(computeHash(a) === computeHash(b));

// 9999809 9999809
// true

不过,在去重的过程中,每次都调用 computeHash 仍然不能达到减少消耗 CPU 的目的。所以应该使用一个属性把 HASH 缓存起来。对于可以改变原对象的情况,直接找个无害的名称,比如 _hash 作为属性名保存起来就好:

// 假设去重数组叫 list

for (const it of list) {
    it._hash = computeHash(it);
}

如果不能改变原对象,可以考虑对原对象进行一层封装:

// 去重前封装,这里封装成数组,也可以封装成对象
const wrapped = list.map(it => ([it, computeHash(it)]));

// 去重后拆封
const result = resultList.map(it => it[0]);

使用 Hash 的办法的确是可以较大程度地节约比较时间,但它仍然存在两个问题:

  1. 计算 Hash 需要知道参与比较的单个元素结构
  2. Hash 存在碰撞,也就是说,可能存在两个不同的对象算出相同的 Hash。好的 Hash 算法可以降低碰撞概率,但不能杜绝。

综合比对:hashCode + equals

鉴于 Hash 算法存在碰撞的可能 ,我们在比较时并不能完全信任 Hash 比较。我们知道:

  1. 相同对象 Hash 得到的结果相同
  2. 不同对象的 Hash 存在碰撞的可能

可以总结出:Hash 不同时,计算出这个 Hash 的对象一定不同。

因此,我们可以使用 Hash 来进行快速失败计算,也就是比较 Hash 不同时,这两个对象一定不同,直接返回 false。比较 Hash 相同,再进行细致地比对,也就是完整信息比对。那么这个算法的示意就是:

function compare(a, b) {
    if (computeHash(a) !== computeHash(b)) { return false; }
    return deepCompare(a, b);
}

这就是我们常说的 hashCode + equals 比对方法。像 Java、C# 等语言都在 Object 基类中定义了 hash code 和 equals 接口,方便用于快速比较。JavaScript 虽然没有定义这样的接口,但是可以自己在写对象的时候进行实现。如果使用 TypeScript 和 class 语法,还可以有更强的静态检查来确保这两个方法得以实现。

在数组去重过程中,关于判定相同的方法就介绍这些。接下来是介绍“去重”这一过程。

去除重复项

有去除就有保留。我们首先要确定保留什么,去除什么。

在数组去重的过程中,通常会保留数组中找到的第一个非重复对象,并将其作为参照对象,拿数组中后面的元素跟它进行比较。下面是一个典型的去重过程:

典型去重(不改变原数组)

function makeUnique(arr) {
    // 结果集,也是非重复对象参照集
    const result = [];
    for (const it of arr) {
        // 遍历数组,检查数组中每个元素是否存在于 result 中,
        // 已存在则抛弃,未存在则加入 result
        if (!result.find(rIt => compare(it, rIt))) {
            result.push(it);
        }
    }
    return result;
}

这个算法不会改变原数组,去除重复项的结果会保存到一个新的数组中返回出来。

Lodash 中也提供了很方便的去重方法 _.uniqWith(arr, equals)equals 是用于比较两个对象是否相同的函数,可以用上面定义的 compare 函数,或者干脆就用 Lodash 提供的 _.isEqual(),所以使用 Lodash 去重很简单:

const result = _.uniqWith(list, _.isEqual);

直接从原数组去重

function makeUnique(arr) {
    for (let i = 0; i < arr.length; i++) {
        // 在之前的对象中检索,看是否已经存在
        for (let j = 0; j < i; j++) {
            if (compare(arr[j], arr[i])) {
                // 若在之前的部分中已经存在,删除当前元素,
                // 注意删除后,后面的元素会前移,所以 i 值不应该改变,
                // 考虑到下次循环前会进行 i++,所以先 i--
                arr.splice(i, 1);
                i--;
            }
        }
    }
}

直接从原数组去重时,已经遍历过的元素一定是非重复的,可以作为非重复项缓存来使用。所以这里不需要再单独定义一个缓存,直接使用数组的前半部分就好,因此第 2 重循环中的 j 值范围是 [0, i)

基于 Hash 算法的去重

function makeUnique(arr, hashCode, equals = () => true) {
    // 用新的对象将 Hash 值和原对象封装起来,
    // 为了方便阅读,这里使用了对象封装,而不是前面示例中的数组封装
    const wrapped = arr.map(value => ({
        hash: hashCode(value),
        value
    }));

    // 遍历去重的算法和前面典型去重算法一样
    const wrappedResult = [];
    for (const it of wrapped) {
        if (!wrappedResult.find(rIt =>
            it.hash === rIt.hash
            // 如果 hash 相同,还需要细致对比。
            // 不过默认的 equals 放弃了细致对比(直接返回 true)
            && equals(it, rIt)
        )) {
            wrappedResult.push(it);
        }
    }

    // 去重后的结果要解除封装后返回
    return wrappedResult.map(it => it.value);
}

Lodash 的 uniqBy()

Lodash 也提供了 _.uniqBy(arr, identity),用于根据 identity 的计算结果来判断重复,注意,这并不是基于 hashCode + equals 的判重算法。_.uniqBy() 方法并没有提供第三个参数,不能进行细致比较,所以它要求 identity 参数要能找到或算出唯一识别对象的值。

所以 _.uniqBy() 多数是用于对对象的唯一值属性判断,比如:

_.uniqBy(users, user => user.id);

如果需要对对象的多个属性进行联合判断,也就是非唯一关键信息比对时,_.uniqWith()_.uniqBy() 更合适。

保留最后一个,或者合并

通常我们认为重复的对象是完全一样的,所以保留找到的第 1 个,而将后面出现的删除掉。但是如果通过关键信息比对,这些被判定重复的对象就有可能不完全一样。这种情况下,根据业务需求,可能存在两种处理方式:

  1. 保留最后一个。可扩展为保留最近的、版本号最大的等。
  2. 合并重复对象,比如前面“关键信息比较”示例中的 u1u2

保留最后一个,就是找到重复项之后,把非重复项缓存中的那一个给替换掉。以经典去重为例,因为要改变目标数组的元素,所以 find() 就不好用了,应该改为 findIndex()

function makeUnique(arr) {
    const result = [];
    for (const it of arr) {
        const index = result.findIndex(rIt => compare(it, rIt));
        if (index < 0) {
            // 没找到仍然加入
            result.push(it);
        } else {
            // 找到了则替换
            result[index] = it;
        }
    }
    return result;
}

其中 else 的部分可以进一步判断,比如比较两个对象的 version 属性,留大舍小:

if (index < 0) { result.push(it); }
else if (it.version > result[index].version) {
    // 当新元素的 version 比较大时替换结果中的旧元素
    result[index] = it;
}

而合并也是一样的在 else 分支进行处理,比如

if (index < 0) { result.push(it); }
else {
    Object.assign(result[index], it);
}

因为不需要替换元素,而且 Object.assign 会直接修改第 1 个参数的对象,所以用 find() 也是可以的:

const found = result.find(rIt => compare(it, rIt));
if (!found) { result.push(it); }
else { Object.assign(found, it); }

小结

数组去重是一个老生长谈的问题,从众多提问者的疑惑来看,主要问题是在查找重复项上,找不到正确的判断重复的办法,本文的第一部分详细介绍了判断对象相同的方法。

另外常见的一个疑惑在于不能正确把握删除数组元素之后的元素序号。关于这个问题,只需要关注到,删除数组元素会改变后序元素的序号就容易理解了。当然,如果不改变原数组,处理起来会更方便也更不容易出错。

在进行“完整信息比对”的时候,请注意到 deepCompare 是一个很“重”的方法,不仅存在大量的判断,还需要进行递归。如果我们的对象结构明确,在很大程度上可以简化比对过程。TypeScript 无疑可以很好地约束对象结构,在 TypeScript 类型约束下,采用“关键信息比对”方法对对象的部分属性或所有属性进行比对 (equals),再适当结合 hashCode 算法,可以极大的提高比对效率。

TypeScript 已经成为前端必备技能之一,欢迎来到我的《TypeScript从入门到实践 【2020 版】》课程,好好地学一盘。


请关注公众号边城客栈

看完别走,点个赞 ⇓ 啊,或者 ⇘ 请作者喝咖啡

03-05 18:21