中高階前端必須瞭解的--陣列亂序

雲中橋發表於2019-06-21

引言

陣列亂序指的是:將陣列元素的排列順序隨機打亂。

將一個陣列進行亂序處理,是一個非常簡單但是非常常用的需求。
比如,“猜你喜歡”、“點選換一批”、“中獎方案”等等,都可能應用到這樣的處理。

sort 結合 Math.random

微軟曾在browserchoice.eu上做過一個關於不同瀏覽器使用情況的調查,微軟會在頁面中以隨機順序向使用者顯示不同的瀏覽器。

avatar

然而每個瀏覽器出現的位置並不是隨機的。IE在最後一個位置出現的概率大概是50%,Chrome在大部分情況下都會出現在瀏覽器列表的前三位。

這是怎麼回事,不是說好的隨機順序麼?

這是他們用來做隨機shuffle的程式碼:

arr.sort(() =>Math.random() - 0.5);

乍一看,這似乎是一個合理的解決方案。事實上在使用搜尋引擎搜尋“隨機打亂陣列”,這種方式會是出現最多的答案。

然而,這種方式並不是真正意思上的亂序,一些元素並沒有機會相互比較,
最終陣列元素停留位置的概率並不是完全隨機的。

來看一個例子:

/**
* 陣列亂序
*/
function shuffle(arr) {
  return arr.sort(() => Math.random() - 0.5);
}
/**
* 用於驗證 shuffle 方法是否完全隨機
*/
function test_shuffle(shuffleFn) {
  // 多次亂序陣列的次數
  let n = 100000; 
  // 儲存每個元素在每個位置上出現的次數
  let countObj = {
      a:Array.from({length:10}).fill(0),
      b:Array.from({length:10}).fill(0),
      c:Array.from({length:10}).fill(0),
      d:Array.from({length:10}).fill(0),
      e:Array.from({length:10}).fill(0),
      f:Array.from({length:10}).fill(0),
      g:Array.from({length:10}).fill(0),
      h:Array.from({length:10}).fill(0),
      i:Array.from({length:10}).fill(0),
      j:Array.from({length:10}).fill(0),
  }
  for (let i = 0; i < n; i ++) {
      let arr = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
      shuffleFn(arr);
      countObj.a[arr.indexOf('a')]++;
      countObj.b[arr.indexOf('b')]++;
      countObj.c[arr.indexOf('c')]++;
      countObj.d[arr.indexOf('d')]++;
      countObj.e[arr.indexOf('e')]++;
      countObj.f[arr.indexOf('f')]++;
      countObj.g[arr.indexOf('g')]++;
      countObj.h[arr.indexOf('h')]++;
      countObj.i[arr.indexOf('i')]++;
      countObj.j[arr.indexOf('j')]++;
  }
  console.table(countObj);
}
//驗證 shuffle 方法是否隨機
test_shuffle(shuffle)

在這個例子中,我們定義了兩個函式,shuffle 中使用 sort 和 Math.random() 進行陣列亂序操作;
test_shuffle 函式定義了一個長度為 10 的陣列 ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'],
並使用傳入的亂序函式進行十萬次操作,並將陣列中每個元素在每個位置出現的次數存放到變數 countObj 中,最終將 countObj 列印出來。

結果如下:

avatar

從這個表格中我們能夠看出,每個元素在每個位置出現的概率相差很大,比如元素 a ,
在索引0的位置上出現了 19415 次,在索引4 的位置上只出現了 7026 次,
元素 a 在這兩個位置出現的次數相差很大(相差一倍還多)。
如果排序真的是隨機的,那麼每個元素在每個位置出現的概率都應該一樣,
實驗結果各個位置的數字應該很接近,而不是像現在這樣各個位置的數字相差很大。

為什麼會有問題呢?這需要從array.sort方法排序底層說起。

v8在處理sort方法時,使用了插入排序和快排兩種方案。
當目標陣列長度小於10時,使用插入排序;反之,使用快速排序。

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

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

從插入排序來看 sort 的不完全比較

一段簡單的插入排序程式碼:

function insertSort(list = []) {
    for(let i = 1 , len = list.length; i < len; i++){
        let j = i - 1;
        let temp = list[ i ];
        while (j >= 0 && list[ j ] > temp){
            list[j + 1] = list[ j ];
            j = j - 1;
        }
        list[j + 1] = temp;
    }
    return list;
}

其原理在於將第一個元素視為有序序列,遍歷陣列,將之後的元素依次插入這個構建的有序序列中。

我們來個簡單的示意圖:

avatar

我們來具體分析下 ['a', 'b', 'c'] 這個陣列亂序的結果,需要注意的是,由於陣列長度小於10,所以 sort 函式內部是使用插入排序實現的。

演示程式碼為:

var values = ['a', 'b', 'c'];

values.sort(function(){
    return Math.random() - 0.5;
});

詳細分析如下:

由於插入排序將第一個元素視為有序的,所以陣列的外層迴圈從 i = 1 開始。此時比較 a 和 b
,因為 Math.random() - 0.5 的結果有 50% 的概率小於 0 ,有 50% 的概率大於 0,所以有 50% 的概率陣列變成 ['b','a','c'],50% 的結果不變,陣列依然為 ['a','b','c']。

假設依然是 ['a','b','c'],我們再進行一次分析,接著遍歷,i = 2,比較 b 和 c:

有 50% 的概率陣列不變,依然是 ['a','b','c'],然後遍歷結束。

有 50% 的概率變成 ['a', 'c', 'b'],因為還沒有找到 c 正確的位置,所以還會進行遍歷,所以在這 50% 的概率中又會進行一次比較,比較 a 和 c,有 50% 的概率不變,陣列為 ['a','c','b'],此時遍歷結束,有 50% 的概率發生變化,陣列變成 ['c','a','b']。

綜上,在 ['a','b','c'] 中,有 50% 的概率會變成 ['a','b','c'],有 25% 的概率會變成 ['a','c','b'],有 25% 的概率會變成 ['c', 'a', 'b']。

另外一種情況 ['b','a','c'] 與之分析類似,我們將最終的結果彙總成一個表格:

avatar

改造 sort 和 Math.random() 的結合方式

我們已然知道 sort 和 Math.random() 來實現陣列亂序所存在的問題,
主要是由於缺少每個元素之間的比較,那麼我們不妨將陣列元素改造一下,
將其改造為一個物件。

let arr = [
    {
        val:'a',
        ram:Math.random()
    },
    {
        val:'b',
        ram:Math.random()
    }
    //...
]

我們將陣列中原來的值儲存在物件的 val 屬性中,同時為物件增加一個屬性 ram ,值為一個隨機數。

接下來我們只需要對陣列中每個物件的隨機數進行排序,即可得到一個亂序陣列。

程式碼如下:

function shuffle(arr) {
    let newArr = arr.map(item=>({val:item,ram:Math.random()}));
    newArr.sort((a,b)=>a.ram-b.ram);
    arr.splice(0,arr.length,...newArr.map(i=>i.val));
    return arr;
}

將 shuffle 方法應用於我們之前實現的驗證函式 test_shuffle 中

test_shuffle(shuffle)

結果如下:

avatar

從表格中我們可以看出,每個元素在每個位置出現的次數已經相差不大。

雖然已經滿足了隨機性的要求,但是這種實現方式在效能上並不好,需要遍歷幾次陣列,並且還要對陣列進行 splice 操作。

那麼如何高效能的實現真正的亂序呢?而這就要提到經典的 Fisher–Yates 演算法。

Fisher–Yates

為什麼叫 Fisher–Yates 呢? 因為這個演算法是由 Ronald Fisher 和 Frank Yates 首次提出的。

這個演算法其實非常的簡單,就是將陣列從後向前遍歷,然後將當前元素與隨機位置的元素進行交換。結合圖片來解釋一下:

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

avatar

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

avatar

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

avatar

avatar

avatar

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

avatar

avatar

avatar

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

avatar

接下來我們用程式碼來實現一下 Fisher–Yates

function shuffle(arr) {
    let m = arr.length;
    while (m > 1){
        let index = Math.floor(Math.random() * m--);
        [ arr[m] , arr[index] ] = [ arr[index] , arr[m] ]
    }
    return arr;
}

接著我們再用之前的驗證函式 test_shuffle 中

test_shuffle(shuffle);

結果如下:

avatar

從表格中我們可以看出,每個元素在每個位置出現的次數相差不大,說明這種方式滿足了隨機性的要求。

而且 Fisher–Yates 演算法只需要通過一次遍歷即可將陣列隨機打亂順序,效能極為優異~~

至此,我們找到了將陣列亂序操作的最優辦法:Fisher–Yates~

參考


未徵得作者同意,不可轉載!

寫作不易,如果覺得稍有收穫,歡迎~點贊~關注~

本文同步首發與github,歡迎在issues與我互動,歡迎Watch & Star ★

相關文章