使用 JavaScriptArray.sort()方法进行洗牌是否正确?

我当时正在帮助一个人解决 JavaScript 代码的问题,我的眼睛被一个看起来像这样的部分吸引住了:

function randOrd(){
return (Math.round(Math.random())-0.5);
}
coords.sort(randOrd);
alert(coords);

我的第一个想法是: 嘿,这不可能成功的!但随后我做了一些实验,发现它确实至少似乎提供了很好的随机结果。

然后我做了一些网络搜索,几乎在顶部发现了一个 文章,这个代码是最清楚地复制。看起来是个不错的网站和作者。

但我的直觉告诉我,这一定是错的。特别是由于 ECMA 标准没有明确规定排序算法。我认为不同的排序算法会导致不同的非均匀洗牌。一些排序算法甚至可能无限循环..。

你觉得怎么样?

作为另一个问题... 我现在该如何去测量这种洗牌技术的结果有多随机?

更新: 我做了一些测量,并将结果作为答案之一发布在下面。

57316 次浏览

它从来都不是我最喜欢的洗牌方式,部分原因是它像您说的那样是特定于 实现的。特别是,我似乎记得从 Java 或。NET (不确定是哪一个)可以经常检测到某些元素之间是否有不一致的比较(例如,你首先声称是 A < BB < C,然后是 C < A)。

它最终也会变成一个比您实际需要的更复杂的洗牌(在执行时间方面)。

我更喜欢 shuffle 算法,它有效地将集合划分为“ shuffle”(在集合的开始,最初为空)和“ unshuffle”(集合的其余部分)。在算法的每个步骤中,选择一个随机的非洗牌元素(可能是第一个) ,并将其与第一个非洗牌元素交换——然后将其视为洗牌元素(即在心理上移动分区以包含它)。

这是 O (n) ,只需要对随机数生成器进行 n-1调用,这很好。它也产生一个真正的洗牌-任何元素有1/n 的机会结束在每个空间,无论它的原始位置(假设一个合理的 RNG)。排序后的版本 大概是均匀分布的(假设随机数生成器不会选择相同的值两次,如果它返回随机双精度数,这种情况发生的可能性很小) ,但我发现对于 shuffle 版本更容易推断:)

这种方法称为 Fisher-Yates 洗牌

我认为这是一个最佳实践,编码这个洗牌一次,并重用它的任何地方,你需要洗牌项目。那么您就不必担心可靠性或复杂性方面的排序实现。这只是几行代码(我不会在 JavaScript 中尝试!)

关于洗牌的维基百科文章(特别是 shuffle 算法部分)讨论了对随机投影进行排序——值得一读关于一般性的不良 shuffle 实现的部分,这样您就知道应该避免什么了。

实际上,无限循环算法是不可能的。 如果你要对对象进行排序,你可以循环遍历坐标数组,然后执行以下操作:

for (var i = 0; i < coords.length; i++)
coords[i].sortValue = Math.random();


coords.sort(useSortValue)


function useSortValue(a, b)
{
return a.sortValue - b.sortValue;
}

(然后再次遍历它们以删除 sortValue)

如果你想做得好,你必须用最难的方法:)

没什么问题。

传递给. sort () 通常的函数类似于

function sortingFunc( first, second )
{
// example:
return first - second ;
}

你在分类方面的工作是返回:

  • 如果第一个数在第二个数之前,则为负数
  • 一个正数,如果第一个应该在第二个后面
  • 如果它们完全相等,则为0

上面的排序函数使事情井然有序。

如果返回 -s 和 + 的顺序是随机的,就会得到一个随机排序。

比如 MySQL:

SELECT * from table ORDER BY rand()

在 Jon 已经有了 说明了这个理论之后,下面是一个实现:

function shuffle(array) {
var tmp, current, top = array.length;


if(top) while(--top) {
current = Math.floor(Math.random() * (top + 1));
tmp = array[current];
array[current] = array[top];
array[top] = tmp;
}


return array;
}

算法是 O(n),而排序应该是 O(n log n)。与本机 sort()函数相比,执行 JS 代码的开销有所不同,这可能导致 表现上的显著差异随着数组大小的增加而增加。


在对 波波波的回答的评论中,我指出有问题的算法可能不会产生均匀分布的概率(取决于 sort()的实现)。

我的观点是这样的: 一个排序算法需要一定数量的 c比较,例如对于 Bubblesort 来说就是 c = n(n-1)/2。我们的随机比较函数使每个比较的结果同样可能,即有 2^c 同样可能结果。现在,每个结果都必须对应于数组条目的 n!排列之一,这使得在一般情况下不可能出现均匀分布。(这是一种简化,因为实际需要的比较数量取决于输入数组,但断言应该仍然成立。)

正如 Jon 指出的那样,这一点本身并不能说明 Fisher-Yates 优于使用 sort()的原因,因为随机数生成器也会将有限数目的伪随机值映射到 n!排列。但费舍尔-耶茨的结果应该更好:

Math.random()产生一个范围为 [0;1[的伪随机数。由于 JS 使用双精度浮点值,这对应于 2^x的可能值,其中 52 ≤ x ≤ 63(我太懒了,找不到实际数字)。如果原子事件的数量相同,那么使用 Math.random()生成的概率分布就会停止正常工作数量级。

当使用 Fisher-Yates 时,相关参数是阵列的大小,由于实际限制,阵列不应该接近 2^52

当使用随机比较函数进行排序时,函数基本上只关心返回值是正值还是负值,因此这不会成为问题。但是有一个类似的例子: 因为比较函数表现良好,所以如上所述,2^c可能的结果是同样可能的。如果是 c ~ n log n,那么就是 2^c ~ n^(a·n),其中的 a = const,这至少使得 2^c的大小与(甚至小于) n!相同,从而导致了不均匀的分布,即使排序算法在哪里映射到排列上是均匀的。如果这有任何实际影响,我都不知道。

真正的问题是排序算法不能保证均匀地映射到排列上。很容易看出,Mergesort 是对称的,但是对 Bubblesort 或者更重要的是 Quicksort 或 Heapsort 这样的东西进行推理就不是对称的了。


底线: 只要 sort()使用归并排序,除了在角落情况下(至少我希望 2^c ≤ n!是一个角落情况) ,你的 应该是相当安全的,如果不是,所有的赌注都关闭。

我认为对于不挑剔发行版的情况,并且希望源代码很小的情况,这样做是可以的。

在 JavaScript 中(源代码不断传输) ,小的带宽成本会有所不同。

我测量了这种随机排序的结果有多随机。

我的技巧是获取一个小数组[1,2,3,4]并创建所有(4!它的排列组合。然后,我将对数组应用洗牌函数大量的次数,并计算每个排列生成的次数。一个好的洗牌算法会将结果均匀地分布在所有的排列中,而一个坏的算法不会产生统一的结果。

使用下面的代码,我在 Firefox,Opera,Chrome,IE6/7/8中测试过。

令我惊讶的是,随机排序和真正的洗牌都创造了同样统一的分布。因此,似乎(正如许多人所建议的那样)主要浏览器都在使用合并排序。当然,这并不意味着,没有一个不同的浏览器,但是我想说,这意味着,这种随机排序方法足够可靠,可以在实践中使用。

编辑: 这个测试并没有真正正确地测量随机性或缺乏随机性。参见我发布的另一个答案。

但是在性能方面,克里斯托弗赋予的洗牌功能是一个明显的赢家。即使对于小型的四元素数组,真正的 shuffle 的执行速度也是 Random-sort 的两倍!

// The shuffle function posted by Cristoph.
var shuffle = function(array) {
var tmp, current, top = array.length;


if(top) while(--top) {
current = Math.floor(Math.random() * (top + 1));
tmp = array[current];
array[current] = array[top];
array[top] = tmp;
}


return array;
};


// the random sort function
var rnd = function() {
return Math.round(Math.random())-0.5;
};
var randSort = function(A) {
return A.sort(rnd);
};


var permutations = function(A) {
if (A.length == 1) {
return [A];
}
else {
var perms = [];
for (var i=0; i<A.length; i++) {
var x = A.slice(i, i+1);
var xs = A.slice(0, i).concat(A.slice(i+1));
var subperms = permutations(xs);
for (var j=0; j<subperms.length; j++) {
perms.push(x.concat(subperms[j]));
}
}
return perms;
}
};


var test = function(A, iterations, func) {
// init permutations
var stats = {};
var perms = permutations(A);
for (var i in perms){
stats[""+perms[i]] = 0;
}


// shuffle many times and gather stats
var start=new Date();
for (var i=0; i<iterations; i++) {
var shuffled = func(A);
stats[""+shuffled]++;
}
var end=new Date();


// format result
var arr=[];
for (var i in stats) {
arr.push(i+" "+stats[i]);
}
return arr.join("\n")+"\n\nTime taken: " + ((end - start)/1000) + " seconds.";
};


alert("random sort: " + test([1,2,3,4], 100000, randSort));
alert("shuffle: " + test([1,2,3,4], 100000, shuffle));

有趣的是,微软也使用了同样的技术在他们的 pick- 随机-浏览器-页面。

他们使用的比较函数略有不同:

function RandomSort(a,b) {
return (0.5 - Math.random());
}

看起来几乎一样,但 事实证明并非如此随机

因此,我再次使用链接文章中使用的相同方法制作了一些 testrun,事实证明,随机排序方法产生了有缺陷的结果。新的测试代码:

function shuffle(arr) {
arr.sort(function(a,b) {
return (0.5 - Math.random());
});
}


function shuffle2(arr) {
arr.sort(function(a,b) {
return (Math.round(Math.random())-0.5);
});
}


function shuffle3(array) {
var tmp, current, top = array.length;


if(top) while(--top) {
current = Math.floor(Math.random() * (top + 1));
tmp = array[current];
array[current] = array[top];
array[top] = tmp;
}


return array;
}


var counts = [
[0,0,0,0,0],
[0,0,0,0,0],
[0,0,0,0,0],
[0,0,0,0,0],
[0,0,0,0,0]
];


var arr;
for (var i=0; i<100000; i++) {
arr = [0,1,2,3,4];
shuffle3(arr);
arr.forEach(function(x, i){ counts[x][i]++;});
}


alert(counts.map(function(a){return a.join(", ");}).join("\n"));

我已经在我的网站上放置了 一个简单的测试页,显示了当前浏览器相对于其他流行浏览器使用不同方法进行洗牌的偏见。它显示了仅仅使用 Math.random()-0.5的可怕的偏差,另一个没有偏差的“随机”洗牌,以及上面提到的 Fisher-Yates 方法。

您可以看到,在某些浏览器上,有高达50% 的几率某些元素在“洗牌”过程中根本不会发生变化!

注意: 你可以通过@Christoph 使得 Safari 的 Fisher-Yates shuffle 的实现稍微快一点,方法是将代码更改为:

function shuffle(array) {
for (var tmp, cur, top=array.length; top--;){
cur = (Math.random() * (top + 1)) << 0;
tmp = array[cur]; array[cur] = array[top]; array[top] = tmp;
}
return array;
}

测试结果: http://jsperf.com/optimized-fisher-yates

可以使用 Array.sort()函数对数组进行洗牌吗。

结果是否足够随机 -没有

考虑下面的代码片段:

/*
* The following code sample shuffles an array using Math.random() trick
* After shuffling, the new position of each item is recorded
* The process is repeated 100 times
* The result is printed out, listing each item and the number of times
* it appeared on a given position after shuffling
*/
var array = ["a", "b", "c", "d", "e"];
var stats = {};
array.forEach(function(v) {
stats[v] = Array(array.length).fill(0);
});
var i, clone;
for (i = 0; i < 100; i++) {
clone = array.slice();
clone.sort(function() {
return Math.random() - 0.5;
});
clone.forEach(function(v, i) {
stats[v][i]++;
});
}
Object.keys(stats).forEach(function(v, i) {
console.log(v + ": [" + stats[v].join(", ") + "]");
});

输出样本:

a: [29, 38, 20,  6,  7]
b: [29, 33, 22, 11,  5]
c: [17, 14, 32, 17, 20]
d: [16,  9, 17, 35, 23]
e: [ 9,  6,  9, 31, 45]

理想情况下,计数应该是均匀分布的(对于上面的示例,所有计数应该在20左右)。但事实并非如此。显然,这种分布取决于浏览器实现了什么排序算法,以及它如何迭代数组项以进行排序。

如果你使用的是 D3,有一个内置的 shuffle 函数(使用 Fisher-Yates) :

var days = ['Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi','Dimanche'];
d3.shuffle(days);

以下是迈克详细介绍的内容:

Http://bost.ocks.org/mike/shuffle/

已经过去四年了,但我想指出的是,无论你使用什么排序算法,随机比较器方法都不会被正确分配。

证据:

  1. 对于 n元素的数组,恰好存在 n!排列(即可能的洗牌)。
  2. 洗牌过程中的每次比较都是在两组排列中进行选择。对于随机比较器,有1/2的机会选择每个集合。
  3. 因此,对于每个排列 p,最终得到排列 p 的机会是分母为2 ^ k 的分数(对于某些 k) ,因为它是这些分数的总和(例如1/8 + 1/16 = 3/16)。
  4. 对于 n = 3,有六个同样可能的排列。那么,每种排列的概率是1/6。1/6不能用2的幂作为分母来表示。
  5. 因此,硬币翻转排序永远不会导致洗牌的公平分配。

唯一可能正确分布的大小是 n = 0,1,2。


作为一个练习,尝试画出 n = 3的不同排序算法的决策树。


证明中存在一个空白: 如果排序算法依赖于比较器的一致性,并且具有无界运行时和不一致的比较器,那么它可以有无限的概率和,即使和中的每个分母都是2的幂,它的概率加起来也可以达到1/6。试着找一个。

此外,如果一个比较器有一个固定的机会给出任一个答案(例如 (Math.random() < P)*2 - 1,对于常数 P) ,上述证明成立。如果比较者改变它的赔率基于以前的答案,它可能会产生公平的结果。为给定的排序算法找到这样一个比较器可能是一篇研究论文。

下面是一种使用单个数组的方法:

基本逻辑是:

  • 从 n 个元素的数组开始
  • 从数组中删除一个随机元素并将其推送到数组中
  • 从数组的第一个 n-1元素中删除一个随机元素,并将其推送到数组中
  • 从数组的第一个 n-2个元素中删除一个随机元素,并将其推送到数组中
  • ...
  • 删除数组的第一个元素并将其推送到数组上
  • 密码:

    for(i=a.length;i--;) a.push(a.splice(Math.floor(Math.random() * (i + 1)),1)[0]);
    

    不,不对。正如其他答案所指出的那样,这将导致不统一的 shuffle,而 shuffle 的质量也将取决于浏览器使用的排序算法。

    现在,这可能听起来不坏的 也是给你,因为即使理论上的分布是不均匀的,在实践中它可能是 差不多均匀的,对不对?没有还差得远呢。下面的图表分别显示了 Chrome 和 Firefox 中每个元素被洗牌到哪个索引的热图: 如果像素(J)是绿色的,这意味着位于 索引的元素被洗牌到 J索引的次数太多了,如果是红色的,那么它被洗牌到 J索引的次数就太少了。

    Heat-map showing biases for Chrome

    Heat-map showing biases for Firefox

    这些截图来自 Mike Bostock 的主页

    正如你所看到的,在 Chrome 中,使用随机比较器进行洗牌存在严重的偏差,在 Firefox 中更是如此。特别是,两个元素沿对角线都有大量的绿色,这意味着太多的元素在非常接近它们在原始序列中的位置的某个地方被“洗牌”。相比之下,对于无偏移的洗牌(例如使用 Fisher-Yates 算法) ,类似的图表将全部是淡黄色,只有少量的随机噪声。