也談JavaScript數組去重

本文同時發佈於我的博客https://www.toobug.net/articl...javascript

JavaScript的數組去重是一個老生常談的話題了。隨便搜一搜就能找到很是多不一樣版本的解法。html

昨天在微博上看到一篇文章,也寫數組去重,主要推崇的方法是將利用數組元素看成對象key來去重。我在微博轉發了「用對象key去重不是個好辦法…」而後做者問什麼纔是推薦的方法。java

細想一下,這樣一個看似簡單的需求,若是要作到完備,涉及的知識和須要注意的地方着實很多,因而誕生此文。git

定義重複(相等)

要去重,首先得定義,什麼叫做「重複」,即具體到代碼而言,兩個數據在什麼狀況下能夠算是相等的。這並非一個很容易的問題。程序員

對於原始值而言,咱們很容易想到11是相等的,'1''1'也是相等的。那麼,1'1'是相等的麼?github

若是這個問題還好說,只要回答「是」或者「不是」便可。那麼下面這些狀況就沒那麼容易了。正則表達式

NaN

初看NaN時,很容易把它當成和nullundefined同樣的獨立數據類型。但其實,它是數字類型。數組

// number
console.log(typeof NaN);

根據規範,比較運算中只要有一個值爲NaN,則比較結果爲false,因此會有下面這些看起來略蛋疼的結論:函數

// 全都是false
0 < NaN;
0 > NaN;
0 == NaN;
0 === NaN;

以最後一個表達式0 === NaN爲例,在規範中有明確規定(http://www.ecma-international...):性能

  1. If Type(x) is Number, then

    1. If x is NaN, return false.

    2. If y is NaN, return false.

    3. If x is the same Number value as y, return true.

    4. If x is +0 and y is −0, return true.

    5. If x is −0 and y is +0, return true.

    6. Return false.

這意味着任何涉及到NaN的狀況都不能簡單地使用比較運算來斷定是否相等。比較科學的方法只能是使用isNaN()

var a = NaN;
var b = NaN;

// true
console.log(isNaN(a) && isNaN(b));

原始值和包裝對象

看完NaN是否是頭都大了。好了,咱們來輕鬆一下,看一看原始值和包裝對象這一對冤家。

若是你研究過'a'.trim()這樣的代碼的話,不知道是否產生過這樣的疑問:'a'明明是一個原始值(字符串),它爲何能夠直接調用.trim()方法呢?固然,極可能你已經知道答案:由於JS在執行這樣的代碼的時候會對原始值作一次包裝,讓'a'變成一個字符串對象,而後執行這個對象的方法,執行完以後再把這個包裝對象脫掉。能夠用下面的代碼來理解:

// 'a'.trim();
var tmp = new String('a');
tmp.trim();

這段代碼只是輔助咱們理解的。但包裝對象這個概念在JS中倒是真實存在的。

var a = new String('a');
var b = 'b';

a便是一個包裝對象,它和b同樣,表明一個字符串。它們均可以使用字符串的各類方法(好比trim()),也能夠參與字符串運算(+號鏈接等)。

但他們有一個關鍵的區別:類型不一樣!

typeof a; // object
typeof b; // string

在作字符串比較的時候,類型的不一樣會致使結果有一些出乎意料:

var a1 = 'a';
var a2 = new String('a');
var a3 = new String('a');

a1 == a2; // true
a1 == a3; // true
a2 == a3; // false
a1 === a2; // false
a1 === a3; // false
a2 === a3; // false

一樣是表示字符串a的變量,在使用嚴格比較時居然不是相等的,在直覺上這是一件比較難接受的事情,在各類開發場景下,也很是容易忽略這些細節。

對象和對象

在涉及比較的時候,還會碰到對象。具體而言,大體能夠分爲三種狀況:純對象、實例對象、其它類型的對象。

純對象

純對象(plain object)具體指什麼並非很是明確,爲減小沒必要要的爭議,下文中使用純對象指代由字面量生成的、成員中不含函數和日期、正則表達式等類型的對象。

若是直接拿兩個對象進行比較,無論是==仍是===,毫無疑問都是不相等的。可是在實際使用時,這樣的規則是否必定知足咱們的需求?舉個例子,咱們的應用中有兩個配置項:

// 原來有兩個屬性
// var prop1 = 1;
// var prop2 = 2;

// 重構代碼時兩個屬性被放到同一個對象中

var config = {
    prop1: 1,
    prop2: 2
};

假設在某些場景下,咱們須要比較兩次運行的配置項是否相同。在重構前,咱們分別比較兩次運行的prop1prop2便可。而在重構後,咱們可能須要比較config對象所表明的配置項是否一致。在這樣的場景下,直接用==或者===來比較對象,獲得的並非咱們指望的結果。

在這樣的場景下,咱們可能須要自定義一些方法來處理對象的比較。常見的多是經過JSON.stringify()對對象進行序列化以後再比較字符串,固然這個過程並不是徹底可靠,只是一個思路。

若是你以爲這個場景是無中生有的話,能夠再回想一下斷言庫,一樣是基於對象成員,判斷結果是否和預期相符。

實例對象

實例對象主要指經過構造函數(類)生成的對象。這樣的對象和純對象同樣,直接比較都是不等的,但也會碰到須要判斷是不是同一對象的狀況。通常而言,由於這種對象有比較複雜的內部結構(甚至有一部分數據在原型上),沒法直接從外部比較是否相等。比較靠譜的判斷方法是由構造函數(類)來提供靜態方法或者實例方法來判斷是否相等。

var a = Klass();
var b = Klass();

Klass.isEqual(a, b);

其它對象

其它對象主要指數組、日期、正則表達式等這類在Object基礎上派生出來的對象。這類對象各有各的特殊性,通常須要根據場景來構造判斷方法,決定兩個對象是否相等。

好比,日期對象,可能須要經過Date.prototype.getTime()方法獲取時間戳來判斷是否表示同一時刻。正則表達式可能須要經過toString()方法獲取到原始字面量來判斷是不是相同的正則表達式。

==和===

在一些文章中,看到某一些數組去重的方法,在判斷元素是否相等時,使用的是==比較運算符。衆所周知,這個運算符在比較前會先查看元素類型,當類型不一致時會作隱式類型轉換。這實際上是一種很是不嚴謹的作法。由於沒法區分在作隱匿類型轉換後值同樣的元素,例如0''falsenullundefined等。

同時,還有可能出現一些只能黑人問號的結果,例如:

[] == ![]; //true

Array.prototype.indexOf()

在一些版本的去重中,用到了Array.prototype.indexOf()方法:

function unique(arr) {
    return arr.filter(function(item, index){
        // indexOf返回第一個索引值,
        // 若是當前索引不是第一個索引,說明是重複值
        return arr.indexOf(item) === index;
    });
}
function unique(arr) {
    var ret = [];
    arr.forEach(function(item){
        if(ret.indexOf(item) === -1){
            ret.push(item);
        }
    });
    return ret;
}

既然=====在元素相等的比較中是有巨大差異的,那麼indexOf的狀況又如何呢?大部分的文章都沒有說起這點,因而只好求助規範。經過規範(http://www.ecma-international...),咱們知道了indexOf()使用的是嚴格比較,也就是===

再次強調:按照前文所述,===不能處理NaN的相等性判斷。

Array.prototype.includes()

Array.prototype.includes()是ES2016中新增的方法,用於判斷數組中是否包含某個元素,因此上面使用indexOf()方法的第二個版本能夠改寫成以下版本:

function unique(arr) {
    var ret = [];
    arr.forEach(function(item){
        if(!ret.includes(item)){
            ret.push(item);
        }
    });
    return ret;
}

那麼,你猜猜,includes()又是用什麼方法來比較的呢?若是想固然的話,會以爲確定跟indexOf()同樣嘍。可是,程序員的世界裏最怕想固然。翻一翻規範,發現它實際上是使用的另外一種比較方法,叫做「SameValueZero」比較(https://tc39.github.io/ecma26...)。

  1. If Type(x) is different from Type(y), return false.

  2. If Type(x) is Number, then

    1. If x is NaN and y is NaN, return true.

    2. If x is +0 and y is -0, return true.

    3. If x is -0 and y is +0, return true.

    4. If x is the same Number value as y, return true.

    5. Return false.

  3. Return SameValueNonNumber(x, y).

注意2.a,若是xy都是NaN,則返回true!也就是includes()是能夠正確判斷是否包含了NaN的。咱們寫一段代碼驗證一下:

var arr = [1, 2, NaN];
arr.indexOf(NaN); // -1
arr.includes(NaN); // true

能夠看到indexOf()includes()對待NaN的行爲是徹底不同的。

一些方案

從上面的一大段文字中,咱們能夠看到,要判斷兩個元素是否相等(重複)並非一件簡單的事情。在瞭解了這個背景後,咱們來看一些前面沒有涉及到的去重方案。

遍歷

雙重遍歷是最容易想到的去重方案:

function unique(arr) {
    var ret = [];
    var len = arr.length;
    var isRepeat;
    for(var i=0; i<len; i++) {
        isRepeat = false;
        for(var j=i+1; j<len; j++) {
            if(arr[i] === arr[j]){
                isRepeat = true;
                break;
            }
        }
        if(!isRepeat){
            ret.push(arr[i]);
        }
    }
    return ret;
}

雙重遍歷還有一個優化版本,可是原理和複雜度幾乎徹底同樣:

function unique(arr) {
    var ret = [];
    var len = arr.length;
    for(var i=0; i<len; i++){
        for(var j=i+1; j<len; j++){
            if(arr[i] === arr[j]){
                j = ++i;
            }
        }
        ret.push(arr[i]);
    }
    return ret;
}

這種方案沒什麼大問題,用於去重的比較部分也是本身編寫實現(arr[i] === arr[j]),因此相等性能夠本身針對上文說到的各類狀況加以特殊處理。惟一比較受詬病的是使用了雙重循環,時間複雜度比較高,性能通常。

使用對象key來去重

function unique(arr) {
    var ret = [];
    var len = arr.length;
    var tmp = {};
    for(var i=0; i<len; i++){
        if(!tmp[arr[i]]){
            tmp[arr[i]] = 1;
            ret.push(arr[i]);
        }
    }
    return ret;
}

這種方法是利用了對象(tmp)的key不能夠重複的特性來進行去重。但因爲對象key只能爲字符串,所以這種去重方法有許多侷限性:

  1. 沒法區分隱式類型轉換成字符串後同樣的值,好比1'1'

  2. 沒法處理複雜數據類型,好比對象(由於對象做爲key會變成[object Object]

  3. 特殊數據,好比'__proto__'會掛掉,由於tmp對象的__proto__屬性沒法被重寫

對於第一點,有人提出能夠爲對象的key增長一個類型,或者將類型放到對象的value中來解決:

function unique(arr) {
    var ret = [];
    var len = arr.length;
    var tmp = {};
    var tmpKey;
    for(var i=0; i<len; i++){
        tmpKey = typeof arr[i] + arr[i];
        if(!tmp[tmpKey]){
            tmp[tmpKey] = 1;
            ret.push(arr[i]);
        }
    }
    return ret;
}

該方案也同時解決第三個問題。

而第二個問題,若是像上文所說,在容許對對象進行自定義的比較規則,也能夠將對象序列化以後做爲key來使用。這裏爲簡單起見,使用JSON.stringify()進行序列化。

function unique(arr) {
    var ret = [];
    var len = arr.length;
    var tmp = {};
    var tmpKey;
    for(var i=0; i<len; i++){
        tmpKey = typeof arr[i] + JSON.stringify(arr[i]);
        if(!tmp[tmpKey]){
            tmp[tmpKey] = 1;
            ret.push(arr[i]);
        }
    }
    return ret;
}

Map Key

能夠看到,使用對象key來處理數組去重的問題,實際上是一件比較麻煩的事情,處理很差很容易致使結果不正確。而這些問題的根本緣由就是由於key在使用時有限制。

那麼,能不能有一種key使用沒有限制的對象呢?答案是——真的有!那就是ES2015中的Map

Map是一種新的數據類型,能夠把它想象成key類型沒有限制的對象。此外,它的存取使用單獨的get()set()接口。

var tmp = new Map();
tmp.set(1, 1);
tmp.get(1); // 1

tmp.set('2', 2);
tmp.get('2'); // 2

tmp.set(true, 3);
tmp.get(true); // 3

tmp.set(undefined, 4);
tmp.get(undefined); // 4

tmp.set(NaN, 5);
tmp.get(NaN); // 5

var arr = [], obj = {};

tmp.set(arr, 6);
tmp.get(arr); // 6

tmp.set(obj, 7);
tmp.get(obj); // 7

因爲Map使用單獨的接口來存取數據,因此不用擔憂key會和內置屬性重名(如上文提到的__proto__)。使用Map改寫一下咱們的去重方法:

function unique(arr) {
    var ret = [];
    var len = arr.length;
    var tmp = new Map();
    for(var i=0; i<len; i++){
        if(!tmp.get(arr[i])){
            tmp.set(arr[i], 1);
            ret.push(arr[i]);
        }
    }
    return ret;
}

Set

既然都用到了ES2015,數組這件事情不能再簡單一點麼?固然能夠。

除了Map之外,ES2015還引入了一種叫做Set的數據類型。顧名思義,Set就是集合的意思,它不容許重複元素出現,這一點和數學中對集合的定義仍是比較像的。

var s = new Set();
s.add(1);
s.add('1');
s.add(null);
s.add(undefined);
s.add(NaN);
s.add(true);
s.add([]);
s.add({});

若是你重複添加同一個元素的話,Set中只會存在一個。包括NaN也是這樣。因而咱們想到,這麼好的特性,要是能和數組互相轉換,不就能夠去重了嗎?

function unique(arr){
    var set = new Set(arr);
    return Array.from(set);
}

咱們討論了這麼久的事情,竟然兩行代碼搞定了,簡直難以想象。

然而,不要只顧着高興了。有一句話是這麼說的「不要由於走得太遠而忘了爲何出發」。咱們爲何要爲數組去重呢?由於咱們想獲得不重複的元素列表。而既然已經有Set了,咱們爲何還要捨近求遠,使用數組呢?是否是在須要去重的狀況下,直接使用Set就解決問題了?這個問題值得思考。

小結

最後,用一個測試用例總結一下文中出現的各類去重方法:

var arr = [1,1,'1','1',0,0,'0','0',undefined,undefined,null,null,NaN,NaN,{},{},[],[],/a/,/a/]
console.log(unique(arr));

測試中沒有定義對象的比較方法,所以默認狀況下,對象不去重是正確的結果,去重是不正確的結果。

方法 結果 說明
indexOf#1 NaN被去掉
indexOf#2 NaN重複
includes 正確
雙重循環#1 NaN重複
雙重循環#2 NaN重複
對象#1 字符串和數字沒法區分,對象、數組、正則表達式被去重
對象#2 對象、數組、正則表達式被去重
對象#3 對象、數組被去重,正則表達式被消失 JSON.stringify(/a/)結果爲{},和空對象同樣
Map 正確  
Set 正確  

最後的最後:任何脫離場景談技術都是妄談,本文也同樣。去重這道題,沒有正確答案,請根據場景選擇合適的去重方法。

相關文章
相關標籤/搜索