如何在 O(n) 中编写优先左洗牌算法?

How to write a prioritized left-shuffle algorithm in O(n)?

有像 FisherYates 这样的随机播放算法。他们采用一个数组和 return 一个具有随机顺序元素的数组。这在 O(n) 中运行。

我想做的是实现一个优先左洗牌算法。这是什么意思?

让我们举个例子[ (1, 10), (2, 10), (3, 60), (4, 20) ]。最可能的结果应该是 [ 3, 4, 1, 2 ][ 3, 4, 2, 1 ].


我尝试实现这个,但我还没有在 O(n) 中找到任何解决方案。

O(n^2) 基于 FisherYates 的伪代码:

sum = 100  #100%
for i = 0 to n-2:
    r = random value between 0 and sum
    localsum = 0
    for j = i to n-1:
        localsum = localsum + pair[j].Probability
        if localsum >= r + 1:
            swap(i, j)
            break
    sum = sum - pair[i].Probability

有什么可能可以改进这一点:在开始时对按概率递减的元素进行排序,以最大限度地减少内循环中的交换次数和迭代次数。

是否有更好的解决方案(甚至可能在 O(n) 中)?

有一种方法可以使用扩充二叉搜索树在时间 O(n log n) 内完成此操作。这个想法如下。取出要打乱顺序的项目并将它们添加到二叉搜索树中,每个项目都用相关的权重进行注释。然后,对于 BST 中的每个节点,计算以该节点为根的子树中所有节点的总权重。比如根节点的权重为1(所有权重之和,因为是概率分布所以为1),根节点左边child的权重之和就是总权重在左子树中,根的右 child 中的权重之和将是右子树的总权重。

有了这个结构,您可以在 O(log n) select 时间内从树中随机选择一个元素,并根据您的权重进行分配。该算法是这样工作的。选择一个随机数 x,均匀地从 0 到树中剩余的总重量(最初为 1,但随着项目被挑选,这将减少)。然后,从树根开始。令 L 为树的左子树的权重,w 为根的权重。递归地使用此过程 select 一个节点:

  • 如果 x < L,向左移动并从那里递归 select 一个节点。
  • 如果 L ≤ x < L + w,return 根。
  • 如果 L + w ≤ x,设置 x := x - L - w 并递归 select 来自右子树的节点。

这种技术有时被称为 轮盘赌 selection,以防您想了解更多信息。

一旦您从 BST select编辑了一个项目,您就可以从 BST 中删除该项目以确保您不会再次选择它。有一些技术可以确保在从树中删除节点后,您可以在时间 O(log n) 内确定树中剩余节点的权重和,以便它们正确反映剩余项的权重。搜索 augmented binary search tree 以了解有关如何执行此操作的详细信息。总的来说,这意味着您将花费 O(log n) 的工作来采样和删除单个项目,所有 n 个项目的总和给出了一个 O(n log n) 时间的算法来生成您的随机播放。

我不确定是否可以对此进行改进。还有另一种从离散分布中抽样的算法,称为 Vose 的别名方法,它提供 O(1) 时间查询,但效果不佳处理对基础分布的更改,这是您的用例所需要的。

我找到了 paper,其中引入了 O(1) 的 'Roulette-wheel selection via stochastic acceptance'。这使得算法复杂度为 O(n) 且易于实现

from random import randint
from random import random

data = [ (1, 10), (2, 10), (3, 60), (4, 20) ]

def swap(i, j, array):
    array[j], array[i] = array[i], array[j]

def roulette_wheel_selection(data, start, sum):
    while True:
        r = random()
        r_index = randint(start, len(data) - 1)
        if r <= data[r_index][1] / sum:
            return r_index
    

def shuffle(data):
    data = data.copy()
    n = len(data)
    sum = 100.0
    for i in range(n-1):
        r_index = roulette_wheel_selection(data, i, sum)
        swap(i, r_index, data)
        sum = sum - data[i][1]
    return data

for i in range(10):
    print(shuffle(data))

输出

[(3, 60), (4, 20), (2, 10), (1, 10)]
[(3, 60), (1, 10), (4, 20), (2, 10)]
[(3, 60), (1, 10), (4, 20), (2, 10)]
[(3, 60), (4, 20), (1, 10), (2, 10)]
[(3, 60), (4, 20), (2, 10), (1, 10)]
[(3, 60), (4, 20), (2, 10), (1, 10)]
[(3, 60), (4, 20), (2, 10), (1, 10)]
[(4, 20), (3, 60), (1, 10), (2, 10)]
[(3, 60), (2, 10), (4, 20), (1, 10)]
[(4, 20), (3, 60), (2, 10), (1, 10)]

注意:为了获得最佳性能,roulette_wheel_selection 应根据每次迭代使用 p_max 而不是 sum。我使用 sum 因为它易于计算和更新。

更新我的第一个回答:

我找到了 paper,其中引入了 O(1) 的 'Roulette-wheel selection via stochastic acceptance'。这使得算法复杂度为 O(n) 且易于实现

from random import randint
from random import random
import time

data = [ (1, 10), (2, 10), (3, 60), (4, 20) ]

def swap(i, j, array):
    array[j], array[i] = array[i], array[j]

def roulette_wheel_selection(data, start, max_weight_limit):
    while True:
        r = random()
        r_index = randint(start, len(data) - 1)
        if r <= data[r_index][1] / max_weight_limit:
            return r_index
    

def shuffle(data, max_weight):
    data = data.copy()
    n = len(data)
    for i in range(n-1):
        r_index = roulette_wheel_selection(data, i, max_weight)
        swap(i, r_index, data)
    return data

def performance_test(iterations, data):
    start = time.time()
    max_weight = max([item[1] for item in data])
    for i in range(iterations):
        shuffle(data, max_weight)
    end = time.time()
    print(len(data), ': ',end - start)
    return end - start

performance_test(1000, data)

data2 = []
for i in range(10):
    data2 += data
performance_test(1000, data2)  

data3 = []
for i in range(100):
    data3 += data
performance_test(1000, data3) 

data4 = []
for i in range(1000):
    data4 += data
performance_test(1000, data4) 

性能输出

4 :  0.09153580665588379
40 :  0.6010794639587402
400 :  5.142168045043945
4000 :  50.09365963935852

所以它是 n(数据大小)的线性时间。我从我的第一个答案中更新了从“更新总和”到“所有数据项的最大权重”的常量,但确定它取决于 max_weight 常数。如果有人有以适当方式更新 max_weight 的策略,性能将会提高。

@StefanFenn 的 'Roulette-wheel selection via stochastic acceptance' 回答从技术上回答了我的问题。

但它有一个缺点:

算法中的最大值只计算一次。更频繁地计算它会导致性能比 O(n) 更差。如果有像 [100.000.000, 1, 2, 3] 这样的优先级,如果算法选择数字 100.000.000,则该算法可能需要通过 roulette_wheel_selection 的 while 循环进行 1 次迭代,但只要选择 100.000,就需要通过 while 循环进行数百万次迭代。 000 被选中。

所以我想向您展示一个非常短的 O(n*log(n)) 解决方案,我发现它不依赖于优先级本身的大小(C# 代码):

var n = elements.Count;
Enumerable.Range(0, n)
          .OrderByDescending(k => Math.Pow(_rng.NextDouble(), 1.0 / elements[k].Priority))
          .Select(i => elements[i].Value);

描述:基于具有 n 个元素的优先级的集合,我们创建一个值为 0、1、... n-1 的新集合。对于它们中的每一个,我们调用 Math.Pow 方法来计算一个键并按该键降序排列值(因为我们希望左侧具有较高优先级的值,而不是右侧)。现在,我们得到了一个包含 0、1、... n-1 的集合,但顺序为 prioritized/weighted。这些是指数。在最后一步中,我们根据这些索引的顺序插入值。