zoukankan      html  css  js  c++  java
  • 蓄水池抽样算法/水塘采样算法

    参考:https://blog.csdn.net/weixin_43495317/article/details/103943957

    https://leetcode-cn.com/problems/linked-list-random-node/solution/xu-shui-chi-chou-yang-suan-fa-by-jackwener/

    https://www.cnblogs.com/krcys/p/9121487.html

    https://zhuanlan.zhihu.com/p/107793995

    1、概念介绍

    简介:

    水塘抽样是一种随机抽样算法,它能够在一个很大的集合中,抽取一部分样本,并保证每个样本的选取概率都是相等并随机的。

    特点:

    1、选取集合可以非常大,甚至不知道边界。

    2、每个样本的选取随机且概率相等。

    3、时间复杂度较低,O(n),节省内存。

    适用场景:

    1、在一些非常大的集合,或者未知大小的集合,不知道边界的集合,不知道文件总行数的情况下,随机抽取k个元素。

    2、保证每个元素抽取都是均匀随机的并且概率相等。

    3、尽量高效,节省内存地抽取

    2、常见问题场景

    很多大公司的面试题都考察过这个算法,下面介绍几个这样的例题

    问题一:

    问题:我有一个长度为N的单链表,N的值非常大,我不清楚N的确切值.我怎样能写一个尽可能高效的算法来返回链表中K个完全随机的数.
    要求:
    1、只能遍历一次链表
    2、返回的值要均匀随机,每个的概率要相等

    一般的想法就是,我先遍历一遍链表,得到链表的总长度 n,再生成一个 [1,n] 之间的随机数为索引,然后再遍历链表,找到索引对应的节点,不就是一个随机的节点了吗?

    但题目说了,只能遍历一次,意味着这种思路不可行。题目还可以再泛化,给一个未知长度的序列,如何在其中随机地选择 k 个元素?想要解决这个问题,就需要著名的水塘抽样算法了。

    问题二:

    问题:我们有1T的文本文件存在硬盘中,想随机抽取几行,需要保证尽可能少得使用内存并且能够完全随机。

    之前想到的加载到内存就不太适合了,但是还可以想到别的办法,比如每次读取一行记录加载到内存,记数+1,清空内存中行数据,直到最后统计一共多少行,然后根据总行数来计算K个随机数.如何再取回行对应的数据呢?我们可以再遍历一遍,一边遍历一边记录这一行的号码是不是在k个随机数中,如果是,则将该行内容保留.

    这样的话遍历两次应该可以做到,但是1T数据遍历两次的时间消耗是非常高的.

    所以还有更好的方案吗,那就是水塘抽样算法.

    问题三:

    问题:给定一个数据流,数据流长度N很大,且N直到处理完所有数据之前都不可知,如何在只遍历一遍数据(O(N))的情况下,能够随机选取出这组数据的k个概率相等的均匀抽样。
    要求:
    1、仅扫描数据一次
    2、空间复杂度为O(N),空间复杂度与整个数据量无关,只与抽样大小有关。
    3、扫描到数据的前n个数据时(n>k),保存当前已扫描数据的k个均匀抽样。
    

    根据要求,首先体积很大内存一次装不下,不能直接不能直接取N内的k个随机数,因为N的长度是未知的。此外也不能采用不能先遍历一遍,然后分块存储数据,再随机选取。最后要求是数据选取绝对随机的保证。

    可以采用水塘抽样算法:

    3、水塘抽样算法原理

    问题切入:

    给定一个数据流,数据流长度N很大,如何从中随机选取k个数据,并且要保证每个数据被抽取到的概率相等。
    注意:数据流长度N很大,内存无法加载全部数据

    算法思路分析
    对这个问题,如何做到均匀随机,概率相等呢?我们以什么样的方式去抽取样本呢?

    首先N很大,不知道长度,所以我们无法调用random(N)来取一个1-N之间的随机数,我们只能将元素一个一个遍历加载进入内存。

    比如我们已经遍历了i个元素,可以从这i个元素中随机取了一个元素a,那如果现在再给你一个新元素b,你是继续留着a呢?还是抽取b作为样本结果呢?以什么样的原则来选择a和b呢?你的选择能够保证概率相等吗?

    这里就用到了我们的蓄水池抽样算法


    当k = 1时


    首先考虑最简单的情况,当k = 1,只取一个元素时,如何选取:

    假设数据流含有N个数据,要保证每条数据被抽取到的概率相等,那么每个数被抽取的概率应该为1/N

    当遇到第1个数的时候,概率为1,直接保留第一个数为样本。

    当遇到第2个数的时候,概率为1/2,第1个数和第2个数各有1/2的概率被保留。

    当遇到第3个数的时候,3被保留的概率为1/3;(之前剩下的数假设1被保留),2/3的概率 1 被保留,(此时1被保留的总概率为 2/3 * 1/2 = 1/3)

    当遇到第4个数的时候,4被保留的概率为1/4,(之前剩下的数假设1被保留),3/4的概率 1 被保留,(此时1被保留的总概率为 3/4 * 2/3 * 1/2 = 1/4)

    当遇到第i个数的时候,i被保留的概率为1/i,之前的样本数保留不被替换的概率也为1/i

    所以,依次类推,每个数被保留的概率都是1/N。


    通过以上规律可以看出,对于k = 1的情况,数据流中第i个数被保留的概率为1/i,只要采取这种策略,只需要遍历一遍数据流就可以得到采样值,并且保证所有数据被选中的概率均为1/N


    clipboard

    算法总结:

    水塘采样算法总结:
    也就是说,在取第i个数据的时候,生成一个0到1的随机数p,如果p < k / i,替换池中任意一个数替换为第i个数;当p > k / i,继续保留前面的数。直到数据流结束,返回此k个数。但是为了保证计算准确性,一般是生成一个0到i的随机数,跟k相比。

    4、水塘抽样算法实现

    算法图解:

    当K=1时:
    如果K=1,即每次只取一个样本值,那么逐个遍历数组,当遇到第i个数时,就以 1 / i 的概率抽取它做样本值,以(i - 1)/ i 的概率保留原来的样本值,不替换。
    算法原理如下图:

    image
    当K > 1时:
    如果k > 1,即要从数据流N中取K个样本值,那么要保证每条数据被抽取到的概率相等,每个数被抽取的概率必然是K / N
    算法实现:对于前K个数,全部保留,作为样本集合。对于第i (i > k)个数,则以k/i 的概率抽取第i个数,并以1/k 的概率与前面已经选择的k个数中的任意一个替换。
    算法原理如下图:

    image

    代码实现(java)

    当K=1时:

    /**
     * 当k = 1时,即只取1个数
     *
     * 算法步骤:
     * 1、遍历集合第1个元素,直接取出,作为样本数sample
     * 2、从集合第2个元素开始,使用下标 i 标记遍历的第几个元素。取一个随机数 rand = random【1,i】
     *    如果 rand == 1,则开始替换,即将当前样本元素,替换为当前遍历集合N中第i个元素。即sample = N[i]
     *    如果 rand > 1,则不替换,保留之前样本元素。
     * 3、继续往下迭代遍历,直到结束。最终样本为sample。
     */
    public class ReservoirSampingTest {
        public static void main(String[] args) {
            // 定义我们要遍历的集合,原则上是无限大,我们这里简化为6个元素。
            int[] data = {1, 2, 3, 4, 5, 6};
            // 只取一个元素,所以直接定义一个变量,初始化样本值
            int sample = 0;
            // 调用抽样算法,获取样本
            sample = reservoirSamping(sample, data);
            System.out.println("最终的样本为:" + sample);
        }
    
        /**
         * 采样算法
         * @param sample 返回的采样的数 (k=1时,这里不传也可以)
         * @param data 遍历的大集合
         * @return 返回采样的结果
         */
        public static int reservoirSamping(int sample, int[] data) {
            // 第一个元素,直接取出
            sample = data[1];
            for (int i=1; i<data.length; i++) {
                // 定义一个随机数,范围为[1, i],注意Random是左闭右开,所以要加1才能把i包括进去
                int rand = new Random().nextInt(i) + 1;
                // 此时随机数范围最小为1,不是0,所以当随机数等于1时,则替换为当前数
                if (rand == 1) {
                    sample = data[i];
                }
            }
    
            // 返回采样的结果
            return sample;
        }
    }

    当K>1时,取k个数

    跟我们上面的算法思想是一致的,只是把k=1,替换为了k=k

    即概率为P = k / i

    /**
    * 当k > 1时,取k个数
    * 算法步骤:
    * 1、遍历集合前k个元素,直接取出,作为样本集合sample[]
    * 2、从集合第K+1个元素开始,使用下标 i 标记遍历的第几个元素。取一个随机数 rand = random【1,i】
    *    如果 rand < k,则开始替换,即将当前样本集合sample中第rand个元素,替换为当前遍历集合N中第i个元素。即 sample[rand] = N[i]
    *    如果 rand > k,则不替换,保留之前样本集合sample中的元素。
    * 3、继续往下迭代遍历,直到结束。最终获得k个元素的样本。
    */
    public class ReservoirSampingTest {
        public static void main(String[] args) {
            // 定义我们要遍历的集合,原则上是无限大,我们这里简化为10个元素。
            int[] data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
            // 定义我们需要抽样的样本集合,假设k=5,抽取5个元素
            int[] sample = new int[5];
            // 调用抽样算法,获取样本
            sample = reservoirSamping(sample, data);
    
            // 打印最终的样本集合
            for (int s : sample) {
                System.out.println("样本元素:" + s);
            }
        }
    
        /**
         * 水塘抽样算法
         * @param sample 要采集的样本集合
         * @param data 需要遍历大集合
         * @return 返回抽样后的样本集合sample
         */
        public static int[] reservoirSamping(int[] sample, int[] data) {
            // 假设k为样本集合的大小
            int k = sample.length;
            // 前k个元素,直接取出,放到sample中
            for (int i=0; i<k; i++) {
                sample[i] = data[i];
            }
    
            // 从第K+1个元素开始,根据算法随机置换sample中的元素
            for (int i=k; i<data.length; i++) {
                // 取得[1, i]之间的随机数,注意Random是左闭右开,所以要加1才能把i包括进去
                int rand = new Random().nextInt(i) + 1;
                // rand小于k,则开始置换,将sample中第rand个元素,替换为data中第i个元素
                if (rand < k) {
                    sample[rand] = data[i];
                }
            }
    
            // 遍历一遍,采样完成,返回样本集合sample
            return sample;
        }
    }


    5、LeetCode中算法案例

    382. 链表随机节点】:https://leetcode-cn.com/problems/linked-list-random-node/

    题解查看:https://www.cnblogs.com/doublexi/p/15666056.html


    398. 随机数索引】:https://leetcode-cn.com/problems/random-pick-index/

    题解查看:https://www.cnblogs.com/doublexi/p/15666221.html

    6、spark分区器(水塘采样应用案例)

    最近在学习spark的分区器的时候,发现一个很有意思的东西水塘采样算法,又称蓄水池抽样算法。

    spark的分区器主要有三种:

    (只有key-value类型的RDD才有分区器,Value类型的RDD分区器的值是None。)

    1、HashPartitioner:数据分区规则为 partitionId = Key.hashCode % numPartitions。

    特点:采用哈希的方式将同一类型的Key分配到同一个Partition中。

    但是当某个key特别多时,容易导致某个分区数非常多,导致各个分区数据不均匀,形成数据倾斜。


    2、RangePartitioner:将一定范围内的数映射到某一个分区内。分区与分区之间数据是有序的,但分区内的元素是不能保证顺序的。sortByKey会使用RangePartitioner。

    特点:能够尽量保证每个分区中数据量的均匀。(主要用于数值类型)

    clipboard

    3、自定义分区器。

    你可以通过partitionBy来指定自己想要用哪种分区器。


    关于RangePartitioner:

    现在的问题:

    在RangePartitioner中,在执行分区之前其实并不知道数据的分布情况,不知道数据的总量,不知道数据的范围边界(这个边界很重要),也不知道数据分布在哪个范围。

    那我们就很难给数据分区,把哪些数据,或者是哪一段范围的数据分配到对应分区,从而保证各个分区的数据量都比较均匀。因为不知道数据的分布范围,不好给数据划分边界,也不好分区。

    这时,就可以用到水塘采样算法了。


    RangePartitioner在分区的时候,不知道数据总量,数据分布情况,但是它需要知道这些去划分分区后的边界。所以它会调用水塘采样算法,返回RDD的总采样数据量,分区ID和,每个分区数据总量,每个分区的采样数据。

    RangePartitioner中是对水塘采样算法,进行了变种和加强,有兴趣的可以参考:

    https://www.cnblogs.com/tongxupeng/p/10435976.html


    它的主要步骤为:

    1、计算总体的数据抽样大小sampleSize,计算规则是:至少每个分区抽取20个数据或者最多1M的数据量。

    2、根据sampleSize和分区数量计算每个分区的数据抽样样本数量最大值sampleSizePrePartition

    3、根据以上两个值进行水塘抽样,返回RDD的总采样数据量,分区ID和,每个分区数据总量,每个分区的采样数据。

    4、 数据抽样完成后,需要对不均衡的Partition重新进行抽样,默认当Partition中包含的数据量大于平均值的三倍时,该Partition是不均衡的。当采样完成后,利用样本容量和RDD中包含的数据总量,可以得到整体的一个数据采样率fraction。利用此采样率对不均衡的Partition调用sample算子重新进行抽样。(此时,各个分区的数据规模已经知道了,就没必要再水塘采样了,直接调用sample算子二次采样)

    5、根据各个分区的key与权重值,调用determineBounds方法计算出步长与边界

    6、计算每个Key所在Partition:当分区范围长度在128以内,使用顺序搜索来确定Key所在的Partition,否则使用二分查找算法来确定Key所在的Partition。

  • 相关阅读:
    星时代曹波涛:分享一个测试数据生成的工具
    myabtis-plus 分页
    吴恩达机器学习_55过拟合问题/56代价函数
    吴恩达机器学习_51高级优化/52多元分类:一对多
    吴恩达机器学习_49代价函数/50简化代价函数与梯度
    吴恩达机器学习_46分类/47假设函数/48决策边界
    Windows10下安装VMware workstation 15.1虚拟机+配置Ubuntu16系统
    编程作业ex1:附加练习
    编程作业ex1:线性回归
    吴恩达机器学习_43矢量
  • 原文地址:https://www.cnblogs.com/doublexi/p/15665695.html
Copyright © 2011-2022 走看看