在游戏中经常遇到按一定的掉落概率来随机掉落指定物品的情况,例如按照:钻石10%,金币20%,银币50%,饰品10%,装备10% 来计算掉落物品的类型。
问题的抽象
给定一个集合,要从中以给定的概率分布随机抽取其中的元素。
给定一个离散型随机变量的概率分布
P(X=i)=pi?,i∈1,...N;∑i=1N?pi?=1.,从离散集合中进行采样,要求采样结果尽可能服从概率分布P.
平凡解法(Trivial Solution)
比较容易想到的方法是
- 根据要求的概率分布计算累积分布,映射到[0,1]的线段上;
- 然后通过一个[0,1]之间均匀分布的随机生成器,生成一个[0,1]之间的随机数;
- 判断这个数落在[0,1]线段上的哪个分段,就输出相应的元素。
例如【钻石,金币,银币,饰品,装备】的概率[0.1, 0.2, 0.5, 0.1, 0.1],其累积分布的列表[0.1, 0.3, 0.8, 0.9, 1];
然后生成一个随机数比如0.618,那么累加概率列表中第一个大于0.618的数为0.8,对应的元素类别是银币。又生成一个随机数0.816,则找到0.9对应的元素是饰品。
复杂度主要来自判断随机数落在哪个分段,线性搜索时是O(n),用BST(二分查找)存储时,复杂度降为O(logN).
一个朴素的想法
将上面的线段扩展成二维的矩形方块。
原论文中的例子:
一个随机事件包含四种情况A,B,C,D,每种情况发生的概率分别为: 1/2,1/3,1/12,1/12,问怎么用产生符合这个概率的采样方法。
第一步:将四个事件排列成1-4,扔两次骰子,第一次扔1~4之间的整数,决定落在哪一列;
第二步:按照四种概率中最大的那个概率进行归一化。第一次扔骰子决定好了哪一列,第二次扔骰子,得到[0~1]之间的随机数。如图,如果落在了第一列上,这次不论随机数是多少,都采样事件A。
如果落在第二列上,第二次的随机数如果小于2/3则采样成功,取事件B;如果超过2/3则失败,重新采样。依次类推。
这样算法复杂度最好为O(1)最坏有可能无穷次,平均意义上需要采样O(N)次。怎样优化呢?
Alias Method
虽然非均匀分布的平凡解法最好能做到对数时间复杂度,但对于非均匀的伯努利实验(随机变量可能取值只有2种),我们仍能在常数时间内解决问题。
若随机变量的取值可能有 k 个,那必然有部分取值的概率小于
k1?,同时有另一些不小于
k1?。
我们可以通过拆借的方法,把概率大于
k1? 的部分借给概率小于
k1? 的部分,使得所有取值上的概率都恰好等于
k1?;从而使非均匀采样问题变成均匀采样问题。
Alias Method 充分利用概率分布加和为1的性质,通过空间换时间的方法,在常数时间内,完成非均匀到均匀采样的映射。
还是如上面的那样的思路,但是如果我们不按照其中最大的值去归一化,而是按照其均值归一化。
按照
N1?归一化,即是所有概率乘以N。
上面的例子,四种事件均值是1/4,[1/2,1/3,1/12,1/12]分别乘以4得到下图:
其总面积为N,然后可以将其分成一个1*N的长方形。将前两个多出来的部分补到后面两个缺失的部分中。
先将1(第一列)中的部分补充到4中:
这时如果,将1,2中多出来的部分,补充到3中,就麻烦了,因为又陷入到如果从中采样超过2个以上的事件这个问题中,所以
Alias Method一定要保证:每列中最多只放两个事件
所以此时需要将1中的补到3中去,不管1中是不是少于1,先把3填满;再将2中的补到1中:
Alias Method具体算法如下:
- 按照上面说的方法,将整个概率分布拉平成为一个1*N的长方形即为Alias Table,
构建上面那张图之后,储存两个数组accept和alias,accept[i]里面存着第i列对应的事件i矩形占的面积百分比【也即其概率】,上图的话数组就为Prab[32?,1,31?,31?],另一个数组alias[i]里面储存着第i列不是事件i的另外一个事件的标号,像上图就是Alias[2 NULL 1 1]
- 产生两个随机数,第一个产生1~N 之间的整数i,决定落在哪一列。扔第二次骰子,0~1之间的任意数,判断其与Prab[i]大小,如果小于Prab[i],则采样i,如果大于Prab[i],则采样Alias[i]。
构建方法:
1.找出其中面积小于等于1的列,如i列,这些列说明其一定要被别的事件矩形填上,所以在Prab[i]中填上其面积
2.然后从面积大于1的列中,选出一个,比如j列,用它将第i列填满,然后Alias[i] = j,第j列面积减去填充用掉的面积。
以上两个步骤一直循环,直到所有列的面积都为1了为止。
如果按照上面的方法去构建Alias Table,算法复杂度是
O(n2)的,因为最多需要跑N轮,而每跑一轮的最大都需要遍历N次。一个更好的办法是用两个队列A,B去储存面积大于1的节点标号,和小于1的节点标号,每次从两个队列中各取一个,将大的补充到小的之中,小的出队列,再看大的减去补给之后,如果大于1,继续放入A中,如果等于1,则也出去,如果小于1则放入B中。
这样算法复杂度为
O(n)
原论文中的算法:
Algorithm: Vose’s Alias Method
- Initialization:
- Create arrays Alias and Prob, each of size n.
- Create two worklists, Small and Large.
- Multiply each probability by n.
- For each scaled probability
pi?<1:
- If
pi?<1, add
i to Small.
- Otherwise (pi≥1), add i to Large.
- If
- While Small and Large are not empty: (Large might be emptied first)
- Remove the first element from Small; call it
l.
- Remove the first element from Large; call it
g.
- Set
Prob[l]=pl?.
- Set
Alias[l]=g.
- Set
pg?:=(pg?+pl?)?1. (This is a more numerically stable option.)
- If
pg?<1, add g to Small.
- Otherwise (
pg?≥1), add g to Large.
- Remove the first element from Small; call it
- While Large is not empty:
- Remove the first element from Large; call it g.
- Set
Prob[g]=1.
- While Small is not empty: This is only possible due to numerical instability.
- Remove the first element from Small; call it
l.
- Set
Prob[l]=1.
- Remove the first element from Small; call it
- Generation:
- Generate a fair die roll from an n-sided die(n面骰子); call the side
i.
- Flip a biased coin that comes up heads with probability
Prob[i].
- If the coin comes up “heads,” return i.
- Otherwise, return
Alias[i].
- Generate a fair die roll from an n-sided die(n面骰子); call the side
c++实现:
https://liam.page/2019/12/02/non-uniform-random-choice-in-constant-time-complexity/
python实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | import numpy as np def gen_prob_dist(N): p = np.random.randint(0,100,N) return p/np.sum(p) def create_alias_table(area_ratio): l = len(area_ratio) accept, alias = [0] * l, [0] * l small, large = [], [] for i, prob in enumerate(area_ratio): if prob < 1.0: small.append(i) else: large.append(i) while small and large: small_idx, large_idx = small.pop(), large.pop() accept[small_idx] = area_ratio[small_idx] alias[small_idx] = large_idx area_ratio[large_idx] = area_ratio[large_idx] - (1 - area_ratio[small_idx]) if area_ratio[large_idx] < 1.0: small.append(large_idx) else: large.append(large_idx) while large: large_idx = large.pop() accept[large_idx] = 1 while small: small_idx = small.pop() accept[small_idx] = 1 return accept,alias def alias_sample(accept, alias): N = len(accept) i = int(np.random.random()*N) r = np.random.random() if r < accept[i]: return i else: return alias[i] def simulate(N=100,k=10000,): truth = gen_prob_dist(N) area_ratio = truth*N accept,alias = create_alias_table(area_ratio) ans = np.zeros(N) for _ in range(k): i = alias_sample(accept,alias) ans[i] += 1 return ans/np.sum(ans),truth if __name__ == "__main__": alias_result,truth = simulate() |
Ref:
Darts, Dice, and Coins: Sampling from a Discrete Distribution
时间复杂度为O(1)的离散采样算法
浅梦-Alias Method