Executando verificação de segurança...
6
kht
6 min de leitura ·

Como gerar números aleatórios sem repetição

Vamos supor que preciso gerar um conjunto qualquer de números aleatórios, mas que não hajam valores repetidos. Por exemplo, para gerar um jogo da mega-sena (6 números distintos entre 1 e 60).

Uma ideia inicial - mas que tem seus problemas (já vamos entender melhor mais abaixo) - é ir guardando os números em uma lista/array, e para cada número gerado, verificar se ele já está na lista. Em JavaScript seria assim:

const quantidade = 6, minimo = 1, maximo = 60;
const numeros = [];
while (numeros.length < 6) { // enquanto não tem 6 números
    const n = Math.floor(Math.random() * (maximo - minimo + 1)) + minimo; // gera número entre 1 e 60
    if (!numeros.includes(n)) { // se o número não está no array, adiciona
        numeros.push(n);
    }
}
console.log(numeros);

A princípio funciona, mas essa solução não escala tão bem se a quantidade de números gerados for muito próxima da quantidade total. Vamos alterar o código acima para mostrar quando um número repetido é gerado:

while (numeros.length < quantidade) {
    const n = Math.floor(Math.random() * (maximo - minimo + 1)) + minimo;
    if (!numeros.includes(n)) {
        numeros.push(n);
    } else console.log(`array com ${numeros.length} elementos, ${n} repetido`);
}

Quando a quantidade é 6, não há tantas repetições (na maioria das vezes tem uma ou nenhuma). Mas se aumentarmos para quantidade = 40, por exemplo, aí já muda bastante:

array com 4 elementos, 8 repetido
array com 16 elementos, 32 repetido
array com 23 elementos, 1 repetido
array com 23 elementos, 31 repetido
array com 24 elementos, 44 repetido
array com 24 elementos, 50 repetido
array com 28 elementos, 30 repetido
array com 29 elementos, 30 repetido
array com 29 elementos, 15 repetido
array com 32 elementos, 15 repetido
array com 32 elementos, 4 repetido
array com 33 elementos, 8 repetido
array com 33 elementos, 21 repetido
array com 34 elementos, 24 repetido
array com 34 elementos, 39 repetido
array com 35 elementos, 47 repetido
array com 36 elementos, 4 repetido
array com 36 elementos, 12 repetido
array com 36 elementos, 3 repetido
array com 37 elementos, 8 repetido
array com 37 elementos, 49 repetido
array com 37 elementos, 43 repetido
array com 37 elementos, 53 repetido
array com 38 elementos, 28 repetido
array com 38 elementos, 58 repetido
array com 38 elementos, 35 repetido
array com 38 elementos, 3 repetido
array com 38 elementos, 8 repetido
array com 38 elementos, 34 repetido
array com 38 elementos, 6 repetido
array com 38 elementos, 53 repetido
array com 38 elementos, 12 repetido
array com 38 elementos, 15 repetido

No começo ainda não há tantas repetições, mas conforme o array cresce e o tamanho se aproxima da quantidade que queremos, a probabilidade de sortear um número já existente aumenta. Ou seja, o loop se repete várias e várias vezes até encontrar um número que ainda não foi gerado. E ainda vale lembrar que o método includes precisa percorrer todo o array até encontrar o elemento em questão, para saber se ele está no array. Ou seja, o array numeros é percorrido várias vezes durante este processo.

Fiz um teste rodando este algoritmo mil vezes e computando a quantidade de repetições, além do mínimo e máximo:

const quantidade = 40, minimo = 1, maximo = 60;
let sum = 0, qtd = 1000, min = Number.MAX_SAFE_INTEGER, max = Number.MIN_SAFE_INTEGER;
for (let c = 0; c < qtd; c++) {
    const numeros = [];
    let rep = 0;
    while (numeros.length < quantidade) {
        const n = Math.floor(Math.random() * (maximo - minimo + 1)) + minimo;
        if (!numeros.includes(n)) {
            numeros.push(n);
        } else rep++;
    }
    sum += rep;
    if (rep > max) max = rep;
    if (rep < min) min = rep;
}
console.log(sum / qtd, min, max);

Rodei este código várias vezes, e a média variou em torno de 25 repetições. O máximo variou entre 45 e 60, o mínimo entre 6 e 10. Ou seja, em todas as vezes, sempre teve números repetidos.


A solução: Fisher-Yates

Originalmente, o algoritmo Fisher-Yates serve para embaralhar um array. Mas podemos adaptá-lo para o nosso problema: no caso da mega-sena (6 números entre 1 e 60), basta gerar um array com todos os 60 números, embaralhá-lo e pegar os 6 primeiros. Porém, não precisamos embaralhar tudo, apenas as 6 primeiras posições:

const quantidade = 6, minimo = 1, maximo = 60;
const todos = [];
for (let i = minimo; i <= maximo; i++) { // gera um array com todos os números
    todos.push(i);
}
// embaralha as 6 primeiras posições
for (let i = 0; i < quantidade; i++) {
    // pega uma posição aleatória do array e troca com a posição atual
    const j = Math.floor(Math.random() * todos.length);
    const tmp = todos[j];
    todos[j] = todos[i];
    todos[i] = tmp;
}
// pega os 6 primeiros elementos
const result = todos.slice(0, quantidade);

Este algoritmo evita tanto as repetições quanto a necessidade de verificar se o número gerado está no array, tornando-se muito mais eficiente.

É claro que para poucos arrays pequenos, a diferença será insignificante. Afinal, para poucos dados, tudo é rápido. Mas se tiver que gerar muitos números várias vezes, começa a fazer diferença.

Fiz um teste usando o Benchmark.js para medir cada algoritmo. O código ficou assim:

var Benchmark = require('benchmark');
var suite = new Benchmark.Suite;

const quantidade = 6, minimo = 1, maximo = 60;
// só precisa criar todos os números uma vez, pois nas vezes seguintes o array será sempre re-embaralhado
const todos = [];
for (let i = minimo; i <= maximo; i++) {
    todos.push(i);
}

suite
.add('loop', function () {
    const numeros = [];
    while (numeros.length < quantidade) {
        const n = Math.floor(Math.random() * (maximo - minimo + 1)) + minimo;
        if (!numeros.includes(n)) {
            numeros.push(n);
        }
    }
})
.add('fisher yates', function () {
    for (let i = 0; i < quantidade; i++) {
        const j = Math.floor(Math.random() * todos.length);
        const tmp = todos[j];
        todos[j] = todos[i];
        todos[i] = tmp;
    }
    const result = todos.slice(0, quantidade);
})
.on('complete', function () {
    console.log('Fastest is ' + this.filter('fastest').map('name'));
})
.on('cycle', function (event) {
    console.log(String(event.target));
})
.run({ 'async': true });

O resultado pode variar de máquina para outra, mas em geral o Fisher-Yates foi mais rápido:

loop x 5,815,553 ops/sec ±1.96% (81 runs sampled)
fisher yates x 7,341,581 ops/sec ±3.01% (79 runs sampled)
Fastest is fisher yates

Os números acima são as operações por segundo (quanto mais, melhor - e note que estão em notação americana, com vírgulas separando os milhares). Ou seja, o loop conseguiu mais de 5 milhões de operações por segundo, enquanto o Fisher-Yates conseguiu mais de 7 milhões (cerca de 1,26 vezes o número de operações do loop).

Mudando para quantidade = 40, a diferença se torna ainda mais gritante:

loop x 280,240 ops/sec ±1.14% (88 runs sampled)
fisher yates x 1,470,527 ops/sec ±1.79% (85 runs sampled)
Fastest is fisher yates

Agora o Fisher-Yates conseguiu cerca de 5,24 vezes o número de operações do loop. Isso porque, como já vimos, para quantidade igual a 40 o loop começa a ter muitas repetições, tendo que gerar outro número (e para cada número gerado, precisa percorrer o array novamente para verificar se ele já existe).


Considerações Finais

Quando alguém te diz para estudar os fundamentos (algoritmos, estruturas de dados, etc), é disso que estamos falando. A maioria dos problemas comuns já foi resolvida, com soluções exaustivamente testadas - e comprovadas - no mundo real. Claro que como exercício (para fins puramente didáticos) é interessante você mesmo tentar resolver - e provavelmente a ideia inicial que muitos têm é usar a primeira solução indicada acima. Mas saiba que para muita coisa já existem um ou mais algoritmos prontos. A maioria, inclusive, foi criada há décadas e aperfeiçoada ao longo do tempo. Em código sério que vai para a produção, geralmente você não precisa reinventar a roda.

Aliás, em algumas linguagens já existem bibliotecas que te trazem isso pronto. Em Python, por exemplo, existe a função random.sample:

from random import sample

# números entre 1 e 60
todos = range(1, 61) # em um range o valor final não é incluso, por isso é 61
# pega 6 números, sem repetição
numeros = sample(todos, k=6)
print(numeros)

Por fim, este algoritmo não vale apenas para números, e sim para qualquer array/lista/coleção de dados. Podemos, por exemplo, ter um array contendo objetos. Como o Fisher-Yates está trabalhando com os índices e em nenhum momento é preciso comparar os elementos em si, para ele tanto faz o que são esses elementos.

Artigo chupinhado ipsis literis do meu blog.

Carregando publicação patrocinada...
2

Já passei por essa dor de cabeça, e quem dera eu descobrir que fazer o shuffle de um array é a solução mais sana mesmo 😅. A alternativa insana é implementar um gerador de numero aleatório no dedo (LFSR quando eu tentei) que por natureza tem o número máximo de valores que gera antes de repetir, o problema é que eles só vão trabalhar corretamente com periodos em bases de 2. Então o melhor que poderia acontecer é gerar valores entre 0 e 64, e ignorar os maiores que 60. Mas como eu vivo aprendendo e esquecendo, muitas vezes é melhor ter um código simple do que um que roda 20ms mais rápido

1

Implementar seu próprio gerador cai no caso de reinventar a roda. É muito interessante como exercício, mas pra aplicações reais, use o que já tem pronto (especialistas muito mais inteligentes do que nós já pensaram exaustivamente nisso e raramente - a não ser que vc seja um gênio - conseguiremos algo melhor) :-)

1

Acho que uma alternativa interessante a se testar é criar uma lista com todas as possibilidades e ir sorteando um elemento aleatório dentro dela — no python isso poderia ser feito usando random.choice() — e removendo esse elemento da lista para que ele não possa ser sorteado novamente

1

Sim, isso funciona se vc só precisa fazer uma vez.

Mas e se eu quiser gerar vários conjuntos (por exemplo, quero gerar vários jogos da mega sena)? Nesse caso, não compensa ficar recriando a lista toda hora, é melhor criá-la apenas uma vez no começo, e depois embaralhá-la várias vezes (nem precisa "resetar" a lista).

Mesmo assim, no caso específico do Python, não vejo motivo pra não usar random.sample, que é feito justamente pra isso (e sem modificar a lista original).

1

Seed

Os números "aleatórios" são gerados atráves de uma seed, algumas linguagens permite você setar a seed (não é o caso do JavaScript).

Em jogos é normal se utilizar o os-time em um infinite-loop para seed. Então uma sugestão para resultados mais aleatórios mexa na seed a cada interação.

1

Opa, mesmo que não seja muito relacionado ao post eu ainda quero complementar esse fato aleatório. Não acho que isso impacte nós meros mortais, deve ser mais significantes para criptografia e afins, mas até onde eu sei não é uma boa prática setar a seed mais de uma vez. Eu acho que já vi isso acontecendo em jogo, tenho alguma memória de alguém falar que em stardew valley a seed é setada a cada passo do player, mas não sei de onde veio isso. Mas para computação em geral, boa prática é só setar uma vez mesmo. O motivo é que re-setar a seed vai diminiur a aleatóriadade como um todo, não consigo achar muitas fontes sobre, só umas Pessoas bravas no stackoverflow e uma nota na documentação do cpp. Se você conhecer algum conteudo falado do porque o jogos fazerem desse jeito eu ficaria bem grato, pesquisar sobre seed e jogos no google só me leva a geração do munto e tals

2

Se vc seta o seed a cada iteração de um loop, e ele é baseado em algo como o timestamp atual (que é uma implementação bem comum), corre o risco de 2 iterações serem tão rápidas que acabam usando o mesmo seed, e aí ele vai gerar o mesmo número.

De qualquer forma, isso é desnecessário. Basta setar apenas uma vez no início, que os algoritmos usados atualmente garantem uma boa aleatoriedade.

Quanto à criptografia, não tem nada a ver com setar o seed várias vezes, e sim com o algoritmo usado. Leia mais sobre isso aqui, aqui e aqui.

1

Interessante, eu acabo usando as linguagens mais comuns para dados (Python e R) e nunca nem tinha pensado nesse problema porque as funções estão prontas, como você citou, e são bastante utilizadas.

Um detalhe no texto me chamou a atenção, me permite uma crítica construtiva? :)

Você disse que

o loop conseguiu mais de 5 milhões de operações por segundo, enquanto o Fisher-Yates conseguiu mais de 7 milhões (cerca de 1,26 vezes mais operações).

E também que

Agora o Fisher-Yates conseguiu cerca de 5,24 vezes mais operações por segundo.

É uma maneira comum de expressar a coisa, mas que não é rigorosamente correta. 20 não é duas vezes mais que 10, é simplesmente duas vezes 10. E aqui a notação matemática nos ajuda, certo?

2 * 10 = 20

Desse modo, as mais de 7 milhões de operações por Fisher-Yates teriam que ser mais de 13 milhões para corresponder a cerca de 1,26 vezes mais operações que as mais de 5 milhões do loop. Algo mais ou menos como

(1,26 * 5.815.553) + 5.815.553 = 13.143.149 (aproximadamente)

E este é um ótimo exemplo para usar, porque como então expressar corretamente?

Falar que "Fisher-Yates teve resultado 0,26 superior" é bem estranho, né? E isso, me parece, é porque habitualmente não lidamos com a lógica de que 1 é o limite de algo – como no campo da probabilidade, em que sua medida vai de 0 a 1. Pior, ficaria ambíguo, alguém poderia facilmente entender que então o loop fez 5.815.553 operações e Fisher-Yates alcançou 5.815.553,26.

Mas trabalhamos mais frequentemente com porcentagens, e aí elas funcionam legal: Fisher-Yates teve resultado 26% superior. E também daria pra usar a versão "Fisher-Yates conseguiu 1,26 vezes o número de operações do loop", sem usar o "mais".

Espero que toda essa explicação tenha sido didática, e não pedante. Porque convencer as pessoas a não expressar quantidades nesse formato é quase que um objetivo de vida para mim. :)

E no próximo capítulo eu encho as paciências falando sobre o "Isso é n vezes menor que aquilo", uma impossibilidade. XD

1

De fato, esse é um vício de linguagem que tenho e muitas vezes sai automático.

Corrigi o texto, obrigado!

1
2
1