作为一个常识,每个程序员在做入门学习时,都会被老师谆谆教导:我们用的编程语言中的随机函数,只能产生出伪随机数。它有它的内在规律,只能作为对显示世界的随机事件的近似模拟。接下来,我们通常会被传授随机种子的概念。以及用物理上更随机的量做种子。比如系统时间、两次敲击键盘的时间间隔、多次移动鼠标的偏移、甚至系统出错的出错信息码等等。
作为游戏数值策划,除了加减乘除,用的最多的数学概念恐怕就是随机数了。有经验的数值策划或许从他的前辈那得知计算机中程序产生的随机数并不太可靠;或者他本身就受过程序方面的训练。如果游戏项目更幸运一点,担当数值策划的他是一个数学爱好者,并读过诸如《计算机程序设计艺术》这样的技术书籍,那么事情会好的多。可惜大多数境遇下,策划们从不深究计算机随机数背后的细节,也不太关心所谓“伪”随机数究竟“伪”到什么程度。
最近几天,有测试人员向我抱怨,我们游戏中某些概率设定总感觉有点怪怪的。似乎跟文档上的不同。
这种抱怨并不少见,许多网络游戏玩家都在抱怨系统生成的随机数不太对劲。善良点的玩家会归咎到自己的 RP 上,阴谋论者则指责系统作弊。运营着的游戏背后,数值策划和程序员们有苦说不出。
有必要科普一些数学常识,也作为我周末读书的一些笔记。
鉴于我所接触过的多数游戏策划大多没有很高的数学素养(这里用于对比的参照物——我自己,在数学方面的修养已经够差了),下面不列公式,只列常识。如果涉及一些数学上的结论,也回避证明过程。
以扔硬币为例来看概率:
如果硬币本身没有问题,那么每次实验的结果,正面和反面的出现概率理应一致,都是 50% 。
btw ,如果碰巧连丢了 10 次都是正面的话,认为第 11 次出现反面的概率更高的读者可以不必看下去了。根据基本的概率理论,作为独立实验,相互间是不会造成概率影响的。无论前面出现多少次正面,下一次正面的概率依旧是 50% ,不会多也不会少。当然,如果是现实中出现这种情况,我会认为是道具本身出了问题,或许这个硬币两面都是正面。这样的话,第 11 次倒是极有可能再次出现正面。
对独立的随机事件的单次预测做不到准确,但却可以从统计上得到一个稳定的数值。我们知道,大量做丢硬币的实验 n 次。随着 n 的增大,出现正面和反面的次数会趋与一致。
OK ,以上都是作为一个游戏数值策划应该具有的常识。只是人们往往忽略了一点:若是正面和反面在多次实验中出现次数严格一致的话,又反过来是一件小概率事件。例如:丢一万次硬币,正好出现 5000 次正面的概率大概不会比你买彩票中个小奖的概率高多少。
补充:
在 10000 次一组的丢硬币的实验中,正好出现 5000 次正面的概率依旧是比所以其它情况概率更大一些的。其它情况可能是 10000 次 …… 5001 次、4999 次、4998 次 …… 甚至零次正面。
正好出现 5000 次正面有最大的可能性,但是绝对概率却不大(也不算太小)。
前段时间写过一篇 blog 谈到了用交换法洗牌 ,那篇文章中提及的一篇论文的结论谈到:
“当 N 大于等于 18 时,用这个方法洗牌后,居然恒等排列(identity permutation)是最有可能出现的。(所谓恒等排列大概是指第 n 张排在第 n 个位置)”
这句话很容易被人误解。概率最大并不等于很容易出现,正如 10000 次丢硬币刚好出现 5000 次正面不易出现一样。
如何看一个均匀分布的随机数发生器产生的数值到底随机不随机,简单的用统计产生出来的数字的分布是否接近均等是远远不够的。接下来介绍一下统计学中最著名的 χ 方检验。
假设我们投一个六面骰。每次 1 ~ 6 的点数出现的概率均为 1/6 。如果用计算机来模拟它,采用函数 random (1,6) 来产生一个 1 到 6 之间的随机整数。怎样判断产生的数字够不够随机呢?
我们可以投 n 次(n 很大,比如 n=10000 ),统计出每个点数出现的次数:Y1,Y2,Y3,Y4,Y5,Y6 。理想的次数则都应该接近于 p=n/6 次。
取 V=(Y1 - p)^2 / n + (Y2 - p)^2 / n + (Y3 - p)^2 / n + (Y4 - p)^2 / n + (Y5 - p)^2 / n + (Y6 - p)^2 / n
划简后 V=6/n * ( Y1 到 Y6 的平方和) - n
这个 V 即量化表示了这 n 次实验反应出来的数字随机性。当 V 过大时,骰子可能偏向某几个特定点数更多一些。而 V 过小的话,则可能是随机数发生器有一些明显的规律(事实上,所有伪随机数产生算法都是一定有规律的)。
我们现在需要知道的是,对于一个随机数列,什么样的 V 是比较合理的。对于用随机数做骰子的实验,V 的值也是随机的,当然不是均匀分布。实际上它符合 χ 方分布。如果我们投骰子用的随机数是真正的均匀分布的话,V 值出现特别小或特别大的概率都很小。
实际上,在这个六面骰的例子中,V 有 1% 的可能小于 0.5543 ,有 5% 的可能小于 1.1455 ,有 25% 的可能小于 2.675 ,有 50% 的可能小于 4.351 ,有 75% 的可能小于 6.626 ,有 95% 的可能小于 11.07 ,有 99% 的可能小于 15.09 。
我实验了 gcc 3.4.2 的 libc 中带的随机函数去模拟六面骰。做了五组实验,每组分别为 2000, 4000, 6000, 8000, 10000 次。得到的 V 的值分别为:
9.088 3.371 6.432 15.805 1.7204
注:我使用了系统时间做种子,每次做此实验都会得到不同结果,本质上这些值也是随机的。
每组次数不同是因为:伪随机数列往往具有一定的周期性,当不知道它的周期特性时,n 选的不合适可能导致多段非随机带有某中倾向性的区间相互抵消其影响。
我们再次分析上面得到的五个数据,若随机数真的随机,他们出现的概率大约落在这样五个区间75%~95%) ,(25%~50%) ,(50%~75%),(99%~100%),(5%~25%)
表现不太坏,但也不算好。尤其是第四组实验数据,V=15.805 ,这是个很大的值。因为 V 值本应有 99% 的概率小于 15.09 ,所以出现这个值的概率应该只有不到 1% 。当然这也可能是个巧合(1% 的事件发生并不算太奇怪)。我后来又反复取不同的 n 测算了几次,有一次 V 又小于了 0.55 ,这也不太正常(也只有不到 1% 的可能性)。
最后,我的结论是:我用的 gcc 这个版本的 rand 函数不算很好。至少不能应用于极端要求随机性的场合。它对大量模拟六面骰这件事情上做的不太成功。
ps. 关于 χ 方分布的选定的百分值,可以参考《计算机程序设计艺术 第二卷 半数值算法》的 P39 页。上面我摘取的第五行。因为这里六面骰的点数分布有五个自由度(第六种点数的出现次数可以用其它五种出现次数推算出来)。
前几天写过一篇 blog ,里面随手举了个例子:游戏设计人员设计了一个 10 万分之一的凋落率 。一个做策划的朋友在 gtalk 上问我,这并不是个复杂的问题呀。难道有什么玄机?
按这个朋友的思路,产生两次随机数就够了,一次 random(1,1000) 一次 random(1,100) ,只有两个数值都为 1 时,这个十万分之一个小概率事件才发生。
不错,理论上是这样,实际我们大多也这样的。而且很高兴看到,作为一个数值策划,他回避了直接用 random(1,100000) 。有编程常识的兄弟们都知道,大多数语言提供的标准数学函数库中不提供十万分之一级别精度的随机函数。
但是精度问题依然存在。我反过来问如果产生一个十万分之七的随机数时,他出现了一点小失误:random(1,100) * random(1,1000) <=7 是不对的。当然这个小错误很容易被修正。
实际应用中,更为正确的做法是回避直接使用高分辨率的随机数列。因为通常所用的线形同余序列产生的伪随机数列都不适用(道理很简单,每个随机数的精度要求提高意味着伪随机数列的周期变短,过短的周期性必然带来随机性的丧失)。如果真的有这类需求,我们应该让程序员去专门写一个了。
不光是这种超高精度随机数的需求应该尽量被回避。单次随机量的自由度也最好被限制。计算机模拟丢硬币总比模拟投六面骰看起来要真实一些。而模拟六面骰又好过二十面。用固定面数的骰子模拟出来,也比数值设定时八卦一个百分比概率要强。
原因并非完全源于它们需要的精度不同。更重要的是自由度小一些的随机量,更容易被统计方法检验是否合理。一旦程序产生的伪随机数随机性被检验出来不太好,我们总有机会去尝试一个更好的,不是吗?
最后推荐一个随机数发生器:http://www.agner.org/random/