JavaScript 專題系列第十九篇,講解數組亂序,重點探究 Math.random() 爲何不能真正的亂序?git
亂序的意思就是將數組打亂。github
嗯,沒有了,直接看代碼吧。算法
一個常常會碰見的寫法是使用 Math.random():數組
var values = [1, 2, 3, 4, 5]; values.sort(function(){ return Math.random() - 0.5; }); console.log(values)
Math.random() - 0.5
隨機獲得一個正數、負數或是 0,若是是正數則降序排列,若是是負數則升序排列,若是是 0 就不變,而後不斷的升序或者降序,最終獲得一個亂序的數組。瀏覽器
看似很美好的一個方案,實際上,效果卻不盡如人意。不信咱們寫個 demo 測試一下:dom
var times = [0, 0, 0, 0, 0]; for (var i = 0; i < 100000; i++) { let arr = [1, 2, 3, 4, 5]; arr.sort(() => Math.random() - 0.5); times[arr[4]-1]++; } console.log(times)
測試原理是:將 [1, 2, 3, 4, 5]
亂序 10 萬次,計算亂序後的數組的最後一個元素是 一、二、三、四、5 的次數分別是多少。函數
一次隨機的結果爲:測試
[30636, 30906, 20456, 11743, 6259]
該結果表示 10 萬次中,數組亂序後的最後一個元素是 1 的狀況共有 30636 次,是 2 的狀況共有 30906 次,其餘依此類推。spa
咱們會發現,最後一個元素爲 5 的次數遠遠低於爲 1 的次數,因此這個方案是有問題的。code
但是我明明感受這個方法還不錯吶?初見時還有點驚豔的感受,爲何會有問題呢?
是的!我很好奇!
若是要追究這個問題所在,就必須瞭解 sort 函數的原理,然而 ECMAScript 只規定了效果,沒有規定實現的方式,因此不一樣瀏覽器實現的方式還不同。
爲了解決這個問題,咱們以 v8 爲例,v8 在處理 sort 方法時,當目標數組長度小於 10 時,使用插入排序;反之,使用快速排序和插入排序的混合排序。
因此咱們來看看 v8 的源碼,由於是用 JavaScript 寫的,你們也是能夠看懂的。
源碼地址:https://github.com/v8/v8/blob/master/src/js/array.js
爲了簡化篇幅,咱們對 [1, 2, 3]
這個數組進行分析,數組長度爲 3,此時採用的是插入排序。
插入排序的源碼是:
function InsertionSort(a, from, to) { for (var i = from + 1; i < to; i++) { var element = a[i]; for (var j = i - 1; j >= from; j--) { var tmp = a[j]; var order = comparefn(tmp, element); if (order > 0) { a[j + 1] = tmp; } else { break; } } a[j + 1] = element; } };
其原理在於將第一個元素視爲有序序列,遍歷數組,將以後的元素依次插入這個構建的有序序列中。
咱們來個簡單的示意圖:
明白了插入排序的原理,咱們來具體分析下 [1, 2, 3] 這個數組亂序的結果。
演示代碼爲:
var values = [1, 2, 3]; values.sort(function(){ return Math.random() - 0.5; });
注意此時 sort 函數底層是使用插入排序實現,InsertionSort 函數的 from 的值爲 0,to 的值爲 3。
咱們開始逐步分析亂序的過程:
由於插入排序視第一個元素爲有序的,因此數組的外層循環從 i = 1
開始,a[i] 值爲 2,此時內層循環遍歷,比較 compare(1, 2)
,由於 Math.random() - 0.5
的結果有 50% 的機率小於 0 ,有 50% 的機率大於 0,因此有 50% 的機率數組變成 [2, 1, 3],50% 的結果不變,數組依然爲 [1, 2, 3]。
假設依然是 [1, 2, 3],咱們再進行一次分析,接着遍歷,i = 2
,a[i] 的值爲 3,此時內層循環遍歷,比較 compare(2, 3)
:
有 50% 的機率數組不變,依然是 [1, 2, 3]
,而後遍歷結束。
有 50% 的機率變成 [1, 3, 2],由於尚未找到 3 正確的位置,因此還會進行遍歷,因此在這 50% 的機率中又會進行一次比較,compare(1, 3)
,有 50% 的機率不變,數組爲 [1, 3, 2],此時遍歷結束,有 50% 的機率發生變化,數組變成 [3, 1, 2]。
綜上,在 [1, 2, 3] 中,有 50% 的機率會變成 [1, 2, 3],有 25% 的機率會變成 [1, 3, 2],有 25% 的機率會變成 [3, 1, 2]。
另一種狀況 [2, 1, 3] 與之分析相似,咱們將最終的結果彙總成一個表格:
數組 | i = 1 | i = 2 | 總計 |
---|---|---|---|
[1, 2, 3] | 50% [1, 2, 3] | 50% [1, 2, 3] | 25% [1, 2, 3] |
25% [1, 3, 2] | 12.5% [1, 3, 2] | ||
25% [3, 1, 2] | 12.5% [3, 1, 2] | ||
50% [2, 1, 3] | 50% [2, 1, 3] | 25% [2, 1, 3] | |
25% [2, 3, 1] | 12.5% [2, 3, 1] | ||
25% [3, 2, 1] | 12.5% [3, 2, 1] |
爲了驗證這個推算是否準確,咱們寫個 demo 測試一下:
var times = 100000; var res = {}; for (var i = 0; i < times; i++) { var arr = [1, 2, 3]; arr.sort(() => Math.random() - 0.5); var key = JSON.stringify(arr); res[key] ? res[key]++ : res[key] = 1; } // 爲了方便展現,轉換成百分比 for (var key in res) { res[key] = res[key] / times * 100 + '%' } console.log(res)
這是一次隨機的結果:
咱們會發現,亂序後,3
還在原位置(即 [1, 2, 3] 和 [2, 1, 3]) 的機率有 50% 呢。
因此根本緣由在於什麼呢?其實就在於在插入排序的算法中,當待排序元素跟有序元素進行比較時,一旦肯定了位置,就不會再跟位置前面的有序元素進行比較,因此就亂序的不完全。
那麼如何實現真正的亂序呢?而這就要提到經典的 Fisher–Yates 算法。
爲何叫 Fisher–Yates 呢? 由於這個算法是由 Ronald Fisher 和 Frank Yates 首次提出的。
話很少說,咱們直接看 JavaScript 的實現:
function shuffle(a) { var j, x, i; for (i = a.length; i; i--) { j = Math.floor(Math.random() * i); x = a[i - 1]; a[i - 1] = a[j]; a[j] = x; } return a; }
原理很簡單,就是遍歷數組元素,而後將當前元素與之後隨機位置的元素進行交換,從代碼中也能夠看出,這樣亂序的就會更加完全。
若是利用 ES6,代碼還能夠簡化成:
function shuffle(a) { for (let i = a.length; i; i--) { let j = Math.floor(Math.random() * i); [a[i - 1], a[j]] = [a[j], a[i - 1]]; } return a; }
仍是再寫個 demo 測試一下吧:
var times = 100000; var res = {}; for (var i = 0; i < times; i++) { var arr = shuffle([1, 2, 3]); var key = JSON.stringify(arr); res[key] ? res[key]++ : res[key] = 1; } // 爲了方便展現,轉換成百分比 for (var key in res) { res[key] = res[key] / times * 100 + '%' } console.log(res)
這是一次隨機的結果:
真正的實現了亂序的效果!
JavaScript專題系列目錄地址:https://github.com/mqyqingfeng/Blog。
JavaScript專題系列預計寫二十篇左右,主要研究平常開發中一些功能點的實現,好比防抖、節流、去重、類型判斷、拷貝、最值、扁平、柯里、遞歸、亂序、排序等,特色是研(chao)究(xi) underscore 和 jQuery 的實現方式。
若是有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。若是喜歡或者有所啓發,歡迎 star,對做者也是一種鼓勵。