详解js数组的完全随机排列算法

Array.prototype.sort 方法被许多 JavaScript 程序员误用来随机排列数组。最近做的前端星计划挑战项目中,一道实现 blackjack 游戏的问题,就发现很多同学使用了 Array.prototype.sort 来洗牌。

详解js数组的完全随机排列算法

洗牌

以下就是常见的完全错误的随机排列算法:

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

以上代码看似巧妙利用了 Array.prototype.sort 实现随机,但是,却有非常严重的问题,甚至是完全错误。

证明 Array.prototype.sort 随机算法的错误

为了证明这个算法的错误,我们设计一个测试的方法。假定这个排序算法是正确的,那么,将这个算法用于随机数组 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],如果算法正确,那么每个数字在每一位出现的概率均等。因此,将数组重复洗牌足够多次,然后将每次的结果在每一位相加,最后对每一位的结果取平均值,这个平均值应该约等于 (0 + 9) / 2 = 4.5,测试次数越多次,每一位上的平均值就都应该越接近于 4.5。所以我们简单实现测试代码如下:

var arr = [0,1,2,3,4,5,6,7,8,9]; var res = [0,0,0,0,0,0,0,0,0,0]; var t = 10000; for(var i = 0; i < t; i++){ var sorted = shuffle(arr.slice(0)); sorted.forEach(function(o,i){ res[i] += o; }); } res = res.map(function(o){ return o / t; }); console.log(res);

将上面的 shuffle 方法用这段测试代码在 chrome 浏览器中测试一下,可以得出结果,发现结果并不随机分布,各个位置的平均值越往后越大,这意味着这种随机算法越大的数字出现在越后面的概率越大

为什么会产生这个结果呢?我们需要了解 Array.prototype.sort 究竟是怎么作用的。

首先我们知道排序算法有很多种,而 ECMAScript 并没有规定 Array.prototype.sort 必须使用何种排序算法。

排序不是我们今天讨论的主题,但是不论用何种排序算法,都是需要进行两个数之间的比较和交换,排序算法的效率和两个数之间比较和交换的次数有关系。

最基础的排序有冒泡排序和插入排序,原版的冒泡或者插入排序都比较了 n(n-1)/2 次,也就是说任意两个位置的元素都进行了一次比较。那么在这种情况下,如果采用前面的 sort 随机算法,由于每次比较都有 50% 的几率交换和不交换,这样的结果是随机均匀的吗?我们可以看一下例子:

function bubbleSort(arr, compare){ var len = arr.length; for(var i = 0; i < len - 1; i++){ for(var j = 0; j < len - 1 - i; j++){ var k = j + 1; if(compare(arr[j], arr[k]) > 0){ var tmp = arr[j]; arr[j] = arr[k]; arr[k] = tmp; } } } return arr; } function shuffle(arr){ return bubbleSort(arr, function(){ return Math.random() - 0.5; }); } var arr = [0,1,2,3,4,5,6,7,8,9]; var res = [0,0,0,0,0,0,0,0,0,0]; var t = 10000; for(var i = 0; i < t; i++){ var sorted = shuffle(arr.slice(0)); sorted.forEach(function(o,i){ res[i] += o; }); } res = res.map(function(o){ return o / t; }); console.log(res);

上面的代码的随机结果也是不均匀的,测试平均值的结果越往后的越大。(笔者之前没有复制原数组所以错误得出均匀的结论,已更正于 2016-05-10)

冒泡排序总是将比较结果较小的元素与它的前一个元素交换,我们可以大约思考一下,这个算法越后面的元素,交换到越前的位置的概率越小(因为每次只有50%几率“冒泡”),原始数组是顺序从小到大排序的,因此测试平均值的结果自然就是越往后的越大(因为越靠后的大数出现在前面的概率越小)。

我们再换一种算法,我们这一次用插入排序:

function insertionSort(arr, compare){ var len = arr.length; for(var i = 0; i < len; i++){ for(var j = i + 1; j < len; j++){ if(compare(arr[i], arr[j]) > 0){ var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } } } return arr; } function shuffle(arr){ return insertionSort(arr, function(){ return Math.random() - 0.5; }); } var arr = [0,1,2,3,4,5,6,7,8,9]; var res = [0,0,0,0,0,0,0,0,0,0]; var t = 10000; for(var i = 0; i < t; i++){ var sorted = shuffle(arr.slice(0)); sorted.forEach(function(o,i){ res[i] += o; }); } res = res.map(function(o){ return o / t; }); console.log(res);

由于插入排序找后面的大数与前面的数进行交换,这一次的结果和冒泡排序相反,测试平均值的结果自然就是越往后越小。原因也和上面类似,对于插入排序,越往后的数字越容易随机交换到前面。

所以我们看到即使是两两交换的排序算法,随机分布差别也是比较大。除了每个位置两两都比较一次的这种排序算法外,大多数排序算法的时间复杂度介于 O(n) 到 O(n2) 之间,元素之间的比较次数通常情况下要远小于 n(n-1)/2,也就意味着有一些元素之间根本就没机会相比较(也就没有了随机交换的可能),这些 sort 随机排序的算法自然也不能真正随机。

我们将上面的代码改一下,采用快速排序:

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/wwfzdy.html