一个关于权重随机数的简单研究——抽奖示例(一)
前言
某一天的我在打游戏,卡池更新,充了648的我加上一些上版本的存货,几十发下卡池,一点水花都没溅起来,我对这个概率感到疑惑,太非了吧,但仔细一看卡池说明,只有百分之一点几的出货率,加上80发大保底、10发小保底的机制,期望出的次数大概在60±5之间,又能理解。稍微了解了一下,发现除开保底机制,实际上也是伪概率的,不会让你吃到大保底,也不会让你很容易就出。
为啥一定要伪概率,不用真概率呢,以前听过一个说法,真概率+不保底=无限抽,运气差到一定程度,只有靠保底来拯救。
然后我抽了点时间,研究了一下“抽奖”或者是“权重随机数抽取”的一些有趣内容。
随机数的产生
一般的编程语言,都有随机数生成方法,直接定义和调用即可,部分会有语言需要定义一个变动的“种子”来避免生成的随机数规律性“雷同”或重现。
本文用的编程语言是JavaScript,编译及运行工具是edge浏览器,别疑惑为啥不用稍微专业点IDE,很简单,没必要,只是测试算法而非独立项目的话,浏览器本地引擎就足以胜任了。
关于权重,这里不做详细介绍,在本次讨论中,只简单说明为:权重越大,越容易被抽到。
最简单的随机数调用
生成范围为[0,1)
的随机数,默认浮点型。
//随机数生成器
const RNG = ()=>{
let ra = Math.random();
console.log(ra);
}
//输出10次
//0.06631025861544249 0.683283041168188 0.7586949449825899 0.42456507700636004 0.18698114218603212 0.5668754365527569 0.10401423479870187 0.6632089775344485 0.3369250172551357 0.3127754552312254
生成范围为[0,100)
的随机数,向下取整。
//随机数生成器
const RNG = ()=>{
let ra = Math.floor(Math.random() * 100);
return ra;
}
//输出10次
//67 90 4 45 15 90 0 20 99 17
一个稍微实用点的权重随机数生成器
下面,我们直接上点难度,根据一个权重数组,来取随机数,数组数据格式为[{weigth(number),id(Number or Any),obj(Object)}]
,代码及测试效果我先放出来,具体实现逻辑后面再介绍。
//随机数生成器
const WeigthRNG = (randArr)=>{
var randAdd = 0;
if(randArr && randArr.length && randArr.length>0){
for(let i = 0; i<randArr.length; i++){
let el = randArr[i];
randAdd += el.weigth;
}
}
else return 0;
//范围为[0,n)
var ra = Math.floor(Math.random() * randAdd);
var randAddTemp = 0;
for(let i = 0; i<randArr.length; i++){
let el = randArr[i];
randAddTemp += el.weigth;
if(randAddTemp > ra) return el;
}
return 0;
}
生成一个权重数组
//生成测试输入数据
var testArr = [];
for(let i=0; i<5; i++){
let weigth = Math.floor(Math.random() * 10);
testArr.push({weigth:weigth,id:i, obj:null});
}
console.log('intput:',testArr);
//得到权重数组:[{"weigth":1,"id":0,"obj":null},{"weigth":7,"id":1,"obj":null},{"weigth":8,"id":2,"obj":null},{"weigth":1,"id":3,"obj":null},{"weigth":7,"id":4,"obj":null}]
输出10次
//测试输出10次
var resOutArr = [];
for(let i=0; i<10; i++){
var resOut = WeigthRNG(testArr);
resOutArr.push(resOut.id);
}
//输出结果
console.log('output:',resOutArr);
//[2,1,2,4,4,1,2,4,2,1]
光输出不太看得出来权重的体现,加上数据分析。
//分组计次计算分布情况,验证权重是否生效
let countObj = resOutArr.reduce((prev, item) => {
if (item in prev) {
prev[item]++;
} else {
prev[item] = 1;
}
return prev;
}, {});
console.log('count result:');
console.log(countObj);
//输出结果
//{1: 3, 2: 4, 4: 3}
我输入的有5个id,10次打印只出了3个,没出现的是是0和3,这也正常,它们权重远低于其他的,下面我们直接输出1000次,看效果咋样,是否符合权重比例。
//单次结果就不放了,数量太多了,直接放分布情况。
//{0: 35, 1: 303, 2: 340, 3: 46, 4: 276}
从0~5,输入的权重比为1:7:8:1:7
,1000次输出后的权重比约为1:8.66:9.71:1.31:7.89
,看起来有些出入,我们输出10000次试一下。
//{0: 465, 1: 2891, 2: 3317, 3: 395, 4: 2932}
再换算一下权重比列为1:6.22:7.13:0.85:6.31
,貌似还是有出入,加量!!!
/ | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
10w | 4101 | 29148 | 33387 | 4058 | 29306 |
20w | 8309 | 58170 | 66732 | 8377 | 58412 |
30w | 12497 | 86956 | 100766 | 12446 | 87335 |
40w | 16611 | 116728 | 133757 | 16566 | 116338 |
50w | 20862 | 146335 | 166505 | 20668 | 145630 |
60w | 24808 | 175176 | 200363 | 24952 | 174701 |
70w | 28939 | 204065 | 233642 | 29145 | 204209 |
80w | 33424 | 233324 | 266602 | 33173 | 233477 |
90w | 37532 | 262342 | 300171 | 37653 | 262302 |
100w | 41408 | 292364 | 333673 | 41279 | 291276 |
比例换算
/ | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
weight | 1 | 7 | 8 | 1 | 7 |
10w | 1 | 7.11 | 8.14 | 0.99 | 7.15 |
20w | 1 | 7 | 8.03 | 1.01 | 7.03 |
30w | 1 | 6.96 | 8.06 | 1 | 6.99 |
40w | 1 | 7.03 | 8.05 | 1 | 7 |
50w | 1 | 7.01 | 7.98 | 0.99 | 6.98 |
60w | 1 | 7.06 | 8.08 | 1.01 | 7.04 |
70w | 1 | 7.05 | 8.07 | 1.01 | 7.06 |
80w | 1 | 6.98 | 7.98 | 0.99 | 6.99 |
90w | 1 | 6.99 | 8 | 1 | 6.99 |
100w | 1 | 7.06 | 8.06 | 1 | 7.03 |
结论,只要样本量够大,就能够足够接近最开始设定的那个权重值,算法没问题。
代码解析
代码中使用的操作对象是普通数组,严格来说,使用“字典”(js中用map)类型,会更加好处理,毕竟字典天然存在“键”,且能通过键去访问值,这里之所以不用,是为了降低了入门的。
权重随机数生成器
这个代码的逻辑实际非常简单,构造一个比较大的符合比例的随机域,在这个随机域里面取数,再匹配权重序列即可。相当于构造一个足够长的分段线段,在里面随便标记一个点,判断这个点是位于那个分段线段内。
我们把代码再po出来,逐行加上注释:
const WeigthRNG = (randArr)=>{
//随机域放大系数
var randAdd = 0;
//通过累加权重得到随机域放大系数
if(randArr && randArr.length && randArr.length>0){
for(let i = 0; i<randArr.length; i++){
let el = randArr[i];
randAdd += el.weigth;
}
}
//默认值
else return 0;
//Math.random()默认范围为[0,1),加上系数后范围为[0,randAdd),Math.floor()可向下取整
var ra = Math.floor(Math.random() * randAdd);
//临时的累加数
var randAddTemp = 0;
//判断生成的随机数位于那个权重段内
for(let i = 0; i<randArr.length; i++){
let el = randArr[i];
randAddTemp += el.weigth;
if(randAddTemp > ra) return el;
}
//默认值
return 0;
}
输入对象构造及处理
输入对象在本系列是非常重要的东西,做些许处理,即可实现很多需求。务必要注意一点的是,生成器每一次生成数据,都是独立的,与上一次无关,所以,输入端在生成后一个随机数后,需要再根据这个随机数调整输入策略,以完成个性化的内容,比如设置保底、限量抽取等,在下一节会详细展示,本节不做分析。
分析方式
一般来说,涉及到大量数据需要处理和验证的情况,没有编程基础的话,用excel录入数据进行计算分析是个比较好的选择。我们计算数据使用程序来,展示用excel(markdown)。为了方便,我用到了一个相对高级数组方法reduce()来分析数组数据,其原型如下,这里不细讲用法,大家有兴趣可以去网上查一下,附一个官方介绍说明:reduce()方法
array.reduce(function(accumulator, currentValue, currentIndex, arr), initialValue);
/*
accumulator: 必需。累计器
currentValue: 必需。当前元素
currentIndex: 可选。当前元素的索引;
arr: 可选。要处理的数组
initialValue: 可选。传递给函数的初始值,相当于accumulator的初始值
*/
至于markdown中表格的用法,可参考markdown表格。同时推荐一个在线表格转换工具,可以把ecxel格式表格,快速转换为markdown格式,参考excel to markdown
下期预告
给“抽奖”加奖池限制机制