常用的sort打乱数组方法真的有用?
JavaScript 开发中有时会遇到要将一个数组随机排序(shuffle)的需求,一个常见的写法是这样:
function shuffle(arr) {
arr.sort(function () {
return Math.random() - 0.5;
});
}
或者使用更简洁的 ES6 的写法:
function shuffle(arr) {
arr.sort(() => Math.random() - 0.5);
}
我也曾经经常使用这种写法,不久前才意识到,这种写法是有问题的,它并不能真正地随机打乱数组。
问题
看下面的代码,我们生成一个长度为 10 的数组['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'],使用上面的方法将数组乱序,执行多次后,会发现每个元素仍然有很大机率在它原来的位置附近出现。
let n = 10000;
let count = (new Array(10)).fill(0);
for (let i = 0; i < n; i ++) {
let arr = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
arr.sort(() => Math.random() - 0.5);
count[arr.indexOf('a')]++;
}
console.log(count);
在 Node.JS 6 中执行,输出[ 2891, 2928, 1927, 1125, 579, 270, 151, 76, 34, 19 ](带有一定随机性,每次结果都不同,但大致分布应该一致),即进行 10000 次排序后,字母'a'(数组中的第一个元素)有约 2891 次出现在第一个位置、2928 次出现在第二个位置,与之对应的只有 19 次出现在最后一个位置。如果把这个分布绘制成图像,会是下面这样:
类似地,我们可以算出字母'f'(数组中的第六个元素)在各个位置出现的分布为[ 312, 294, 579, 1012, 1781, 2232, 1758, 1129, 586, 317 ],图像如下:
如果排序真的是随机的,那么每个元素在每个位置出现的概率都应该一样,实验结果各个位置的数字应该很接近,而不应像现在这样明显地集中在原来位置附近。因此,我们可以认为,使用形如arr.sort(() => Math.random() - 0.5)
这样的方法得到的并不是真正的随机排序。
另外,需要注意的是上面的分布仅适用于数组长度不超过 10 的情况,如果数组更长,比如长度为 11,则会是另一种分布。比如:
let a = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k']; // 长度为11
let n = 10000;
let count = (new Array(a.length)).fill(0);
for (let i = 0; i < n; i ++) {
let arr = [].concat(a);
arr.sort(() => Math.random() - 0.5);
count[arr.indexOf('a')]++;
}
console.log(count);
在 Node.JS 6 中执行,结果为[ 785, 819, 594, 679, 941, 1067, 932, 697, 624, 986, 1876 ],其中第一个元素'a'的分布图如下:
分布不同的原因是 v8 引擎中针对短数组和长数组使用了不同的排序方法(下面会讲)。可以看到,两种算法的结果虽然不同,但都明显不够均匀。
国外有人写了一个Shuffle算法可视化的页面,在上面可以更直观地看到使用arr.sort(() => Math.random() - 0.5)
的确是很不随机的。
探索
看了一下ECMAScript中关于Array.prototype.sort(comparefn)
的标准,其中并没有规定具体的实现算法,但是提到一点:
Calling comparefn(a,b) always returns the same value v when given a specific pair of values a and b as its two arguments.
也就是说,对同一组a、b的值,comparefn(a, b)
需要总是返回相同的值。而上面的() => Math.random() - 0.5
(即(a, b) => Math.random() - 0.5
)显然不满足这个条件。
翻看v8引擎数组部分的源码,注意到它出于对性能的考虑,对短数组使用的是插入排序,对长数组则使用了快速排序,至此,也就能理解为什么() => Math.random() - 0.5
并不能真正随机打乱数组排序了。(有一个没明白的地方:源码中说的是对长度小于等于 22 的使用插入排序,大于 22 的使用快排,但实际测试结果显示分界长度是 10。)
解决方案
知道问题所在,解决方案也就比较简单了。
方案一
既然(a, b) => Math.random() - 0.5
的问题是不能保证针对同一组a、b每次返回的值相同,那么我们不妨将数组元素改造一下,比如将每个元素i改造为:
let new_i = {
v: i,
r: Math.random()
};
即将它改造为一个对象,原来的值存储在键v中,同时给它增加一个键r,值为一个随机数,然后排序时比较这个随机数:
arr.sort((a, b) => a.r - b.r);
完整代码如下:
function shuffle(arr) {
let new_arr = arr.map(i => ({v: i, r: Math.random()}));
new_arr.sort((a, b) => a.r - b.r);
arr.splice(0, arr.length, ...new_arr.map(i => i.v));
}
let a = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
let n = 10000;
let count = (new Array(a.length)).fill(0);
for (let i = 0; i < n; i ++) {
shuffle(a);
count[a.indexOf('a')]++;
}
console.log(count);
一次执行结果为:[ 1023, 991, 1007, 967, 990, 1032, 968, 1061, 990, 971 ]。多次验证,同时在这儿查看shuffle(arr)
函数结果的可视化分布,可以看到,这个方法可以认为足够随机了。
方案二(Fisher–Yates shuffle)
需要注意的是,上面的方法虽然满足随机性要求了,但在性能上并不是很好,需要遍历几次数组,还要对数组进行splice等操作。
考察Lodash 库中的 shuffle 算法,注意到它使用的实际上是Fisher–Yates 洗牌算法,这个算法由 Ronald Fisher 和 Frank Yates 于 1938 年提出,然后在 1964 年由 Richard Durstenfeld 改编为适用于电脑编程的版本。用伪代码描述如下:
-- To shuffle an array a of n elements (indices 0..n-1):
for i from n−1 downto 1 do
j ← random integer such that 0 ≤ j ≤ i
exchange a[j] and a[i]
一个实现如下(ES6):
function shuffle(arr) {
let i = arr.length;
while (i) {
let j = Math.floor(Math.random() * i--);
[arr[j], arr[i]] = [arr[i], arr[j]];
}
}
// 2019.12.20优化版本,去除多余循环
function shuffle(arr) {
let i = arr.length;
while (--i) {
let j = Math.floor(Math.random() * i);
[arr[j], arr[i]] = [arr[i], arr[j]];
}
}
或者对应的 ES5 版本:
function shuffle(arr) {
var i = arr.length, t, j;
while (i) {
j = Math.floor(Math.random() * i--);
t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
}
// 2019.12.20优化版本,去除多余循环
function shuffle(arr) {
var i = arr.length, t, j;
while (--i) {
j = Math.floor(Math.random() * i);
t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
}
小结
如果要将数组随机排序,千万不要再用(a, b) => Math.random() - 0.5
这样的方法。
目前而言,Fisher–Yates shuffle 算法应该是最好的选择。
常用的sort打乱数组方法真的有用?的更多相关文章
- js简易随机打乱数组方法
打乱随机数算法很多,不过看这个还挺简便的,记录下来. function shuffle(a) { var len = a.length; for(var i=0;i<len;i++){ var ...
- javascript 原生常用api 数组方法大全
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title> ...
- js中常用数组方法concat join push pop slice splice shift
javascript给我们很多常用的 数组方法,极大方便了我们做程序.下面我们来介绍下常用的集中数组方法. 比如 concat() join() push() pop() unshift() shif ...
- js与jquery常用数组方法总结
昨天被问数组方法的时候,问到sort()方法是否会改变原来的数组.本来我猜是不会,也是这么说,马上我又觉得,知识这种东西,不确定的时候直接说不确定或不知道就好,只是凭借着不确定的猜测或者是记忆,害人害 ...
- 迟早要知道的JS系列之常用数组方法
常用数组方法 一.不会改变原始数组的方法: 即访问方法,下面的这些方法绝对不会改变调用它们的对象的值,只会返回一个新的数组或者返回一个其它的期望值. 1. concat() ** 语法:** Java ...
- PHP常用函数、数组方法
常用函数:rand(); 生成随机数rand(0,50); 范围随机数时间:time(); 取当前时间戳date("Y-m-d H:i:s"); Y:年 m:月份 d:天 H:当前 ...
- ES6新增的常用数组方法(forEach,map,filter,every,some)
ES6新增的常用数组方法 let arr = [1, 2, 3, 2, 1]; 一 forEach => 遍历数组 arr.forEach((v, i) => { console.log( ...
- JavaScript常用数组方法
JavaScript数组方法有以下这些: forEach() 方法对数组的每一个元素执行一次提供的函数. map() 方法创建一个新数组,其结果是该数组都执行一次函数,原函数保持不变. filter( ...
- js中常用的数组方法
在数组的尾部增加或删除某个元素:push() 和 pop() push() : 在数组的尾部追加一个或多个元素,并返回数组的长度 pop():在数组的尾部删除一个元素,并返回被删除项 var arr ...
随机推荐
- webpack学习笔记--配置plugins
Plugin Plugin 用于扩展 Webpack 功能,各种各样的 Plugin 几乎让 Webpack 可以做任何构建相关的事情. 配置 Plugin Plugin 的配置很简单, plugi ...
- [转] mongoose 之Shema
总 之见到我写点啥简直是奇迹,由于现在喜欢上玩转node.js +mongoose,个人爱好,靠近前端的又是英文文档,苦逼得很,悟出来一个写一个吧.之前喜欢误打误撞,网上搜索一点解决一下问题,后来实在 ...
- 【BZOJ2067】[Poi2004]SZN
题解: 比上一题水多了 首先树上贪心,肯定要考虑儿子 然后我们会发现这个东西就是要先把儿子连起来 然后如果儿子个数为奇数我们可以把这一条和它连向父亲的并在一起 由于根没有父亲所以要单独考虑 答案就是s ...
- [转]DBCP连接池的最简单应用(用于ORACLE数据库)
http://blog.csdn.net/iihero/article/details/8254107 http://www.programgo.com/article/81693457907/ 鉴于 ...
- linux服务器查看tcp链接shell
netstat -nt |awk '{++S[$NF]} END {for (a in S ) print a,S[a]}'
- 今天才知道原来我还没弄清楚js中全局变量和局部变量的定义...
查资料看到这段还不错,来源:原文:https://blog.csdn.net/czh500/article/details/80429133 粘过来记录一下... 1.使用var声明变量,在方法内部是 ...
- Java版统计文件中的每个单词出现次数
正则表达式之Pattern和Matcher,请参见转载博客 http://www.cnblogs.com/haodawang/p/5967219.html 代码实现: import java.i ...
- 【python】进程与线程
No1: 多进程 from multiprocessing import Process import os # 子进程要执行的代码 def run_proc(name): print('Run ch ...
- JS的异步世界
前言 JS的异步由来已久,各种异步概念也早早堆在开发者面前.可现实代码中,仍然充斥了各种因异步顺序处理不当的bug,或因不好好思考,或因不了解真相.今天,就特来再次好好探索一番JS的异步世界. 01 ...
- CLR Via第一 章 知识点整理(3)CLR执行程序集的IL代码
在了解CLR运行之前让我们先简单了解一下IL 除了编译器编译的IL代码,IL也是一种汇编语言,也就是说我们可以直接编写IL代码,当然也有对应的IL编译器,值得一提的是对于面向CLR的其他语言,CLR只 ...