31

For example: There are four items in an array. I want to get one randomly, like this:

array items = [
    "bike"    //40% chance to select
    "car"     //30% chance to select
    "boat"    //15% chance to select
    "train"   //10% chance to select
    "plane"   //5%  chance to select
]
Benjamin W.
  • 46,058
  • 19
  • 106
  • 116
Geza
  • 313
  • 1
  • 3
  • 4
  • 4
    Possible duplicate of [Generate A Weighted Random Number](http://stackoverflow.com/questions/8435183/generate-a-weighted-random-number) – Bill the Lizard Apr 23 '17 at 00:14

8 Answers8

70

Both answers above rely on methods that will get slow quickly, especially the accepted one.

function weighted_random(items, weights) {
    var i;

    for (i = 1; i < weights.length; i++)
        weights[i] += weights[i - 1];
    
    var random = Math.random() * weights[weights.length - 1];
    
    for (i = 0; i < weights.length; i++)
        if (weights[i] > random)
            break;
    
    return items[i];
}

I replaced my older ES6 solution with this one as of December 2020, as ES6 isn't supported in older browsers, and I personally think this one is more readable.

If you'd rather use objects with the properties item and weight:

function weighted_random(options) {
    var i;

    var weights = [options[0].weight];

    for (i = 1; i < options.length; i++)
        weights[i] = options[i].weight + weights[i - 1];
    
    var random = Math.random() * weights[weights.length - 1];
    
    for (i = 0; i < weights.length; i++)
        if (weights[i] > random)
            break;
    
    return options[i].item;
}

Explanation:

I've made this diagram that shows how this works:

Diagram showing items' weights and how they add up and interact with the random numbers. Without this diagram this explanation isn't very helpful, so feel free to ask me any questions about it in a comment if it won't render for you.

This diagram shows what happens when an input with the weights [5, 2, 8, 3] is given. By taking partial sums of the weights, you just need to find the first one that's as large as a random number, and that's the randomly chosen item.

If a random number is chosen right on the border of two weights, like with 7 and 15 in the diagram, we go with the longer one. This is because 0 can be chosen by Math.random but 1 can't, so we get a fair distribution. If we went with the shorter one, A could be chosen 6 out of 18 times (0, 1, 2, 3, 4), giving it a higher weight than it should have.

Radvylf Programs
  • 1,429
  • 1
  • 12
  • 31
  • 6
    You might want to clarify what "answer #2" is. Answers are ordered by votes so their order can change. People can also have a different sorting for answers (e.g. by oldest first.) – JJJ May 04 '19 at 19:05
  • Just to clarify: The for loop one is the fastest, but less readable. This solution, at 200,000 ops/sec, isn't terribly slow either. – Radvylf Programs May 04 '19 at 19:30
  • `sum` is useless while `acc` have the same value – Yukulélé Feb 25 '20 at 18:55
  • the last line can be simplified: `return items[chances.findIndex(el => el > rand)]` – Yukulélé Feb 25 '20 at 19:01
  • That looks awesome ! Any chance you have an idea on how to adapt that to a 2 dimensional array efficiently ? :) – Benoît Apr 24 '20 at 12:57
  • @Benoît Assuming you just want to pick an item in the 2-dimensional array using another 2-dimensional array for the chances, you can just add the lines `items = items.flat()` and `chances = chances.flat()` at the top of the function (or, for IE compatibility, `items = [].concat.apply([], items)` and `chances = [].concat.apply([],chances`) – Radvylf Programs Apr 24 '20 at 14:08
  • This method is hard to read because you have to mentally match up the value in `items` with the number in `chances`. If you have more than a few elements, it's going to be a bear to debug. – matt Jan 26 '21 at 21:03
  • @matt I could design a version that uses an array of objects with the properties `item` and `weight`, if you want – Radvylf Programs Jan 26 '21 at 21:19
  • Thanks for responding; your second way does look better for larger problems. I had come up with my own version which was a mishmash of the approaches here, which I think ended up being basically the same as your second version. – matt Jan 27 '21 at 18:23
  • ES6 and var? better to use let! – Leigh Cheri Feb 08 '21 at 21:59
  • @LeighCheri One of my main goals here was browser compatiblity, so I dropped the ES6 stuff like `let`. – Radvylf Programs Feb 08 '21 at 23:01
  • running this sometimes also selects an element which has 0 weight, i ran a loop of 100, a 0 weight element gets picked about 2-7 times – Yash Jul 27 '21 at 16:58
  • @Yash I cannot reproduce this, and if you look at the way this works it's impossible for that to occur. Are you sure you're providing the inputs correctly? – Radvylf Programs Jul 27 '21 at 17:02
  • Would having weights as 30, 40, 15 compared to 0.30, 0.40, 0.15 make a difference? In the first case the total would be 100, and in the second case the total would be 1. It doesn't seem to be making a difference, just wondering... – yenren Jan 09 '22 at 07:43
  • 1
    @Envayo Nope, no difference. It bases it off of the sum of the weights, so you can scale it however you want. – Radvylf Programs Jan 09 '22 at 19:55
  • @RadvylfPrograms Hi. This is awesome, but I'm trying to understand how and why it works. 1) why do you add the previous element's weight to the current element in `weights[i] = options[i].weight + (weights[i - 1] || 0);`, and 2) why do you multiply `random * last weight in array` and use it to which `weights[i]` is > than that random? – user3871 Apr 17 '22 at 20:28
  • @user3871 All good questions! I've added an explanation of how it works to the answer, hopefully it helps. The reason I add all of the previous elements to the weights is that it does two steps in one, since the last element becomes the sum of the weights, which you need in order to pick a random item. – Radvylf Programs Apr 17 '22 at 21:13
  • @RadvylfPrograms clever. I like it. I used to do this by loading the element into the array n times (where n is its probability * 10) and randomly select from array. So, to be clear, you're multiplying "random" * sum of weights because its product gives a value we can use to determine which probability of the elements is greater than or equal to it (ie its chance)? Secondly, in your chart, shouldn't the first line be 5, and not 6? – user3871 Apr 18 '22 at 12:31
  • @user3871 The approach with adding the element n times works, but it gets slow with large arrays and doesn't work with fractional weights like `0.25` or `1.18`. You're correct about what the random number is doing. In the chart, I started the random numbers at 6 mostly just as a random starting point, the random number could be anything from 0 to 17 so I only chose some values that would be enough demonstrate how it works. – Radvylf Programs Apr 18 '22 at 13:29
  • amazing method, is there any chance to avoid duplicates? – Matteobikk90 May 03 '22 at 10:45
  • 1
    @LovelyWeather89 You can use a `for` loop going over the array backwards, checking if `i` (the for loop variable) is equal to `items.indexOf(items[i])`. If it's not, that means the item at `i` is a duplicate. And then you just need to `.push` any non-duplicate items and their weights into empty arrays. Something like [this](https://gist.github.com/Radvylf/ef51b338ec8148083275b32f00430fcd). – Radvylf Programs May 03 '22 at 12:47
  • @RadvylfPrograms, Hi thanks a lot, still what I would like to archieve is to return an array of 20 elements with no duplicates, the code above freeze the page, maybe I implemented that in a wrong way – Matteobikk90 May 04 '22 at 12:23
  • 1
    @LovelyWeather89 Oh my mistake, the `i++` should be `i--`, and the `>` should be `>=`. If you just want to remove duplicates in an ordinary array though, instead of the `items`/`weights` used in this answer, you can do `Array.from(new Set(x))` where `x` is the array to remove duplicates from. – Radvylf Programs May 04 '22 at 13:31
8

Some es6 approach, with wildcard handling:

const randomizer = (values) => {
    let i, pickedValue,
            randomNr = Math.random(),
            threshold = 0;

    for (i = 0; i < values.length; i++) {
        if (values[i].probability === '*') {
            continue;
        }

        threshold += values[i].probability;
        if (threshold > randomNr) {
                pickedValue = values[i].value;
                break;
        }

        if (!pickedValue) {
            //nothing found based on probability value, so pick element marked with wildcard
            pickedValue = values.filter((value) => value.probability === '*');
        }
    }

    return pickedValue;
}

Example usage:

let testValues = [{
    value : 'aaa',
    probability: 0.1
},
{
    value : 'bbb',
    probability: 0.3
},
{
    value : 'ccc',
    probability: '*'
}]

randomizer(testValues); // will return "aaa" in 10% calls, 
//"bbb" in 30% calls, and "ccc" in 60% calls;
Per Enström
  • 902
  • 8
  • 33
Paul Walczewski
  • 1,316
  • 10
  • 13
1

Here's a faster way of doing that then other answers suggested...

You can achieve what you want by:

  1. dividing the 0-to-1 segment into sections for each element based on their probability (For example, an element with probability 60% will take 60% of the segment).
  2. generating a random number and checking in which segment it lands.

STEP 1

make a prefix sum array for the probability array, each value in it will signify where its corresponding section ends.

For example: If we have probabilities: 60% (0.6), 30%, 5%, 3%, 2%. the prefix sum array will be: [0.6,0.9,0.95,0.98,1]

so we will have a segment divided like this (approximately): [ | | ||]

STEP 2

generate a random number between 0 and 1, and find it's lower bound in the prefix sum array. the index you'll find is the index of the segment that the random number landed in

Here's how you can implement this method:

let obj = {
    "Common": "60",
    "Uncommon": "25",
    "Rare": "10",
    "Legendary": "0.01",
    "Mythical": "0.001"
}
// turning object into array and creating the prefix sum array:
let sums = [0]; // prefix sums;
let keys = [];
for(let key in obj) {
    keys.push(key);
    sums.push(sums[sums.length-1] + parseFloat(obj[key])/100);
}
sums.push(1);
keys.push('NONE');
// Step 2:
function lowerBound(target, low = 0, high = sums.length - 1) {
    if (low == high) {
        return low;
    }
    const midPoint = Math.floor((low + high) / 2);
  
    if (target < sums[midPoint]) {
        return lowerBound(target, low, midPoint);
    } else if (target > sums[midPoint]) {
        return lowerBound(target, midPoint + 1, high);
    } else {
        return midPoint + 1;
    }
}

function getRandom() {
    return lowerBound(Math.random());
}

console.log(keys[getRandom()], 'was picked!');

hope you find this helpful. Note: (In Computer Science) the lower bound of a value in a list/array is the smallest element that is greater or equal to it. for example, array:[1,10,24,99] and value 12. the lower bound will be the element with value 24. When the array is sorted from smallest to biggest (like in our case) finding the lower bound of every value can be done extremely quickly with binary searching (O(log(n))).

atanay
  • 174
  • 10
  • I'm sorry, this is helpful but could you please provide an example of it being used too? – Jon Aug 15 '21 at 15:03
  • Code doesn't return based on probability defined - I ran it 100,000 times and got 0 common, 5990 uncommon, 25144 rare, etc. – Jon Aug 15 '21 at 20:21
  • the 0 at the start is not part of the keys, it's 59900 common, 25144 uncommon etc. – atanay Aug 15 '21 at 21:48
1

Here is a O(1) (constant time) algo to solve your problem.

Generate a random number from 0 to 99 (100 total numbers). If there are 40 numbers (0 to 39) in a given sub-range, then there is a 40% probability that the randomly chosen number will fall in this range. See the code below.

const number = Math.floor(Math.random() * 99); // 0 to 99
let element;

if (number >= 0 && number <= 39) {
    // 40% chance that this code runs. Hence, it is a bike.
    element = "bike";
}

else if (number >= 40 && number <= 69) {
    // 30% chance that this code runs. Hence, it is a car.
    element = "car";
}

else if (number >= 70 && number <= 84) {
    // 15% chance that this code runs. Hence, it is a boat.
    element = "boat";
}

else if (number >= 85 && number <= 94) {
    // 10% chance that this code runs. Hence, it is a train.
    element = "train";
}

else if (number >= 95 && number <= 99) {
    // 5% chance that this code runs. Hence, it is a plane.
    element = "plane";
}

Remember this, one Mathematical principle from elementary school? "All the numbers in a specified distribution have equal probability of being chosen randomly."

This tells us that each of the random numbers have equal probability of occurring in a specific range, no matter how large or small that range might be.

That's it. This should work!

John Doe
  • 87
  • 1
  • 9
0

I added my solution as a method that works well on smaller arrays (no caching):

    static weight_random(arr, weight_field){
    
        if(arr == null || arr === undefined){
            return null;
        }
        const totals = [];
        let total = 0;
        for(let i=0;i<arr.length;i++){
            total += arr[i][weight_field];
            totals.push(total);
        }
        const rnd = Math.floor(Math.random() * total);
        let selected = arr[0];
        for(let i=0;i<totals.length;i++){
            if(totals[i] > rnd){
                selected = arr[i];
                break;
            }
        }
    return selected;

}

Run it like this (provide the array and the weight property):

const wait_items =  [
    {"w" : 20, "min_ms" : "5000", "max_ms" : "10000"},
    {"w" : 20, "min_ms" : "10000", "max_ms" : "20000"},
    {"w" : 20, "min_ms" : "40000", "max_ms" : "80000"}
]   

const item = weight_random(wait_items, "w");
console.log(item);
Mitzi
  • 2,652
  • 1
  • 20
  • 15
0

ES2015 version of Radvylf Programs's answer

function getWeightedRandomItem(items) {
    const weights = items.reduce((acc, item, i) => {
        acc.push(item.weight + (acc[i - 1] || 0));
        return acc;
    }, []);
    const random = Math.random() * weights[weights.length - 1];
    return items[weights.findIndex((weight) => weight > random)];
}

And ES2022

function getWeightedRandomItem(items) {
    const weights = items.reduce((acc, item, i) => {
        acc.push(item.weight + (acc[i - 1] ?? 0));
        return acc;
    }, []);
    const random = Math.random() * weights.at(-1);
    return items[weights.findIndex((weight) => weight > random)];
}
Georgiy Bukharov
  • 356
  • 3
  • 10
0

I recently ran into this issue and solved it with a switch case, which is helpful because you can assign multiple "cases" to each element, thus weighting them.

I have a simple, common random number function which I run for the randomness:

const array = [el1, el2, el3];

const getWeightedElement = () => {
        switch (randNum(1, 10)) {
            case 10:
                return array[2];
            case 8:
            case 9:
                return array[1];
            default:
                return array[0];
        }
    }

You can change the values however you like, but the gist is that the switch rolls a random number from 1 to 10, and the cases determine that el3 has a 10% chance (1/10), el2 has a 20% chance, and for cases 1-7, el1 is the winner (70%).

if you want to weight a range of array elements in a larger array, you can just have the case run another random number to pick from the range instead of returning a fixed element. i.e.

...
case 1:
case 2:
case 3:
    return array[randNum(0, rangeMax)];
Writhyn
  • 1
  • 2
-2

Sure you can. Here's a simple code to do it:

    // Object or Array. Which every you prefer.
var item = {
    bike:40, // Weighted Probability
    care:30, // Weighted Probability
    boat:15, // Weighted Probability
    train:10, // Weighted Probability
    plane:5 // Weighted Probability
    // The number is not really percentage. You could put whatever number you want.
    // Any number less than 1 will never occur
};

function get(input) {
    var array = []; // Just Checking...
    for(var item in input) {
        if ( input.hasOwnProperty(item) ) { // Safety
            for( var i=0; i<input[item]; i++ ) {
                array.push(item);
            }
        }
    }
    // Probability Fun
    return array[Math.floor(Math.random() * array.length)];
}

console.log(get(item)); // See Console.
Noobit
  • 411
  • 3
  • 8
  • 4
    This works reasonably well for small integers (which was also my use case), but since it works by creating a new array with a length equal to the sum of the weights, it could become huge/slow with high numbers. It also doesn't work for non-integers, so you would need to find the lowest common denominator to get to an integer (which may not be possible for very precise weights). – mattsoave Jan 24 '18 at 20:04
  • 2
    @mattsoave Yeah, this is already several thousand times slower than the second answer, and that's with five options. – Radvylf Programs Apr 14 '19 at 04:23