關於JavaScript的陣列隨機排序
之前再做陣列的隨機排序問題,潛意識想到的第一個方法就是 產生隨機下標排序。曾經網上一直流傳著這樣一個寫法:
function shuffle(arr) {
arr.sort(function () {
return Math.random() - 0.5;
});
}
之前一直覺得這種方法簡單,但是後來總結時,再思考, 每個元素仍然有很大機率在它原來的位置附近出現,他好像不是真正的隨機排序。
探索
看了一下ECMAScript中關於Array.prototype.sort(comparefn)的標準,其中並沒有規定具體的實現演算法,但是提到一點:
Calling comparefn(a,b) always returns the same value v when given a
specific pair of values a and b as its two arguments.也就是說,對同一組a、b的值,comparefn(a, b)需要總是返回相同的值。而上面的
() => Math.random() -0.5
即(a, b) => Math.random() - 0.5
顯然不滿足這個條件。
翻看v8引擎陣列部分的原始碼,注意到它出於對效能的考慮,對短陣列使用的是插入排序,對長陣列則使用了快速排序。至此,也就能理解為什麼() => Math.random() - 0.5並不能真正隨機打亂陣列排序了。
(原始碼中說的是對長度小於等於 22 的使用插入排序,大於 22 的使用快排,但實際測試結果顯示分界長度是 10。)
解決方案
既然(a, b) => Math.random() - 0.5的問題是不能保證針對同一組a、b每次返回的值相同,那麼我們不妨將陣列元素改造一下,比如將每個元素i改造為:
let new_i = {
v: i,
r: Math.random()
};
完整程式碼:
function shuffle(arr) {
//將原陣列改為物件陣列(值、隨機編號 為物件的兩個屬性)
let new_arr = arr.map(i => ({v: i, r: Math.random()}));
//將物件陣列 按照隨機編號進行排序
new_arr.sort((a, b) => a.r - b.r);
//將陣列提取出v值,插入到原陣列中
arr.splice(0, arr.length, ...new_arr.map(i => i.v));
}
let a = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
let n = 10000;
let count = (new Array(a.length)).fill(0);
for (let i = 0; i < n; i ++) {
shuffle(a);
count[a.indexOf('a')]++;
}
console.log(count);
多次驗證,這個方法足夠隨機了。但在效能上並不是很好,需要遍歷幾次陣列,還要對陣列進行splice等操作。
方法二: (Fisher–Yates shuffle費雪耶茲隨機置亂演算法) !!!推薦
考察Lodash 庫中的 shuffle 演算法,注意到它使用的實際上是Fisher–Yates 洗牌演算法。
演算法思想:從0~i(i的變化為 n-1到0遞減)中隨機取得一個下標,和最後一個元素(i)交換。
function shuffle(arr) {
var i = arr.length, t, j;
while (i) {
j = Math.floor(Math.random() * i--); //!!!
t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
}
es6版本:
function shuffle(arr) {
let i = arr.length;
while (i) {
let j = Math.floor(Math.random() * i--);
[arr[j], arr[i]] = [arr[i], arr[j]];
}
}
演算法需要的時間正比於要隨機置亂的數,不需要額為的儲存空間開銷。
小結:
如果要將陣列隨機排序,千萬不要再用(a, b) => Math.random() - 0.5這樣的方法。目前而言,Fisher–Yates shuffle 演算法應該是最好的選擇。