Comment créer une collection pondérée puis en tirer un élément aléatoire?

34

J'ai une boîte de butin que je veux remplir avec un élément aléatoire. Mais je veux que chaque article ait une chance différente d'être choisi. Par exemple:

  • 5% de chance de 10 pièces d'or
  • 20% de chance d'épée
  • 45% de chances de bouclier
  • 20% de chance d'armure
  • 10% de chance de potion

Comment puis-je faire en sorte que je sélectionne exactement l'un des éléments ci-dessus, où ces pourcentages représentent les chances respectives d'obtenir le butin?

Evorlor
la source
1
Pour info, théoriquement, un temps par échantillon est possible pour toute distribution finie, même une distribution dont les entrées changent de manière dynamique. Voir par exemple cstheory.stackexchange.com/questions/37648/… .
Neal Young

Réponses:

37

La solution de probabilités codées de manière douce

La solution de probabilité codée en dur présente l'inconvénient de devoir définir les probabilités dans votre code. Vous ne pouvez pas les déterminer au moment de l'exécution. Il est également difficile à maintenir.

Voici une version dynamique du même algorithme.

  1. Créer un tableau de paires d'éléments réels et le poids de chaque élément
  2. Lorsque vous ajoutez un élément, son poids doit être égal à son poids et à la somme des poids de tous les éléments déjà présents dans le tableau. Donc, vous devriez suivre la somme séparément. Surtout parce que vous en aurez besoin pour la prochaine étape.
  3. Pour récupérer un objet, générez un nombre aléatoire compris entre 0 et la somme des poids de tous les éléments.
  4. répétez le tableau du début à la fin jusqu'à ce que vous trouviez une entrée de poids supérieur ou égal au nombre aléatoire

Voici un exemple d'implémentation en Java sous la forme d'une classe de modèle que vous pouvez instancier pour tout objet utilisé par votre jeu. Vous pouvez ensuite ajouter des objets avec la méthode .addEntry(object, relativeWeight)et sélectionner l’une des entrées ajoutées précédemment avec.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
    }
}

Usage:

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

Voici la même classe implémentée en C # pour votre projet Unity, XNA ou 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
    }
}

Et en voici un 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:

  • Peut gérer tous les rapports de poids. Vous pouvez avoir des objets avec une probabilité astronomique faible dans l'ensemble si vous le souhaitez. Les poids n'ont pas besoin d'ajouter 100.
  • Vous pouvez lire les articles et les poids au moment de l'exécution
  • Utilisation de la mémoire proportionnelle au nombre d'éléments dans le tableau

Contra:

  • Nécessite plus de programmation pour réussir
  • Dans le pire des cas, vous devrez peut-être itérer le tableau entier ( O(n)complexité d'exécution). Ainsi, lorsque vous avez un très grand nombre d’articles et que vous dessinez très souvent, cela risque de devenir lent. Une optimisation simple consiste à placer les éléments les plus probables en premier afin que l'algorithme se termine tôt dans la plupart des cas. Une optimisation plus complexe consiste à exploiter le fait que le tableau est trié et à effectuer une recherche par bissection. Cela ne prend que du O(log n)temps.
  • Vous devez créer la liste en mémoire avant de pouvoir l'utiliser (bien que vous puissiez facilement ajouter des éléments au moment de l'exécution. Vous pouvez également supprimer des éléments, mais cela nécessiterait de mettre à jour les poids cumulés de tous les éléments après l'entrée supprimée. a de nouveau le O(n)pire temps d'exécution)
Philipp
la source
2
Le code C # peut être écrit en utilisant LINQ: renvoyer les entrées.PremierOuPar défaut (e => e.accumulatedWeight> = r). Plus important encore, il existe une faible possibilité qu'en raison de la perte de précision en virgule flottante, cet algorithme renvoie la valeur null si la valeur aléatoire devient juste un tout petit peu plus grande que la valeur accumulée. Par précaution, vous pouvez ajouter une petite valeur (disons, 1.0) au dernier élément, mais vous devez alors indiquer explicitement dans votre code que la liste est finale.
IMil
1
Personnellement, une petite variante que j’ai utilisée personnellement, si vous souhaitez que les valeurs de poids du runtime ne soient pas remplacées par la valeur weight plus toutes les valeurs précédentes, vous pouvez soustraire le poids de chaque entrée transmise de votre valeur aléatoire, en vous arrêtant quand. la valeur aléatoire est inférieure au poids actuel des éléments (ou lorsque vous soustrayez le poids, la valeur <0)
Lunin
2
@ Optimisation prématurée de BlueRaja-DannyPflughoeft ... la question portait sur la sélection d'un objet dans un coffre à butin ouvert. Qui va ouvrir 1000 boîtes par seconde?
IMil
4
@IMil: Non, la question est un fourre-tout général pour la sélection d'éléments pondérés au hasard . Pour les casiers en particulier, cette réponse est probablement satisfaisante car il existe un petit nombre d’articles et les probabilités ne changent pas (cependant, puisque celles-ci sont généralement effectuées sur un serveur, 1000 / sec n’est pas irréaliste pour un jeu populaire) .
BlueRaja - Danny Pflughoeft
4
@opa puis drapeau pour fermer comme une dupe. Est-ce vraiment une erreur de relire une bonne réponse simplement parce que la question a déjà été posée?
Baldrickk
27

Remarque: j'ai créé une bibliothèque C # pour ce problème précis.

Les autres solutions sont acceptables si vous ne disposez que d’un petit nombre d’articles et que vos probabilités ne changent jamais. Cependant, avec de nombreux éléments ou des probabilités changeantes (par exemple, supprimer des éléments après les avoir sélectionnés) , vous souhaiterez un système plus puissant.

Voici les deux solutions les plus courantes (les deux sont inclus dans la bibliothèque ci-dessus)

Méthode du pseudonyme du marcheur

Une solution intelligente extrêmement rapide ( O(1)!) Si vos probabilités sont constantes. En substance, l'algorithme crée un jeu de fléchettes 2D ("table d'alias") à partir de vos probabilités et lance une fléchette dessus.

Jeu de fléchettes

Il existe de nombreux articles en ligne sur le fonctionnement de ce logiciel si vous souhaitez en savoir plus.

Le seul problème est que si vos probabilités changent, vous devez régénérer la table des alias, qui est lente. Par conséquent, si vous devez supprimer des éléments une fois qu'ils ont été sélectionnés, ce n'est pas la solution.

Solution basée sur l'arbre

L’autre solution courante consiste à créer un tableau dans lequel chaque élément stocke la somme de sa probabilité et de tous les éléments qui le précèdent. Ensuite, générez simplement un nombre aléatoire à partir de [0,1) et effectuez une recherche binaire pour savoir où ce nombre se situe dans la liste.

Cette solution est très facile à coder / comprendre, mais la sélection est plus lente que la méthode Alias ​​de Walker et la modification des probabilités le reste O(n). Nous pouvons l’améliorer en transformant le tableau en un arbre de recherche binaire, dans lequel chaque nœud garde une trace de la somme des probabilités dans tous les éléments de sa sous-arborescence. Ensuite, lorsque nous générons le nombre à partir de [0,1), nous pouvons simplement parcourir l’arbre pour trouver l’article qu’il représente.

Cela nous donne O(log n)de choisir un article et de changer les probabilités! Cela rend NextWithRemoval()extrêmement rapide!

Les resultats

Voici quelques repères rapides de la bibliothèque ci-dessus, en comparant ces deux approches

         Points de repère pondérés en Randomizer | Arbre | Table
-------------------------------------------------- ---------------------------------
Ajouter () x10000 + NextWithReplacement () x10: | 4 ms | 2 ms
Ajouter () x10000 + NextWithReplacement () x10000: | 7 ms | 4 ms
Ajouter () x10000 + NextWithReplacement () x100000: | 35 ms | 28 ms
(Ajouter () + NextWithReplacement ()) x10000 (entrelacé) | 8 ms | 5403 ms
Ajouter () x10000 + NextWithRemoval () x10000: | 10 ms | 5948 ms

Comme vous pouvez le constater, dans le cas particulier de probabilités statiques (non changeantes), la méthode Alias ​​de Walker est environ 50-100% plus rapide. Mais dans les cas les plus dynamiques, l'arbre est plus rapide de plusieurs ordres de grandeur !

BlueRaja - Danny Pflughoeft
la source
La solution basée sur l’arborescence nous donne également une durée d’exécution décente ( nlog(n)) lors du tri des éléments en fonction du poids.
Nathan Merrill
2
Je suis sceptique quant à vos résultats, mais c'est la bonne réponse. Vous ne savez pas pourquoi ce n’est pas la bonne réponse, étant donné que c’est en fait le moyen canonique de traiter ce problème.
quand
Quel fichier contient la solution arborescente? Deuxièmement, votre tableau de référence: l'alias de Walker est-il la colonne "tableau"?
Yakk
1
@Yakk: Le code de la solution arborescente est ici . Il est construit sur une implémentation open-source d'une arborescence AA . Et «oui» à votre deuxième question.
BlueRaja - Danny Pflughoeft
1
La partie Walker n’est qu’une simple liaison.
Accumulation du
17

La roue de la solution de fortune

Vous pouvez utiliser cette méthode lorsque les probabilités de votre groupe d'éléments ont un dénominateur commun assez important et que vous devez en tirer très souvent.

Créez un tableau d'options. Mais mettez-y chaque élément plusieurs fois, avec le nombre de doublons de chaque élément proportionnel à ses chances d'apparition. Pour l'exemple ci-dessus, tous les éléments ont des probabilités multiplicateurs de 5%, vous pouvez donc créer un tableau de 20 éléments comme celui-ci:

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

Ensuite, choisissez simplement un élément aléatoire de cette liste en générant un entier aléatoire compris entre 0 et la longueur du tableau - 1.

Désavantages:

  • Vous devez créer le tableau la première fois que vous souhaitez générer un élément.
  • Lorsque l'un de vos éléments est supposé avoir une très faible probabilité, vous vous retrouvez avec un très grand tableau, qui peut nécessiter beaucoup de mémoire.

Avantages:

  • Lorsque vous avez déjà le tableau et que vous voulez en tirer plusieurs fois, c'est très rapide. Juste un entier aléatoire et un accès au tableau.
Philipp
la source
3
En tant que solution hybride pour éviter le second inconvénient, vous pouvez désigner le dernier emplacement comme "autre" et le gérer via d'autres moyens, tels que l'approche par matrice de Philipp. Ainsi, vous pourriez remplir cette dernière case avec un tableau offrant 99,9% de chances d’une potion et seulement 0,1% d’une Epic Scepter of the Apocalypse. Une telle approche à deux niveaux exploite les avantages des deux approches.
Cort Ammon - Rétablir Monica
1
J'utilise quelque peu une variante de cela dans mon propre projet. Ce que je fais est de calculer chaque élément et chaque poids, et de les stocker dans un tableau, de [('gold', 1),('sword',4),...]résumer tous les poids, puis de faire rouler un nombre aléatoire de 0 à la somme, puis de réitérer le tableau et de calculer où le nombre aléatoire atterrit (c.-à-d. a reduce). Fonctionne bien pour les tableaux qui sont mis à jour souvent et sans problème de mémoire important.
1
@Thebluefish Cette solution est décrite dans mon autre réponse "La solution de probabilités codées en douceur"
Philipp
7

La solution de probabilités codées en dur

Le moyen le plus simple de trouver un élément aléatoire dans une collection pondérée consiste à parcourir une chaîne d'instructions if-else, où chaque if-else augmente probablement, étant donné que le précédent ne touche pas.

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 raison pour laquelle les conditionnels sont égaux à sa chance, plus toutes les chances conditionnelles précédentes, c'est parce que les conditionnels précédents ont déjà éliminé la possibilité qu'il s'agisse de ces éléments. Donc, pour le bouclier conditionnel else if(rand <= 70), 70 est égal à 45% de chance du bouclier, plus les 5% de chance de l'or et 20% de chance de l'épée.

Avantages:

  • Facile à programmer, car il ne nécessite aucune structure de données.

Désavantages:

  • Difficile à gérer, car vous devez conserver vos taux de drop dans votre code. Vous ne pouvez pas les déterminer au moment de l'exécution. Donc, si vous voulez quelque chose de plus pérenne, vous devriez vérifier les autres réponses.
Evorlor
la source
3
Ce serait vraiment ennuyeux à maintenir. Par exemple, si vous souhaitez retirer de l'or et faire en sorte que la potion prenne sa place, vous devez ajuster les probabilités de tous les objets qui les séparent.
Alexander - Réintégrer Monica
1
Pour éviter le problème mentionné par @Alexander, vous pouvez plutôt soustraire le taux actuel à chaque étape, au lieu de l'ajouter à chaque condition.
AlexanderJ93
2

En C #, vous pouvez utiliser un scan Linq pour exécuter votre accumulateur afin de vérifier un nombre aléatoire compris entre 0 et 100.0f et .First () à obtenir. Donc, comme une ligne de code.

Donc, quelque chose comme:

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

sumest un entier zéro initialisé et aune liste de structures prob / item / tuples / instances. randest un nombre aléatoire précédemment généré dans la plage.

Cela accumule simplement la somme sur la liste des plages jusqu'à ce qu'il dépasse le nombre aléatoire précédemment sélectionné, et renvoie soit l'élément, soit la valeur null, où la valeur null serait renvoyée si la plage de nombres aléatoires (par exemple 100) est inférieure à la plage de pondération totale par erreur , et le nombre aléatoire sélectionné est en dehors de la plage de pondération totale.

Cependant, vous remarquerez que les poids dans OP correspondent étroitement à une distribution normale (courbe de Bell). Je pense qu'en général, vous ne voudrez pas de plages spécifiques, vous aurez tendance à vouloir une distribution qui se rétrécit autour d'une courbe en cloche ou simplement sur une courbe exponentielle décroissante (par exemple). Dans ce cas, vous pouvez simplement utiliser une formule mathématique pour générer un index dans un tableau d'éléments, triés par ordre de probabilité privilégiée. Un bon exemple est CDF dans la distribution normale

Aussi un exemple ici .

Un autre exemple est que vous pouvez prendre une valeur aléatoire de 90 degrés à 180 degrés pour obtenir le quadrant inférieur droit d'un cercle, prenez la composante x à l'aide de cos (r) et utilisez-la pour l'indexer dans une liste hiérarchisée.

Avec différentes formules, vous pouvez avoir une approche générale dans laquelle vous introduisez simplement une liste hiérarchisée de n'importe quelle longueur (par exemple N) et mappez le résultat de la formule (par exemple: cos (x) est compris entre 0 et 1) par multiplication (par exemple: Ncos (x ) = 0 à N) pour obtenir l'index.

Sentinelle
la source
3
Pourriez-vous nous donner cette ligne de code s'il ne s'agit que d'une ligne? Je ne suis pas aussi familier avec C #, donc je ne sais pas ce que vous voulez dire.
HEGX64
@ HEGX64 ajouté mais l'utilisation de Mobile et de l'éditeur ne fonctionne pas. Pouvez-vous éditer?
Sentinel
4
Pouvez-vous modifier cette réponse pour expliquer le concept qui la sous-tend, plutôt qu’une modification spécifique dans une langue donnée?
Raimund Krämer
@ RaimundKrämer Erm, c'est fini?
Sentinel
Vote négatif sans explication = inutile et antisocial.
WGroleau
1

Les probabilités n'ont pas besoin d'être codées en dur. Les éléments et les seuils peuvent être ensemble dans un tableau.

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

Vous devez toujours accumuler les seuils, mais vous pouvez le faire lors de la création d'un fichier de paramètres au lieu de le coder.

WGroleau
la source
3
Pourriez-vous préciser comment calculer le seuil correct? Par exemple, si vous avez trois objets avec 33% de chance chacun, comment construirez-vous ce tableau? Puisqu'un nouveau random () est généré à chaque fois, le premier aurait besoin de 0,3333, le second aurait besoin de 0,5 et le dernier aurait besoin de 1,0. Ou ai-je mal lu l'algorithme?
pipe
Vous le calculez comme d'autres l'ont fait dans leurs réponses. Pour des probabilités égales d'éléments X, le premier seuil est 1 / X, le second, 2 / X, etc.
WGroleau
Faire cela pour 3 items de cet algorithme donnerait les seuils 1/3, 2/3 et 3/3 mais les probabilités de résultat 1/3, 4/9 et 2/9 pour les premier, deuxième et troisième items. Voulez-vous vraiment avoir l'appel random()dans la boucle?
pipe
Non, c'est vraiment un bug. Chaque chèque nécessite le même nombre aléatoire.
WGroleau
0

J'ai fait cette fonction: https://github.com/thewheelmaker/GDscript_Weighted_Random Now! dans votre cas, vous pouvez l'utiliser comme ceci:

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

Il donne juste un nombre entre 0 et 4 mais vous pouvez le mettre dans un tableau où vous avez obtenu les éléments.

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

Ou en fonction:

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

Voici le code. Je l'ai fait sur GDscript, vous pouvez, mais cela peut modifier une autre langue, vérifiez également les erreurs de logique:

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

Cela fonctionne comme ceci: on_normal_case ([50,50], 0) Ceci donne 0 ou 1, la probabilité est la même.

on_normal_case ([50,50], 1) Ceci donne 1 ou 2, il a la même probabilité les deux.

on_normal_case ([20,80], 1) Ceci donne 1 ou 2, le changement est plus important pour obtenir deux.

on_normal_case ([20,80,20,20,30], 1) Ceci donne des nombres aléatoires allant de 1 à 5 et les plus grands sont plus susceptibles que les plus petits.

on_normal_case ([20,80,0,0,20,20,30,0,0,0,0,3,33], 45) Ce lancer se lance entre les nombres 45,46,49,50,51,56 vous voyez quand il y a est zéro il ne se produit jamais.

Ainsi, sa fonction ne renvoie qu’un nombre aléatoire qui dépend de la longueur de ce tableau et de ce nombre de transformation, et ints dans le tableau sont des poids de probabilité qu’un nombre puisse se produire, où ce nombre est l’emplacement du tableau, plus un nombre de transformation.

Narutofan
la source