几个月前因为某个需求,需要写一个乱序函数,于是乎就撸了一个,然而撸完又开始思考,怎么证明结果够不够乱呢,接下来我们看下。
别问为啥要重复造这么小的轮子,就是正好用到又没写过随便写写。
怎么写乱序函数
先定义一下我们的乱序函数,首先它支持接受一个数组或一个字符串,然后会将数组或字符串中的元素乱序输出。
代码使用 TS
编写,先定义接口:
function shuffle(target: string): string; function shuffle<T = any>(target: T[]): T[]; 复制代码
为了兼容两种数据类型且有良好的类型推断,这里使用了重载。
实现很简单,如果为字符串,我们需要先将其转换为数组;如果是数组,为了避免影响原数组,就 clone
一份:
const isString = typeof target === 'string'; let shuffleTarget: T[] | string[]; if (isString) { shuffleTarget = target.split(''); } else { shuffleTarget = target.slice(); } 复制代码
随后呢,我们将数组中的元素进行打乱,打乱的方式有很多种,我这里选择每次从数组中随机一个元素然后将其放到最末端,然后每次将末端索引向前移动,这样即可保证一个元素只被操作一次。
const randomIndex = (l: number) => Math.floor(Math.random() * l); let l = shuffleTarget.length; while (l) { let i = randomIndex(l--); const tmp = shuffleTarget[i]; shuffleTarget[i] = shuffleTarget[l]; shuffleTarget[l] = tmp; } 复制代码
这种方式之后在 lodash
文档中看到,叫做 Fisher–Yates_shuffle。
当然乱序还有很多种办法,比如:
- 创建一个新数组,每次随机从数组中选取一个元素,然后将其丢入新数组中
- 使用
sort
随机返回 -1 或 1 - 或者每次随机两个索引后让它们交换位置
不过综合看下来,还是上述的方案性能更高(还有改进的地方,比如 l
为 1 时就可退出了,不过无伤大雅),效果最好。
问题出现
这么小的一个乱序,咋还能出问题呢,别急,真不是我菜 🐶,主要是字符串太骚。
大家应该都知道,很多字符串长度不为 1,主要就是因为它超出了 Unicode
编码单字符的范围,所以需要使用多个 Unicode
编码区来表示,而大部分语言中的长度,都是 Unicode
编码单字符的长度。最常见的就是 emoji
了,比如 👷♀️ 这个 emoji
,它的 length
就是 5,这带来的问题不只是会出现在字符长度中,还出现在字符串分割中,上面我们使用 split('')
来分割字符串,就会导致这个 emoji
被分割为 ['\uD83D', '\uDC77', '', '♀', '️'] 这 5 个字符,显然并不是我们想要的结果。
比较简单的解决方案是,我们直接使用 Array.from
或数组解构来拆解字符串,但是他只能拆解比较简单的 emoji
,如 '📦' 可以成功,但是遇到上面的 '👷♀️' 依旧要躺,🤦♂️ 这个就超纲了, 需要配合专业库来解决。
怎么证明它够乱
写完随之而来的一个问题便是,怎么证明这个函数够不够乱呢,虽然代码看着没毛病,但是咱还是得靠数据说话。
于是乎,我想到将一组数据乱序上万次,然后将乱序后的数据分布统计出来这个办法。咱先上图看看效果:
这是我将 A-P
16 个字母进行 10W 次乱序,然后统计出来的分布图表。我为每个字母生成了一个颜色,然后横坐标是每个索引和出现在该索引处的字符,纵坐标为对应的次数。
从图表可以看出,每个字母出现在每个索引位置的次数基本相同,都在 6250 左右(100000/6),没有出现分布不均的情况,由此可见,果然够乱。🐶
再顺手贴上图表的地址和代码:
import shuffle from 'z-shuffle'; const LetterCount = 16; let t = 100000; const chartCodeOfA = 'A'.charCodeAt(0); const array = new Array(LetterCount).fill(null).map((v, i) => String.fromCharCode(i + chartCodeOfA)); const distributionMap = {}; for (const letter of array) { distributionMap[letter] = new Array(LetterCount).fill(0); } while (t--) { const arrayAfterShuffle = shuffle(array); for (const index in arrayAfterShuffle) { const letter = arrayAfterShuffle[index]; distributionMap[letter][index]++; } } const datasets = []; const l = Object.keys(distributionMap).length; const d = Math.floor(255 / l); for (const key in distributionMap) { const distribution = distributionMap[key]; const code = key.charCodeAt(0) - chartCodeOfA; datasets.push({ label: key, data: distribution, backgroundColor: `rgb(${d * code}, ${255 - d * code}, ${255 - code})` }); } const data = { labels: new Array(l).fill(null).map((v, i) => i), datasets }; const config = { type: 'bar', data: data, options: { responsive: true, plugins: { legend: { position: 'right' } } } }; new window.Chart(document.getElementById('myChart'), config); 复制代码
benchmark
乱是够乱了,那就顺手再测试下性能好了,具体代码可以看下 benchmark.js 就不贴了,贴下测试结果。
lodash/shuffle: 1 357 426 ops/s, ±1.60% | slowest, 26.89% slower array-shuffle: 1 856 774 ops/s, ±0.64% | fastest shuffle-array: 1 807 762 ops/s, ±1.66% | 2.64% slower z-shuffle: 1 792 127 ops/s, ±0.61% | 3.48% slower z-shuffle pure=false: 1 769 492 ops/s, ±1.22% | 4.7% slower 复制代码
基本上测下来性能都差不多,因为我这里还有几个参数判断,以及刚刚上面说到的循环多了一次,所以稍微慢一丢丢,不过 lodash
这。。。
最后吹嘘一波:本库 ts
编写,MIT
协议,尺寸不到 400B,无依赖,100% 测试覆盖率,支持 tree-sharking
,支持 worker
,支持 esm
、cjs
、umd
各种,支持字符串和数组,童叟无欺,欢迎使用。🐶 保命。