假设我有一个长度数字的链表N
。N
非常大,我事先不知道 的确切值N
。
我怎样才能最有效地编写一个从列表中返回k
完全随机数的函数?
有一个非常好的和有效的算法,使用一种叫做储层采样的方法。
让我从给你它的历史开始:
Knuth在 p 上将此算法称为 R。他 1997 年版的半数算法(计算机编程艺术的第 2 卷)的第 144 卷,并在那里提供了一些代码。Knuth 将该算法归功于 Alan G. Waterman。尽管搜索了很长时间,但我还是找不到 Waterman 的原始文档(如果存在),这可能就是为什么您经常会看到 Knuth 被引用为该算法的来源的原因。
McLeod 和 Bellhouse,1983 (1) 提供了比 Knuth 更彻底的讨论,以及该算法有效的第一个公开证明(据我所知)。
Vitter 1985 (2) 回顾了算法 R,然后提出了另外三种算法,它们提供相同的输出,但有所不同。他的算法不是选择包含或跳过每个传入元素,而是预先确定要跳过的传入元素的数量。在他的测试中(诚然,这些测试现在已经过时了),通过避免随机数生成和对每个传入数字的比较,显着减少了执行时间。
在伪代码中,算法是:
Let R be the result array of size s
Let I be an input queue
> Fill the reservoir array
for j in the range [1,s]:
R[j]=I.pop()
elements_seen=s
while I is not empty:
elements_seen+=1
j=random(1,elements_seen) > This is inclusive
if j<=s:
R[j]=I.pop()
else:
I.pop()
请注意,我专门编写了代码以避免指定输入的大小。这是该算法的一个很酷的特性:您可以运行它而无需事先知道输入的大小,它仍然可以确保您遇到的每个元素都有相同的概率结束R
(也就是说,没有偏见)。此外,R
包含算法一直考虑的元素的公平和代表性样本。这意味着您可以将其用作在线算法。
为什么这行得通?
McLeod 和 Bellhouse (1983) 使用组合数学提供了一个证明。它很漂亮,但在这里重建它会有点困难。因此,我生成了一个更容易解释的替代证明。
我们通过归纳证明进行。
假设我们想要生成一组s
元素并且我们已经看到n>s
了元素。
让我们假设我们当前的s
元素已经以概率被选择s/n
。
根据算法的定义,我们选择n+1
概率为 的元素s/(n+1)
。
已经是我们结果集中一部分的每个元素都有1/s
被替换的概率。
n
因此,来自 -seen 结果集中的元素在-seen 结果集中被替换的概率n+1
为(1/s)*s/(n+1)=1/(n+1)
。相反,元素未被替换的概率为1-1/(n+1)=n/(n+1)
。
因此,n+1
-seen 结果集包含一个元素,如果它是n
-seen 结果集的一部分并且没有被替换---这个概率是(s/n)*n/(n+1)=s/(n+1)
---或者如果元素被选择---具有概率s/(n+1)
。
算法的定义告诉我们,第一个s
元素自动包含n=s
在结果集中的第一个成员中。因此,n-seen
结果集包括具有 (=1) 概率的每个元素,s/n
为我们提供了必要的归纳基础案例。
参考
这称为储层采样问题。简单的解决方案是为列表的每个元素分配一个随机数,如您所见,然后保持顶部(或底部)k 个元素按随机数排序。
我建议:首先找到你的 k 个随机数。对它们进行排序。然后遍历链表和你的随机数一次。
如果您不知何故不知道链表的长度(如何?),那么您可以将第一个 k 抓取到一个数组中,然后对于节点 r,在 [0, r) 中生成一个随机数,如果它小于比 k,替换数组的第 r 项。(不完全相信没有偏见......)
除此之外:“如果我是你,我不会从这里开始。” 您确定链表适合您的问题吗?有没有更好的数据结构,比如好的旧的平面数组列表。
如果您不知道列表的长度,那么您将必须完整地遍历它以确保随机选择。我在这种情况下使用的方法是 Tom Hawtin ( 54070 ) 所描述的方法。在遍历列表时,您将k
构成随机选择的元素保留到该点。(最初你只是添加k
你遇到的第一个元素。)然后,你用列表中的第 th 个元素(即你当时所在的元素)k/i
替换你选择的随机元素。i
很容易证明这给出了随机选择。在看到m
元素 ( m > k
) 之后,我们发现m
列表的第一个元素中的每一个都是您随机选择的一部分,概率为k/m
。这最初成立是微不足道的。然后对于每个元素m+1
,您将其放入您的选择中(替换随机元素) 概率k/(m+1)
。您现在需要证明所有其他元素也有k/(m+1)
被选中的概率。我们知道概率是k/m * (k/(m+1)*(1-1/k) + (1-k/(m+1)))
(即元素在列表中的概率乘以它仍然存在的概率)。使用微积分,您可以直接证明这等于k/(m+1)
。
好吧,您至少需要知道 N 在运行时是多少,即使这涉及对列表进行额外的传递以计算它们。最简单的算法是在 N 中选择一个随机数并删除该项目,重复 k 次。或者,如果允许返回重复数字,请不要删除该项目。
除非你有一个非常大的 N 和非常严格的性能要求,否则这个算法运行起来很O(N*k)
复杂,这应该是可以接受的。
编辑:没关系,Tom Hawtin 的方法要好得多。先选择随机数,然后遍历列表一次。我认为,理论上的复杂性相同,但预期的运行时间要好得多。
为什么你不能做类似的事情
List GetKRandomFromList(List input, int k)
List ret = new List();
for(i=0;i<k;i++)
ret.Add(input[Math.Rand(0,input.Length)]);
return ret;
我敢肯定你的意思不是那么简单,所以你能进一步说明吗?