關於陣列亂序的深挖——“感覺一直在寫毒程式碼”

LucasHC發表於2019-02-19

最近看了一篇非常有趣的文章:關於JavaScript的陣列隨機排序,其作者為oldj前輩。文中指出我們用來“將一個陣列隨機排序”的經典寫法所存在的問題,獲益匪淺。

本文將以更加詳盡的材料和更多樣的code demo進行闡述。並嘗試用“Fisher–Yates shuffle”洗牌演算法進行終極解答。

多個熟悉的場景

將一個陣列進行亂序處理,是一個非常簡單但是非常常用的需求。
比如,“猜你喜歡”、“點選換一批”、“中獎方案”等等,都可能應用到這樣的處理。包括我自己在寫程式碼的時候,也確實遇到過。
一般比較經典且流行的方案為:對物件陣列採用array.sort()方法,並傳入一個比較函式(comparison function),這個比較函式隨機返回一個介於[-0.5, 0.5]之間的數值:

var numbers = [12,4,16,3];
numbers.sort(function() {
    return .5 - Math.random();
});複製程式碼

關於這麼做的理論基礎這裡不再進行闡釋。如果您不明白,可以瞭解一下JS中sort函式的使用方法。

有毒的array.sort方法

正像oldj前輩文章指出的那樣,其實使用這個方法亂序一個陣列是有問題的。

為此,我寫了一個指令碼進行驗證。並進行了視覺化處理。強烈建議讀者去Github圍觀一下,clone下來自己試驗。

指令碼中,我對

var letters = [`A`,`B`,`C`,`D`,`E`,`F`,`G`,`H`,`I`,`J`];複製程式碼

letters這樣一個陣列使用array.sort方法進行了10000次亂序處理,並把亂序的每一次結果儲存在countings當中。
結果在頁面上進行輸出:

var countings = [
    {A:0,B:0,C:0,D:0,E:0,F:0,G:0,H:0,I:0,J:0},
    {A:0,B:0,C:0,D:0,E:0,F:0,G:0,H:0,I:0,J:0},
    {A:0,B:0,C:0,D:0,E:0,F:0,G:0,H:0,I:0,J:0},
    {A:0,B:0,C:0,D:0,E:0,F:0,G:0,H:0,I:0,J:0},
    {A:0,B:0,C:0,D:0,E:0,F:0,G:0,H:0,I:0,J:0},
    {A:0,B:0,C:0,D:0,E:0,F:0,G:0,H:0,I:0,J:0},
    {A:0,B:0,C:0,D:0,E:0,F:0,G:0,H:0,I:0,J:0},
    {A:0,B:0,C:0,D:0,E:0,F:0,G:0,H:0,I:0,J:0},
    {A:0,B:0,C:0,D:0,E:0,F:0,G:0,H:0,I:0,J:0},
    {A:0,B:0,C:0,D:0,E:0,F:0,G:0,H:0,I:0,J:0}
];
var letters=[`A`,`B`,`C`,`D`,`E`,`F`,`G`,`H`,`I`,`J`];
for (var i = 0; i < 10000; i++) {
    var r = [`A`,`B`,`C`,`D`,`E`,`F`,`G`,`H`,`I`,`J`].sort(function() {
        return .5 - Math.random();
    });
    for(var j = 0; j <= 9; j++) {
        countings[j][r[j]]++;
    }
}
for(var i = 0; i <= 9;i++) {
    for(var j = 0;j <= 9;j++) {
        document.getElementById(`results`).rows[i + 1].cells[j + 1].firstChild.data = countings[i][letters[j]];
    }
}複製程式碼

得到結果如圖:

關於陣列亂序的深挖——“感覺一直在寫毒程式碼”
最終結果

這個結果對陣列中的每一項元素在亂序後的結果進行了統計。
如果點選“recalculate”按鈕,可以進行多次10000次取樣試驗。

不管點選按鈕幾次,你都會發現整體亂序之後的結果絕對不是“完全隨機”。
比如A元素大概率出現在陣列的頭部,J元素大概率出現在陣列的尾部,所有元素大概率停留在自己初始位置。

由此可以先粗暴地得出結論:
使用array.sort方法進行亂序處理,絕對是有問題的。

array.sort方法底層究竟如何實現?

但是為什麼會有問題呢?這需要從array.sort方法排序底層說起。
Chrome v8引擎原始碼中,可以清晰看到,

v8在處理sort方法時,使用了插入排序和快排兩種方案。當目標陣列長度小於10時,使用插入排序;反之,使用快排。
Chrome’s v8 uses a combination of InsertionSort and QuickSort. That is, if the array is less than 10 elements in length, it uses an InsertionSort.

其實不管用什麼排序方法,大多數排序演算法的時間複雜度介於O(n)到O(n2)之間,元素之間的比較次數通常情況下要遠小於n(n-1)/2,也就意味著有一些元素之間根本就沒機會相比較(也就沒有了隨機交換的可能),這些 sort 隨機排序的演算法自然也不能真正隨機。

怎麼理解上邊這句話呢?其實我們想使用array.sort進行亂序,理想的方案或者說純亂序的方案是陣列中每兩個元素都要進行比較,這個比較有50%的交換位置概率。這樣一來,總共比較次數一定為n(n-1)。
而在sort排序演算法中,大多數情況都不會滿足這樣的條件。因而當然不是完全隨機的結果了。

順便說一下,關於v8引擎的排序方案,原始碼使用JS實現的,非常利於前端程式設計師閱讀。其中,對應不同的陣列長度,使用了快排和插入排序不同方法。同時使用了大量的效能優化技巧,尤其是關於快排的pivot選擇上十分有意思。感興趣的讀者不妨研究一下。

真正意義上的亂序

要想實現真正意義上的亂序,其實不難。我們首先要規避不穩定的array.sort方法。
在電腦科學中,有一個專門的:洗牌演算法Fisher–Yates shuffle。如果你對演算法天生遲鈍,也不要慌張。這裡我一步一步來實現,相信您一定要得懂。

先來整體看一下所有程式碼實現,一共也就10行:

Array.prototype.shuffle = function() {
    var input = this;
    for (var i = input.length-1; i >=0; i--) {
        var randomIndex = Math.floor(Math.random()*(i+1)); 
        var itemAtIndex = input[randomIndex]; 
        input[randomIndex] = input[i]; 
        input[i] = itemAtIndex;
    }
    return input;
}
var tempArray = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
tempArray.shuffle();
console.log(tempArray);  複製程式碼

解析:
首先我們有一個已經排好序的陣列:

關於陣列亂序的深挖——“感覺一直在寫毒程式碼”
a1.png

Step1:
第一步需要做的就是,從陣列末尾開始,選取最後一個元素。

關於陣列亂序的深挖——“感覺一直在寫毒程式碼”
a2.png

在陣列一共9個位置中,隨機產生一個位置,該位置元素與最後一個元素進行交換。

關於陣列亂序的深挖——“感覺一直在寫毒程式碼”
a3.png

關於陣列亂序的深挖——“感覺一直在寫毒程式碼”
a4.png

關於陣列亂序的深挖——“感覺一直在寫毒程式碼”
a5.png

Step2:
上一步中,我們已經把陣列末尾元素進行隨機置換。
接下來,對陣列倒數第二個元素動手。在除去已經排好的最後一個元素位置以外的8個位置中,隨機產生一個位置,該位置元素與倒數第二個元素進行交換。

關於陣列亂序的深挖——“感覺一直在寫毒程式碼”
a6.png

關於陣列亂序的深挖——“感覺一直在寫毒程式碼”
a7.png

關於陣列亂序的深挖——“感覺一直在寫毒程式碼”
a8.png

Step3:
理解了前兩部,接下來就是依次進行,如此簡單。

關於陣列亂序的深挖——“感覺一直在寫毒程式碼”
a9.png

自己實現亂序

以上方法,是基於Fisher–Yates shuffle洗牌演算法。下面,我們就需要自己開動腦筋,完成一個亂序方案。
其實這並不難,關鍵在於如何生產真正的亂序。因為往往生成的並不是完全意義上的亂序,關於這一點,讀者可以參考The Danger of Naïveté一文。

我們來看一下社群上劉哇勇的一系列進階方案:

function shuffle (array) {
    var copy = [],
        n = array.length,
        i;
    while (n) {
        i = Math.floor(Math.random() * array.length);
        if (i in array) {
            copy.push(array[i]);
            delete array[i];
            n--;
        }
    }
    return copy;
}複製程式碼

關於這種方案,也給出了分析:

我們建立了一個copy陣列,然後遍歷目標陣列,將其元素複製到copy陣列裡,同時將該元素從目標陣列中刪除,這樣下次遍歷的時候就可以跳過這個序號。而這一實現的問題正在於此,即使一個序號上的元素已經被處理過了,由於隨機函式產生的數是隨機的,所有這個被處理過的元素序號可能在之後的迴圈中不斷出現,一是效率問題,另一個就是邏輯問題了,存在一種可能是永遠執行不完。

改進的方案為:

function shuffle(array) {
    var copy = [],
        n = array.length,
        i;
    while (n) {
        i = Math.floor(Math.random() * n--);
        copy.push(array.splice(i, 1)[0]);
    }
    return copy;
}複製程式碼

改進的做法就是處理完一個元素後,用Array的splice()方法將其從目標陣列中移除,同時也更新了目標陣列的長度。如此一來下次遍歷的時候是從新的長度開始,不會重複處理的情況了。

當然這樣的方案也有不足之處:比如,我們建立了一個copy陣列進行返回,在記憶體上開闢了新的空間。
不過,這可以完全避免:

function shuffle(array) {
    var m = array.length,
        t, i;
    while (m) {
        i = Math.floor(Math.random() * m--);
        t = array[m];
        array[m] = array[i];
        array[i] = t;
    }
    return array;
}複製程式碼

有趣的是,這樣的實現已經完全等同於上文洗牌演算法Fisher–Yates shuffle的方案了。

總結

本文剖析了“陣列亂序”這麼一個簡單,但是有趣的需求場景。
對這個場景的深入分析,讓我們認識到JS和計算機演算法中的一些玄妙。
文章簡要提到了V8引擎對array.sort的處理、洗牌演算法Fisher–Yates等內容。希望對讀者有所啟發。

Happy Coding!

PS:
作者Github倉庫,歡迎通過程式碼各種形式交流。
百度知識搜尋部大前端繼續招兵買馬,高階工程師、實習生職位均有,有意向者火速聯絡。

相關文章