本文为王赟博士在知乎上的文章:10809 一种错误的洗牌算法,以及乱排常数 (1)节选
「洗牌」,或者说随机打乱一个数组中元素的顺序,是编程中的一个常见需求。标准的洗牌算法是 Fisher-Yates shuffle,用 JavaScript 实现如下:
function shuffle(A) {
for (var i = A.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = A[i]; A[i] = A[j]; A[j] = t;
}
}
其基本思路是,每次从未打乱的部分等可能地选一个元素,把它与未打乱部分的最后一个元素交换。
Fisher-Yates 洗牌算法的实现十分简单,并且它可以保证均匀性,即元素的各种排列顺序出现的概率都相等。但是,很多人闭门造车地发明了一些「错误」的洗牌算法实现,它们不能保证均匀。例如,最常见的一种错误实现如下:
function shuffle(A) {
for (var i = 0; i < A.length; i++) {
var j = Math.floor(Math.random() * A.length);
var t = A[i]; A[i] = A[j]; A[j] = t;
}
}
其原理是,在第 i 次循环中,从所有元素中等可能地选一个元素,与第 i 个元素交换。这种算法的错误可以如下证明:对于一个长度为 [公式] 的数组,算法创造了 [公式] 个等可能的基本事件,这些事件对应于 [公式] 种排列顺序。在非平凡情况下, [公式] 不能被 [公式] 整除,所以各种排列顺序不可能等概率。
另一种错误的洗牌算法是 @Lucas HC在这篇答案中指出的:
A.sort(function() {
return .5 - Math.random();
});
补充:据说网狐棋牌中洗牌就用的该算法。
JavaScript 中数组自带的 sort 方法允许提供一个比较器,其返回值的正负号代表两个元素的大小关系。在上面的代码中,比较器返回的是 -0.5 到 0.5 之间的一个随机数,也就是说每次比较的结果是随机且均匀的。但是,基于随机比较的整个洗牌算法是不均匀的:它的各种运行结果的概率都形如 [公式]([公式] 为算法执行过程中的比较次数),而我们希望每种顺序的概率都是 [公式] ,在非平凡情况下,后者不能由前者通过加法组合出来。
Lucas HC 指出,当 sort 函数采用插入排序的实现时,各个元素都有较大的概率留在初始位置,并通过统计多次运行的结果进行了验证。
如果你不熟悉编程,我在此可以用大白话把 Lucas HC答案中错误算法的流程叙述一下:设想有 [公式] 个人依次来到一个队伍。每个人到来之后,都想向前插队,但前面每一个人放他过去的概率都是 1/2。最终队伍的状态就是洗牌结果。
5 号元素插到了 3 号位置。这个事件要发生,需要队尾的两个元素放它过去,而前面的一个元素不放它过去,故概率为 1/8。