cover
2022年8月3日 - 2023年9月8日

洗牌算法验证

几个月前因为某个需求,需要写一个乱序函数,于是乎就撸了一个,然而撸完又开始思考,怎么证明结果够不够乱呢,接下来我们看下。

别问为啥要重复造这么小的轮子,就是没写过随便写写。

怎么写乱序函数

先定义一下我们的乱序函数,首先它支持接受一个数组或一个字符串,然后会将数组或字符串中的元素乱序输出。

代码使用 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,支持 esmcjsumd 各种,支持字符串和数组,童叟无欺,欢迎使用。🐶 保命。

好了本篇水文到此为止,顺手贴下该库的 github 地址,欢迎大家三连 🐶。