¿Cómo creo una colección ponderada y luego elijo un elemento aleatorio?

34

Tengo una caja de botín que quiero llenar con un elemento aleatorio. Pero quiero que cada elemento tenga una probabilidad diferente de ser elegido. Por ejemplo:

  • 5% de probabilidad de 10 de oro
  • 20% de probabilidad de espada
  • 45% de probabilidad de escudo
  • 20% de probabilidad de armadura
  • 10% de probabilidad de poción

¿Cómo puedo lograr que seleccione exactamente uno de los elementos anteriores, donde esos porcentajes son las posibilidades respectivas de obtener el botín?

Evorlor
fuente
1
Para su información, en teoría, el tiempo O (1) por muestra es posible para cualquier distribución finita, incluso una distribución cuyas entradas cambian dinámicamente. Ver, por ejemplo, cstheory.stackexchange.com/questions/37648/… .
Neal Young

Respuestas:

37

La solución de probabilidades de código suave

La solución de probabilidad codificada tiene la desventaja de que necesita establecer las probabilidades en su código. No puedes determinarlos en tiempo de ejecución. También es difícil de mantener.

Aquí hay una versión dinámica del mismo algoritmo.

  1. Cree una matriz de pares de elementos reales y peso de cada elemento
  2. Cuando agrega un artículo, el peso del artículo debe ser su propio peso más la suma de los pesos de todos los artículos que ya están en la matriz. Por lo tanto, debe realizar un seguimiento de la suma por separado. Especialmente porque lo necesitarás para el siguiente paso.
  3. Para recuperar un objeto, genere un número aleatorio entre 0 y la suma de los pesos de todos los artículos.
  4. itere la matriz de principio a fin hasta que encuentre una entrada con un peso mayor o igual que el número aleatorio

Aquí hay una implementación de muestra en Java en forma de una clase de plantilla que puedes instanciar para cualquier objeto que use tu juego. Luego puede agregar objetos con el método .addEntry(object, relativeWeight)y elegir una de las entradas que agregó anteriormente con.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
    }
}

Uso:

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());
}

Aquí está la misma clase implementada en C # para su proyecto Unity, XNA o MonoGame:

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
    }
}

Y aquí hay uno en 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;
    }   
}

Pro:

  • Puede manejar cualquier relación de peso. Puede tener elementos con probabilidad astronómicamente pequeña en el conjunto si lo desea. Los pesos tampoco necesitan sumar 100.
  • Puedes leer los artículos y los pesos en tiempo de ejecución
  • Uso de memoria proporcional al número de elementos en la matriz

Contra:

  • Requiere algo más de programación para hacerlo bien
  • En el peor de los casos, es posible que deba iterar toda la matriz ( O(n)complejidad de tiempo de ejecución). Entonces, cuando tiene un conjunto muy grande de elementos y dibuja con mucha frecuencia, puede volverse lento. Una optimización simple es poner los elementos más probables primero para que el algoritmo termine temprano en la mayoría de los casos. Una optimización más compleja que puede hacer es explotar el hecho de que la matriz está ordenada y hacer una búsqueda de bisección. Esto solo lleva O(log n)tiempo.
  • Debe crear la lista en la memoria antes de poder usarla (aunque puede agregar elementos fácilmente en tiempo de ejecución. También se podrían agregar elementos de eliminación, pero eso requeriría actualizar los pesos acumulados de todos los elementos que vienen después de la entrada eliminada, que nuevamente tiene el O(n)peor tiempo de ejecución)
Philipp
fuente
2
El código C # podría escribirse utilizando LINQ: return return.FirstOrDefault (e => e.accumulatedWeight> = r). Más importante aún, existe una ligera posibilidad de que, debido a la pérdida de precisión de coma flotante, este algoritmo devuelva nulo si el valor aleatorio es solo un poquito mayor que el valor acumulado. Como precaución, puede agregar un pequeño valor (digamos, 1.0) al último elemento, pero luego deberá indicar explícitamente en su código que la lista es final.
IMil
1
Una pequeña variante de esto que he usado personalmente, si desea que los valores de peso en tiempo de ejecución no se cambien al valor de peso más todo anterior, puede restar el peso de cada entrada pasada de su valor aleatorio, deteniéndose cuando el valor aleatorio es menor que el peso actual de los artículos (o al restar el peso hace que el valor sea <0)
Lunin
2
@ BlueRaja-DannyPflughoeft optimización prematura ... la pregunta era sobre la selección de un objeto de un botín abierto. ¿Quién abrirá 1000 cajas por segundo?
IMil
44
@IMil: No, la pregunta es general para seleccionar elementos ponderados al azar . Para las cajas de botín específicamente, esta respuesta probablemente esté bien porque hay una pequeña cantidad de elementos y las probabilidades no cambian (aunque, dado que generalmente se hacen en un servidor, 1000 / seg no es poco realista para un juego popular) .
BlueRaja - Danny Pflughoeft
44
@opa luego marca para cerrar como un engañado. ¿Es realmente incorrecto votar una buena respuesta solo porque la pregunta ya se ha hecho antes?
Baldrickk
27

Nota: creé una biblioteca de C # para este problema exacto

Las otras soluciones están bien si solo tiene una pequeña cantidad de elementos y sus probabilidades nunca cambian. Sin embargo, con muchos elementos o probabilidades cambiantes (por ejemplo, eliminar elementos después de seleccionarlos) , querrá algo más poderoso.

Estas son las dos soluciones más comunes (ambas incluidas en la biblioteca anterior)

Método de alias de Walker

Una solución inteligente que es extremadamente rápida ( O(1)!) Si sus probabilidades son constantes. En esencia, el algoritmo crea un tablero de dardos 2D ("tabla de alias") a partir de sus probabilidades y le lanza un dardo.

Diana

Hay muchos artículos en línea sobre cómo funciona si desea obtener más información.

El único problema es que si sus probabilidades cambian, necesita regenerar la tabla de alias, que es lenta. Por lo tanto, si necesita eliminar elementos después de haberlos elegido, esta no es la solución para usted.

Solución basada en árboles

La otra solución común es hacer una matriz donde cada elemento almacena la suma de su probabilidad y todos los elementos anteriores. Luego, simplemente genere un número aleatorio a partir de [0,1) y realice una búsqueda binaria para saber dónde cae ese número en la lista.

Esta solución es muy fácil de codificar / comprender, pero hacer una selección es más lento que el Método de alias de Walker, y cambiar las probabilidades sigue siendo O(n) . Podemos mejorarlo convirtiendo la matriz en un árbol de búsqueda binaria, donde cada nodo realiza un seguimiento de la suma de probabilidades en todos los elementos de su subárbol. Luego, cuando generamos el número desde [0,1), podemos simplemente caminar hacia abajo del árbol para encontrar el elemento que representa.

¡Esto nos da O(log n)para elegir un artículo y cambiar las probabilidades! ¡Esto lo hace NextWithRemoval()extremadamente rápido!

Los resultados

Aquí hay algunos puntos de referencia rápidos de la biblioteca anterior, comparando estos dos enfoques

         Puntos de referencia de WeightedRandomizer | Arbol | Mesa
-------------------------------------------------- ---------------------------------
Agregar () x10000 + NextWithReplacement () x10: | 4 ms | 2 ms
Agregar () x10000 + NextWithReplacement () x10000: | 7 ms | 4 ms
Agregar () x10000 + NextWithReplacement () x100000: | 35 ms | 28 ms
(Agregar () + NextWithReplacement ()) x10000 (intercalado) | 8 ms | 5403 ms
Agregar () x10000 + NextWithRemoval () x10000: | 10 ms | 5948 ms

Como puede ver, para el caso especial de probabilidades estáticas (que no cambian), el método Alias ​​de Walker es aproximadamente 50-100% más rápido. ¡Pero en los casos más dinámicos, el árbol es varios órdenes de magnitud más rápido !

BlueRaja - Danny Pflughoeft
fuente
La solución basada en árbol también nos da un tiempo de ejecución decente ( nlog(n)) cuando clasificamos artículos por peso.
Nathan Merrill
2
Soy escéptico de sus resultados, pero esta es la respuesta correcta. No estoy seguro de por qué esta no es la respuesta principal, teniendo en cuenta que esta es realmente la forma canónica de manejar este problema.
cuando
¿Qué archivo contiene la solución basada en árbol? En segundo lugar, su tabla de referencia: ¿es el alias de Walker la columna "tabla"?
Yakk
1
@Yakk: El código para la solución basada en árbol está aquí . Se basa en una implementación de código abierto de un árbol AA . Y 'sí' a su segunda pregunta.
BlueRaja - Danny Pflughoeft
1
La parte de Walker es bastante solo enlace.
Acumulación
17

La solución de la Rueda de la Fortuna

Puede usar este método cuando las probabilidades en su grupo de elementos tienen un denominador común bastante grande y necesita recurrir a él con mucha frecuencia.

Crea una variedad de opciones. Pero coloque cada elemento en él varias veces, con el número de duplicados de cada elemento proporcional a su posibilidad de aparecer. Para el ejemplo anterior, todos los elementos tienen probabilidades que son multiplicadores del 5%, por lo que puede crear una matriz de 20 elementos como este:

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

Luego, simplemente elija un elemento aleatorio de esa lista generando un entero aleatorio entre 0 y la longitud de la matriz - 1.

Desventajas

  • Necesita construir la matriz la primera vez que desea generar un elemento.
  • Cuando se supone que uno de sus elementos tiene una probabilidad muy baja, terminará con una matriz realmente grande, que puede requerir mucha memoria.

Ventajas:

  • Cuando ya tiene la matriz y desea extraerla varias veces, entonces es muy rápida. Solo un entero aleatorio y un acceso de matriz.
Philipp
fuente
3
Como solución híbrida para evitar la segunda desventaja, puede designar el último espacio como "otro" y manejarlo por otros medios, como el enfoque de matriz de Philipp. Por lo tanto, puede llenar ese último espacio con una matriz que ofrece una probabilidad del 99.9% de una poción, y solo una probabilidad del 0.1% de una Epic Scepter of the Apocalypse. Tal enfoque de dos niveles aprovecha las ventajas de ambos enfoques.
Cort Ammon - Restablece a Mónica el
1
Utilizo algo una variación de esto en mi propio proyecto. Lo que hago es calcular cada elemento y peso, y almacenarlos en una matriz, [('gold', 1),('sword',4),...]sumar todos los pesos, y luego rodar un número aleatorio de 0 a la suma, luego iterar la matriz y calcular dónde aterriza el número aleatorio (es decir, a reduce) Funciona bien para matrices que se actualizan con frecuencia, y no hay un gran problema de memoria.
1
@Thebluefish Esa solución se describe en mi otra respuesta "La solución de probabilidades de código suave"
Philipp
7

La solución de probabilidades codificada

La forma más simple de encontrar un elemento aleatorio de una colección ponderada es atravesar una cadena de instrucciones if-else, donde cada if-else aumenta probablemente, ya que la anterior no golpea.

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!");
}

La razón por la cual los condicionales son iguales a su probabilidad más todas las posibilidades condicionales anteriores es porque los condicionales anteriores ya han eliminado la posibilidad de que sean esos elementos. Entonces, para el condicional del escudo else if(rand <= 70), 70 es igual al 45% de probabilidad del escudo, más el 5% de probabilidad del oro y el 20% de probabilidad de la espada.

Ventajas:

  • Fácil de programar, ya que no requiere estructuras de datos.

Desventajas

  • Difícil de mantener, porque necesita mantener sus tasas de caída en su código. No puedes determinarlos en tiempo de ejecución. Entonces, si desea algo más a prueba de futuro, debe verificar las otras respuestas.
Evorlor
fuente
3
Esto sería realmente molesto de mantener. Por ejemplo, si desea eliminar el oro y hacer que la poción tome su lugar, debe ajustar las probabilidades de todos los elementos entre ellos.
Alexander - Restablece a Mónica el
1
Para evitar el problema que menciona @Alexander, puede restar la tasa actual en cada paso, en lugar de sumarla a cada condición.
AlexanderJ93
2

En C #, podría usar un escaneo de Linq para ejecutar su acumulador para verificar un número aleatorio en el rango de 0 a 100.0f y .First () para obtener. Entonces, como una línea de código.

Entonces algo como:

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

sumes un entero inicializado cero y aes una lista de estructuras de prob / ítem / tuplas / instancias. randes un número aleatorio generado previamente en el rango.

Esto simplemente acumula la suma sobre la lista de rangos hasta que excede el número aleatorio seleccionado previamente y devuelve el elemento o nulo, donde nulo se devolvería si el rango de números aleatorios (por ejemplo, 100) es menor que el rango de ponderación total por error , y el número aleatorio seleccionado está fuera del rango de ponderación total.

Sin embargo, notará que los pesos en OP coinciden estrechamente con una distribución normal (curva de campana). Creo que, en general, no querrá rangos específicos, tenderá a querer una distribución que se reduzca, ya sea alrededor de una curva de campana o simplemente en una curva exponencial decreciente (por ejemplo). En este caso, podría usar una fórmula matemática para generar un índice en una matriz de elementos, ordenados por orden de probabilidad preferida. Un buen ejemplo es CDF en distribución normal.

También un ejemplo aquí .

Otro ejemplo es que podría tomar un valor aleatorio de 90 grados a 180 grados para obtener el cuadrante inferior derecho de un círculo, tomar el componente x usando cos (r) y usarlo para indexar en una lista priorizada.

Con diferentes fórmulas, podría tener un enfoque general en el que simplemente ingrese una lista priorizada de cualquier longitud (por ejemplo, N) y asigne el resultado de la fórmula (por ejemplo: cos (x) es 0 a 1) por multiplicación (por ejemplo: Ncos (x ) = 0 a N) para obtener el índice.

Centinela
fuente
3
¿Podría darnos esta línea de código si es solo una línea? No estoy tan familiarizado con C #, así que no sé a qué te refieres.
HEGX64
@ HEGX64 agregado pero usando el móvil y el editor no funciona. ¿Puedes editar?
Sentinel
44
¿Puedes cambiar esta respuesta para explicar el concepto detrás de ella, en lugar de una implementación específica en un idioma específico?
Raimund Krämer
@ RaimundKrämer Erm, ¿listo?
Sentinel
Voto sin explicación = inútil y antisocial.
WGroleau
1

Las probabilidades no necesitan estar codificadas. Los elementos y los umbrales pueden estar juntos en una matriz.

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

Aún debe acumular los umbrales, pero puede hacerlo al crear un archivo de parámetros en lugar de codificarlo.

WGroleau
fuente
3
¿Podría explicar cómo calcular el umbral correcto? Por ejemplo, si tiene tres elementos con un 33% de probabilidad cada uno, ¿cómo construiría esta tabla? Como se genera un nuevo random () cada vez, el primero necesitaría 0.3333, el segundo necesitaría 0.5 y el último necesitaría 1.0. ¿O leí mal el algoritmo?
tubería
Se calcula de la misma manera que otros lo hicieron en sus respuestas. Para probabilidades iguales de X elementos, el primer umbral es 1 / X, el segundo, 2 / X, etc.
WGroleau
Hacer eso para 3 ítems en este algoritmo haría que los umbrales sean 1/3, 2/3 y 3/3 pero las probabilidades de resultado 1/3, 4/9 y 2/9 para el primer, segundo y tercer ítem. ¿Realmente quieres tener la llamada random()en el bucle?
tubería
No, definitivamente es un error. Cada cheque necesita el mismo número aleatorio.
WGroleau el
0

Hice esta función: https://github.com/thewheelmaker/GDscript_Weighted_Random Now! en tu caso puedes usarlo así:

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

Solo da un número entre 0 y 4, pero puede colocarlo en la matriz donde obtuvo los elementos.

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

O en función:

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

Aquí está el código. Lo hice en GDscript, puedes, pero puede alterar otro idioma, también verifica si hay errores lógicos:

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

Funciona así: on_normal_case ([50,50], 0) Esto da 0 o 1, tiene la misma probabilidad de ambos.

on_normal_case ([50,50], 1) Esto da 1 o 2, tiene la misma probabilidad de ambos.

on_normal_case ([20,80], 1) Esto da 1 o 2, tiene un cambio mayor para obtener dos.

on_normal_case ([20,80,20,20,30], 1) Esto da un rango de números aleatorios 1-5 y los números más grandes son más probables que los números más pequeños.

on_normal_case ([20,80,0,0,20,20,30,0,0,0,0,33], 45) Este lanzamiento corta entre los números 45,46,49,50,51,56 que ves cuando hay es cero, nunca ocurre.

Por lo tanto, la función devuelve solo un número aleatorio que depende de la longitud de esa matriz de matriz y el número de transformm, y las entradas en la matriz son pesos de probabilidad de que un número pueda ocurrir, donde ese número es la ubicación en la matriz, más el número de transformm.

Narutofan
fuente