Spiga

用JavaScript玩转游戏编程(一)掉宝类型概率

2010-04-21 14:50 by Milo Yip, 8355 visits, 收藏, 编辑

问题定义

游戏(和一些模拟程序)经常需要使用随机数,去应付不同的游戏(或商业)逻辑。本文分析一个常见问题:有N类物件,设第i类物件的出现概率为P(X=i),如何产生这样的随机变量X?

例如对概率的要求是

P(X=0)=0.12
P(X=1)=0.4
P(X=2)=0.4
P(X=3)=0.07
P(X=4)=0.01

输入数组<0.12, 0.4, 0.4, 0.07, 0.01> 输出符合以上概率的随机数序列,如<1, 4, 2, 1, 2, 2, 1, 0, ...> 。

以下先谈一些统计学背景知识,再给这问题的可行解法。

概率分布

这问题要产生一个随机变量,接近指定的概率分布(probability distribution)。大部份程序语言都提供接近均匀分布(uniformly distributed)的伪随机数产生器(pseudorandom number generator, PRNG),例如JavaScript提供的Math.random()函数,可传回 [0, 1)半开区间的均匀分布伪随机数。

密度分布函数

现在,不仿测试一下JavaScript的Math.random()函数,看看它是否均匀分布。一个变数的分布以密度分布函数(probability density function, PDF)定义,一般写作f_X(x),随机变量X在区间[a,b]上的概率为定积分:

P(a\leq x\leq b)=\int_{a}^{b} f_X(x)dx

为了把PDF视觉化,可以把X分为若干区间,统计各区间X出现的频率,绘画其直方图(histogram)。笔者写了一个简单的JavaScript框架,用HTML5 Canvas绘画直方图。以下测试代码,可绘画Math.random()的PDF估值(estimate)。



在统计学中,每个数据称为取样(sample),当取样数目n越大,可以看到其PDF估值越接近平均。

读者可以试试,把x的赋值改为Math.pow(Math.random(), 2)。你会发现,PDF的分布改变了,其密度更集中于左边。读者也可以改为其他表达式(只要其输出在[0, 1)的范围),看看其分布。如果想看精确一点,也可以加大frequency数组。

累积分布函数

密度分布函数,可以变换为累积分布函数(cumulative distribution function, CDF),代表随机变量X小于x的概率:

F_X(x)=P(X\leq x)

在X为连续(continuous)的情况下,CDF可用PDF定义:

F_X(x)=\int_{-\infty }^{x}f_X(t)dt

在X为离散(discrete)的情况下,CDF可定义为:

F_X(x)=\sum_{x_i\leq x}{P(X=x_i)}

以下的pdf2cdf()函数,能把离散的PDF数组,转换为CDF数组。由于浮点小数相加会有误差,最后的值可能少于1,有机会产生bug,函数里强制指定最后一个元素为1。

function pdf2cdf(pdf) {
    var cdf = pdf.slice();
    
    for (var i = 1; i < cdf.length - 1; i++)
        cdf[i] += cdf[i - 1];

    // Force set last cdf to 1, preventing floating-point summing error in the loop.
    cdf[cdf.length - 1] = 1;
    
    return cdf;
}

以下代码测试绘画Math.random()的CDF估值(只把plotPdf改了為plotCdf):



均匀分布的Math.random(),其CDF估值接近斜线。

题解

这问题其实正式来说,可称为模拟离散取样(simulated discrete sampling),跟据有限类别的指定概率,来模拟取样。

要制造指定的概率分布随机变量,关键就是如何把均匀分布变换。

逆变换取样

在上节中,显示了CDF的一些特性,例如CDF的范围是[0,1],而且是一个单调递增(monotonic increasing)函数。逆变换取样(inverse transform sampling)利用了这些特性,去解决这个问题。逆变换取样方法其实很简单,给一个目标CDF,只要计算其逆函数(inverse function),就可以把均匀的随机变数转换为目标CDF:

X=F_X^{-1}(Y)

这方法能用在所有CDF(包括连续及离散的)。其数学证明可参考维基百科

下图显示这个方法的直观解读,在Y轴[0,1]范围里均匀取样(y_i),之后向右和CDF取交点,求交点的X轴位置(x_i),X则是符合CDF的概率分布。

这个方法用在离散的情况就更简单,只需搜寻目标的CDF,找出超过均匀取样的元素即可。代码如下:

function discreteSampling(cdf) {
    var y = Math.random();
    for (var x in cdf)
        if (y < cdf[x])
            return x;
            
    return -1; // should never runs here, assuming last element in cdf is 1
}

题目的测试:



分析

在离散的情况下(本文题目要求),其时间复杂度是O(N),其中N为类别数目。

读者可能会注意到,这里用了线性搜寻(linear search),如果targetPdf数组是由大至小排列,平均而言会更快找到结果。另外,也可以用二分搜寻(binary search),那么复杂度会降低为O(lg N),这留给读者作为练习。

事实上,这个问题用二分搜寻是标准的方法。那么,还有没有更快的方法呢?答案是肯定的,例如别名方法(alias method)、近似方法等,有兴趣的读者可参考[1]。当然,在N很小的情况下,线性搜寻和二分搜寻也足够。

结语

笔者以前的著作也简单提及过这个问题,不过本文加入理论背景,希望读者能更深入了解。这个问题在游戏中经常使用,例如按设计概率产生怪物、宝物,或是用来控制非玩家角色(non-playable character, NPC)的行为。模拟取样亦使用在计算机图形学上,例如粒子系统,或是采用蒙地卡罗积分法(Monte Carlo integration)的渲染算法。后者大概会在不可预计的将来,于另一系列里探讨。

笔者撰写本文,灵感来自这篇博文。其算法实际上是储存CDF的逆函数取样,利用空间和有限的CDF精确度,换取O(1)的时间复杂度。衡量N的大小、精确度、空间需求、缓存延迟后,或许该方法也能适合某些个别需求。但对于该文作者说N最大为100,二分搜寻只需最多7次迭代,因缓存问题可能二分搜寻更快。有鉴于该文未详细讨论这些需求分析、背后理论、以至代码可能对一些网友来说比较难理解,希望本文能加以补充。

这系列会探讨一些游戏编程相关的问题,例如随机相关(PRNG、洗牌、其他随机取样方法)、游戏机制相关(状态机、细包自动机)等等。网友们也可以提供一些题目,大家互相讨论学习。

本文的JavaScript完整程序可在此下载

参考

更新

  • 2010-04-21 感谢livepine,修正问题定义中,数组最后一个值。
  • 2010-04-26 修正下载连结
Add your comment

12 条回复

  1. #1楼 Justin      2010-04-21 15:15
    推荐++
    向楼主学习!
     回复 引用 查看   
  2. #2楼 voop      2010-04-21 15:18
    LZ出本游戏游戏开发书籍吧。。。
     回复 引用 查看   
  3. #3楼 无为无知无欲      2010-04-21 15:41
    你说的太理论了,没看懂.
    我这样想不知行不行,
    [0.12, 0.4, 0.4, 0.07, 0.01] 数组 X100,
    P(X=0)=0-12
    P(X=1)=p0+1-p0+40+1
    P(X=2)=p1+1-p1+40+1
    P(X=3)=p2+1-p2+7+1
    P(X=4)=p3+1-p3+1+1

    这样只要取0-100的随机数,判断落在那个范围内就可以了.
     回复 引用 查看   
  4. #4楼[楼主] Milo Yip      2010-04-21 16:18
    @无为无知无欲
    其實是否乘上100,並不重要。你的想法其實和那個discreteSampling()函數是等價的。

    你可以考慮,先檢測那個(均勻)隨機數y,是否落在 y < 0.12,再檢查y < 0.12 + 0.4、y < 0.12 + 0.4 + 0.4等等。

    這些測試的右側,其實就是CDF。而discreteSampling()函數裡的for循環就是做這個。
     回复 引用 查看   
  5. #5楼 atyuwen      2010-04-21 16:53
    对于离散概率分布来说,alias method 应该算是完美方法了吧
    O(nlogn)的预处理,O(1)的随机数生成,实现简单,编码方便
     回复 引用 查看   
  6. #6楼 atyuwen      2010-04-21 16:57
    milo大博客更新这么勤,别耽搁了译书啊...
     回复 引用 查看   
  7. #7楼 mylhei      2010-04-21 17:57
    路过顶之!
     回复 引用 查看   
  8. #8楼 livepine      2010-04-21 20:48
    问题定义中的“P(X=4)=0.1”和“输入数组<0.12, 0.4, 0.4, 0.07, 0.1>”,最后一个都应该是 0.01 吧?
     回复 引用 查看   
  9. #9楼[楼主] Milo Yip      2010-04-21 22:01
    @livepine
    引用livepine:问题定义中的“P(X=4)=0.1”和“输入数组<0.12, 0.4, 0.4, 0.07, 0.1>”,最后一个都应该是 0.01 吧?

    是我錯了,去改。
     回复 引用 查看   
  10. #10楼[楼主] Milo Yip      2010-04-26 12:36
    @atyuwen
    引用atyuwen:
    对于离散概率分布来说,alias method 应该算是完美方法了吧
    O(nlogn)的预处理,O(1)的随机数生成,实现简单,编码方便

    我自己末實現過alias method,有興致的時候再實現一下 :)
     回复 引用 查看   
  11. #11楼 王洪剑      2010-04-26 20:14
    肖老师看到你这篇文章又会说:你小子又来砸我场子!
    http://blog.csdn.net/tonyxiaohome/archive/2010/04/15/5488203.aspx
     回复 引用 查看   
  12. #12楼 Kevan      2010-06-18 18:03
    再次膜拜MILO高手!!!
     回复 引用 查看