水塘抽样算法
一:
简介
作用:水塘抽样算法是一种抽样算法,对于一个很大的集合,抽取的样本值能够保证随机.
特点:其复杂度并不很高O(n)
,并且能够很大程度地节省内存.
问题导入
很多大公司的面试题都考察过这个算法,以谷歌为例,有一道关于水塘抽样的例题
我有一个长度为N的链表,N的值非常大,我不清楚N的确切值.我怎样能写一个尽可能高效地算法来返回K个完全随机的数.
这道题有两个限制:
1.高效,即节省内存的使用
2:尽量随机地返回值
假如我们去掉限制1,可以很简单地做出来,将所有数据加载进内存,计算链表长度,然后通过random函数来求取几个随机数.
这样的效率并不高,把所有数据加载到内存,如果数据非常大可能会导致无法计算.
注意题目中有一个小tip,就是链表.链表这种数据结构是通过数据节点首尾相连形成的链式存储结构.
既然是链表,那么可以一个一个节点处理,不需要将所有数据加载到内存.一个节点一个节点去处理,这还不够形象,将题目换个形式来表述:
我们有1T的文本文件存在硬盘中,想随机抽取几行,保证尽可能少得使用内存并且能够完全随机.
之前想到的加载到内存就不太适合了,但是还可以想到别的办法,比如每次读取一行记录加载到内存,记数+1,清空内存中行数据,直到最后统计一共多少行,然后根据总行数来计算K个随机数.如何再取回行对应的数据呢?我们可以再遍历一遍,一边遍历一边记录这一行的号码是不是在k个随机数中,如果是,则将该行内容保留.
这样的话遍历两次应该可以做到,但是1T数据遍历两次的时间消耗是非常高的.
所以还有更好的方案吗,那就是水塘抽样算法.
水塘抽样算法实现
具体例子
我们先从具体案例中理解水塘抽样算法的实现,再从抽象的角度来理解.
假如10000个数,我们要抽取十个随机数.
一万个数的样本集合数组记作S
.
十个随机数的数组记作R
,代表result
.
先取数组S
中前十个数填充进数组R
.
算法的第一次迭代流程是这样的:
- 从第十一个数(下标为10)开始迭代,生成一个0到10的随机整数
j
,如果j<10
(假如J=4
),我们就将数组R
中的第5项(R[4]
)替换成S
数组中的第11项(S[10]
).
遍历完成生成的R数组,就是我们要求的随机数组.
抽象概念
S[N]S[N]记作:样本集合
R[K]R[K]记作:结果集合
NN记作:S数组大小
JJ记作:每次的随机数
KK记作:前K个随机数
ii记作:迭代次数.
步骤
-
取SS集合中前KK个数填入RR集合
-
从S[K]S[K]开始遍历
生成随机数JJ,范围是0−>K+i−10−>K+i−1.因为数组下标从0开始,所以-1.
如果J<KJ<K,则替换RR中的值->R[j]=S[i]R[j]=S[i].
-
遍历结束,生成结果数组RR.
算法实现(JAVA)
int[] S = new int[10000];
int N = S.length;
Random random = new Random();
//生成一万个数的数组
for (int r = 0;r < N; r ++){
S[r] = random.nextInt(10000);
}
int k = 10;
int[] R = new int[k];
//S前K个数填充R数组
for (int f = 0;f < k; f++){
R[f] = S[f];
}
int j ;
//遍历数组S,根据算法,替换R数组中的元素,最终生成结果R数组.
for (int i = k;i < S.length;i++){
j = random.nextInt(i);
if (j < k) R[j] = S[i];
}
//打印R数组的结果
for (int i =0;i < R.length;i++) {
System.out.println(R[i]);
}
总结一下这种算法.通过一遍遍历就获得了K个随机数,在很大数据的情况下效率是非常高的,非常适合我们的应用场景.
但是为什么这样生成的数是完全随机的呢?
就刚才的具体例子来讲,第一次遍历时,i=10,随机数的范围是0到10共11个数,那么不替换的概率是10/1110/11,等到第二次迭代时,不替换的概率变成10/1210/12,第三次10/1310/13,第四次10/1410/14.......
这样看来好像每一次的概率并不相等,其实并不是这样,我们要看的是最终进入数组RR中的概率,虽然第十一个数进入RR的概率比较大,但是到最后他被替换的概率也很大,所以每个数最终保留在RR中的概率到底是多少呢?
可以参考一下维基百科中的证明,我觉得非常清晰.
在循环内第n行被抽取的机率为k/n,以PnPn表示。如果档案共有N行,任意第n行(注意这里n是序号,而不是总数).
被抽取的机率为:
我们可以求得每行被抽取的概率是相同的,等于k/Nk/N.
非常巧妙,所以当我们面对这种情景时,可以考虑使用水塘抽样进行随机抽取.
二:
水塘抽样是一系列的随机算法,其目的在于从包含n个项目的集合S中选取k个样本,其中n为一很大或未知的数量,尤其适用于不能把所有n个项目都存放到主内存的情况。
在高德纳的计算机程序设计艺术中,有如下问题:可否在一未知大小的集合中,随机取出一元素?。或者是Google面试题: I have a linked list of numbers of length N. N is very large and I don’t know in advance the exact value of N. How can I most efficiently write a function that will return k completely random numbers from the list(中文简化的意思就是:在不知道文件总行数的情况下,如何从文件中随机的抽取一行?)。两题的核心意思都是在总数不知道的情况下如何等概率地从中抽取一行?即是说如果最后发现文字档共有N行,则每一行被抽取的概率均为1/N?
我们可以:定义取出的行号为choice,第一次直接以第一行作为取出行 choice ,而后第二次以二分之一概率决定是否用第二行替换 choice ,第三次以三分之一的概率决定是否以第三行替换 choice ……,以此类推。由上面的分析我们可以得出结论,在取第n个数据的时候,我们生成一个0到1的随机数p,如果p小于1/n,保留第n个数。大于1/n,继续保留前面的数。直到数据流结束,返回此数,算法结束。
问题一
首先考虑k为1的情况,即:给定一个长度很大或者长度未知数据流,限定对每个元素只能访问一次,写出一个随机选择算法,使得所有元素被选中的概率相等。
设当前读取的是第n个元素,采用归纳法分析如下:
- n = 1 时,只有一个元素,直接返回即可,概率为1。
- n = 2 时,需要等概率返回前两个元素,显然概率为1/2。可以生成一个0~1之间的随机数p,p < 0.5 时返回第一个,否则返回第二个。
- n = 3 时,要求每个元素返回的概率为1/3。注意此时前两个元素留下来的概率均为1/2。做法是:生成一个0~1之间的随机数,若<1/3,则返回第三个,否则返回上一步留下的那个。元素1和2留下的概率均为:1/2 * (1 - 1/3) = 1/3,即上一步留下的概率乘以这一步留下(即元素3不留下)的概率。
- 假设 n = m 时,前n个元素留下的概率均为:1/n = 1/m;
- 那么 n = m+1 时,生成0~1之间的随机数并判断是否<1/(m+1),若是则留下元素m+1,否则留下上一步留下的元素。这样一来,元素m+1留下的概率为1/(m+1),前m个元素留下来的概率均为:1/m * (1 - 1/(m+1)) = 1/(m+1),也就是1/n。
- 综上可知,算法成立。
问题二
将问题一中的条件变为,k为任意整数的情况,即要求最终返回的元素有k个,这就是水塘抽样(Reservoir Sampling)问题。要求是:取到第n个元素时,前n个元素被留下的几率相等,即k/n。
算法同上面思路类似,将1/n换乘k/n即可。在取第n个数据的时候,我们生成一个0到1的随机数p,如果p小于k/n,替换池中任意一个为第n个数。大于k/n,继续保留前面的数。直到数据流结束,返回此k个数。但是为了保证计算机计算分数额准确性,一般是生成一个0到n的随机数,跟k相比,道理是一样的。
同样采用归纳法来分析:
- 初始情况 n <= k:此时每个元素留下的概率均为1。
- 当 n = k+1 时,第k+1个元素留下的概率为k/(k+1),前k个元素留下的概率均为:k/k * (1 - k/(k+1) * 1/k) = k/(k+1),即上一步留下的概率乘以这一步留下的概率。
- 假设 n = m 时,每个元素留下的概率均为 k/n = k/m。
- 那么,当 n = m+1 时,第m+1个元素留下的概率为1/(m+1),前m个元素留下的概率均为:k/m * (1 - k/(m+1) * 1/k) = k/(m+1),其中:k/m为上一步留下来的概率,k/(m+1) * 1/k 为这一步不能留下来的概率(第m+1个留下来,同时池中一个元素被踢出的概率)。
- 综上可知,算法成立。