由于背单词软件中需实现测试单词与答案选项的随机排列和带权值的概率抽取,程序中实现了以下三个算法:
1.等概率随机排列数组(洗牌算法)
假设有一个数组,包含n个元素。现在要重新排列这些元素,要求每个元素被放到任何一个位置的概率都相等(即1/n),并且直接在数组上重排(in place),不要生成新的数组。用 O(n) 时间、O(1) 辅助空间。
算法是非常简单了,当然在给出算法的同时,我们也要证明概率满足题目要求。
先想想如果可以开辟另外一块长度为n的辅助空间时该怎么处理,显然只要对n个元素做n次(不放回的)随机抽取就可以了。先从n个元素中任选一个,放入新空间的第一个位置,然后再从剩下的n-1个元素中任选一个,放入第二个位置,依此类推。
按照同样的方法,但这次不开辟新的存储空间。第一次被选中的元素就要放入这个数组的第一个位置,但这个位置原来已经有别的(也可能就是这个)元素了,这时候只要把原来的元素跟被选中的元素互换一下就可以了。很容易就避免了辅助空间。
来计算一下概率。如果某个元素被放入第i(1≤i≤n)个位置,就必须是在前 i - 1 次选取中都没有选到它,并且第 i 次选取是恰好选中它。其概率为:
可见任何元素出现在任何位置的概率都是相等的。
实现代码
public static T[] Shuffle(IList list)
{
int x = 0;
T[] array = new T[list.Count];
list.CopyTo(array, 0);
for (x = list.Count()-1; x >= 0; x--)
{
int y = random.Next(x);
T temp = array[y];
array[y] = array[x];
array[x] = temp;
}
return array;
}
2.单次遍历,等概率随机选取一组元素
假设我们有一堆数据(可能在一个链表里,也可能在文件里),数量未知。要求只遍历一次这些数据,随机选取其中的一个元素,任何一个元素被选到的概率相等。O(n)时间,O(1)辅助空间(n是数据总数,但事先不知道)。
如果元素总数为n,那么每个元素被选到的概率应该是1/n。然而n只有在遍历结束的时候才能知道,在遍历的过程中,n的值还不知道,可以利用乘法规则来逐渐凑出这个概率值。在《利用等概率Rand5产生等概率Rand3》中提到过,如果要通过有限步概率的加法和乘法运算,最终得到分子为1、分母为n的概率,那必须在某一次运算中引入一个n在分母上,而分母和分子上其他的因数则通过加法、乘法、约分等规则去除。
OK,问题解决了。结束之前再做个简单的扩展,改成等概率随机选取m个元素(可知每个元素被选中的概率都是m/n)。
实现代码
public static T[] SelectItems(IEnumerable source, int count)
{
List list = new List();
int x = 0;
foreach (T item in source)
{
if (x < count)
list.Add(item);
else
{
int rand = random.Next(x);
if (rand < count)
list[rand] = item;
}
x++;
}
return list.ToArray();
}
3.单次遍历,带权随机选取
还是同样的问题:有一组数量未知的数据,每个元素有非负权重。要求只遍历一次,随机选取其中的一个元素,任何一个元素被选到的概率与其权重成正比。
算法很简单:对于任意的i(1 <= i <= n
),按照如下方法给第i个元素分配一个键值key(其中ri是一个0到1之间等概率分布的随机数):
之后,如果要随机选取一个元素,就去key最大的那个;如果要选取m个元素,就取key最大的m个。
真不知道是怎么想出来的这样的方法,不过还是先来关注一下证明的过程。
m=1证明
对于m=1的证明过程会介绍得详细些,主要是怕我自己过几天就忘记了。概率达人可以直接秒杀之。
m=1时,第i个元素被选取到的概率,就等于它所对应的键值key(i)是最大值的概率,即:
把key(i)的计算公式代入,但要注意公式中的ri并不是一个固定的数值,而是随机变量。不考虑计算机数值表示的精度,可以假设ri是一个在0到1之间的连续均匀概率分布,因此如果要计算key(i)是最大的概率,必须要对ri所有的可能值进行概率累加,也就是积分。于是上面的概率表达式就被写成:
再看式子中的∀,它表示每一个j都要满足后面的条件,而各个j之间相互独立,因此可以写成概率乘积,于是得到:
对于给定的j,,另外rj也是个均匀概率分布,将概率密度函数代入可以得到:
因此,上面的概率算式就变成(其中w就是之前提到的所有元素的权重之和):
m>=1证明
当m取任意值时,概率公式变得非常复杂,在前一篇文章中使用了第i个元素不被选到的概率来简化表达式。现在的证明也从同样的角度进行。
第i个元素不被选到的概率,显然等于这n个元素中,至少存在m个元素的键值大于key(i),与之前的讨论一样,不妨设这m个元素的下标(按键值从大到小)依次为j1, j2, ..., jm,,满足。注意jk和tk的取值范围,为了简单起见,下面的式子中就不再重复了。
为了能够进一步求解,必须把这个连等式拆开。这里要非常小心,各个jk并不是相互独立的,比如当j1改变的时候,j2的取值范围也会随之变化,依此类推。拆开之后的式子如下:
看起来还是相当恐怖的,一层套一层。注意等式右边已经没有显式地关于i的信息了,这些信息被隐含在jk和tk的取值范围中,切记。对每个jk,把key(jk)的式子代进去,转换成积分;同时把∀tk转换为∏tk。这些在m=1的证明中都提到过了。新出现的是∃jk,这个显然适用概率加法,因为jk取不同的值对应于不同的互斥方案。经过这些变换得到:
其中的积分式在之前已经见过了,其运算过程如下(注意tk的取值范围):
最终,概率计算式子变成:
与之前的理论值完全一样。
呼,可怕的推导过程。
实现代码
public static T[] SelectWeightedItems(IEnumerableie, int count, Func > weightfunc) { SortedList<double, T> sd = new SortedList<double, T>(); foreach (T item in ie) { double weight = weightfunc(item); if (weight <= 0) continue; double key = Math.Pow(random.NextDouble(), (1.0 / weight)); if (sd.Count < count) sd.Add(key, item); else { sd.RemoveAt(0); sd.Add(key, item); } } return sd.Values.ToArray(); } }double