1. 程式人生 > >從dedup說起之JS陣列去重

從dedup說起之JS陣列去重

作者: Cheng, Pengpeng

在JavaScript中,陣列去重是一個基本的操作,方法眾多:遍歷去重到Set、Map去重、hashTable、LodashUniq,陣列中是否存在物件、函式,每個去重方法的表現各有差異,本文將以此作為切入點深入原始碼進行分析。

一.定義重複

在JS中,對於原始值而言,我們很容易想到1和1是相等的,'1'和'1'也是相等的。1和'1'是不相等的。那麼對於如下情況呢?

1.NaN,NaN不是獨立的資料型別,是數字型別,但是NaN不等於任何一個數字,也不等於任何一個其他的NaN,例如a=NaN,b=NaN,a== b不成立。但是這顯然是不符合我們的判斷是重複的需求的。好在js中有isNaN來彌補這個不足:isNaN(a) && isNaN(b)。

2.物件和函式

初始化一個物件時,物件名放在桟中,它指向堆中存放的內容。所以兩個物件直接對比是否相等是永遠不等的,{a:1} == {a:1}永遠不成立。函式也是類似的。要判斷兩個物件是否顯式相等,我們可以採用遍歷遞迴的方式來進行判斷。

3.Null

Null並不是一個單獨的特定型別,當物件的屬性賦值為null時,表示該屬性是空。Null的型別是object。

本文討論ref去重,在這裡不再贅述顯式相等。有興趣可以參看物件顯式判等【程式碼1】

二.陣列去重

1.遍歷(indexOf和includes)

遍歷是最容易想到也比較直觀的方法:

程式碼如下:

function uniq1(arr) {

    var newArr= [];

   arr.forEach(function(item){

       if(!newArr.includes(item)){

           newArr.push(item);

        }

    });

   return newArr ;

}

之所以使用includes而不使用indexOf方法,是因為對於NaN的判定,indexOf是不符合我們的去重規定的:

var arr = [1, 2,NaN];

arr.indexOf(NaN);// -1

arr.includes(NaN);// true

評價:includes是ES6的新語法,因此使用有侷限性。另外不管是使用indexOf還是includes,本質上都是一個雙重迴圈的方法,時間複雜度比較高,效能一般。

1.hashTable方法

簡介一下hashTable方法:HashTable演算法有一個規則,約束鍵與儲存位置的關係。此方法就是利用鍵值對的對應關係,計算出一個hash值存為key,value儲存將要判斷的資料(數字或字串)。因此通過使用HashTable結構記錄已有的元素hash是一種典型以空間換時間的演算法,其查詢鍵值對的速度非常快。另外HashTable在JavaScript中實現較為簡單,詳見程式碼。

function uniq2(array) {

       var newArr = []

       var current_temp = {}

       for(var i = 0 ; i < array.length ; i++){       

           if(!current_temp[array[i]]){

               newArr.push(array[i])

               current_temp[array[i]] = true

           }

        }

       return newArr

            }

評價:需要注意的是,hashTable存放的key是不能區分物件和函式的,Key都將被轉換為string型別,物件儲存的key都是’[object object]’,函式儲存的都是’[function function]’。

hashTable快於原生的Set方法,hashTable本身不存在對於Boolean、Number、String、NaN、undefined、null等型別的判斷,一律會轉為String,因此需要額外新增判斷語句,本例hashTable方法還有許多不足。

2.改進的hashTable方法

如果像上文所說,hashTable方法是不完善的,我們可以通過手動新增條件,將hashTable方法完善起來。除了下面的方法可以完善hashTable,注:還有一種序列化Key的方法可以完善hashTable方法,使用JSON.stringify()進行序列化 ,將typeof arr[i] + JSON.stringify(arr[i])儲存為key值從而保證每個元素的獨一性,但是時間複雜度與雙重迴圈一樣,效能較差,並且不能判定是否是function。

function uniq3 (arr) {

      var tmp_map = {

           string: {},

           number: {},

      };

      varhas_undefined = false

      var has_true =false

      var has_false =false

      var returnList =[]

      varnoneHashableList = []

      var len =arr.length;

      for (var i = 0;i < len; i++) {

           var value = arr[i];

           var type = typeof value;

           if (type === 'string' || type === 'number') {

                 if (!tmp_map[type][value]) {

                       returnList.push(value);

                       tmp_map[type][value] = true;

                 }

           } else if (value === undefined) {

                 if (has_undefined) continue;

                 has_undefined = true;

                 returnList.push(undefined);

           } else if (value === true) {

                 if (has_true) continue;

                 has_true = true;

                 returnList.push(true);

           } else if (value === false) {

                 if (has_false) continue;

                 has_false = false;

                 returnList.push(has_false);

           } else if (!noneHashableList.includes[value]) {

                 noneHashableList.push(value);

                 returnList.push(value);

           }

      }

      returnreturnList;

}

總結:經過完善後的hashTable方法,增加了多次判斷,因此效能會有所影響。但是在只有number和String型別的情況下效能應該還是很快。

4.Map方法

總結上述方法,因為key型別的限制,使得hashTable方法存在侷限性,那麼有沒有對key型別沒有限制的物件呢?答案就是利用Es6的Map方法:Map是一種新的資料型別,可以把它想象成key型別沒有限制的物件。此外,它的存取使用單獨的get()、set()、has()介面。使用原生的Map方法時,但是object和func作為Map元素的key 時, 會執行toString方法,導致速度變慢。

            functionuniq4(array) {

                        constseen = new Map()

                        array.filter((a)=> !seen.has(a) && seen.set(a, 1))

                        returnarray;

            }

5.Set方法

利用Es6的Set方法,Set將陣列轉換為一個不含有重複元素的物件,然後再使用Array.from方法轉換為陣列。因為 Set 中的值總是唯一的,所以需要判斷兩個值是否相等。判斷相等的演算法與嚴格相等(===操作符)不同。具體來說,對於 Set +0 +0 嚴格相等於-0)和-0是不同的值。儘管在最新的 ECMAScript 6規範中這點已被更改。從Gecko 29.0recent nightly Chrome開始,Set +0 -0 為相同的值。另外,NaNundefined都可以被儲存在Set 中, NaN之間被視為相同的值(儘管 NaN !== NaN)。綜上所述,Set方式可以說是最全面的陣列去重的方法。

 functionuniq5(array) {

                        returnArray.from(new Set(array));

            }

6.裸寫的Map方法

不同的瀏覽器對於方法是否包裹在函式內部有針對性的優化,例如谷歌瀏覽器。

程式碼如下:

//裸寫的Map方法

const seen = new Map()

array.filter((a) => !seen.has(a) && seen.set(a, 1))

//裸寫的Set方法

arr = [...new Set(arr2)];

7.uniq方法

            functionuniq6(array) {

                        return_.uniq(array);

            }

二.測試

1.測試用例

示例:

測試陣列:[true,false, false, 1, "1", "1", 0, 0, "0","0", undefined, undefined, null, null, () => (false),() =>(false),Array(0), Array(0), /a/, /a/]

預期結果:[true,false, 1, "1", 0, "0", undefined, null,  () => (false),() => (false), Array(0),Array(0), /a/, /a/]

測試用例程式碼:

   //數字
   for (var i = 0; i < 900000; i++) {
      arr1.push(parseInt(Math.random() *10) + 1);
   }
   //
字串
   for (var i = 0; i < 20000; i++) {
      stringArr.push((parseInt(Math.random()* 10) + 1).toString())
   }
   for (var i = 0; i < 20000; i++) {
      arr1.push(stringArr[i]);
   }
   //
物件
   for (var i = 0; i < 20000; i++) {
      objArr.push({a:(parseInt(Math.random() * 10) + 1)})
   }
   for (var i = 0; i < 20000; i++) {
      arr1.push(objArr[i]);
   }
   //
函式
   for (var i = 0; i < 20000; i++) {
      var a = () =>(parseInt(Math.random() * 10) + 1)
      funArr.push(a)
   }
   for (var i = 0; i < 20000; i++) {
      arr1.push(funArr[i]);
   }
   //undefined
   for (var i = 0; i < 10000; i++) {
      funArr.push(undefined)
   }
   for (var i = 0; i < 20000; i++) {
      arr1.push(funArr[i]);
   }
   //null
   for (var i = 0; i < 5000; i++) {
      funArr.push(null)
   }
   for (var i = 0; i < 5000; i++) {
      arr1.push(funArr[i]);
   }
   //true
false
   for (var i = 0; i < 5000; i++) {
      arr1.push(parseInt(i%2 === 0 ? true: false);
   }

實際測試資料量:

90萬數字、2萬字符串、2萬物件、2萬函式、1萬undefined、5千null、5千true和false,合計100萬

2.測試結果

測試環境:Win7 64 8G記憶體 Core i7 5320 資料量(100萬)Chrome瀏覽器

遍歷(includes

hashTable(改進)

Map

Map without function

Set

Set without function

Loda

Uniq

Number only

128ms

10ms

61ms

59ms

66ms

52ms

51ms

Number & String

146ms

10ms

66ms

66ms

85ms

98ms

95ms

Number & String & Object & Function

19772ms

85ms

104ms

91ms

93ms

75ms

75ms


測試環境:Win7 64 8G記憶體 Core i7 5320 資料量(100萬)Firefox瀏覽器

遍歷(includes

hashTable(改進)

Map

Map without function

Set

Set without function

Loda

Uniq

Number only

39ms

7ms

55ms

53ms

28ms

28ms

33ms

Number & String

39ms

9ms

52ms

50ms

33ms

32ms

33ms

Number & String & Object & Function

16774ms

87ms

95ms

91ms

66ms

60ms

62ms

4.測試總結

只有數字和字串的型別的測試中,Chrome和Firefox瀏覽器hashTable方法都是最快的,甚至遠遠快於原生的set方法。而在Firefox中,對ES6的原生方法includes支援是最快的,set方法和includes方法很接近。對於includes方法,Firefox的底層實現是直接取物件而不是通過指標讀取資料,所以它的includes要快於Chrome。

對於,個人判斷是對於if的執行語句Firefox是同步執行的,而chrome是非同步執行。

在含有物件和函式的型別中,改進的HashTable要慢很多。Chrome和Firefox都是lodash的uniq方法較為出色,該方法效能也很穩定。

對於原生的includes方法,一旦陣列中含有物件會慢很多。

此外,因為不同的瀏覽器對裸露函式和包裹函式的執行進行過各自的優化,可以看出Chrome瀏覽器在這方面要優於Firefox,同時可以看到,包裹在Function裡面的執行方法與裸露的方法優化是根據不同的方法進行的優化。

【程式碼1】

深度遞迴判斷物件是否顯式相等

deepEquals = (obj1, obj2) => {
   if (obj1 === obj2 || (isNaN(obj1)&& isNaN(obj2))) return true;
   if (typeof obj1 !== typeof obj2 ||obj1 === null || obj2 === null) {
      return false;
   } else if (typeof obj1 === 'object') {
      const keys1 = Object.keys(obj1);
      const keys2 = Object.keys(obj2);
      if (keys1.length !== keys2.length)return false;

      for (let i = keys1.length - 1; i>= 0; i -= 1) {
         const key = keys1[i];
         if (!deepEquals(obj1[key],obj2[key])) return false;
      }
      return true;
   }
   return false;
};