【WEB前端】JavaScript陣列去重

於明昊發表於2013-07-21

前幾日在做js-assessment時,發現其陣列這一章裡也有陣列去重的這一問題:這個問題說起來十分簡單,就是把陣列中重複的元素去除。其實個人感覺陣列去重問題實際上就是排序的升級版,目前開來最好的去重方法就是字典去重,這一點和排序中的基數排序不謀而合。下面就簡單的說一說自己解決這個問題的思路。

編寫AOP時間函式

對於解決陣列去重演算法的好壞,最終效率是第一位的,所以需要編寫一個計算函式執行時間的切面函式。實現如下:

Function.prototype.time = function() {
    var t1 = +new Date()
    ,   foo = this()
    ,   t2 = +new Date()
    return t2 - t1     //返回單位為毫秒
}

但是寫完這個方法之後發現,對於要測試執行的函式而言,在進行測試之前不能夠執行(即只能寫成 foo.time() 的樣子),這樣就不能用普通傳參的方法對其進行引數傳遞。突然想到了在前幾日看到過prototypejs中的原始碼中有一個 bind 函式,其功能就在與給一個函式繫結特定上下文,且返回函式本身而不立即執行,於是就馬上實現了這樣一個函式,程式碼如下:

Function.prototype.bind = function(ob) {
    var fn = this
    ,   slice = Array.prototype.slice
    ,   args = slice.call(arguments, 1)
    return function(){
        return fn.apply(ob, args.concat(slice.apply(arguments)))
    }   
}

寫完這兩個,我們就可以對測試函式進行執行時間計算,假如陣列為 arr ,測試函式為 delrep ,則在實際操作中可以這樣實現:delrep.bind(arr).time() (執行函式的同時輸出運算時間)。

雙重迴圈去重

在就去重方法討論的文章中,愚人碼頭的文章裡說到過這個方法,當然,作者本身也承認,這種雙重for迴圈巢狀的方法在大資料量的情況下十分耗時。作者的原始碼引用如下:

Array.prototype.delRepeat=function(){
    var newArray=new Array();
    var len=this.length;
    for (var i=0;i<len ;i++){
        for(var j=i+1;j<len;j++){
            if(this[i]===this[j]){
                j=++i;
            }
        }
        newArray.push(this[i]);
    }
    return newArray;
}

這裡我也用ECMAScript中宣告的 forEach 方法和 indexOf 方法模擬實現一下雙重迴圈:

function delrep1() {
    var n = []

    this.forEach(function(v) {
        if (n.indexOf(v) == -1) 
            n.push(v)
    })  
    return n
}

作者的程式碼看起來像極了氣泡排序:每一次操作都會冒出一個沒有重複元素的,放入新的陣列(對應氣泡排序冒出最小的)。而氣泡排序的時間複雜度是O(n^2),可以想見這個演算法的效率著實不高。而在我的演算法中採用了 indexOf 的方法,沒想這個遍歷的效率要高很多,最終執行的時間要比作者的方法高不少。這裡貼一下最終執行的時間(用隨機生成的15w長度的陣列進行測試,編譯器使用的是想向大家極力推薦的nodejs,雖然這裡只用了它一個小小的功能):

malcolm@malcolm:~/test/aop$ node aop.js 
method0: 1389ms    #我的方法
method1: 9087ms    #作者的方法

各中原理,還需要仔細的分析才行。

字典去重

之後作者提了一個字典去重的方法,我用自己的方法簡化了一下:

function delrep2() {
    var n = {}
    ,   r = []

    this.forEach(function(v){
        if (!n[v]) {
            n[v] = true
            r.push(v)
        }
    })
    return r
},

這個一看就很臉熟——傳說中時間複雜度只有O(n)的基數排序麼!類似與撲克牌的發牌,一次遍歷什麼的不是最快捷了麼。當然這裡用到了“空間換時間”的策略,多出來一個龐大的字典,但是為了效率,做一點犧牲也是必要的。執行的時間也令人歎為觀止:

malcolm@malcolm:~/test/aop$ node aop.js
method0: 1389ms
method1: 9087ms
method2: 9ms

但是令人遺憾的是,這個方法是有bug的:你把所有的元素都轉化成字典的鍵值key,也就是字串,那必然會出現1和'1'的問題。在陣列中他們並不是重複元素,而這裡只能保留一個。這可怎麼辦呢?在愚人碼頭帖子的回覆中,馬上有人提到了,既然鍵值轉化為字串後失去了型別,那如果在轉化之前給他加上型別會怎麼樣呢?程式碼如下:

function delrep3() {
    var n = {}
    ,   r = []

    this.forEach(function(v){
        if (!n[typeof(v) + v]) {
            n[typeof(v) + v] = true
            r.push(v)
        }
    })
    return r
},

不過作者在回覆裡說,這個方法的效率和兩重迴圈的差不多,難道真的是這樣麼?實際測試了一下:

malcolm@malcolm:~/test/aop$ node aop.js 
method0: 1389ms
method1: 9087ms
method2: 9ms
method3: 56ms

明顯已經好了不少啊~可是可以看出因為 typeof 的原因,效率比第二個方法低了不少,但是沒有bug的優勢足以彌補這一缺憾。

排序遍歷

問題說到這裡好像已經解決了,但是實際上背後的演算法問題我自己還是沒有搞清楚,希望以後能把詳細的原因補上。這裡也寫一個我自己最初想到的方法:要去重先遍歷,依靠javascript自身的sort函式先幫我們過一關。據說這個函式用的是O(nlgn)的快速排序,顯然是已經比冒泡要好不少了。排完序之後重複的資料都疊在一起,排頭向棧裡壓資料,遇到與棧頂相同的元素則不壓。我的演算法如下:

function delrep4() {
    var n = []

    this.sort()
    n.push(this[0])
    this.forEach(function(v) {
        if (v !== n[0])
            n.unshift(v)
    })
    return n
}

最終的時間還是比較可觀的,不過還是沒有方法2的改進版本好,資料如下:

malcolm@malcolm:~/test/aop$ node aop.js 
method0: 1389ms
method1: 9087ms
method2: 9ms
method3: 56ms
method4: 88ms

看起來還不錯~

在完成測試的同時參考了這篇文章:JS陣列去重問題,文中還提到了用排序之後用splice刪除重複元素的方法,不得不承認這樣確實節省了一部分空間,但是js的splice方法,要涉及刪除後的陣列整列移動,道爺可是在蝴蝶書中點名說本方法“大型陣列中效率較低”的。自己在測試的時候發現在15w資料量的時候用splice還是效率還是可以,再多的話就明顯不如我自己的方法了。不過綜合來說,如果可以保證陣列內部是純數值,用字典排序絕對還是最明智的選擇。

希望下週可以補上對各個演算法時間複雜度的簡要說明。

相關文章