如果你想跟朋友一起玩德州撲克的話,你應該先洗牌,以隨機的牌序來確保一個公平的游戲。但是怎么做呢?
有一個簡單而有效的做法就是把牌隨機選一疊放到另一邊,形成一個新的牌堆,并且重復這一步。只要你從剩余的牌堆中隨機選出來的牌的概率是相等的,那么你就會得到一個完美且公平的牌堆。如圖1所示(譯注:為了不影響閱讀,我把gif圖都放到了文章末尾)。
假設這不是一副實體的牌,你可能想寫一段代碼,用內(nèi)存中的n個元素來做同樣的事情。聽起來很簡單(某種程度上),但你如何從最初的牌堆中精確的選擇一個隨機的剩余的元素?
有一個很慢的方法:從開始的地方,在數(shù)組中(在[0, n - 1]中)選擇一個隨機的元素,然后判斷是否已經(jīng)是被打亂了。這個方法可以運行,但是隨著剩余元素的減少會變得越來越慢,你會一直選擇已經(jīng)被打亂的元素。觀察那些導致洗牌變慢的重復的選擇(紅色)。如圖2所示。
這里有一段用JavaScript實現(xiàn)的代碼,但是你不應該使用它。
function shuffle(array) {
var copy = [], n = array.length, i;
// 如果還有剩余的需要打亂的元素...
while (n) {
// 選擇一個剩余的元素
i = Math.floor(Math.random() * array.length);
// 如果已經(jīng)打亂,把它移動到新的數(shù)組
if (i in array) {
copy.push(array[i]);
delete array[i];
n--;
}
}
return copy;
}
這個實現(xiàn)是不好的,我們能夠做的更好。你可以只選擇剩余的元素,避免重復選擇。在[0, m - 1]之間選擇一個隨機數(shù),在每一次循環(huán)后,m也會隨著n的遞減而遞減。換句話說,m指的是需要打亂的剩余的元素。當你移動卡牌的時候并且合并剩余的牌,因此你能夠很容易的選出下一張要洗的牌。如圖3所示。
function shuffle(array) {
var copy = [], n = array.length, i;
// 如果還有剩余的需要打亂的元素...
while (n) {
// 選擇一個剩余的元素
i = Math.floor(Math.random() * n--);
// 把它移動到新的數(shù)組
copy.push(array.splice(i, 1)[0]);
}
return copy;
}
這段程序運行的非常好,但是還能再次優(yōu)化性能。主要的問題是當你從原始數(shù)組中移動每個元素(array.splice),你不得不移動該元素后續(xù)的所有元素,平均來說,打亂每個元素需要移動n/2個元素。復雜度是 O(n2)
但是有一個非常有意思的地方,如果你認真的觀察:每一個被打亂過的元素的數(shù)量(n - m)加上剩余的元素的數(shù)量(m)會一直等于總長度n。這意味著我們可以原地洗牌,不需要額外的空間!我們在數(shù)組的后面的部分存儲打亂過的元素,在數(shù)組的前面的部分存儲剩余的元素。我們不需要關心剩余元素的順序,只要我們在選擇的時候樣本一致!
為了實現(xiàn)這個O(n)復雜度的原地洗牌算法,隨機選擇一個剩余的元素(從數(shù)組的前面),然后放在新的位置(數(shù)組的后面),還未被打亂的元素交換到數(shù)組前面,如圖4所示。
function shuffle(array) {
var m = array.length, t, i;
// 如果還有剩余的需要打亂的元素...
while (m) {
// 選擇一個剩余的元素
i = Math.floor(Math.random() * m--);
// 和當前元素交換
t = array[m];
array[m] = array[i];
array[i] = t;
}
return array;
}
更多的關于Fisher–Yates shuffle內(nèi)容請看Wikipedia article和Jeff Atwood的文章The Danger of Na?veté。
圖1
圖2
圖3
圖4
原文地址 Fisher–Yates Shuffle