蓄水池抽样(Reservoir Sampling)是一种从数据流中随机选出若干个不重复数据的抽样方法。
这个方法的特点是:
- 数据流的长度很大且未知,不能将这些数据全部放进内存。
- 数据只能被遍历一次而不能重复遍历。
简单算法
维护一个一个大小为k的蓄水池,最初包含数据流中前k个数据。然后遍历数据流的剩余数据。设数组索引从1开始,当遍历到第 i 个数据时,生成一个随机数字 j,随机数的范围为 1 <= j <= i。
- 如果 j<=k, 说明这个数据被选中放进蓄水池中,将它放进蓄水池中第 j 个位置中,原来在第j个位置的数据被替换出蓄水池。
- 如果 j>k,则放弃这个索引为j的数据。
重复上述操作,直至所有数据被遍历。可以证明,每个数据被选中的几率为 1/N。
题目实例
382. 链表随机节点
给定一个单链表,随机选择链表的一个节点,并返回相应的节点值。保证每个节点被选的概率一样。
进阶:
如果链表十分大且长度未知,如何解决这个问题?你能否使用常数级空间复杂度实现?
思路
题目要求返回一个随机结点的值,故定义一个大小为1的蓄水池reservoir,按照上面的方法,当遍历到第 i 个结点时,生成一个随机数 rand,其中 0 <= rand <= i(下标从0开始),如果rand的值为0,就将该结点放进蓄水池中。遍历所有结点,最后得到的结点就是随机结点。
代码
class Solution {
private ListNode list;
public Solution(ListNode head) {
this.list = head;
}
public int getRandom() {
int count = 0;
ListNode reservoir = this.list;
ListNode cur = list;
while(cur != null){
int rand = (int) (Math.random() * (count+1));
if(rand == 0)
resevoir = cur;
cur = cur.next;
count++;
}
return resevoir.val;
}
}
398. 随机数索引
给定一个可能含有重复元素的整数数组,要求随机输出给定的数字的索引。 您可以假设给定的数字一定存在于数组中。
思路
和382. 链表随机节点不同的是,这里生成随机数的区间不是0~i,而是应该记录当前出现的值为target的元素个数n,进而生成随机数0<=rand<=n。
代码
class Solution {
int[] data;
public Solution(int[] nums) {
data = nums;
}
public int pick(int target) {
int i = 0;
while(data[i] != target)
i++;
int reservoir = i, count = 0;
while(i < data.length){
if(data[i] == target){
int rand = (int)(Math.random() * (count+1));
if(rand == 0)
reservoir = i;
count++;
}
i++;
}
return reservoir;
}
}
最优算法
该算法改进了上述简单算法,该算法计算在下一个数据进入蓄水池之前应该丢弃多少个数据。关键点在于这个数字遵循一个几何分布,于是可以在常数时间内计算得出。
(* S has items to sample, R will contain the result *)
ReservoirSample(S[1..n], R[1..k])
// fill the reservoir array
for i = 1 to k
R[i] := S[i]
(* random() generates a uniform (0,1) random number *)
W := exp(log(random())/k)
while i <= n
i := i + floor(log(random())/log(1-W)) + 1
if i <= n
(* replace a random item of the reservoir with item i *)
R[randomInteger(1,k)] := S[i] // random index between 1 and k, inclusive
W := W * exp(log(random())/k)
该算法的时间复杂度为O(k(1+log(n/k)))。
参考资料:https://en.wikipedia.org/wiki/Reservoir_sampling#cite_note-vitter-1