從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.0和recent nightly Chrome開始,Set 視 +0 和 -0 為相同的值。另外,NaN和undefined都可以被儲存在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;
};