使用JavaScript Array.sort()方法进行洗牌操作是否正确?

130

我正在帮助别人调试他的JavaScript代码,当我的眼睛看到下面这段代码时:

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

起初我认为: 嘿,这不可能起作用! 但是我进行了一些实验,发现它确实至少提供了漂亮的随机结果。

然后我进行了一些网络搜索,几乎在顶部找到了一篇文章,其中这段代码肯定是复制的。看起来这是一个相当受人尊敬的网站和作者...

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

但你怎么想?

还有另一个问题...我该如何度量这种洗牌技术的结果有多随机?

更新:我进行了一些测量,并在下面的答案中发布了结果。


只是提醒一下,在结果中仅四舍五入而不考虑符号数是没有意义的。 - bormat
4
我发现这个方法提供的结果似乎很好地实现了随机化。 - Bergi
1
@Bergi 我喜欢那个页面!!! - Ruan Mendes
13个回答

118

在 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)。根据执行JS代码与本地sort()函数相比的开销,这可能会导致性能上的明显差异,并且随着数组大小的增加而增加。


bobobobo的回答的评论中,我指出所讨论的算法可能不会产生均匀分布的概率(取决于sort()的实现方式)。
我的观点是这样的:排序算法需要进行一定数量的比较,例如Bubblesort的c = n(n-1)/2。我们的随机比较函数使每个比较的结果同等可能,即有2^c等可能的结果。现在,每个结果都必须对应于数组条目的n!个排列之一,这在一般情况下使得均匀分布不可能。(这是一个简化,因为实际需要比较的数量取决于输入数组,但这个断言仍然成立。)
正如Jon所指出的,这本身并不足以使Fisher-Yates优于使用sort(),因为随机数生成器也将将有限数量的伪随机值映射到n!个排列中。但Fisher-Yates的结果仍然应该更好: Math.random()在范围[0;1[内产生伪随机数。由于JS使用双精度浮点值,因此这对应于52 ≤ x ≤ 632^x个可能值(我懒得找实际数字)。使用Math.random()生成的概率分布如果原子事件的数量与其数量级相同,它将停止表现良好。
使用Fisher-Yates算法时,相关参数是数组的大小,由于实际限制,它不应接近2^52。
使用随机比较函数进行排序时,该函数基本上只关心返回值是否为正或负,因此这永远不会成为问题。但是有一个类似的问题:由于比较函数是良好行为的,所以如上所述,可能结果的2^c个取值是等概率的。如果c~nlogn,则2^c~n^(a·n),其中a=const,这使得2^c至少可能与n!相同数量级,甚至更小,从而导致不均匀分布,即使排序算法能够平均映射到排列中。我不确定这是否会对实际产生影响。
真正的问题在于排序算法不能保证均匀地映射到排列。很容易看出Mergesort是对称的,因此可以均匀映射,但是像Bubblesort、Quicksort或Heapsort这样的算法则不行。
底线:只要sort()使用Mergesort,您应该是相对安全的,除非在极端情况下(至少我希望2^c ≤ n!是一个极端情况),否则一切都无法确定。

感谢您的实现。它非常快!特别是与我自己写的那些慢东西相比。 - Rene Saarsoo
1
如果您正在使用underscore.js库,以下是如何使用上述Fisher-Yates洗牌方法进行扩展的方法:https://github.com/ryantenney/underscore/commit/4890699d922cc9924ea28dd9ed21c1fefe33e4de#commitcomment-528646 - Steve
非常感谢您,您和约翰的答案结合起来帮助我解决了一个问题,我和同事花了将近4个小时才解决。我们最初有一个类似于OP的方法,但发现随机性非常不稳定,所以我们采用了您的方法,并稍微改变了一下,使用了一点jquery来打乱图像列表(用于滑块),以获得一些令人惊叹的随机效果。 - Hello World

112

这从来不是我最喜欢的洗牌方式,部分原因是正如你所说,它是特定于实现的。特别地,我记得标准库排序算法Java或.NET(不确定哪个)通常能够检测到一些元素之间存在不一致的比较(例如,你首先声明A < BB < C,但是接下来又有 C < A)。

相对而言,这种方法也会变成一个更加复杂(在执行时间方面)的洗牌操作,其实并非真正需要的。

我更喜欢的是将集合有效地划分为“洗过的”(在集合开头,最初为空)和“未洗过的”(集合的其余部分)所采用的洗牌算法。在算法的每个步骤中,选择一个随机的未洗过元素(可以是第一个)并将其与第一个未洗过的元素交换 - 然后将其视为已洗过(即在心里移动分区以包含它)。

这是O(n)的,并且只需要n-1次调用随机数生成器,这很好。它还产生了一个真正的洗牌效果 - 任何元素都有1/n的机会进入每个空间,而不管其原始位置如何(假设随机数生成器是合理的)。排序版本近似于均匀分布(假设随机数生成器不会选择相同的值两次,这在返回随机双倍时极不可能),但我发现关于洗牌版本更容易推理:)

这种方法称为Fisher-Yates shuffle

我认为最好的实践是编写一次此洗牌操作并在需要洗牌项目的所有地方重复使用。然后你就不必担心排序实现的可靠性或复杂性了。这只需要几行代码(我不会尝试在JavaScript中编写代码!)

《洗牌的维基百科文章》(特别是关于洗牌算法部分)讨论了对随机投影进行排序 - 值得阅读有关普遍洗牌实现的糟糕部分,以便知道避免什么。


5
Raymond Chen在这篇博客中深入讨论了排序比较函数遵循规则的重要性。链接:http://blogs.msdn.com/oldnewthing/archive/2009/05/08/9595334.aspx - Jason Kresowaty
1
如果我的推理是正确的,排序后的版本并不产生一个“真正”的洗牌! - Christoph
@Christoph:思考一下,即使使用 Fisher-Yates 算法,如果 rand(x) 不能保证在其范围内完全均匀分布,也只能得到“完美”的分布。考虑到对于某些 x,RNG 可能有 2^x 种可能的状态,我认为 rand(3) 不可能完全均匀。 - Jon Skeet
@Jon:但 Fisher-Yates 算法会为每个数组索引创建 2^x 个状态,即总共将有 2^(xn) 个状态,这应该比 2^c 大得多——请参见我编辑后的答案以获取详细信息。 - Christoph
我认为你在上面的算法问题中太过宽容了。那是一个可怕的hack,没有人应该使用它。它低效且结果带有极大的偏见(或如果你真的很不走运,它会导致你的程序崩溃)。它会洗牌吗?提供了一个好的可视化方式来展示损坏的洗牌算法所固有的偏见(并且也预防了Fisher-Yates和一个使用“sort”但实际有效的算法)。 - JLRishe
显示剩余3条评论

16
我做了一些关于随机排序结果的随机性度量...
我的技术是取一个小数组 [1,2,3,4] 并创建所有的排列组合(4!= 24)。接着,我会对这个数组应用洗牌函数多次,并计算每个排列被生成的次数。好的洗牌算法会将结果相当均匀地分布在所有排列上,而糟糕的算法则不会创造出那种均匀的结果。
使用下面的代码我在 Firefox、Opera、Chrome、IE6/7/8 中进行了测试。
令我惊讶的是,随机排序和真实的洗牌都创造出了同样均匀的分布。因此,看来(正如许多人所建议的),大多数浏览器都在使用归并排序。当然,这并不意味着可能没有某个浏览器表现不同,但我会说这意味着这种随机排序方法在实践中足够可靠。
编辑:这个测试没有正确衡量随机性或缺乏随机性。请参考我发布的另一个答案。
但在性能方面,由 Cristoph 提供的洗牌函数是显而易见的赢家。即使对于小型四元素数组,真正的洗牌的执行速度也是随机排序的两倍左右!
```javascript // 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; };
// 随机排序函数 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);
var permutations = function(A) {
  if (A.length === 0)
    return [[]];
  else {
    var perms = [];
    for (var i=0; i<A.length; i++) {
      var x = A[i];
      var subperms = permutations(A.slice(0,i).concat(A.slice(i+1)));
      for (var j=0; j<subperms.length; j++) {
        perms.push(x.concat(subperms[j]));
      }
    }
    return perms;
  }
};
var test = function(A, iterations, func) { // 初始化排列 var stats = {}; var perms = permutations(A); for (var i in perms){ stats[""+perms[i]] = 0; }
// 洗牌多次并收集统计数据 var start=new Date(); for (var i=0; i<iterations; i++) { var shuffled = func(A); stats[""+shuffled]++; } var end=new Date();
// 格式化结果 var arr=[]; for (var i in stats) { arr.push(i+" "+stats[i]); } return arr.join("\n")+"\n\n花费时间: " + ((end - start)/1000) + " 秒."; };
alert("随机排序: " + test([1,2,3,4], 100000, randSort)); alert("洗牌: " + test([1,2,3,4], 100000, shuffle));

11

有趣的是,微软在其pick-random-browser-page中也使用了相同的技术。

他们使用了稍微不同的比较函数:

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

在我看来,它们几乎相同,但实际上情况并非如此,它被证明不太随机...

因此,我再次使用与链接文章中相同的方法进行了一些测试,确实发现随机排序方法产生了有缺陷的结果。新的测试代码在这里:

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"));

我不明白为什么要是0.5 - Math.random(),为什么不能只是Math.random()呢? - Alexander Mills
3
дј йҖ’з»ҷsort()зҡ„жҜ”иҫғеҮҪж•°еә”иҜҘиҝ”еӣһдёҖдёӘж•°еӯ—пјҢж №жҚ®aе’Ңbзҡ„жҜ”иҫғз»“жһңеӨ§дәҺгҖҒе°ҸдәҺжҲ–зӯүдәҺйӣ¶гҖӮ (https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) - LarsH
@LarsH 是的,那很有道理。 - Alexander Mills

10

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

您可以看到,在某些浏览器上,有高达50%的概率某些元素在“洗牌”期间根本不会改变位置!

注意:您可以通过更改代码来使@Christoph编写的Fisher-Yates shuffle在Safari中稍微快一点:

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


5
我认为对于那些不在意分发方式且希望源代码尽可能小的情况下,选择这种方式还是可以的。
在 JavaScript 中(其中源代码不断传输),大小对于带宽成本来说很重要。

3
问题是,你在分配方面的选择要求几乎总是比你想象的更严格,对于“小代码”,总是有 arr = arr.map(function(n){return [Math.random(),n]}).sort().map(function(n){return n[1]}); 这种方式,它的优点是长度不会过长,而且分配实际上是正确的。还有一些非常压缩的 Knuth/F-Y 洗牌变体。 - Daniel Martin
@DanielMartin 那个一行代码应该是一个答案。另外,为了避免解析错误,需要添加两个分号,使其看起来像这样:arr = arr.map(function(n){return [Math.random(),n];}).sort().map(function(n){return n[1];}); - Giacomo1968

3
不,这是不正确的。正如其他答案所指出的那样,它将导致非均匀洗牌,而且洗牌的质量还取决于浏览器使用的排序算法。
现在,这可能对您来说听起来并不太糟糕,因为即使理论上分布不均匀,在实践中它可能几乎是均匀的,对吗?好吧,不是的,根本不接近。以下图表显示了在Chrome和Firefox中每个元素被洗牌到哪个索引的热图:如果像素(i,j)是绿色的,则表示索引为i的元素被过度频繁地洗牌到索引为j,如果是红色的,则洗牌得太少。

Heat-map showing biases for Chrome

Heat-map showing biases for Firefox

这些屏幕截图来自Mike Bostock的页面,讨论这个主题。
正如你所看到的,在Chrome中使用随机比较器进行洗牌严重偏颇,而在Firefox中更加明显。特别是两者都有很多沿着对角线的绿色,意味着太多的元素被“洗牌”到了非常接近原始序列的地方。相比之下,使用无偏洗牌算法(例如Fisher-Yates算法)的类似图表将会全部呈淡黄色,并仅有少量随机噪声。

3
已经过去了四年,但我想指出无论使用什么排序算法,随机比较器方法都不会正确分布。
证明如下:
1. 对于一个由n个元素组成的数组,有恰好n!个排列(即可能的洗牌)。 2. 每次洗牌期间的每次比较都是在两组排列之间进行选择。对于随机比较器,选择每组的概率为1/2。 3. 因此,对于每个排列p,最终得到排列p的概率是一个分母为2^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。试着找到一个。
另外,如果比较器有固定的概率给出任一答案(例如,对于常数P,Math.random() < P)*2 - 1),上述证明成立。如果比较器根据先前的答案改变其胜算,则可能会生成公平的结果。为给定的排序算法找到这样的比较器可能需要进行研究论文。

2

这确实是一种hack。在实际应用中,无限循环算法不太可能出现。 如果你正在对对象进行排序,可以遍历coords数组并执行类似以下的操作:

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。

但这仍然是一种折衷方法。如果您想要漂亮地完成它,您必须走弯路 :)


一行代码:coords.map(v=>[v,Math.random()]).sort((a,b)=>a[1]-b[1]).map(v=>v[0]) - user20091357

1
如果您正在使用D3,那么内置了一个洗牌函数(使用Fisher-Yates算法):
var days = ['Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi','Dimanche'];
d3.shuffle(days);

以下是Mike详细说明:

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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接