【问题标题】:Weighted random selection from array从数组中加权随机选择
【发布时间】:2011-05-26 16:49:29
【问题描述】:

我想从一个数组中随机选择一个元素,但每个元素都有一个已知的选择概率。

所有机会加在一起(在数组内)总和为 1。

您认为哪种算法最快且最适合大型计算?

例子:

id => chance
array[
    0 => 0.8
    1 => 0.2
]

对于这个伪代码,有问题的算法应该在多次调用中统计返回 id 0 上的四个元素,id 1 上的一个元素。

【问题讨论】:

    标签: arrays algorithm random


    【解决方案1】:

    另一种可能性是将数组的每个元素与从exponential distribution 抽取的随机数关联起来,参数由该元素的权重给出。然后选择具有最低此类“订购号”的元素。在这种情况下,特定元素具有数组最低排序数的概率与数组元素的权重成正比。

    这是 O(n),不涉及任何重新排序或额外存储,并且可以在一次遍历数组的过程中完成选择。权重必须大于零,但不必总和为任何特定值。

    这还有一个好处,如果您将排序号与每个数组元素一起存储,您可以选择通过增加排序号来对数组进行排序,以获得数组的随机排序,其中权重较高的元素具有提早出现的可能性更高(我发现这在决定选择哪个 DNS SRV 记录、决定查询哪台机器时很有用)。

    带放回的重复随机抽样每次都需要一个新的遍历数组;对于无放回的随机选择,数组可以按照序号递增的顺序进行排序,k个元素可以按照这个顺序读出。

    请参阅Wikipedia page about the exponential distribution(特别是关于此类变量集合的最小值分布的注释)以证明上述是正确的,以及指向生成此类变量的技术的指针:如果 T 在 [0,1) 中具有均匀随机分布,则 Z=-log(1-T)/w(其中 w 是分布参数;这里是相关元素的权重)具有指数分布。

    即:

    1. 对于数组中的每个元素i,计算zi = -log(T)/wi(或zi = -log(1-T)/ wi),其中 T 取自 [0,1) 中的均匀分布,wi 是第 I 个元素的权重。
    2. 选择zi最小的元素。

    元素i会以wi/(w1+w2+...+wn)的概率被选中

    请参阅下面的 Python 说明,其中对 10000 次试验中的每一次试验一次通过权重数组。

    import math, random
    
    random.seed()
    
    weights = [10, 20, 50, 20]
    nw = len(weights)
    results = [0 for i in range(nw)]
    
    n = 10000
    while n > 0: # do n trials
        smallest_i = 0
        smallest_z = -math.log(1-random.random())/weights[0]
        for i in range(1, nw):
            z = -math.log(1-random.random())/weights[i]
            if z < smallest_z:
                smallest_i = i
                smallest_z = z
    
        results[smallest_i] += 1 # accumulate our choices
    
        n -= 1
    
    for i in range(nw):
        print("{} -> {}".format(weights[i], results[i]))
    

    编辑(历史记录): 发布此内容后,我确信我不是第一个想到它的人,并且考虑到此解决方案的另一个搜索表明这确实是案例。

    【讨论】:

      【解决方案2】:

      这是 Ruby 中的一个实现:

      def weighted_rand(weights = {})
        raise 'Probabilities must sum up to 1' unless weights.values.inject(&:+) == 1.0
        raise 'Probabilities must not be negative' unless weights.values.all? { |p| p >= 0 }
        # Do more sanity checks depending on the amount of trust in the software component using this method,
        # e.g. don't allow duplicates, don't allow non-numeric values, etc.
        
        # Ignore elements with probability 0
        weights = weights.reject { |k, v| v == 0.0 }   # e.g. => {"a"=>0.4, "b"=>0.4, "c"=>0.2}
      
        # Accumulate probabilities and map them to a value
        u = 0.0
        ranges = weights.map { |v, p| [u += p, v] }   # e.g. => [[0.4, "a"], [0.8, "b"], [1.0, "c"]]
      
        # Generate a (pseudo-)random floating point number between 0.0(included) and 1.0(excluded)
        u = rand   # e.g. => 0.4651073966724186
        
        # Find the first value that has an accumulated probability greater than the random number u
        ranges.find { |p, v| p > u }.last   # e.g. => "b"
      end
      

      使用方法:

      weights = {'a' => 0.4, 'b' => 0.4, 'c' => 0.2, 'd' => 0.0}
      
      weighted_rand weights
      

      大概会发生什么:

      sample = 1000.times.map { weighted_rand weights }
      sample.count('a') # 396
      sample.count('b') # 406
      sample.count('c') # 198
      sample.count('d') # 0
      

      【讨论】:

      • 刚刚用了这个,实现了一个公认的名字!谢谢@wolfgang-teuber!
      • 此方法的一个警告是,如果您的权重为 1.0,其余为 0.0,则此方法将无法按预期工作。我们将权重作为 ENV 变量,当我们将其中一个权重切换为 1.0(即使其始终为真)时,它会产生相反的影响。仅供使用此方法的其他人参考!
      • @AbePetrillo 我更新了weighted_rand 方法来解决您描述的问题。
      【解决方案3】:

      "Wheel of Fortune" O(n),仅用于小数组:

      function pickRandomWeighted(array, weights) {
          var sum = 0;
          for (var i=0; i<weights.length; i++) sum += weights[i];
          for (var i=0, pick=Math.random()*sum; i<weights.length; i++, pick-=weights[i])
              if (pick-weights[i]<0) return array[i];
      }
      

      【讨论】:

        【解决方案4】:

        这是我在生产中使用的 PHP 代码:

        /**
         * @return \App\Models\CdnServer
        */
        protected function selectWeightedServer(Collection $servers)
        {
            if ($servers->count() == 1) {
                return $servers->first();
            }
        
            $totalWeight = 0;
        
            foreach ($servers as $server) {
                $totalWeight += $server->getWeight();
            }
        
            // Select a random server using weighted choice
            $randWeight = mt_rand(1, $totalWeight);
            $accWeight = 0;
        
            foreach ($servers as $server) {
                $accWeight += $server->getWeight();
        
                if ($accWeight >= $randWeight) {
                    return $server;
                }
            }
        }
        

        【讨论】:

          【解决方案5】:

          我发现this article 对完全理解这个问题最有用。 This stackoverflow question 也可能是你要找的。​​p>


          我相信最佳解决方案是使用Alias Method (wikipedia)。 初始化需要 O(n) 时间,进行选择需要 O(1) 时间,以及 O(n) 内存。 p>

          这里是生成滚动加权 n 面骰子结果的算法(从这里从长度-n 数组中选择一个元素是微不足道的)取自this article。 作者假设您具有掷公平骰子 (floor(random() * n)) 和掷偏硬币 (random() &lt; p) 的功能。

          算法:Vose 的别名方法

          初始化:

          1. 创建数组AliasProb,每个数组的大小为n
          2. 创建两个工作清单,SmallLarge
          3. 将每个概率乘以 n
          4. 对于每个缩放概率 pi
            1. 如果 pi,将 i 添加到 Small
            2. 否则 (pi ≥ 1),将 i 添加到 Large
          5. 虽然 SmallLarge 不为空:(Large 可能会先被清空)
            1. Small中移除第一个元素;叫它l
            2. Large中移除第一个元素;称之为g
            3. 设置Prob[l]=pl
            4. 设置别名[l]=g
            5. 设置 pg := (pg+pl)−1。 (这是一个数值更稳定的选项。)
            6. 如果 pg,则将 g 添加到 Small
            7. 否则 (pg ≥ 1),将 g 添加到 Large
          6. 虽然 Large 不为空:
            1. Large中移除第一个元素;称之为g
            2. 设置概率[g] = 1
          7. 虽然 Small 不为空:这仅可能是由于数值不稳定。
            1. Small中移除第一个元素;叫它l
            2. 设置概率[l] = 1

          世代:

          1. n 面的骰子中生成公平的骰子;叫方i
          2. 投掷一枚有偏向的硬币,该硬币以概率Prob[i]出现正面。
          3. 如果硬币出现“正面”,则返回 i
          4. 否则,返回别名[i]

          【讨论】:

            【解决方案6】:

            我将改进https://stackoverflow.com/users/626341/masciugo 的答案。

            基本上你制作一个大数组,其中元素出现的次数与权重成正比。

            它有一些缺点。

            1. 权重可能不是整数。想象元素 1 的概率为 pi,元素 2 的概率为 1-pi。你怎么分?或者想象一下,如果有数百个这样的元素。
            2. 创建的数组可能非常大。想象一下,如果最小公倍数是 100 万,那么我们需要在要选择的数组中包含 100 万个元素的数组。

            为了解决这个问题,这就是你要做的。

            创建这样的数组,但只随机插入一个元素。插入元素的概率与权重成正比。

            然后从常规中选择随机元素。

            因此,如果有 3 个不同权重的元素,您只需从 1-3 个元素的数组中选择一个元素。

            如果构造的元素为空,则可能会出现问题。只是碰巧没有元素出现在数组中,因为它们的骰子滚动不同。

            在这种情况下,我建议插入元素的概率是 p(inserted)=wi/wmax。

            这样,将插入一个元素,即概率最高的元素。其他元素按相对概率插入。

            假设我们有 2 个对象。

            元素 1 出现 0.20% 的时间。 元素 2 出现的概率为 0.40%,概率最高。

            在数组中,元素 2 会一直出现。元素 1 将出现一半时间。

            所以元素 2 将被称为元素 1 的 2 倍。为了一般性,所有其他元素将被称为与其权重成正比。此外,它们所有概率的总和为 1,因为数组总是至少有 1 个元素。

            【讨论】:

            • 我的数学已经关闭了。看起来具有更高数量的元素将具有更高的实际概率使用这种技术。我现在建议投票最多的答案。
            【解决方案7】:

            使用pickup gem 的Ruby 解决方案:

            require 'pickup'
            
            chances = {0=>80, 1=>20}
            picker = Pickup.new(chances)
            

            例子:

            5.times.collect {
              picker.pick(5)
            }
            

            给出输出:

            [[0, 0, 0, 0, 0], 
             [0, 0, 0, 0, 0], 
             [0, 0, 0, 1, 1], 
             [0, 0, 0, 0, 0], 
             [0, 0, 0, 0, 1]]
            

            【讨论】:

              【解决方案8】:

              我想大于或等于 0.8 但小于 1.0 的数字会选择第三个元素。

              换句话说:

              x 是 0 到 1 之间的随机数

              如果 0.0 >= x

              如果 0.2 >= x

              如果 0.8 >= x

              【讨论】:

              • 如果数组有 12000 个元素怎么办?那么你会有 12.000 个 if 语句吗?
              【解决方案9】:

              诀窍可能是使用反映概率的元素重复对辅助数组进行采样

              给定与其概率相关的元素,以百分比表示:

              h = {1 => 0.5, 2 => 0.3, 3 => 0.05, 4 => 0.05 }
              
              auxiliary_array = h.inject([]){|memo,(k,v)| memo += Array.new((100*v).to_i,k) }   
              
              ruby-1.9.3-p194 > auxiliary_array 
               => [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,                                 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4] 
              
              auxiliary_array.sample
              

              如果你想尽可能通用,你需要根据最大小数位数计算乘数,并用它代替100:

              m = 10**h.values.collect{|e| e.to_s.split(".").last.size }.max
              

              【讨论】:

                【解决方案10】:

                这可以在每个样本的 O(1) 预期时间内完成,如下所示。

                计算每个元素 i 的 CDF F(i) 为小于或等于 i 的概率之和。

                定义元素 i 的范围 r(i) 为区间 [F(i - 1), F(i)]。

                对于每个区间 [(i - 1)/n, i/n],创建一个包含范围与区间重叠的元素列表的存储桶。只要您相当小心,整个数组总共需要 O(n) 时间。

                当您对数组进行随机采样时,您只需计算随机数在哪个桶中,然后与列表中的每个元素进行比较,直到找到包含它的区间。

                样本的成本是 O(随机选择的列表的预期长度)

                【讨论】:

                • 如果权重的大小差异很大,该算法的最坏情况复杂度为 O(n)。可能会发生所有间隔都属于同一个存储桶的情况。如果没有对权重的额外限制,这绝对不是 O(1),甚至不是 O(log n)。
                • 最坏的情况很少发生。如果所有 n 个区间都与一个桶重叠,那么几乎所有查询都需要与一个区间进行比较。在实践中,这将比二分搜索快得多。如果坚持对最坏情况进行优化,则可以在每个桶内进行二分查找,使得每个查询的成本在最坏情况下为 O(lg(最大桶的长度)),而 O(lg 的期望值(随机选择的列表的长度))在期望中,仍然只是 O(1)。
                • 谢谢,它看起来很不错。在我的解决方案中,我将不得不进行一些试验以确定它是否真的比 CDF 方式更快。
                • @Mikulas Dite,值得强调的是,这也是一个 CDF 数组解决方案,与纯二进制搜索的区别有点像进行二进制搜索和散列搜索元素的区别在一个数组中。另一种看待它的方法是计算 CDF 数组,而不是对其进行二进制搜索,而是将随机数散列到与存储桶开始相对应的数组索引。然后,您可以使用任何您想要的搜索策略(例如,暴力线性搜索或二分搜索)进一步缩小到正确的采样元素。
                • 请注意,与通常的“最坏情况”评估相比,这里有更好的保证,因为您的访问已知是随机的,通过构造...
                【解决方案11】:

                计算列表的离散累积密度函数 (CDF) - 或者简单地说,就是权重的累积和数组。然后生成一个介于 0 和所有权重之和之间的随机数(在您的情况下可能为 1),进行二进制搜索以在离散 CDF 数组中找到此随机数并获取与此条目对应的值 - 这个是你的加权随机数。

                【讨论】:

                • @Mikulas Dite:这个二分搜索每次查找需要log2(500) = 9 步。
                • 生成0到权重总和之间的随机数,谁能保证生成的随机数会在cdf数组中?假设有 [0.1 0.2 0.4 0.3] 作为权重数组。 cdf 数组将为 [0.1 0.3 0.7 1.0]。 rand 值必须在 0 和 1.0 之间生成。然后可以是例如 0.62,但该值不在 cdf 数组中。
                • @Mazzy:您正在寻找包含您生成的随机数的区间——在本例中,区间为 0.3 到 0.7。当然你不能指望会出现确切的值,但是寻找区间的二进制搜索无论如何都会起作用。
                • @SvenMarnach 也许我不清楚。当我对 cdf 数组 [0.1 0.3 0.7 0.1] 应用二进制搜索时,我期望在数组中找到 rand 值。在上面的示例中,rand 值为 0.62。应用于 cdf 数组的二进制搜索算法将在数组中查找 0.62 值,如果找不到该值,它将显示“未找到”。我的意思是二进制搜索必须找到正确的值,否则不会返回任何值
                • @Mazzy:二进制搜索可以很容易地找到你正在寻找的值所在的区间,这就是你所需要的。大多数编程语言标准库中的二进制搜索实现不需要找到确切的值,例如lower_bound() in C++bisect_left() in Python.
                【解决方案12】:

                Ruby 中的一个例子

                #each element is associated with its probability
                a = {1 => 0.25 ,2 => 0.5 ,3 => 0.2, 4 => 0.05}
                
                #at some point, convert to ccumulative probability
                acc = 0
                a.each { |e,w| a[e] = acc+=w }
                
                #to select an element, pick a random between 0 and 1 and find the first   
                #cummulative probability that's greater than the random number
                r = rand
                selected = a.find{ |e,w| w>r }
                
                p selected[0]
                

                【讨论】:

                • 在这个算法中,最后一个元素永远不会被选中,因为它的概率是1.0,而rand总是在0到1之间。
                【解决方案13】:

                算法很简单

                rand_no = rand(0,1)
                for each element in array 
                     if(rand_num < element.probablity)
                          select and break
                     rand_num = rand_num - element.probability
                

                【讨论】:

                • 这行不通,因为我有机会,而不是该地区。 |即使有人反对这个答案,它也给了我一个可行的想法。限制的计算非常简单,不会影响性能。
                • @Mikulas 假设您有离散的机会和随机数均匀分布在 0 和 1 之间,它将给出等于它们权重的概率。对于您的情况,随机数有 80% 的机会小于 0.8,因此将选择第一个元素,在这种情况下,将选择第二个元素的 20% 的机会大于 .8。
                • 不,它可以在没有排序的情况下工作,如果你想在选择元素后删除它,它的工作速度比二分搜索快。
                • 抱歉这个问题,如果我有两个重量相同的元素怎么办?在这种情况下,我只会得到数组中两个元素中的第一个,还是我错了?
                • @arpho 我测试了你的假设in JavaScript。看来你错了。
                【解决方案14】:

                如果数组很小,我会给数组一个长度,在这种情况下,为 5,并根据需要分配值:

                array[
                    0 => 0
                    1 => 0
                    2 => 0
                    3 => 0
                    4 => 1
                ]
                

                【讨论】:

                • 这是最明显的解决方案,但是我不能真正使用它来处理我想要处理的数据量。
                猜你喜欢
                • 1970-01-01
                • 2022-01-08
                • 2015-07-05
                • 2010-09-08
                • 2017-12-26
                相关资源
                最近更新 更多