如何创建加权集合,然后从中选择随机元素?


34

我有一个战利品盒子,我想填充一个随机物品。但是我希望每个项目都有被拣选的不同机会。例如:

  • 5%几率获得10金币
  • 剑几率20%
  • 45%几率获得盾牌
  • 装甲几率20%
  • 药水的几率10%

如何做到这一点,以便我选择上面的项目之一,这些百分比分别是获得战利品的机会?


1
仅供参考,从理论上讲,每个样本的O(1)时间对于任何有限分布都是可能的,即使其条目动态变化的分布也是如此。参见例如cstheory.stackexchange.com/questions/37648/…
Neal Young,

Answers:


37

软编码概率解决方案

硬编码的概率解决方案的缺点是您需要在代码中设置概率。您无法在运行时确定它们。这也很难维护。

这是同一算法的动态版本。

  1. 创建一组成对的实际物品以及每个物品的重量
  2. 添加项目时,该项目的权重必须是其自身的权重加上数组中已经存在的所有项目的权重之和。因此,您应该分别跟踪总和。尤其是因为下一步将需要它。
  3. 要检索对象,请生成一个介于0和所有项目的权重之和之间的随机数
  4. 从头到尾迭代数组,直到找到一个权重大于或等于随机数的条目

这是以模板类的形式在Java中实现的示例实现,您可以为游戏使用的任何对象实例化。然后,您可以使用方法添加对象,.addEntry(object, relativeWeight)并选择之前添加的条目之一.get()

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class WeightedRandomBag<T extends Object> {

    private class Entry {
        double accumulatedWeight;
        T object;
    }

    private List<Entry> entries = new ArrayList<>();
    private double accumulatedWeight;
    private Random rand = new Random();

    public void addEntry(T object, double weight) {
        accumulatedWeight += weight;
        Entry e = new Entry();
        e.object = object;
        e.accumulatedWeight = accumulatedWeight;
        entries.add(e);
    }

    public T getRandom() {
        double r = rand.nextDouble() * accumulatedWeight;

        for (Entry entry: entries) {
            if (entry.accumulatedWeight >= r) {
                return entry.object;
            }
        }
        return null; //should only happen when there are no entries
    }
}

用法:

WeightedRandomBag<String> itemDrops = new WeightedRandomBag<>();

// Setup - a real game would read this information from a configuration file or database
itemDrops.addEntry("10 Gold",  5.0);
itemDrops.addEntry("Sword",   20.0);
itemDrops.addEntry("Shield",  45.0);
itemDrops.addEntry("Armor",   20.0);
itemDrops.addEntry("Potion",  10.0);

// drawing random entries from it
for (int i = 0; i < 20; i++) {
    System.out.println(itemDrops.getRandom());
}

这是为您的Unity,XNA或MonoGame项目在C#中实现的同一类:

using System;
using System.Collections.Generic;

class WeightedRandomBag<T>  {

    private struct Entry {
        public double accumulatedWeight;
        public T item;
    }

    private List<Entry> entries = new List<Entry>();
    private double accumulatedWeight;
    private Random rand = new Random();

    public void AddEntry(T item, double weight) {
        accumulatedWeight += weight;
        entries.Add(new Entry { item = item, accumulatedWeight = accumulatedWeight });
    }

    public T GetRandom() {
        double r = rand.NextDouble() * accumulatedWeight;

        foreach (Entry entry in entries) {
            if (entry.accumulatedWeight >= r) {
                return entry.item;
            }
        }
        return default(T); //should only happen when there are no entries
    }
}

这是JavaScript中的一个:

var WeightedRandomBag = function() {

    var entries = [];
    var accumulatedWeight = 0.0;

    this.addEntry = function(object, weight) {
        accumulatedWeight += weight;
        entries.push( { object: object, accumulatedWeight: accumulatedWeight });
    }

    this.getRandom = function() {
        var r = Math.random() * accumulatedWeight;
        return entries.find(function(entry) {
            return entry.accumulatedWeight >= r;
        }).object;
    }   
}

优点:

  • 可以处理任何重量比。如果需要,您可以在集合中设置天文概率很小的项目。权重也不需要加起来为100。
  • 您可以在运行时阅读物品和重量
  • 内存使用量与数组中的项目数成正比

相反:

  • 需要更多编程才能正确
  • 在最坏的情况下,您可能必须迭代整个数组(O(n)运行时复杂度)。因此,当您有大量项目并且经常绘制时,它可能会变慢。一种简单的优化方法是将最可能出现的项目放在首位,以便在大多数情况下算法尽早终止。您可以执行的更复杂的优化是利用对数组进行排序的事实并进行二等分搜索。这只需要O(log n)时间。
  • 您需要先在内存中构建列表,然后才能使用它(尽管您可以在运行时轻松添加项目。也可以添加删除项目,但这将需要更新删除条目之后所有项目的累加权重,再次具有O(n)最坏情况的运行时)

2
可以使用LINQ编写C#代码:return entry.FirstOrDefault(e => e.accumulatedWeight> = r)。更重要的是,由于随机点精度损失,如果随机值仅比累积值大一点点,该算法将返回null的可能性很小。作为预防措施,您可以在最后一个元素上添加一个较小的值(例如1.0),但是随后您必须在代码中明确声明该列表为最终列表。
IMil

1
我个人使用的一个小变体,如果您希望运行时的权重值不更改为权重加上所有之前的值,则可以从随机值中减去每个传递的条目的权重,并在随机值小于当前项目的权重(或减去权重使值<0)
Lunin

2
@ BlueRaja-DannyPflughoeft过早优化...问题是关于从打开的战利品箱中选择一个对象。谁要每秒打开1000个盒子?
IMil

4
@IMil:不,问题是选择随机加权项目的一般性问题。特别是对于战利品来说,这个答案可能是很好的,因为物品数量很少并且概率不会改变(尽管由于这些物品通常是在服务器上完成的,对于流行的游戏来说1000 / sec并非不切实际)
BlueRaja-Danny Pflughoeft

4
@opa然后将其标记为欺骗。仅仅因为已经问过这个问题而赞成一个好的答案真的错误吗?
Baldrickk

27

注意:我为这个确切的问题创建了一个C#库

如果您只有少量的项目并且概率永远不变,则其他解决方案也可以。但是,如果有很多项目或更改了概率(例如,选择项目后将其删除),您将需要更强大的功能。

这是两个最常见的解决方案(以上两个库均包含在其中)

沃克的别名方法

一个聪明的解决方案,如果您的概率是恒定的,则非常O(1)!)。本质上,该算法从您的概率中创建一个2D飞镖板(“别名表”)并向其投掷飞镖。

飞镖

如果您想了解更多信息,在线上很多文章介绍了它如何工作。

唯一的问题是,如果您的概率发生变化,则需要重新生成别名表,这很慢。因此,如果您需要在拾取物品后将其删除,这不是您的解决方案。

基于树的解决方案

另一个常见的解决方案是创建一个数组,其中每个项目存储其概率与之前所有项目的和。然后只需从[0,1)生成一个随机数,然后对该数字在列表中的位置进行二进制搜索。

此解决方案非常易于编码/理解,但是进行选择的速度比Walker的Alias方法要慢,并且更改概率仍然是O(n)。我们可以通过将数组变成二叉搜索树来改进它,其中每个节点都跟踪其子树中所有项的概率之和。然后,当我们从[0,1)生成数字时,我们可以沿着树走去查找它代表的项。

这使我们O(log n)可以选择一个项目更改概率!这NextWithRemoval()非常快!

结果

以下是上述库中的一些快速基准测试,比较了这两种方法

         加权随机化器基准| 树| 表
-------------------------------------------------- ---------------------------------
加()x10000 + NextWithReplacement()x10:| 4毫秒| 2毫秒
加()x10000 + NextWithReplacement()x10000:| 7毫秒| 4毫秒
加()x10000 + NextWithReplacement()x100000:| 35毫秒| 28毫秒
(Add()+ NextWithReplacement())x10000(交错)| 8毫秒| 5403毫秒
加()x10000 + NextWithRemoval()x10000:| 10毫秒| 5948毫秒

正如您所看到的,对于静态(不变)概率的特殊情况,Walker的Alias方法的速度要快50-100%。但是在动态情况下,树了几个数量级


当按重量对项目进行排序时,基于树的解决方案还为我们提供了不错的运行时nlog(n))。
内森·美林

2
我对您的结果表示怀疑,但这是正确的答案。不知道为什么,这不是最多的回答,考虑到这真正来处理这个问题的正规途径。
WHN

哪个文件包含基于树的解决方案?其次,您的基准表:Walker的Alias是“表”列吗?
Yakk

1
@Yakk:基于树的解决方案的代码在这里。它建立在AA-tree 的开源实现之上。对第二个问题说“是”。
BlueRaja-Danny Pflughoeft

1
Walker部分只是仅链接。
累积

17

命运之轮解决方案

当项目库中的概率具有相当大的公分母并且需要非常频繁地从中提取时,可以使用此方法。

创建一个选项数组。但是将每个元素多次放入其中,每个元素重复项的数量与它出现的机会成正比。对于上面的示例,所有元素的概率都是5%的乘数,因此您可以创建一个包含20个元素的数组,如下所示:

10 gold
sword
sword
sword
sword
shield
shield
shield
shield
shield
shield
shield
armor
armor
armor
armor
potion
potion

然后,只需生成一个介于0和数组长度-1之间的随机整数,即可从该列表中选择一个随机元素。

缺点:

  • 您需要在首次生成项目时构建数组。
  • 当一个元素的概率很低时,最终会得到一个非常大的数组,这可能需要大量内存。

好处:

  • 当您已经有了数组并想要多次从中绘制时,它的速度非常快。只有一个随机整数和一个数组访问。

2
作为避免第二个缺点的混合解决方案,您可以将最后一个插槽指定为“其他”插槽,并通过其他方式(例如Philipp的阵列方法)进行处理。因此,您可以使用一个阵列来填充最后一个插槽,该阵列提供99.9%的机率,只有0.1%的机率Epic Scepter of the Apocalypse。这样的两层方法利用了这两种方法的优点。
Cort Ammon

1
我在自己的项目中使用了一些变体。我要做的是计算每个项目和权重,并将它们存储在数组中,[('gold', 1),('sword',4),...]对所有权重求和,然后将一个随机数从0滚动到总和,然后迭代该数组并计算该随机数落在哪里(即a reduce)。对于经常更新且无大内存占用的阵列,效果很好。

1
@Thebluefish该解决方案在我的其他答案“软编码概率解决方案”中进行了描述
Philipp,

7

硬编码概率解决方案

从加权集合中找到随机项的最简单方法是遍历一串if-else语句,其中每个if-else可能会增加,因为前一个未命中。

int rand = random(100); //Random number between 1 and 100 (inclusive)
if(rand <= 5) //5% chance
{
    print("You found 10 gold!");
}
else if(rand <= 25) //20% chance
{
    print("You found a sword!");
}
else if(rand <= 70) //45% chance
{
    print("You found a shield!");
}
else if(rand <= 90) //20% chance
{
    print("You found armor!");
}
else //10% chance
{
    print("You found a potion!");
}

条件条件等于其机会加上所有先前条件机会的原因是因为先前条件已经消除了其成为那些项目的可能性。因此,对于盾牌的有条件攻击else if(rand <= 70),70等于盾牌的45%几率,再加上5%的金币几率和20%的剑几率。

好处:

  • 易于编程,因为它不需要数据结构。

缺点:

  • 难以维护,因为您需要在代码中维护下降率。您无法在运行时确定它们。因此,如果您需要更多将来的证明,则应检查其他答案。

3
维护起来确实很烦人。例如,如果您希望去除黄金并使药水占据优势,则需要调整它们之间所有项目的概率。
亚历山大-恢复莫妮卡

1
为避免@Alexander提到的问题,您可以在每一步中减去当前汇率,而不必将其添加到每个条件中。
AlexanderJ93

2

在C#中,您可以使用Linq扫描来运行累加器,以检查0到100.0f范围内的随机数并获取.First()来获取。就像一行代码一样。

所以像这样:

var item = a.Select(x =>
{
    sum += x.prob;
    if (rand < sum)
        return x.item;
    else
        return null;
 }).FirstOrDefault());

sum是一个零初始化整数,a是一个概率/项目结构/元组/实例的列表。rand是该范围内先前生成的随机数。

这只是在范围列表上累计总和,直到超过先前选择的随机数,然后返回该项或null,如果随机数范围(例如100)错误地小于总加权范围,则将返回null ,并且所选的随机数不在总加权范围内。

但是,您会注意到OP中的权重与正态分布(贝尔曲线)非常匹配。我认为一般来说,您将不需要特定的范围,而是倾向于在钟形曲线周围或仅在递减的指数曲线上(例如)逐渐变细的分布。在这种情况下,您可以仅使用数学公式来生成项数组的索引,并按偏好概率的顺序进行排序。CDF 正态分布就是一个很好的例子

这里也是一个例子。

另一个示例是,您可以采用90度到180度之间的随机值来获取圆的右下象限,使用cos(r)取x分量,然后使用它来索引优先列表。

使用不同的公式,您可能会有一种通用的方法,您只需输入任意长度的优先级列表(例如N),然后通过乘法运算映射公式的结果(例如:cos(x)为0到1)(例如:Ncos(x )= 0到N)以获取索引。


3
如果仅一行,您能给我们这行代码吗?我对C#不太熟悉,所以我不知道您的意思。
HEGX64

添加了@ HEGX64,但无法使用移动设备和编辑器。你可以编辑吗?
Sentinel

4
您能否更改此答案以解释其背后的概念,而不是使用特定语言的特定含义?
RaimundKrämer18年

@RaimundKrämerErm,完成了吗?
Sentinel

毫无解释的拒绝投票=毫无用处和反社会。
WGroleau '18年

1

概率不需要硬编码。这些项目和阈值可以一起放在一个数组中。

for X in itemsrange loop
  If items (X).threshold < random() then
     Announce (items(X).name)
     Exit loop
  End if
End loop

您仍然必须累积阈值,但是可以在创建参数文件时进行编码而不是对其进行编码。


3
您能否详细说明如何计算正确的阈值?例如,如果您有三个项目,每个项目的机会为33%,您将如何构建此表?由于每次都会生成一个新的random(),因此第一个需要0.3333,第二个需要0.5,最后一个需要1.0。还是我看错算法了?
管道

您可以按照他人在答案中所做的方式进行计算。对于X个项目的相等概率,第一个阈值为1 / X,第二个阈值为2 / X,等等
。– WGroleau

在此算法中对3个项目执行此操作将使阈值分别为1 / 3、2 / 3和3/3,但对于第一,第二和第三项目,结果概率为1 / 3、4 / 9和2/9。您是否真的要random()在循环中调用?
管道

不,那绝对是一个错误。每张支票需要相同的随机数。
WGroleau

0

我完成了此功能:https : //github.com/thewheelmaker/GDscript_Weighted_Random 现在!在您的情况下,您可以像这样使用它:

on_normal_case([5,20,45,20,10],0)

它只给出0到4之间的数字,但是您可以将其放在可以放置项目的数组中。

item_array[on_normal_case([5,20,45,20,10],0)]

或在功能上:

item_function(on_normal_case([5,20,45,20,10],0))

这是代码。我可以在GDscript上实现,但是可以改变其他语言,还可以检查逻辑错误:

func on_normal_case(arrayy,transformm):
    var random_num=0
    var sum=0
    var summatut=0
    #func sumarrays_inarray(array):
    for i in range(arrayy.size()):
        sum=sum+arrayy[i]
#func no_fixu_random_num(here_range,start_from):
    random_num=randi()%sum+1
#Randomies be pressed down
#first start from zero
    if 0<=random_num and random_num<=arrayy[0]:
        #print(random_num)
        #print(array[0])
        return 0+ transformm
    summatut=summatut+arrayy[0]
    for i in range(arrayy.size()-1):
        #they must pluss together
        #if array[i]<=random_num and random_num<array[i+1]:
        if summatut<random_num and random_num<=summatut+arrayy[i+1]:
            #return i+1+transform
            #print(random_num)
            #print(summatut)
            return i+1+ transformm

        summatut=summatut+arrayy[i+1]
    pass

它的工作方式如下:on_normal_case([50,50],0)这给出0或1,两者的概率相同。

on_normal_case([50,50],1)这给出1或2,两者的概率相同。

on_normal_case([20,80],1)这给出1或2,它的变化更大,得到2。

on_normal_case([20,80,20,20,30],1)这给出了1-5的随机数,较大的数字比较小的数字更有可能。

on_normal_case([20,80,0,0,20,20,30,0,0,0,0,33,,45)此掷骰在数字45,46,49,50,51,56之间看到为零,它永远不会发生。

因此,该函数仅返回一个随机数,该随机数取决于该数组的长度和transformm的数量,并且数组中的int是可能会出现一个数字的概率权重,其中该数字是数组上的位置加上transformm的数量。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.