打造屬於自己的underscore系列(六)- 洗牌演算法

不做祖國的韭菜發表於2019-02-17

javascript如何將一個無序的陣列變成一個有序的陣列,我們有很多的排序演算法可以實現,例如冒泡演算法,快速排序,插入排序等。然而我們是否想過,如何將一個有序的陣列亂序輸出呢?本文將從經典的洗牌演算法出發,詳細講解underscore中關於亂序排列的實現。

六,洗牌演算法

6.1 問題探討

在介紹洗牌演算法的概念前,我們先引入現實生活的一個經典例子。當我們和多人一起玩撲克牌的時候,我們需要先將一份全新的撲克牌打亂,讓牌組隨機化,以確保遊戲的公平性。這個將牌組隨機化的過程,我們衍生到程式碼中可以概括為: 一個有序的陣列[1,2,3,4,5],如何隨機打亂,使生成一個隨機的陣列,如[3,2,5,1,4],且需要保證陣列中的數出現在每個位置的概率相同。如何實現呢?

利用es5的sort函式,其實我們很容易找到一種簡潔的方法。

var shuffle = (arr) => arr.sort(() => Math.random() - 0.5)
複製程式碼

本質上利用es6原生的sort函式,我們可以達到陣列亂序,然而sort方法本身卻無法保證元素出現在每個位置上出現的概率隨機。在關於 JavaScript 的陣列隨機排序一文中,作者詳細講解了使用sort排序並不能保證隨機,並且列舉了造成無法隨機化的原因。文章較長,大概可以總結為以下兩點。

  • 1.各個瀏覽器對於sort中使用排序演算法的差異導致sort本身隨機排序概率的差異,例如chrome瀏覽器對排序演算法做了優化,小於等於10的陣列使用相對穩定的插入排序,大於10的採用相對不穩定的快速排序,而FireFox使用的排序演算法是歸併排序等。顯然,排序演算法的不同,導致使用sort排序的概率結果也不可能相同。
  • 2.sort排序的本質還是兩兩比較,而我們知道,一個n位陣列,要進行兩兩比較,它的時間複雜度為n(n-1)/2, 而不管什麼排序演算法,它的時間複雜度都要小於n(n-1)/2,那既然如此,又如何保證新陣列已經是兩兩比較後生成的隨機陣列呢。
6.2 洗牌演算法概念

其實,為了實現這一場景,前人已經給出了問題的答案,也就是公認成熟的洗牌演算法(Fisher-Yates),簡單的思路如下:

    1. 定義一個陣列,以陣列的最後一個元素為基準點。
    1. 在陣列開始位置到基準點之間隨機取一個位置,將所取位置上的元素和基準點上的元素互換。
    1. 基準點左移一位。
    1. 重複2,3步驟,直到基準點為陣列的開始位置。

由於第二步生成隨機位置的隨機性,所以整個洗牌演算法保證了亂序的隨機性。將文字轉化為程式碼,javascript的簡單實現如下:

function shuffle(arr) {
    var length = arr.length,
        j = length;
    for (var i = 0; i < length; i++) {
        var random = Math.floor(Math.random() * (j--)); // 生成起始位置到基準位置之間的隨機位置,並將基準從結束位置不停左移。
        // es3實現
        var newA = arr[i];
        arr[i] = arr[random];
        arr[random] = newA
        // es6 實現
        [arr[i], arr[random]] = [arr[random], arr[i]]; // 本質為交換元素位置。
    }
    return arr
}
複製程式碼

underscore中在亂序方面同樣用到了洗牌演算法,有了洗牌演算法的概念和實現基礎後,接下來我們將關注點放在underscore中亂序方法的實現中。

6.3 random - _.random(min, max)

洗牌演算法的第二步,生成隨機數的過程,無疑需要使用到原生Math.random()(產生一個[0,1)之間的隨機數),而underscore在原生的Math.random()方法上,重新包裝了random函式,實現在給定範圍內生成隨機整數,如果只傳遞一個引數,那麼將返回0和這個引數之間的整數。

// 隨機函式
_.random = function (min, max) {
    if (max == null) { // max == null即代表只傳遞一個引數,此時最大值為傳遞的最小值,最小值為0
        max = min;
        min = 0;
    }
    return min + Math.floor(Math.random() * (max - min + 1)); // 生成[min, max]之前的隨機數
};
複製程式碼
6.4 sample - _.sample(list, [n])

在underscore 1.8版本以前,洗牌演算法的實現直接放在了 _.shuffle 方法中實現,而1.9的版本直接拋棄了這種寫法,我們將放在下文分析這種寫法。在1.9版本中,洗牌演算法的實現放在了sample函式中,sample會在 list中產生一個隨機樣本。n則代表返回多少個隨機數。同時list不僅可以是陣列,也可以是物件或者類陣列,當list為物件時,隨機返回的是物件的值,如

console.log(_.sample({a: 123, b: 234}, 2)) // 234 123
複製程式碼

實現思路如下

_.sample = function(list, n) {
    if(n == null) { // 不指定個數時,預設在陣列中隨機取出一個。
        if (!isArrayLike(obj)) list = _.values(list); // list為物件時,取出物件值的集合。
        return list[_.random(list.length - 1)] // 生成陣列的隨機位置,返回該位置的值。
    }
    var sample = isArrayLike(obj) ? _.clone(obj) : _.values(obj); // 陣列類陣列得到克隆的陣列,物件得到值的集合。
    var length = getLength(sample);
    n = Math.max(Math.min(n, length), 0); // 對n的大小做限制,不能大於新陣列的長度,不能小於0。
    var last = length - 1;
    for (var index = 0; index < n; index++) { //  for迴圈下的操作為洗牌演算法的核心步驟,和前面講解的實現方式相同。
        var rand = _.random(index, last);
        var temp = sample[index];
        sample[index] = sample[rand];
        sample[rand] = temp;
    }
    return sample.slice(0, n);
}
複製程式碼
6.5 shuffle - _.shuffle(list)

shuffle是洗牌演算法的用法函式,返回一個隨機亂序的list副本。有了_.sample的基礎,shuffle只需要傳遞一個n值為陣列長度的引數給sample函式即可。

_.shuffle = function(list) {
    return _.sample(obj, Infinity); // n值傳遞無窮大,由於sample函式內部對n值的限制,真正執行洗牌演算法時,n的值為陣列的長度。
}
複製程式碼
6.6 underscore其他版本的實現

前面提到,1.8版本以前underscore對於洗牌演算法的實現放在了shuffle函式中,它的實現相比於1.9版本來說,有很多的巧妙之處,我們來看看實現程式碼。

_.shuffle = function(obj) {
    var set = obj && obj.length === +obj.length ? obj : _.values(obj);
    var length = set.length;
    var shuffled = Array(length);
    for (var index = 0, rand; index < length; index++) {
      rand = _.random(0, index);
      if (rand !== index) shuffled[index] = shuffled[rand];
      shuffled[rand] = set[index];
    }
    return shuffled;
};
複製程式碼
    // 交換位置程式碼 
    if (rand !== index) shuffled[index] = shuffled[rand];
    shuffled[rand] = set[index];
複製程式碼

中間關係亂序的實現看起來很巧妙,我們來詳細分析每一步是如何進行的。

  1. 我們定義一個陣列[1,2,3,4]
  2. 生成一個長度相同的空陣列作為亂序後的新陣列 shuffle = [undefined, undefined, undefined, undefined]
  3. index從第一個數開始,也就0開始,生成一個 0 - index 之間的隨機數,第一個固定為0
  4. 隨機數和 index相同,所以執行shuffled[0] = set[0] = 1shuffle依然為shuffle = [1, undefined, undefined, undefined]
  5. index = 1, 生成 0 -1 之間的隨機數,假設隨機數為0
  6. 此時執行交換程式碼 shuffled[1] = shuffled[0] = 1 ; shuffled[0] = set[1] = 2; 即 shuffle改變成shuffle = [2, 1, undefined, undefined]
  7. index = 2, 生成 0 - 2 之間的隨機數,假設隨機數為1
  8. 此時執行交換程式碼 shuffled[2] = shuffled[1] = 1 ; shuffled[1] = set[2] = 3; 即 shuffle改變成shuffle = [2, 3, 1, undefined]
  9. index = 3, 生成 0 - 3 之間的隨機數,假設隨機數為0
  10. 此時執行交換程式碼 shuffled[3] = shuffled[0] = 3 ; shuffled[0] = set[3] = 4; 即 shuffle改變成shuffle = [4, 3, 1, 2] 經過以上十步,亂序陣列也隨之生成



相關文章