蓄水池抽样算法
蓄水池抽样算法
给定一个数据流,数据流长度N很大,且N直到处理完所有数据之前都不可知,请问如何在只遍历一遍数据(O(N))的情况下,能够随机选取出m个不重复的数据。
这个场景强调了3件事:
- 数据流长度N很大且不可知,所以不能一次性存入内存。
- 时间复杂度为O(N)。
- 随机选取m个数,每个数被选中的概率为m/N。
算法思路大致如下:
- 如果接收的数据量小于m,则依次放入蓄水池。
- 当接收到第i个数据时,i >= m,在[0, i]范围内取以随机数d,若d的落在[0, m-1]范围内,则用接收到的第i个数据替换蓄水池中的第d个数据。
- 重复步骤2。
算法的精妙之处在于:当处理完所有的数据时,蓄水池中的每个数据都是以m/N的概率获得的。
下面用白话文推导验证该算法。假设数据开始编号为1.
第i个接收到的数据最后能够留在蓄水池中的概率=第i个数据进入过蓄水池的概率*之后第i个数据不被替换的概率(第i+1到第N次处理数据都不会被替换)。
- 当i<=m时,数据直接放进蓄水池,所以第i个数据进入过蓄水池的概率=1。
- 当i>m时,在[1,i]内选取随机数d,如果d<=m,则使用第i个数据替换蓄水池中第d个数据,因此第i个数据进入过蓄水池的概率=m/i。
- 当i<=m时,程序从接收到第m+1个数据时开始执行替换操作,第m+1次处理会替换池中数据的为m/(m+1),会替换掉第i个数据的概率为1/m,则第m+1次处理替换掉第i个数据的概率为(m/(m+1))(1/m)=1/(m+1),不被替换的概率为1-1/(m+1)=m/(m+1)。依次,第m+2次处理不替换掉第i个数据概率为(m+1)/(m+2)…第N次处理不替换掉第i个数据的概率为(N-1)/N。所以,之后第i个数据不被替换的概率=m/(m+1)(m+1)/(m+2)…(N-1)/N=m/N。
- 当i>m时,程序从接收到第i+1个数据时开始有可能替换第i个数据。则参考上述第3点,之后第i个数据不被替换的概率=i/N。
- 结合第1点和第3点可知,当i<=m时,第i个接收到的数据最后留在蓄水池中的概率=1m/N=m/N。结合第2点和第4点可知,当i>m时,第i个接收到的数据留在蓄水池中的概率=m/ii/N=m/N。综上可知,每个数据最后被选中留在蓄水池中的概率为m/N。
这个算法建立在统计学基础上,很巧妙地获得了“m/N”这个概率。
代码示例:
vector<int> ReservoirSampling(vector<int> v, int n, int k)
{
assert(v.size() == n && k <= n);
// init: fill the first k elems into reservoir
vector<int> res(v.begin(), v.begin() + k);
int i = 0,j=0;
// start from the (k+1)th element to replace
for (i = k; i < n; ++i)
{
j = rand() % (i + 1); // inclusive range [0, i]
if (j < k)
{
res[j] = v[i];
}
}
return res;
}
leetcode例题
398.随机数索引
给定一个可能含有重复元素的整数数组,要求随机输出给定的数字的索引。 您可以假设给定的数字一定存在于数组中。
注意:
数组大小可能非常大。 使用太多额外空间的解决方案将不会通过测试。
示例:
int[] nums = new int[] {1,2,3,3,3};
Solution solution = new Solution(nums);
// pick(3) 应该返回索引 2,3 或者 4。每个索引的返回概率应该相等。
solution.pick(3);
// pick(1) 应该返回 0。因为只有nums[0]等于1。
solution.pick(1);
class Solution {
public:
vector<int> res;
Solution(vector<int>& nums) {
res = nums;
}
int pick(int target) {
int c = 0;
int index = 0;
for(int i = 0;i < res.size();i++)
if(res[i] == target){
c++;
if(rand() % c == 0) index = i;
}
return index;
}
};
蓄水池抽样算法的典型应用
给定一个长度为N且没有重复元素的数组arr和一个整数M,实现函数等概率随机打印arr中的M个数。
首先在0~n-1中随机得到一个位置a,打印arr[a],然后把arr[a]和数组最后位置的arr[n-1]交换;
然后在0~n-2中随机得到一个位置b,然后把arr[b]和数组最后位置的arr[n-2]交换,
arr:{0,1,2,3,…,b,…,n-2},n-1,
依此类推,直到打印m个数即可。
洗牌算法
核心思想:
- 随机选择下标索引,
- 交换
void shuffle(vector<int>& data){
int n=data.size();
for(int i=n-1;i>=0;i++){
int randIndex=rand(0,i);
swap(data[i],data[randIndex];
}
}
分析洗牌算法正确性的准则: 产⽣的结果必须有 n! 种可能,否则就是错误的。 这个很好解释, 因为⼀个⻓度为 n 的数组的全排列就有 n! 种, 也就是说打乱结果总共有 n! 种
其他
从一个长度为N且没有重复元素的数组arr中随机的取k个数的其他方法
给每⼀个元素关联⼀个随机数, 然后把每个元素插⼊⼀个容量为 k 的⼆叉堆(优先级队列) 按照配对的随机数进⾏排序, 最后剩下的 k 个元素也是随机的。
这个⽅案看起来似乎有点多此⼀举, 因为插⼊⼆叉堆需要 O(logk) 的时间复杂度, 所以整个抽样算法就需要 O(nlogk) 的复杂度, 还不如我们最开始的算法。 但是, 这种思路可以指导我们解决加权随机抽样算法, 权重越⾼, 被随机选中的概率相应增⼤, 这种情况在现实⽣活中是很常⻅的, ⽐如你不往游戏⾥充钱, 就永远抽不到⽪肤