Allgemeine Bemerkung
Mein persönlicher Ansatz zur Korrektheit von Wahrscheinlichkeitsalgorithmen: Wenn Sie wissen, wie man beweist, dass es richtig ist, dann ist es wahrscheinlich richtig; Wenn Sie dies nicht tun, ist es sicherlich falsch.
Anders gesagt, es ist im Allgemeinen hoffnungslos zu versuchen, jeden Algorithmus zu analysieren, den Sie sich einfallen lassen könnten: Sie müssen so lange nach einem Algorithmus suchen, bis Sie einen finden, den Sie als richtig erweisen können .
Analyse eines Zufallsalgorithmus durch Berechnung der Verteilung
Ich kenne einen Weg, um ein Shuffle (oder allgemeiner einen Algorithmus mit zufälliger Verwendung) "automatisch" zu analysieren, der stärker ist als das einfache "viele Tests werfen und auf Gleichmäßigkeit prüfen". Sie können die Verteilung, die jeder Eingabe Ihres Algorithmus zugeordnet ist, mechanisch berechnen.
Die allgemeine Idee ist, dass ein Algorithmus mit zufälliger Verwendung einen Teil einer Welt von Möglichkeiten erforscht. Jedes Mal, wenn Ihr Algorithmus nach einem zufälligen Element in einer Menge fragt ({ true
, false
} beim Werfen einer Münze), gibt es zwei mögliche Ergebnisse für Ihren Algorithmus, von denen eines ausgewählt wird. Sie können Ihren Algorithmus so ändern, dass er nicht eines der möglichen Ergebnisse zurückgibt , sondern alle Lösungen parallel untersucht und alle möglichen Ergebnisse mit den zugehörigen Verteilungen zurückgibt.
Im Allgemeinen müsste dazu Ihr Algorithmus gründlich umgeschrieben werden. Wenn Ihre Sprache begrenzte Fortsetzungen unterstützt, müssen Sie dies nicht tun. Sie können die "Untersuchung aller möglichen Ergebnisse" in der Funktion implementieren, indem Sie nach einem zufälligen Element fragen (die Idee ist, dass der Zufallsgenerator, anstatt ein Ergebnis zurückzugeben, die Ihrem Programm zugeordnete Fortsetzung erfasst und mit allen unterschiedlichen Ergebnissen ausführt). Ein Beispiel für diesen Ansatz finden Sie in olegs HANSEI .
Eine vermittelnde und wahrscheinlich weniger arkane Lösung besteht darin, diese "Welt möglicher Ergebnisse" als Monade darzustellen und eine Sprache wie Haskell mit Einrichtungen für monadische Programmierung zu verwenden. Hier ist eine Beispielimplementierung einer Variante¹ Ihres Algorithmus in Haskell unter Verwendung der Wahrscheinlichkeitsmonade des Wahrscheinlichkeitspakets :
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)]
Sie können es für eine bestimmte Eingabe ausführen und die Ausgabeverteilung abrufen:
*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)]
Sie können sehen, dass dieser Algorithmus bei Eingaben der Größe 2 einheitlich ist, bei Eingaben der Größe 3 jedoch nicht einheitlich.
Der Unterschied zum testbasierten Ansatz besteht darin, dass wir in einer endlichen Anzahl von Schritten absolute Sicherheit erlangen können: Er kann ziemlich groß sein, da es sich um eine erschöpfende Erforschung der Welt der Möglichkeiten handelt (aber im Allgemeinen kleiner als 2 ^ N, as Es gibt Faktorisierungen ähnlicher Ergebnisse. Wenn jedoch eine ungleichmäßige Verteilung zurückgegeben wird, wissen wir mit Sicherheit, dass der Algorithmus falsch ist. Wenn es eine gleichmäßige Verteilung für [1..N]
und zurückgibt 1 <= N <= 100
, wissen Sie natürlich nur, dass Ihr Algorithmus bis zu Listen der Größe 100 einheitlich ist. es kann immer noch falsch sein.
¹: Dieser Algorithmus ist aufgrund der spezifischen Pivot-Behandlung eine Variante der Erlang-Implementierung. Wenn ich wie in Ihrem Fall keinen Pivot verwende, verringert sich die Eingabegröße nicht mehr bei jedem Schritt: Der Algorithmus berücksichtigt auch den Fall, in dem sich alle Eingaben in der linken (oder rechten) Liste befinden und in einer Endlosschleife verloren gehen . Dies ist eine Schwäche der Wahrscheinlichkeitsmonadenimplementierung (wenn ein Algorithmus eine Wahrscheinlichkeit 0 für die Nichtbeendigung hat, kann die Verteilungsberechnung immer noch abweichen), die ich noch nicht beheben kann.
Sortierbasierte Mischvorgänge
Hier ist ein einfacher Algorithmus, von dem ich überzeugt bin, dass ich ihn als richtig erweisen kann:
- Wählen Sie einen zufälligen Schlüssel für jedes Element in Ihrer Sammlung.
- Wenn die Tasten nicht alle verschieden sind, starten Sie ab Schritt 1 neu.
- Sortieren Sie die Sammlung nach diesen zufälligen Schlüsseln.
Sie können Schritt 2 weglassen, wenn Sie wissen, dass die Wahrscheinlichkeit einer Kollision (zwei ausgewählte Zufallszahlen sind gleich) ausreichend niedrig ist, aber ohne sie ist das Mischen nicht perfekt gleichmäßig.
Wenn Sie Ihre Schlüssel in [1..N] auswählen, wobei N die Länge Ihrer Sammlung ist, treten viele Kollisionen auf ( Geburtstagsproblem ). Wenn Sie Ihren Schlüssel als 32-Bit-Ganzzahl auswählen, ist die Wahrscheinlichkeit eines Konflikts in der Praxis gering, unterliegt jedoch weiterhin dem Geburtstagsproblem.
Wenn Sie unendliche (träge ausgewertete) Bitstrings als Schlüssel anstelle von Schlüsseln endlicher Länge verwenden, wird die Wahrscheinlichkeit einer Kollision 0, und die Überprüfung der Unterscheidbarkeit ist nicht mehr erforderlich.
Hier ist eine Shuffle-Implementierung in OCaml, bei der faule reelle Zahlen als unendliche Bitstrings verwendet werden:
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))
Es gibt andere Ansätze für "reines Mischen". Eine schöne ist die auf Mergesort basierende Lösung von Apfelmus .
Algorithmische Überlegungen: Die Komplexität des vorherigen Algorithmus hängt von der Wahrscheinlichkeit ab, dass alle Schlüssel unterschiedlich sind. Wenn Sie sie als 32-Bit-Ganzzahlen auswählen, besteht eine Wahrscheinlichkeit von eins zu 4 Milliarden, dass ein bestimmter Schlüssel mit einem anderen Schlüssel kollidiert. Das Sortieren nach diesen Schlüsseln ist O (n log n), vorausgesetzt, die Auswahl einer Zufallszahl ist O (1).
Wenn Sie unendlich viele Bitstrings verwenden, müssen Sie die Auswahl nie neu starten, aber die Komplexität hängt dann davon ab, "wie viele Elemente der Streams durchschnittlich ausgewertet werden". Ich vermute, es ist im Durchschnitt O (log n) (daher immer noch O (n log n) insgesamt), habe aber keinen Beweis.
... und ich denke dein Algorithmus funktioniert
Nach mehr Reflexion denke ich (wie Douplep), dass Ihre Implementierung korrekt ist. Hier ist eine informelle Erklärung.
Jedes Element in Ihrer Liste wird durch mehrere Tests getestetrandom:uniform() < 0.5
. Einem Element können Sie die Liste der Ergebnisse dieser Tests als Liste der Booleschen Werte oder { 0
, 1
} zuordnen . Zu Beginn des Algorithmus kennen Sie die Liste, die einer dieser Nummern zugeordnet ist, nicht. Nach dem ersten partition
Anruf, wissen Sie , das erste Element jeder Liste, etc. Wenn Ihr Algorithmus zurückgibt, werden die Liste der Tests vollständig bekannt und die Elemente werden sortiert nach diesen Listen (in lexikographische Reihenfolge sortiert oder als binäre Darstellungen von realen betrachtet Zahlen).
Ihr Algorithmus entspricht also dem Sortieren nach unendlichen Bitstring-Schlüsseln. Die Partitionierung der Liste, die an die Partitionierung von Quicksort über ein Pivot-Element erinnert, ist tatsächlich eine Möglichkeit, für eine bestimmte Position im Bitstring die Elemente mit Bewertung 0
von den Elementen mit Bewertung zu trennen 1
.
Die Sortierung ist einheitlich, da die Bitstrings alle unterschiedlich sind. In der Tat befinden sich zwei Elemente mit reellen Zahlen, die bis zum n
-ten Bit gleich sind, auf derselben Seite einer Partition, die während eines rekursiven shuffle
Tiefenaufrufs auftritt n
. Der Algorithmus wird nur beendet, wenn alle aus Partitionen resultierenden Listen leer oder Singletons sind: Alle Elemente wurden durch mindestens einen Test getrennt und haben daher eine eindeutige binäre Dezimalstelle.
Probabilistische Beendigung
Ein subtiler Punkt über Ihren Algorithmus (oder meine äquivalente sortbasierte Methode) ist, dass die Beendigungsbedingung probabilistisch ist . Fisher-Yates endet immer nach einer bekannten Anzahl von Schritten (der Anzahl der Elemente im Array). Bei Ihrem Algorithmus hängt die Beendigung von der Ausgabe des Zufallszahlengenerators ab.
Es gibt mögliche Ausgaben, bei denen Ihr Algorithmus divergiert und nicht beendet wird. Wenn der Zufallszahlengenerator beispielsweise immer ausgibt 0
, gibt jeder partition
Aufruf die Eingabeliste unverändert zurück, auf der Sie die Zufallswiedergabe rekursiv aufrufen: Sie werden eine unbegrenzte Schleife ausführen.
Dies ist jedoch kein Problem, wenn Sie sicher sind, dass Ihr Zufallszahlengenerator fair ist: Er betrügt nicht und liefert immer unabhängige, gleichmäßig verteilte Ergebnisse. In diesem Fall beträgt die Wahrscheinlichkeit, dass der Test random:uniform() < 0.5
immer true
(oder false
) zurückgibt, genau 0:
- Die Wahrscheinlichkeit, dass die ersten N Anrufe zurückkehren,
true
beträgt 2 ^ {- N}
- Die Wahrscheinlichkeit, dass alle Anrufe zurückkehren,
true
ist die Wahrscheinlichkeit des unendlichen Schnittpunkts für alle N des Ereignisses, dass die ersten N Anrufe zurückkehren 0
. es ist die unendliche Grenze¹ der 2 ^ {- N}, die 0 ist
¹: Für die mathematischen Details siehe http://en.wikipedia.org/wiki/Measure_(mathematics)#Measures_of_infinite_intersections_of_measurable_sets
Im Allgemeinen wird der Algorithmus nicht genau dann beendet, wenn einige der Elemente demselben booleschen Stream zugeordnet werden. Dies bedeutet, dass mindestens zwei Elemente denselben booleschen Stream haben. Aber die Wahrscheinlichkeit, dass zwei zufällige boolesche Ströme gleich sind, ist wieder 0: Die Wahrscheinlichkeit, dass die Ziffern an Position K gleich sind, ist 1/2, also ist die Wahrscheinlichkeit, dass die N ersten Ziffern gleich sind, 2 ^ {- N} und dieselbe Analyse gilt.
Daher wissen Sie, dass Ihr Algorithmus mit der Wahrscheinlichkeit 1 endet . Dies ist eine etwas schwächere Garantie dafür, dass der Fisher-Yates-Algorithmus immer endet . Insbesondere sind Sie anfällig für einen Angriff eines bösen Gegners, der Ihren Zufallszahlengenerator steuern würde.
Mit mehr Wahrscheinlichkeitstheorie könnten Sie auch die Verteilung der Laufzeiten Ihres Algorithmus für eine bestimmte Eingabelänge berechnen. Dies geht über meine technischen Fähigkeiten hinaus, aber ich gehe davon aus, dass es gut ist: Ich nehme an, dass Sie im Durchschnitt nur die ersten Ziffern von O (log N) betrachten müssen, um zu überprüfen, ob alle N Lazy Streams unterschiedlich sind und ob die Wahrscheinlichkeit von viel höheren Laufzeiten besteht exponentiell abnehmen.