中高級前端必須瞭解的--數組亂序

引言

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

將一個數組進行亂序處理,是一個很是簡單可是很是經常使用的需求。
好比,「猜你喜歡」、「點擊換一批」、「中獎方案」等等,均可能應用到這樣的處理。java

sort 結合 Math.random

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

avatar

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

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

這是他們用來作隨機shuffle的代碼:數組

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

乍一看,這彷佛是一個合理的解決方案。事實上在使用搜索引擎搜索「隨機打亂數組」,這種方式會是出現最多的答案。瀏覽器

然而,這種方式並非真正意思上的亂序,一些元素並無機會相互比較,
最終數組元素停留位置的機率並非徹底隨機的。dom

來看一個例子:函數

/**
* 數組亂序
*/
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 ★

相關文章
相關標籤/搜索