前言
我们有一个营销系统,客户数据中有一部分是没有用户头像的,最近过需求的时候产品说要在前端给这部分用户生成随机的虚拟头像;
心想这不是简单的很,让 UI 设计师给提供一些卡通头像,组成头像库, 我给它按照序号命名放到 CDN 上, 假设 total 是头像库的总个数,通过随机数 Math.floor(Math.random() * total)
就能获取一个元素的下标了,搞定;
产品:我希望每个客户的头像是固定的,不能一刷新页面就变了;
what ? 看来事情没那么简单~
技术分析
我们先不考虑刷新的问题,把第一版实现如下
generateUsers: () => { let total = '100' // 假设头像库数量为 100 let index = Math.floor(Math.random() * total) return `www.cdn-demo.com/avatar${index}.png` }
执行 generateUsers
函数随机生成一个头像地址,其中的随机数是通过 Math.random 实现的;
重点来了,如果它支持传递一个参数,作为变量,每次通过这个参数计算的话,结果都是固定的,那么不就实现我们的需求了吗;
其实在计算机领域,这并不是一个特殊的概念,有一个术语叫“随机种子”,描述的就是这种场景;
随机种子(Random Seed)是计算机专业术语,一种以随机数作为对象的以真随机数(种子)为初始条件的随机数。 一般计算机的随机数都是伪随机数,以一个真随机数(种子)作为初始条件,然后用一定的算法不停迭代产生随机数。
Math.random 的升级: seedRandom 函数
在 Python、Java 等一些语言中,自带了生成随机种子数的函数,如 Python 中 random 模块提供了 seed()函数设置随机种子
import random # 设置随机数生成器的种子为固定值,每次执行结果一致 random.seed(42)
如果不主动设置种子,Python 的 random 模块会使用系统时间作为默认种子,从而使得每次运行程序时产生不同的随机数序列;
在 Js 中,Math 对象提供了 random 函数来生成随机数,其实它也是根据种子生成的,只是它貌似是以当前系统时间的毫秒数作为固定的种子,不支持修改, 所以我们需要手动实现一个随机种子数生成函数;
我们可以覆盖或者重新定义一个函数为 seedRandom, 支持种子入参,然后经过一些简单算法将其转化为 0 到 1 之间的小数;
下面是几种实现方式:
方案1:
// 利用三角正弦函数,所以只支持数字 const seedRandom = seed => { return ('0.'+Math.sin(seed).toString().substr(6)) }
方案2:
// 利用字符的charCodeAt编码将其转化为数字 const seedRandom = str => { let hash = 0 if (str.length === 0) { return hash } for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i) hash = (hash << 5) - hash + char hash |= 0 // 转换为32位有符号整数 } return Math.abs(`0.${hash}`) }
方案3:
// 利用线性同余算法,该算法使用了两个变量`m_w`和`m_z`作为种子,通过一系列的数学运算得到一个32位的随机整数,并将其转换为0到1之间的浮点数。 function seedRandom(seed) { let m_w = seed; let m_z = 987654321; return function random() { m_z = (36969 * (m_z & 65535) + (m_z >> 16)) & 0xFFFFFFFF; m_w = (18000 * (m_w & 65535) + (m_w >> 16)) & 0xFFFFFFFF; let result = ((m_z << 16) + m_w) & 0xFFFFFFFF; result /= 0xFFFFFFFF; return result; }; } // 示例用法 const seed = 42; const randomFunc = seedRandom(seed); // 生成随机数 const randomNum = randomFunc(); console.log(randomNum);
以上几种方法因为策略简单,所以生成的随机数结果都不够完美,数据分布过于集中。
github 上的 seedrandom
最后我在 github 上找到了一个相关的包, seedrandom, 它利用更加复杂的算法,计算结果分散,并且支持中英文数字等多种格式;
seedrandom库使用一个称为"mulberry32"的 线性同余生成器。该生成器在每次生成随机数时,通过将种子值与一个大素数进行乘法运算,然后取得结果的低位作为新的种子值。这个过程会不断重复,从而产生一个连续的伪随机数序列。
为了保证生成的随机数质量和分布性,seedrandom库还会对生成的伪随机数进行一些额外的处理,例如乘以2的-32次方,将其缩放到0到1之间的范围。
除了基本的伪随机数生成,seedrandom库还提供了一些功能和选项,例如支持自定义随机序列、指定种子长度、设置熵值增加随机性等。
基本用法:
import seedrandom from 'seedrandom' const rng = seedrandom(seed) rng() // 生成 0 - 1之间的小数
搞定了可控随机数,基本就实现了最初的需求;
这时我们不妨思考一下,它还有没有其他使用场景,比如说对于抽奖活动,我希望每天的抽奖奖品都可以从100个奖品库中随机选择 5 种,在当天内是不变的,怎么实现这个需求呢?
对于这个需求,日期就是 seed 种子,日期不变情况下可重复计算出一个固定的随机数,然后根据这个随机数来对奖品库进行排序,然后随便你选择几个,其实结果是已经固定了,要实现这套排序,就要了解一下洗牌算法了。
洗牌算法
洗牌算法(Shuffle Algorithm)是一种用于打乱数据顺序的算法,它广泛应用于计算机科学领域,特别是在随机化算法、密码学、游戏开发和数据分析等方面。
洗牌算法的目标是将一组数据的顺序随机化,确保每个元素在打乱后的序列中出现的概率相等,并且尽可能地保持原始数据的随机性,这样可以有效地消除原始数据的有序性,使其更加随机和无偏。
常见的洗牌算法有多种,其中最常用的是称为"费雪-耶茨洗牌算法"(Fisher-Yates Shuffle Algorithm)的方法。以下是该算法的步骤:
- 从要进行洗牌的数据集中选择最后一个元素作为当前元素。
- 生成一个随机数,范围从0到当前元素的索引。
- 将当前元素与随机选中的索引位置的元素进行交换。
- 将当前元素的索引向前移动一位,继续从步骤2开始,直到所有元素都被遍历过。
- 重复执行步骤2到步骤4,直到所有元素都被遍历过。
通过上述步骤,费雪-耶茨洗牌算法可以确保每个元素在洗牌后的序列中的概率均等,从而实现了数据的随机化。
洗牌算法的时间复杂度为O(n),其中n是要进行洗牌的数据集的大小。它是一种高效且可靠的算法,适用于各种规模的数据集。
需要注意的是,洗牌算法在实际应用中应该选择合适的随机数生成器,以保证生成的随机数具有良好的随机性和均匀性。此外,对于某些特定应用,可能需要使用更高级的洗牌算法来满足特定的随机性要求。
seedrandom 和 洗牌算法的结合
介绍了种子随机数和洗牌算法后,让我们来实现刚才说的效果。
generatePrizes: (prizeList, seed, N) => { const target = Array.from({ length: prizeList.length }, (_, i) => i) // 创建包含下标的数组 // 使用 Fisher-Yates 洗牌算法和种子生成可回溯的随机排列 const rng = seedrandom(seed) for (let i = target.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); // 调用 randomGenerator() 生成随机数 [target[i], target[j]] = [target[j], target[i]] // 交换元素位置 } // 构建结果对象数组 const result = target.slice(0, N).map((index) => ({ logo: logoList[index % prizeList.length] })) return result } generatePrizes(100, '2023-06-04', 5)
总结
本文介绍了随机种子数(可控随机数)的概念和应用场景解决了开头的虚拟头像的需求,最后又结合洗牌算法来实现了一个奖品选择的案例。