Google曾经有一道非常经典的面试题:
给你一个长度为N的链表。N很大,但你不知道N有多大。你的任务是从这N个元素中随机取出k个元素。你只能遍历这个链表一次。你的算法必须保证取出的元素恰好有k个,且它们是完全随机的(出现概率均等)?
这道题的解法非常多,网上讨论也非常热烈。本文要讨论的是,这个问题是从何而来,有什么实用价值?
自从有了Hadoop之后,该问题便有了新的应用载体。随着数据量的增多,很多数据挖掘算法被转移到MapReduce上实现,而数据挖掘中有个基本的问题是怎样对数据进行抽样。在Hadoop中,每个job会被分解成多个task并行计算,而数据的总量事先是不知道的(知道job运行结束才能获取数总数,而数据量非常大时,扫描一遍数据的代价非常高),用户知道的只是要获取的样本量,那怎样在类似于Hadoop的分布式平台上进行数据抽样?
回过头来看google的这道面试题,是不是正好时Hadoop平台上海量数据抽样问题?
2. 在Hadoop上编写抽样程序
2.1 解法一
(1) 设计思想
蓄水池抽样:先保存前k个元素, 从第k+1个元素开始, 以1/i (i=k+1, k+2,…,N) 的概率选中第i个元素,并随机替换掉一个已保存的记录,这样遍历一次得到k个元素,可以保证完全随机选取。
(2) MapReduce实现
要实现该抽样算法,只需编写Mapper即可。在Map函数中,用户定义一个vector保存选中的k个元素,待扫描完所有元素后,在析构函数中将vector中的数据写到磁盘中。
用户运行job时,需指定每个map task的采样量。比如,用户该job的map task个数为s,则每个map task需要采集k/s个元素。
(3) 优缺点分析
由于该job没有reduce task,因而效率很高。
2.2 解法二
(1) 设计思想
依次扫描每个元素,为每个元素赋予一个随机的整数值;然后使用Top K算法(譬如最大K个整数)得到需要的K个元素。
(2) MapReduce实现
要实现该算法,用户需要编写mapper和reducer,在map函数中,为每个元素赋予一个随机数,并将该随机数作为key;在reduce函数中,每个reduce输出前k/t个元素(其中t为reduce task个数)。
(3) 优缺点分析
该算法比第一种算法低效,但由于整个过程自然流畅,实现起来非常简单,不易出错。
2.3 解法三
(1) 设计思想
考虑第一个元素,其以K/N的概率被选中;如果该节点被选中,则从剩下的(N-1)个元素中选出(K-1)个元素;如果没有被选中,则从剩下的(N-1)个元素中选出K个元素,…,依次这样下去,直到获取K个元素。
(2) MapReduce实现
用户只需编写Mapper即可。首先要获取每个map task输入的数据量,这个可以在InputFormat中计算得到。然后,在每个map函数中,采集k/s(其中s为map task数据量)个元素。
(3) 优缺点分析
由于该算法没有reduce task,效率比较高,但需要在InputFormat中统计数据量,编程复杂度较高。
3. 延伸
这个问题与《编程珠玑》上讨论的问题很相似:
输入两个整数m和n,其中m<n。输出是0~n-1范围内m个随机整数的有序表,不允许重复。
对于该问题,大致存在四种算法,他们有不同的优缺点。
(1) 第一种方法来自Knuth的《The art of Computer Programming, Volume 2: Seminumerical Algorithms》
伪代码是:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
select = m
remaining = n
for
I = [0 n )
if
(bigrand() % remaining) < select
print i
select—
remaining—
|
只要m<=n,程序选出来的整数就恰为m个。
C++的实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
void
genKnuth(
int
m,
int
n) {
for
(
int
i = 0; i < n; i++) {
if
(bigrand() % (n - i) < m) {
cout <<i << endl;
m--;
}
}
}
|
该算法非常节省空间,但需要全部扫描n个数,当n很多时,效率不高。
(2)第二种方法的复杂度只与m有关,采用了set(实际上是红黑树)节省时间。代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
void
gensets(
int
m,
int
n) {
set<
int
> S;
while
(S.size() < m) {
S.insert(bigrand() % n);
}
// print S
}
|
该方法每次插入均在O(log m)时间内完成,但需要的空间开销很大。
(3)第三种方法克服了(2)的缺点,代码如下:
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
|
void
genshuf(
int
m,
int
n) {
int
i, j;
int
*x =
new
int
[n];
for
(i = 0; i < n; i++) {
x[i] = i;
}
for
(i = 0; i < m; i++) {
j = randint(i, n-1);
int
t = x[i]; x[i] = x[j]; x[j] = x;
}
sort(x, x+m);
//print result
}
|
该算法需要n个元素的内存空间和O(n+mlogm)的时间,其性能通常不吐Knuth的算法。
(4)当m接近n时,基于集合的算法生成的很多随机数都要丢掉,因为之前的数已经存在于集合中了,为了改进这一点,算法如下:
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
|
void
genfloyd(
int
m,
int
n)
{
set<
int
> S;
set<
int
>::iterator i;
for
(
int
j=n-m; j < n; j++) {
int
t = bigrand()%(j+1);
if
(S.find(t) == S.end()){
S.insert(t);
// t not in S
}
else
{
S.insert(j);
// t in S
}
}
//print results
}
|
4. 参考资料
(1) 《编程珠玑》第二版
(2) http://hi.baidu.com/sunxiangwei/blog/item/a7839b51bdbf522e42a75b45.html
原创文章,转载请注明: 转载自董的博客
作者:Dong,作者介绍:http://dongxicheng.org/about/
本博客的文章集合:http://dongxicheng.org/recommend/
随即抽样问题:
要求从N个元素中随机的抽取k个元素,其中N无法确定。
是在 《计算机程序设计与艺术》 中看到的这个题目,书中只给出了解法,没给出证明。
解决方法是叫Reservoir Sampling (蓄水池抽样)
Init : a reservoir with the size: k
for i= k+1 to N
M=random(1, i);
if( M < k)
SWAP the Mth value and ith value
end for
证明:
每次都是以 k/i 的概率来选择
例: k=1000的话, 从1001开始作选择,1001被选中的概率是1000/1001,1002被选中的概率是1000/1002,与我们直觉是相符的。
接下来证明:
假设当前是i+1, 按照我们的规定,i+1这个元素被选中的概率是k/i+1,也即第 i+1 这个元素在蓄水池中出现的概率是k/i+1
此时考虑前i个元素,如果前i个元素出现在蓄水池中的概率都是k/i+1的话,说明我们的算法是没有问题的。
对这个问题可以用归纳法来证明:k < i <=N
1.当i=k+1的时候,蓄水池的容量为k,第k+1个元素被选择的概率明显为k/(k+1), 此时前k个元素出现在蓄水池的概率为 k/(k+1), 很明显结论成立。
2.假设当 j=i 的时候结论成立,此时以 k/i 的概率来选择第i个元素,前i-1个元素出现在蓄水池的概率都为k/i。
证明当j=i+1的情况:
即需要证明当以 k/i+1 的概率来选择第i+1个元素的时候,此时任一前i个元素出现在蓄水池的概率都为k/(i+1).
前i个元素出现在蓄水池的概率有2部分组成, ①在第i+1次选择前得出现在蓄水池中,②得保证第i+1次选择的时候不被替换掉
①.由2知道在第i+1次选择前,任一前i个元素出现在蓄水池的概率都为k/i
②.考虑被替换的概率:
首先要被替换得第 i+1 个元素被选中(不然不用替换了)概率为 k/i+1,其次是因为随机替换的池子中k个元素中任意一个,所以不幸被替换的概率是 1/k,故
前i个元素中任一被替换的概率 = k/(i+1) * 1/k = 1/i+1
则没有被替换的概率为: 1 - 1/(i+1) = i/i+1
综合① ②,通过乘法规则
得到前i个元素出现在蓄水池的概率为 k/i * i/(i+1) = k/i+1
故证明成立