web-dev-qa-db-fra.com

Qu'est-ce qui ne va pas avec cet algorithme de mélange et comment puis-je le savoir?

Tout comme arrière-plan, je suis conscient du Fisher-Yates mélange parfait. C'est un grand mélange avec sa complexité O(n) et son uniformité garantie et je serais idiot de ne pas l'utiliser ... dans un environnement qui permet les mises à jour sur place des tableaux (donc dans la plupart, sinon tous, impératif environnements de programmation).

Malheureusement, le monde de la programmation fonctionnelle ne vous donne pas accès à un état mutable.

À cause de Fisher-Yates, cependant, il n'y a pas beaucoup de littérature que je puisse trouver sur la façon de concevoir un algorithme de brassage. Les quelques endroits qui le traitent du tout le font brièvement avant de dire, en effet, "alors voici Fisher-Yates qui est tout le mélange que vous devez savoir". J'ai finalement dû trouver ma propre solution.

La solution que j'ai trouvée fonctionne comme celle-ci pour mélanger n'importe quelle liste de données:

  • Si la liste est vide, renvoyez l'ensemble vide.
  • Si la liste contient un seul élément, renvoyez-le.
  • Si la liste n'est pas vide, partitionnez la liste avec un générateur de nombres aléatoires et appliquez l'algorithme de manière récursive à chaque partition, en assemblant les résultats.

Dans le code Erlang, cela ressemble à ceci:

shuffle([])  -> [];
shuffle([L]) -> [L];
shuffle(L)   ->
  {Left, Right} = lists:partition(fun(_) -> 
                                    random:uniform() < 0.5 
                                  end, L),
  shuffle(Left) ++ shuffle(Right).

(Si cela vous semble un tri rapide dérangé, eh bien, c'est ce que c'est, en gros.)

Voici donc mon problème: la même situation qui rend difficile la recherche d'algorithmes de réarrangement qui ne sont pas Fisher-Yates rend difficile la recherche d'outils pour analyser un algorithme de réarrangement. Il y a beaucoup de littérature que je peux trouver sur l'analyse des PRNG pour l'uniformité, la périodicité, etc. mais pas beaucoup d'informations sur la façon d'analyser un shuffle. (En effet, certaines des informations que j'ai trouvées sur l'analyse des shuffles étaient tout simplement fausses - facilement trompées par des techniques simples.)

Ma question est donc la suivante: comment analyser mon algorithme de brassage (en supposant que la fonction random:uniform() appelle jusqu'à la tâche de générer des nombres aléatoires appropriés avec de bonnes caractéristiques)? Quels outils mathématiques sont à ma disposition pour juger si, disons, 100 000 exécutions du mélangeur sur une liste d'entiers allant de 1 à 100 m'ont donné des résultats de mélange vraisemblablement bons? J'ai fait moi-même quelques tests (comparant les incréments aux diminutions des shuffles, par exemple), mais j'aimerais en savoir plus.

Et s'il y a un aperçu de cet algorithme de lecture aléatoire lui-même, ce serait également apprécié.

76

Remarque générale

Mon approche personnelle de l'exactitude des algorithmes utilisant des probabilités: si vous savez prouver que c'est correct, alors c'est probablement correct; si vous ne le faites pas, c'est certainement faux.

Autrement dit, il est généralement inutile d'essayer d'analyser tous les algorithmes que vous pourriez trouver: vous devez continuer à chercher un algorithme jusqu'à ce que vous en trouviez un s'avérer correct.

Analyser un algorithme aléatoire en calculant la distribution

Je connais une façon d'analyser "automatiquement" un shuffle (ou plus généralement un algorithme aléatoire) qui est plus fort que le simple "lancer beaucoup de tests et vérifier l'uniformité". Vous pouvez calculer mécaniquement la distribution associée à chaque entrée de votre algorithme.

L'idée générale est qu'un algorithme aléatoire utilise une partie d'un monde de possibilités. Chaque fois que votre algorithme demande un élément aléatoire dans un ensemble ({true, false} lors du retournement d'une pièce), il y a deux résultats possibles pour votre algorithme, et l'un d'eux est choisi. Vous pouvez modifier votre algorithme afin qu'au lieu de renvoyer l'un des résultats possibles, il explore toutes les solutions en parallèle et renvoie tous les résultats possibles avec les distributions associées .

En général, cela nécessiterait une réécriture approfondie de votre algorithme. Si votre langue prend en charge les continuations délimitées, vous n'avez pas à le faire; vous pouvez implémenter "l'exploration de tous les résultats possibles" à l'intérieur de la fonction en demandant un élément aléatoire (l'idée est que le générateur aléatoire, au lieu de renvoyer un résultat, capture la suite associée à votre programme et l'exécute avec tous les différents résultats). Pour un exemple de cette approche, voir oleg's HANSEI .

Une solution intermédiaire, et probablement moins mystérieuse, consiste à représenter ce "monde de résultats possibles" comme une monade, et à utiliser un langage tel que Haskell avec des installations pour une programmation monadique. Voici un exemple d'implémentation d'une variante¹ de votre algorithme, en Haskell, utilisant la monade de probabilité du package probabilité :

import Numeric.Probability.Distribution

shuffleM :: (Num prob, Fractional prob) => [a] -> T prob [a]
shuffleM [] = return []
shuffleM [x] = return [x]
shuffleM (pivot:li) = do
        (left, right) <- partition li
        sleft <- shuffleM left
        sright <- shuffleM right
        return (sleft ++ [pivot] ++ sright)
  where partition [] = return ([], [])
        partition (x:xs) = do
                  (left, right) <- partition xs
                  uniform [(x:left, right), (left, x:right)]

Vous pouvez l'exécuter pour une entrée donnée et obtenir la distribution de sortie:

*Main> shuffleM [1,2]
fromFreqs [([1,2],0.5),([2,1],0.5)]
*Main> shuffleM [1,2,3]
fromFreqs
  [([2,1,3],0.25),([3,1,2],0.25),([1,2,3],0.125),
   ([1,3,2],0.125),([2,3,1],0.125),([3,2,1],0.125)]

Vous pouvez voir que cet algorithme est uniforme avec des entrées de taille 2, mais non uniforme avec des entrées de taille 3.

La différence avec l'approche basée sur les tests est que nous pouvons obtenir une certitude absolue en un nombre fini d'étapes: elle peut être assez grande, car elle revient à une exploration exhaustive du monde des possibles (mais généralement inférieure à 2 ^ N, comme il existe des factorisations de résultats similaires), mais s'il renvoie une distribution non uniforme, nous savons avec certitude que l'algorithme est incorrect. Bien sûr, s'il renvoie une distribution uniforme pour [1..N] Et 1 <= N <= 100, Vous savez seulement que votre algorithme est uniforme jusqu'à des listes de taille 100; cela peut encore être faux.

¹: cet algorithme est une variante de l'implémentation de votre Erlang, en raison de la gestion spécifique du pivot. Si je n'utilise pas de pivot, comme dans votre cas, la taille d'entrée ne diminue plus à chaque étape: l'algorithme considère également le cas où toutes les entrées sont dans la liste de gauche (ou la liste de droite), et se perdent dans une boucle infinie . C'est une faiblesse de l'implémentation de la monade de probabilité (si un algorithme a une probabilité 0 de non-terminaison, le calcul de la distribution peut encore diverger), que je ne sais pas encore comment corriger.

Mélanges basés sur le tri

Voici un algorithme simple que je suis convaincu que je pourrais prouver correct:

  1. Choisissez une clé aléatoire pour chaque élément de votre collection.
  2. Si les clés ne sont pas toutes distinctes, redémarrez à partir de l'étape 1.
  3. Triez la collection par ces clés aléatoires.

Vous pouvez omettre l'étape 2 si vous savez que la probabilité d'une collision (deux nombres aléatoires choisis sont égaux) est suffisamment faible, mais sans cela le shuffle n'est pas parfaitement uniforme.

Si vous choisissez vos clés dans [1..N] où N est la longueur de votre collection, vous aurez beaucoup de collisions ( problème d'anniversaire ). Si vous choisissez votre clé comme un entier 32 bits, la probabilité de conflit est faible en pratique, mais reste sujette au problème d'anniversaire.

Si vous utilisez des chaînes de bits infinies (évaluées paresseusement) comme clés, plutôt que des clés de longueur finie, la probabilité d'une collision devient 0 et la vérification de la distinction n'est plus nécessaire.

Voici une implémentation aléatoire dans OCaml, utilisant des nombres réels paresseux comme chaînes de bits infinies:

type 'a stream = Cons of 'a * 'a stream lazy_t

let rec real_number () =
  Cons (Random.bool (), lazy (real_number ()))

let rec compare_real a b = match a, b with
| Cons (true, _), Cons (false, _) -> 1
| Cons (false, _), Cons (true, _) -> -1
| Cons (_, lazy a'), Cons (_, lazy b') ->
    compare_real a' b'

let shuffle list =
  List.map snd
    (List.sort (fun (ra, _) (rb, _) -> compare_real ra rb)
       (List.map (fun x -> real_number (), x) list))

Il existe d'autres approches du "brassage pur". Un Nice est apfelmus solution basée sur mergesort .

Considérations algorithmiques: la complexité de l'algorithme précédent dépend de la probabilité que toutes les clés soient distinctes. Si vous les choisissez en tant qu'entiers 32 bits, vous avez une probabilité de un sur ~ 4 milliards qu'une clé particulière entre en collision avec une autre clé. Le tri par ces clés est O (n log n), en supposant que le choix d'un nombre aléatoire est O (1).

Si vous avez des chaînes de bits infinies, vous n'avez jamais à recommencer la sélection, mais la complexité est alors liée à "combien d'éléments des flux sont évalués en moyenne". Je suppose que c'est O (log n) en moyenne (donc toujours O (n log n) au total), mais je n'ai aucune preuve.

... et je pense que votre algorithme fonctionne

Après plus de réflexion, je pense (comme douplep), que votre implémentation est correcte. Voici une explication informelle.

Chaque élément de votre liste est testé par plusieurs tests random:uniform() < 0.5. À un élément, vous pouvez associer la liste des résultats de ces tests, comme une liste de booléens ou {0, 1}. Au début de l'algorithme, vous ne connaissez la liste associée à aucun de ces numéros. Après le premier appel partition, vous connaissez le premier élément de chaque liste, etc. Lorsque votre algorithme revient, la liste des tests est complètement connue et les éléments sont triés selon ces listes (triées par ordre lexicographique, ou considérées comme des représentations binaires de nombres réels).

Ainsi, votre algorithme équivaut à trier par des clés de chaîne de bits infinies. L'action de partitionner la liste, qui rappelle la partition de quicksort sur un élément pivot, est en fait un moyen de séparer, pour une position donnée dans la chaîne de bits, les éléments avec valorisation 0 Des éléments avec valorisation 1.

Le tri est uniforme car les chaînes de bits sont toutes différentes. En effet, deux éléments avec des nombres réels égaux au n- ème bit sont du même côté d'une partition se produisant lors d'un appel récursif shuffle de profondeur n. L'algorithme ne se termine que lorsque toutes les listes résultant des partitions sont vides ou singletons: tous les éléments ont été séparés par au moins un test, et ont donc une décimale binaire distincte.

Terminaison probabiliste

Un point subtil à propos de votre algorithme (ou de ma méthode équivalente basée sur le tri) est que la condition de terminaison est probabiliste . Fisher-Yates se termine toujours après un nombre d'étapes connu (le nombre d'éléments dans le tableau). Avec votre algorithme, la terminaison dépend de la sortie du générateur de nombres aléatoires.

Il existe des sorties possibles qui feraient que votre algorithme diverge , pas se terminer. Par exemple, si le générateur de nombres aléatoires génère toujours 0, Chaque appel partition renverra la liste d'entrée inchangée, sur laquelle vous appelez récursivement le shuffle: vous bouclerez indéfiniment.

Cependant, ce n'est pas un problème si vous êtes sûr que votre générateur de nombres aléatoires est juste: il ne triche pas et renvoie toujours des résultats indépendants uniformément distribués. Dans ce cas, la probabilité que le test random:uniform() < 0.5 renvoie toujours true (ou false) est exactement de 0:

  • la probabilité que les N premiers appels renvoient true est de 2 ^ {- N}
  • la probabilité que tous les appels renvoient true est la probabilité de l'intersection infinie, pour tout N, de l'événement que les N premiers appels renvoient 0; c'est la limite infime¹ des 2 ^ {- N}, qui est 0

¹: pour les détails mathématiques, voir http://en.wikipedia.org/wiki/Measure_ (mathématiques) #Measures_of_infinite_intersections_of_measurable_sets

Plus généralement, l'algorithme ne se termine pas si et seulement si certains des éléments sont associés au même flux booléen. Cela signifie qu'au moins deux éléments ont le même flux booléen. Mais la probabilité que deux flux booléens aléatoires soient égaux est de nouveau 0: la probabilité que les chiffres à la position K soient égaux est 1/2, donc la probabilité que les N premiers chiffres soient égaux est 2 ^ {- N}, et la même chose l'analyse s'applique.

Par conséquent, vous savez que votre algorithme se termine avec la probabilité 1 . C'est une garantie légèrement plus faible que l'algorithme de Fisher-Yates, qui se termine toujours . En particulier, vous êtes vulnérable à une attaque d'un adversaire maléfique qui contrôlerait votre générateur de nombres aléatoires.

Avec plus de théorie des probabilités, vous pouvez également calculer la distribution des temps d'exécution de votre algorithme pour une longueur d'entrée donnée. Cela dépasse mes capacités techniques, mais je suppose que c'est bon: je suppose que vous n'avez qu'à regarder O (log N) les premiers chiffres en moyenne pour vérifier que tous les N flux paresseux sont différents, et que la probabilité de durées beaucoup plus élevées diminuer de façon exponentielle.

74
gasche

Votre algorithme est un shuffle basé sur le tri, comme indiqué dans l'article Wikipedia.

De manière générale, la complexité de calcul des shuffles basés sur le tri est la même que l'algorithme de tri sous-jacent (par exemple O ( n log n ) moyenne, O ( n ²) pire cas pour un shuffle basé sur le tri rapide), et tandis que la distribution n'est pas parfaitement uniforme, elle devrait approcher l'uniforme suffisamment près pour la plupart des applications pratiques.

Oleg Kiselyov fournit l'article/discussion suivant:

qui couvre les limites des shuffles basés sur le tri plus en détail, et propose également deux adaptations de la stratégie de Fischer – Yates: un O naïf ( n ²) un, et un O basé sur un arbre binaire ( n log n ) une.

Malheureusement, le monde de la programmation fonctionnelle ne vous donne pas accès à un état mutable.

Ce n'est pas vrai: alors que la programmation purement fonctionnelle évite les effets secondaires , elle prend en charge l'accès à un état mutable avec des effets de première classe, sans nécessiter d'effets secondaires.

Dans ce cas, vous pouvez utiliser les tableaux mutables de Haskell pour implémenter l'algorithme de mutation Fischer – Yates comme décrit dans ce tutoriel:

Addenda

La base spécifique de votre tri aléatoire est en fait une clé infinie tri radix : comme le souligne gasche, chaque partition correspond à un groupe de chiffres.

Le principal inconvénient de cela est le même que tout autre mélange de tri à clé infinie: il n'y a pas de garantie de résiliation. Bien que la probabilité de résiliation augmente à mesure que la comparaison progresse, il n'y a jamais de limite supérieure: la complexité la plus défavorable est O (∞).

21
Pi Delport

Il y a quelque temps, je faisais des choses similaires à cela, et en particulier, vous pourriez être intéressé par les vecteurs de Clojure, qui sont fonctionnels et immuables mais toujours avec des caractéristiques d'accès/mise à jour aléatoires O(1)). Ces deux éléments ont plusieurs implémentations de "prendre N éléments au hasard dans cette liste de taille M"; au moins l'un d'eux se transforme en une implémentation fonctionnelle de Fisher-Yates si vous laissez N = M.

https://Gist.github.com/805546

https://Gist.github.com/805747

3
amalloy

Basé sur Comment tester l'aléatoire (cas d'espèce - Mélange) , je propose:

Mélangez des tableaux (de taille moyenne) composés d'un nombre égal de zéros et de uns. Répétez et concaténez jusqu'à ce que vous vous ennuyiez. Utilisez-les comme entrée pour les tests purs et durs. Si vous avez un bon shuffle, vous devez générer des séquences aléatoires de zéros et de uns (avec la mise en garde que l'excès cumulé de zéros (ou de uns) est nul aux limites des tableaux de taille moyenne, que vous espérez que les tests détecteront , mais plus le "moyen" est grand, moins ils sont susceptibles de le faire).

Notez qu'un test peut rejeter votre mélange pour trois raisons:

  • l'algorithme de lecture aléatoire est mauvais,
  • le générateur de nombres aléatoires utilisé par le mélangeur ou lors de l'initialisation est mauvais, ou
  • l'implémentation du test est mauvaise.

Vous devrez résoudre ce qui est le cas si un test est rejeté.

Diverses adaptations des tests purs et durs (pour résoudre certains nombres, j'ai utilisé le source de la page dure ). Le principal mécanisme d'adaptation est de faire en sorte que l'algorithme de mélange agisse comme une source de bits aléatoires uniformément répartis.

  • Espacements d'anniversaire: dans un tableau de n zéros, insérez le journal n . Mélanger. Répétez jusqu'à ce que vous vous ennuyiez. Construisez la distribution des distances inter-un, comparez avec la distribution exponentielle. Vous devez effectuer cette expérience avec différentes stratégies d'initialisation - celles à l'avant, celles à la fin, celles réunies au milieu, celles dispersées au hasard. (Ce dernier a le plus grand risque d'une mauvaise randomisation d'initialisation (par rapport à la randomisation de shuffling) entraînant le rejet du shuffling.) Cela peut en fait être fait avec des blocs de valeurs identiques, mais a le problème d'introduire une corrélation dans les distributions ( un et deux ne peuvent pas être au même endroit dans un seul mélange).
  • Permutations qui se chevauchent: mélangez cinq valeurs plusieurs fois. Vérifiez que les 120 résultats sont à peu près tout aussi probables. (Test du chi carré, 119 degrés de liberté - le test inflexible (cdoperm5.c) utilise 99 degrés de liberté, mais il s'agit (principalement) d'un artefact de corrélation séquentielle causée par l'utilisation de sous-séquences se chevauchant de la séquence d'entrée.)
  • Rangs de matrices: à partir de 2 * (6 * 8) ^ 2 = 4608 bits en mélangeant un nombre égal de zéros et de uns, sélectionnez 6 sous-chaînes de 8 bits sans chevauchement. Traitez-les comme une matrice binaire 6 x 8 et calculez son rang. Répétez l'opération pour 100 000 matrices. (Regroupez les rangs de 0-4. Les rangs sont alors soit 6, 5 ou 0-4.) La fraction attendue des rangs est de 0,773118, 0,217439. , 0,009443. Le chi carré se compare aux fractions observées avec deux degrés de liberté. Les tests 31 par 31 et 32 ​​par 32 sont similaires. Les rangs 0-28 et 0-29 sont regroupés, respectivement. Les fractions attendues sont 0,2887880952, 0,5775761902, 0,12283502644, 0,0052854502. Le test du chi carré a trois degrés de liberté.

etc...

Vous pouvez également utiliser dieharder et/ou ent pour effectuer des tests adaptés similaires.

1
Eric Towers