在允许更新的同时从分布中随机抽样的高效算法?

时间:2012-01-20 19:59:24

标签: algorithm random random-sample

这是我前一段时间接受采访的问题,我找不到答案。

给出一些样本S1,S2,...... Sn及其概率分布(或权重,无论它叫什么)P1,P2,... Pn,设计算法随机选择样本考虑其概率。我带来的解决方案如下:

  1. 构建权重Ci的累积数组,例如

    C0 = 0; Ci = C [i-1] + Pi。

  2. 同时计算T = P1 + P2 + ... Pn。 需要O(n)时间

    1. 生成均匀随机数R = T * random [0..1]
    2. 使用二分搜索算法,返回最小i,例如Ci> = R. 结果是Si。它需要O(logN)时间。
    3. 现在的实际问题是: 假设我想要更改其中一个初始权重Pj。如何在O(n)时间内做到这一点? 其他数据结构是可以接受的,但随机抽样算法不应该比O(logN)差。

4 个答案:

答案 0 :(得分:5)

解决此问题的一种方法是重新考虑如何构建包含累积总计的二进制搜索树。不要构建二叉搜索树,而是考虑将每个节点解释如下:

  • 每个节点都存储一系列专用于节点本身的值。
  • 左子树中的节点表示从该范围左侧的概率分布中采样。
  • 右子树中的节点表示从该范围右侧的概率分布中采样。

例如,假设事件A,B,C,D,E,F和G的权重为3,2,2,2,1和1.我们构建此二叉树,包含A,B ,C,D,E,F和G:

               D
             /   \
           B       F
          / \     / \
         A   C   E   G

现在,我们用概率注释树。由于A,C,E和G都是叶子,我们给它们每个概率质量一:

               D
             /   \
           B       F
          / \     / \
         A   C   E   G
         1   1   1   1

现在,看B.树的树有2个被选中,A有3个被选中,C有2个被选中的概率。如果我们将这些归一化到范围[0,1],则A占概率的3/7,B和C各占2/7。因此,我们让B的节点表示范围[0,3 / 7]中的任何内容都转到左子树,范围[3/7,5/7]中的任何内容都映射到B,并且范围内的任何内容[5 / 7,1)映射到右子树:

                   D
                 /   \
           B              F
 [0, 3/7) / \  [5/7, 1)  / \
         A   C          E   G
         1   1          1   1

类似地,让我们的过程F.E具有被选择的权重2,而F和G各自具有被选择的概率权重1。因此,E的子树占这里概率质量的1/2,节点F占1/4,G的子树占1/4。这意味着我们可以将概率指定为

                       D
                     /   \
           B                        F
 [0, 3/7) / \  [5/7, 1)   [0, 1/2) / \  [3/4, 1)
         A   C                    E   G
         1   1                    1   1

最后,让我们看一下根。左子树的组合权重是3 + 2 + 2 = 7.右子树的组合权重是2 + 1 + 1 = 4.D本身的权重是2.因此左子树的概率为7/13在被挑选时,D有被挑选的概率为2/13,而右子树有被挑选的概率为4/13。因此,我们可以将概率确定为

                       D
           [0, 7/13) /   \ [9/13, 1)
           B                        F
 [0, 3/7) / \  [5/7, 1)   [0, 1/2) / \  [3/4, 1)
         A   C                    E   G
         1   1                    1   1

要生成随机值,请重复以下操作:

  • 从根开始:
    • 选择[0,1]范围内的均匀随机值。
    • 如果它在左子树的范围内,则进入它。
    • 如果它在正确的子树的范围内,则进入它。
    • 否则,返回与当前节点对应的值。

构建树时,可以递归地确定概率本身:

  • 任何叶节点的左右概率均为0.
  • 如果内部节点本身具有权重W,则其左侧树具有总权重W L ,并且其右侧树具有总权重W R ,则左侧概率为(W L )/(W + W L + W R ),右概率为(W R )/(W + W L + W R )。

这种重新制定有用的原因在于它为我们提供了一种方法来更新每个概率更新的O(log n)时间的概率。特别是,如果我们更新一些特定节点的权重,让我们考虑一下不变量会发生什么变化。为简单起见,我们假设节点现在是一片叶子。当我们更新叶节点的权重时,叶节点的概率仍然是正确的,但它们对于它上面的节点是不正确的,因为该节点的一个子树的权重已经改变。因此,我们可以(在O(1)时间内)通过使用与上面相同的公式重新计算父节点的概率。但是那个节点的父节点不再具有正确的值,因为它的一个子树权重已经改变,所以我们也可以重新计算它的概率。这个过程一直重复到树的根,我们每层进行O(1)计算以纠正分配给每个边的权重。假设树是平衡的,因此我们必须进行O(log n)总工作以更新一个概率。如果节点不是叶节点,则逻辑相同;我们只是从树的某个地方开始。

简而言之,这就是

  • O(n)构建树的时间(使用自下而上的方法),
  • O(log n)生成随机值的时间,
  • O(log n)时间更新任何一个值。

希望这有帮助!

答案 1 :(得分:4)

将搜索结构存储为平衡二叉树,而不是数组。树的每个节点都应该存储它包含的元素的总权重。根据{{​​1}}的值,搜索过程将返回当前节点或搜索左侧或右侧子树。

当元素的权重发生变化时,搜索结构的更新是调整从元素到树根的路径上的权重。

由于树是平衡的,搜索和权重更新操作都是O(log N)。

答案 2 :(得分:1)

对于那些想要一些代码的人来说,这是一个python实现:

import numpy


class DynamicProbDistribution(object):
  """ Given a set of weighted items, randomly samples an item with probability
  proportional to its weight. This class also supports fast modification of the
  distribution, so that changing an item's weight requires O(log N) time. 
  Sampling requires O(log N) time. """

  def __init__(self, weights):
    self.num_weights = len(weights)
    self.weights = numpy.empty((1+len(weights),), 'float32')
    self.weights[0] = 0 # Not necessary but easier to read after printing
    self.weights[1:] = weights
    self.weight_tree = numpy.zeros((1+len(weights),), 'float32')
    self.populate_weight_tree()

  def populate_weight_tree(self):
    """ The value of every node in the weight tree is equal to the sum of all 
    weights in the subtree rooted at that node. """
    i = self.num_weights
    while i > 0:
      weight_sum = self.weights[i]
      twoi = 2*i
      if twoi < self.num_weights:
        weight_sum += self.weight_tree[twoi] + self.weight_tree[twoi+1]
      elif twoi == self.num_weights:
        weight_sum += self.weights[twoi]
      self.weight_tree[i] = weight_sum
      i -= 1

  def set_weight(self, item_idx, weight):
    """ Changes the weight of the given item. """
    i = item_idx + 1
    self.weights[i] = weight
    while i > 0:
      weight_sum = self.weights[i]
      twoi = 2*i
      if twoi < self.num_weights:
        weight_sum += self.weight_tree[twoi] + self.weight_tree[twoi+1]
      elif twoi == self.num_weights:
        weight_sum += self.weights[twoi]
      self.weight_tree[i] = weight_sum
      i /= 2 # Only need to modify the parents of this node

  def sample(self):
    """ Returns an item index sampled from the distribution. """
    i = 1
    while True:
      twoi = 2*i

      if twoi < self.num_weights:
        # Two children
        val = numpy.random.random() * self.weight_tree[i]
        if val < self.weights[i]:
          # all indices are offset by 1 for fast traversal of the
          # internal binary tree
          return i-1
        elif val < self.weights[i] + self.weight_tree[twoi]:
          i = twoi # descend into the subtree
        else:
          i = twoi + 1

      elif twoi == self.num_weights:
        # One child
        val = numpy.random.random() * self.weight_tree[i]
        if val < self.weights[i]:
          return i-1
        else:
          i = twoi

      else:
        # No children
        return i-1


def validate_distribution_results(dpd, weights, samples_per_item=1000):
  import time

  bins = numpy.zeros((len(weights),), 'float32')
  num_samples = samples_per_item * numpy.sum(weights)

  start = time.time()
  for i in xrange(num_samples):
    bins[dpd.sample()] += 1
  duration = time.time() - start

  bins *= numpy.sum(weights)
  bins /= num_samples

  print "Time to make %s samples: %s" % (num_samples, duration)

  # These should be very close to each other
  print "\nWeights:\n", weights
  print "\nBins:\n", bins

  sdev_tolerance = 10 # very unlikely to be exceeded
  tolerance = float(sdev_tolerance) / numpy.sqrt(samples_per_item)
  print "\nTolerance:\n", tolerance

  error = numpy.abs(weights - bins)
  print "\nError:\n", error

  assert (error < tolerance).all()


#@test
def test_DynamicProbDistribution():
  # First test that the initial distribution generates valid samples.

  weights = [2,5,4, 8,3,6, 6,1,3, 4,7,9]
  dpd = DynamicProbDistribution(weights)

  validate_distribution_results(dpd, weights)

  # Now test that we can change the weights and still sample from the 
  # distribution.

  print "\nChanging weights..."
  dpd.set_weight(4, 10)
  weights[4] = 10
  dpd.set_weight(9, 2)
  weights[9] = 2
  dpd.set_weight(5, 4)
  weights[5] = 4
  dpd.set_weight(11, 3)
  weights[11] = 3

  validate_distribution_results(dpd, weights)

  print "\nTest passed"


if __name__ == '__main__':
  test_DynamicProbDistribution()

答案 3 :(得分:0)

我已经实现了与Ken的代码相关的版本,但在最坏情况下的O(log n)操作中平衡了红色/黑色树。可以在以下网址以weightedDict.py的形式获取:https://github.com/google/weighted-dict

(我会将此添加为Ken的答案的评论,但没有做到这一点的声誉!)