數組去重(JavaScript 爲例)

數組去重,就是在數組中查找相同的元素,保留其中一個,去除其餘元素的程。前端

從這句話揭示了數組去重的兩個關鍵因素:算法

  1. 找到重複項
  2. 去除重複項

本文告訴你在遇到去重問題時該如何思考,並以 JavaScript 爲例,進行詳細解釋。使用 JavaScript 示例主要是由於它環境比較好找,並且直接對象 (Plain Object) 用起來很方便。數組

JavaScript 的環境:Node.js 或者瀏覽器的開發者控制檯。瀏覽器

找到重複項

找到重複項最關鍵的算法是斷定元素是否相同。斷定相同,提及來彷佛很簡單 —— 用比較運算符就行了嘛!真的這麼簡單嗎?緩存

用 JavaScript 來舉個例:app

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

肉眼觀察,這裏的 ab 相同吧?可是 JavaScript 不這麼認爲:ide

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() 的結果來比對,也確實不一樣。可是仔細一看,他們的屬性是徹底相同的,惟一的區別在於屬性的順序不同。那麼到底順序應不該該做爲一個判斷相同的依據呢?code

這個問題如今真無法回答。「該如何」取決於咱們的目標,也就是業務需求。

從上面的例子咱們能夠了解:判斷相同並非一個簡單的事情,根據不一樣的業務要求,須要選擇不一樣的判斷方法;而不一樣的判斷方法,可能產生不一樣的判斷結果。

接下來先講講常見的判斷方法。

最直接的:比較運算符

比較運算符主要用於比較基本類型的值,好比字符串、數、布爾等。

普通比較運算符 (==) 在比較不一樣類型的值時,會先把它們轉換爲相同類型再來比較;而嚴格比較運算符 (===) 則更爲嚴格,會直接將類型不一樣值斷定爲不一樣。這些都是基本的 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 是多層屬性結構怎麼辦?

    好比:{ v: 10, n: "1", c: { m: true } }

邏輯更嚴密的比較方法須要判斷類型,不一樣的類型進行不一樣的比較;同時,對於多層次的屬性結構,經過遞歸深刻比較(注意閱讀註釋)。

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 版】》課程,好好地學一盤。


喜歡此文,點個贊 ⇙

支持做者,賞個咖啡豆 ⇓

相關文章
相關標籤/搜索