數組去重(JavaScript 爲例)


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

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

  1. 找到重複項

  2. 去除重複項

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

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

找到重複項

找到重複項最關鍵的算法是判定元素是否相同。判定相同,說起來似乎很簡單 —— 用比較運算符就好了嘛!真的這麼簡單嗎?

用 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 是多層屬性結構怎麼辦?

    比如:{ 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. 合併重複對象,比如前面“關鍵信息 比對”示例中的 u1 和 u2

保留最後一個,就是找到重複項之後,把非重複項緩存中的那一個給替換掉。以經典去重爲例,因爲要改變目標數組的元素,所以 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 版】》課程,好好地學一盤。




喜歡此文,點個在看 ⇘

支持作者,賞個咖啡豆 ⇓

本文分享自微信公衆號 - 邊城客棧(fancyidea-full)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章