Dies ist ein langer Text. Bitte bei mir tragen. Die Frage lautet: Gibt es einen funktionsfähigen Radix-Sortieralgorithmus an Ort und Stelle ?
Vorläufig
Ich habe eine große Anzahl kleiner Zeichenfolgen mit fester Länge , die nur die Buchstaben "A", "C", "G" und "T" (ja, Sie haben es erraten: DNA ) verwenden, die ich sortieren möchte.
Im Moment benutze ich std::sort
die verwendet Introsort in allen gängigen Implementierungen des STL . Das funktioniert ganz gut. Ich bin jedoch davon überzeugt, dass die Radix-Sortierung perfekt zu meinem Problem passt und in der Praxis viel besser funktionieren sollte .
Einzelheiten
Ich habe diese Annahme mit einer sehr naiven Implementierung getestet und für relativ kleine Eingaben (in der Größenordnung von 10.000) war dies wahr (zumindest mehr als doppelt so schnell). Die Laufzeit verschlechtert sich jedoch miserabel, wenn das Problem größer wird ( N > 5.000.000).
Der Grund liegt auf der Hand: Radix-Sortierung erfordert das Kopieren der gesamten Daten (tatsächlich mehr als einmal in meiner naiven Implementierung). Dies bedeutet, dass ich ~ 4 GiB in meinen Hauptspeicher gesteckt habe, was offensichtlich die Leistung beeinträchtigt. Selbst wenn dies nicht der Fall wäre, kann ich es mir nicht leisten, so viel Speicher zu verwenden, da die Problemgrößen sogar noch größer werden.
Anwendungsfälle
Idealerweise sollte dieser Algorithmus mit jeder Zeichenfolgenlänge zwischen 2 und 100 sowohl für DNA als auch für DNA5 (die ein zusätzliches Platzhalterzeichen „N“ zulässt) oder sogar für DNA mit IUPAC- Mehrdeutigkeitscodes (was zu 16 unterschiedlichen Werten führt) funktionieren . Mir ist jedoch klar, dass all diese Fälle nicht abgedeckt werden können, und ich bin mit jeder Geschwindigkeitsverbesserung zufrieden, die ich bekomme. Der Code kann dynamisch entscheiden, an welchen Algorithmus gesendet werden soll.
Forschung
Leider ist der Wikipedia-Artikel über Radix-Sortierung nutzlos. Der Abschnitt über eine In-Place-Variante ist völliger Müll. Der Abschnitt NIST-DADS zur Radix-Sortierung ist so gut wie nicht vorhanden. Es gibt ein vielversprechend klingendes Papier namens Efficient Adaptive In-Place Radix Sorting, das den Algorithmus „MSL“ beschreibt. Leider ist auch dieses Papier enttäuschend.
Insbesondere gibt es die folgenden Dinge.
Erstens enthält der Algorithmus mehrere Fehler und lässt vieles ungeklärt. Insbesondere wird der Rekursionsaufruf nicht detailliert beschrieben (ich gehe einfach davon aus, dass er einen Zeiger erhöht oder reduziert, um die aktuellen Verschiebungs- und Maskenwerte zu berechnen). Außerdem werden die Funktionen verwendet dest_group
und dest_address
keine Definitionen angegeben. Ich sehe nicht ein, wie ich diese effizient implementieren kann (dh in O (1); zumindest dest_address
nicht trivial).
Last but not least erreicht der Algorithmus die In-Place-Funktion, indem Array-Indizes mit Elementen innerhalb des Eingabearrays ausgetauscht werden. Dies funktioniert offensichtlich nur bei numerischen Arrays. Ich muss es für Saiten verwenden. Natürlich könnte ich einfach stark tippen und davon ausgehen, dass der Speicher es toleriert, einen Index dort zu speichern, wo er nicht hingehört. Dies funktioniert jedoch nur, solange ich meine Zeichenfolgen in 32 Bit Speicher komprimieren kann (unter der Annahme von 32-Bit-Ganzzahlen). Das sind nur 16 Zeichen (ignorieren wir für den Moment, dass 16> log (5.000.000)).
Ein anderes Papier eines der Autoren gibt überhaupt keine genaue Beschreibung, aber es gibt die Laufzeit von MSL als sublinear an, was absolut falsch ist.
Um es noch einmal zusammenzufassen : Gibt es Hoffnung, eine funktionierende Referenzimplementierung oder zumindest einen guten Pseudocode / eine gute Beschreibung einer funktionierenden Radix-Sorte zu finden, die an DNA-Strings funktioniert?
quelle
Antworten:
Nun, hier ist eine einfache Implementierung einer MSD-Radix-Sorte für DNA. Es ist in D geschrieben, weil dies die Sprache ist, die ich am häufigsten benutze und daher am wenigsten dumme Fehler mache, aber es könnte leicht in eine andere Sprache übersetzt werden. Es ist vorhanden, erfordert jedoch
2 * seq.length
Durchgänge durch das Array.Offensichtlich ist dies eher spezifisch für die DNA als allgemein, aber es sollte schnell gehen.
Bearbeiten:
Ich wurde neugierig, ob dieser Code tatsächlich funktioniert, und habe ihn getestet / debuggt, während ich darauf gewartet habe, dass mein eigener Bioinformatik-Code ausgeführt wird. Die obige Version ist jetzt tatsächlich getestet und funktioniert. Für 10 Millionen Sequenzen mit jeweils 5 Basen ist es ungefähr 3x schneller als ein optimierter Introsort.
quelle
Ich habe noch nie eine In-Place-Radix-Sortierung gesehen, und aufgrund der Art der Radix-Sortierung bezweifle ich, dass sie viel schneller ist als eine Out-of-Place-Sortierung, solange das temporäre Array in den Speicher passt.
Grund:
Die Sortierung führt einen linearen Lesevorgang für das Eingabearray durch, aber alle Schreibvorgänge sind nahezu zufällig. Ab einem bestimmten N führt dies zu einem Cache-Fehler pro Schreibvorgang. Dieser Cache-Fehler verlangsamt Ihren Algorithmus. Ob es vorhanden ist oder nicht, ändert diesen Effekt nicht.
Ich weiß, dass dies Ihre Frage nicht direkt beantwortet, aber wenn das Sortieren ein Engpass ist, sollten Sie sich als Vorverarbeitungsschritt die Sortieralgorithmen ansehen (die Wiki-Seite auf dem Soft-Heap kann Ihnen den Einstieg erleichtern).
Das könnte einen sehr schönen Cache-Lokalitätsschub geben. Eine fehl am Platz befindliche Radix-Sortierung für Lehrbücher ist dann besser. Die Schreibvorgänge werden immer noch nahezu zufällig sein, aber zumindest werden sie sich um dieselben Speicherblöcke gruppieren und als solche die Cache-Trefferquote erhöhen.
Ich habe keine Ahnung, ob es in der Praxis funktioniert.
Übrigens: Wenn Sie nur mit DNA-Strings arbeiten: Sie können ein Zeichen in zwei Bits komprimieren und Ihre Daten ziemlich oft packen. Dies reduziert den Speicherbedarf gegenüber einer naiiven Darstellung um den Faktor vier. Die Adressierung wird komplexer, aber die ALU Ihrer CPU hat ohnehin viel Zeit für alle Cache-Fehler.
quelle
Sie können den Speicherbedarf sicherlich verringern, indem Sie die Sequenz in Bits codieren. Sie betrachten Permutationen für Länge 2 mit "ACGT", das sind 16 Zustände oder 4 Bits. Für Länge 3 sind das 64 Zustände, die in 6 Bit codiert werden können. Es sieht also aus wie 2 Bits für jeden Buchstaben in der Sequenz oder ungefähr 32 Bits für 16 Zeichen, wie Sie sagten.
Wenn es eine Möglichkeit gibt, die Anzahl der gültigen "Wörter" zu verringern, ist möglicherweise eine weitere Komprimierung möglich.
Für Sequenzen der Länge 3 könnte man also 64 Buckets erstellen, vielleicht mit der Größe uint32 oder uint64. Initialisieren Sie sie auf Null. Durchlaufen Sie Ihre sehr sehr große Liste von 3 Zeichenfolgen und codieren Sie sie wie oben. Verwenden Sie dies als Index und erhöhen Sie diesen Bucket.
Wiederholen Sie diesen Vorgang, bis alle Ihre Sequenzen verarbeitet wurden.
Als nächstes regenerieren Sie Ihre Liste.
Durchlaufen Sie die 64 Buckets, um für die in diesem Bucket gefundene Anzahl so viele Instanzen der durch diesen Bucket dargestellten Sequenz zu generieren.
Wenn alle Buckets iteriert wurden, haben Sie Ihr sortiertes Array.
Eine Folge von 4 addiert 2 Bits, so dass 256 Eimer vorhanden wären. Eine Folge von 5 addiert 2 Bits, so dass es 1024 Buckets geben würde.
Irgendwann nähert sich die Anzahl der Eimer Ihren Grenzen. Wenn Sie die Sequenzen aus einer Datei lesen, anstatt sie im Speicher zu belassen, steht mehr Speicher für Buckets zur Verfügung.
Ich denke, dies wäre schneller als die Sortierung vor Ort, da die Eimer wahrscheinlich in Ihren Arbeitssatz passen.
Hier ist ein Hack, der die Technik zeigt
quelle
Wenn Ihr Datensatz so groß ist, würde ich denken, dass ein festplattenbasierter Pufferansatz am besten ist:
Ich würde auch experimentieren, um eine größere Anzahl von Buckets zu gruppieren, zum Beispiel wenn Ihre Zeichenfolge wäre:
Der erste MSB-Aufruf würde den Bucket für GATT zurückgeben (insgesamt 256 Buckets). Auf diese Weise erstellen Sie weniger Verzweigungen des festplattenbasierten Puffers. Dies kann die Leistung verbessern oder nicht. Experimentieren Sie also damit.
quelle
Ich werde mich auf die Probe stellen und vorschlagen, dass Sie zu einer Heap / Heapsort- Implementierung wechseln . Dieser Vorschlag geht mit einigen Annahmen einher:
Das Schöne an Heap / Heap-Sort ist, dass Sie den Heap erstellen können, während Sie die Daten lesen, und dass Sie sofort Ergebnisse erzielen können, wenn Sie den Heap erstellt haben.
Lass uns zurücktreten. Wenn Sie das Glück haben, dass Sie die Daten asynchron lesen können (dh Sie können eine Art Leseanforderung senden und benachrichtigt werden, wenn einige Daten bereit sind), können Sie einen Teil des Heaps erstellen, während Sie auf die Daten warten nächster Datenblock - sogar von der Festplatte. Oft kann dieser Ansatz den größten Teil der Kosten für die Hälfte Ihrer Sortierung hinter der Zeit begraben, die Sie für das Abrufen der Daten aufgewendet haben.
Sobald Sie die Daten gelesen haben, ist das erste Element bereits verfügbar. Je nachdem, wohin Sie die Daten senden, kann dies sehr hilfreich sein. Wenn Sie es an einen anderen asynchronen Leser oder ein paralleles Ereignismodell oder eine Benutzeroberfläche senden, können Sie Chunks und Chunks unterwegs senden.
Das heißt, wenn Sie keine Kontrolle darüber haben, wie die Daten gelesen werden, und sie synchron gelesen werden und Sie die sortierten Daten erst dann verwenden können, wenn sie vollständig ausgeschrieben sind, ignorieren Sie dies alles. :((
Siehe die Wikipedia-Artikel:
quelle
" Radix-Sortierung ohne zusätzlichen Platz " ist ein Papier, das sich mit Ihrem Problem befasst.
quelle
In Bezug auf die Leistung sollten Sie sich allgemeinere Sortieralgorithmen für den String-Vergleich ansehen.
Momentan berührst du jedes Element jeder Saite, aber du kannst es besser machen!
Insbesondere eine Burst-Sortierung passt sehr gut in diesen Fall. Da Burstsort auf Versuchen basiert, funktioniert es als Bonus lächerlich gut für die kleinen Alphabetgrößen, die in DNA / RNA verwendet werden, da Sie keine Art von ternärem Suchknoten, Hash oder anderem Trie-Node-Komprimierungsschema in das integrieren müssen Implementierung versuchen. Die Versuche können auch für Ihr Suffix-Array-ähnliches Endziel nützlich sein.
Eine anständige Allzweckimplementierung von Burstsort ist in Source Forge unter http://sourceforge.net/projects/burstsort/ verfügbar - sie ist jedoch nicht vorhanden.
Zu Vergleichszwecken wird die unter http://www.cs.mu.oz.au/~rsinha/papers/SinhaRingZobel-2006.pdf beschriebene C-Burstsort-Implementierung für einige typische Workloads 4-5-mal schneller als QuickSort- und Radix-Sortierungen durchgeführt.
quelle
Sie sollten einen Blick auf die groß angelegte Genomsequenzverarbeitung von Dr. Kasahara und Morishita.
Zeichenfolgen, die aus den vier Nukleotidbuchstaben A, C, G und T bestehen, können für eine viel schnellere Verarbeitung speziell in Ganzzahlen codiert werden. Die Radix-Sortierung gehört zu den vielen im Buch diskutierten Algorithmen. Sie sollten in der Lage sein, die akzeptierte Antwort auf diese Frage anzupassen und eine große Leistungsverbesserung zu sehen.
quelle
RADIX
Wert kann (und wird) natürlich an größere Werte angepasst werden.Sie könnten versuchen , eine mit trie . Beim Sortieren der Daten wird der Datensatz einfach durchlaufen und eingefügt. Die Struktur ist natürlich sortiert, und Sie können sich vorstellen, dass sie einem B-Baum ähnelt (außer dass Sie anstelle von Vergleichen immer Zeiger-Indirektionen verwenden).
Das Caching-Verhalten bevorzugt alle internen Knoten, sodass Sie dies wahrscheinlich nicht verbessern werden. Sie können aber auch mit dem Verzweigungsfaktor Ihres Tries herumspielen (stellen Sie sicher, dass jeder Knoten in eine einzelne Cache-Zeile passt, ordnen Sie Trie-Knoten ähnlich einem Heap als zusammenhängendes Array zu, das eine Durchquerung der Ebene darstellt). Da Versuche auch digitale Strukturen sind (O (k) Einfügen / Suchen / Löschen für Elemente der Länge k), sollten Sie eine wettbewerbsfähige Leistung für eine Radix-Sortierung haben.
quelle
Ich würde eine gepackte Bit-Darstellung der Strings sortieren . Es wird behauptet, dass Burstsort eine viel bessere Lokalität als Radix-Sorten aufweist, wodurch die zusätzliche Speicherplatznutzung bei Burst-Versuchen anstelle von klassischen Versuchen gering gehalten wird. Das Originalpapier hat Maße.
quelle
Radix-Sort ist nicht cache-bewusst und nicht der schnellste Sortieralgorithmus für große Mengen. Sie können sehen:
Sie können auch die Komprimierung verwenden und jeden Buchstaben Ihrer DNA in 2 Bits codieren, bevor Sie ihn im Sortierarray speichern.
quelle
qsort
Funktion gegenüber derstd::sort
von C ++ bereitgestellten Funktion hat ? Letzteres implementiert insbesondere ein hochentwickeltes Introsort in modernen Bibliotheken und erweitert die Vergleichsoperation. Ich kaufe nicht die Behauptung, dass es in den meisten Fällen in O (n) funktioniert, da dies einen Grad an Selbstbeobachtung erfordern würde, der im allgemeinen Fall nicht verfügbar ist (zumindest nicht ohne viel Aufwand).Die MSB-Radix-Sortierung von dsimcha sieht gut aus, aber Nils kommt dem Problem mit der Beobachtung näher, dass die Cache-Lokalität Sie bei großen Problemgrößen umbringt.
Ich schlage einen sehr einfachen Ansatz vor:
m
für die eine Radix-Sortierung effizient ist.m
Elementen, sortieren Sie sie mit Radix und schreiben Sie sie aus (in einen Speicherpuffer, wenn Sie über genügend Speicher verfügen, ansonsten aber in Dateien), bis Sie Ihre Eingabe erschöpft haben.Mergesort ist der cachefreundlichste Sortieralgorithmus, den ich kenne: "Lesen Sie das nächste Element aus Array A oder B und schreiben Sie dann ein Element in den Ausgabepuffer." Es läuft effizient auf Bandlaufwerken .
2n
Zum Sortieren ist Platz erforderlichn
Elementen , aber ich wette, dass die stark verbesserte Cache-Lokalität dies unwichtig macht - und wenn Sie eine nicht vorhandene Radix-Sortierung verwenden, benötigen Sie diesen zusätzlichen Speicherplatz trotzdem.Bitte beachten Sie abschließend, dass Mergesort ohne Rekursion implementiert werden kann. Auf diese Weise wird das wahre lineare Speicherzugriffsmuster deutlich.
quelle
Es sieht so aus, als hätten Sie das Problem gelöst, aber für den Datensatz scheint es, dass eine Version einer funktionsfähigen Radix-Sortierung die "American Flag Sort" ist. Es wird hier beschrieben: Engineering Radix Sort . Die allgemeine Idee ist, 2 Durchgänge für jedes Zeichen durchzuführen - zählen Sie zuerst, wie viele von jedem Sie haben, damit Sie das Eingabearray in Bins unterteilen können. Gehen Sie dann erneut durch und tauschen Sie jedes Element in den richtigen Behälter aus. Sortieren Sie nun rekursiv jeden Behälter an der nächsten Zeichenposition.
quelle
std::sort
, und ich bin sicher, dass ein mehrstelliger Digitalisierer noch schneller arbeiten könnte, aber meine Testsuite verfügt über Speicher Probleme (nicht der Algorithmus, die Testsuite selbst)Denken Sie zunächst an die Kodierung Ihres Problems. Entfernen Sie die Zeichenfolgen und ersetzen Sie sie durch eine binäre Darstellung. Verwenden Sie das erste Byte, um Länge + Codierung anzugeben. Alternativ können Sie eine Darstellung mit fester Länge an einer Vier-Byte-Grenze verwenden. Dann wird die Radix-Sortierung viel einfacher. Für eine Radix-Sortierung ist es am wichtigsten, keine Ausnahmebehandlung am Hotspot der inneren Schleife durchzuführen.
OK, ich habe ein bisschen mehr über das 4-Nary-Problem nachgedacht. Sie möchten eine Lösung wie einen Judy-Baum dafür. Die nächste Lösung kann Zeichenfolgen mit variabler Länge verarbeiten. Bei fester Länge entfernen Sie einfach die Längenbits, was es tatsächlich einfacher macht.
Ordnen Sie Blöcke mit 16 Zeigern zu. Das niedrigstwertige Bit der Zeiger kann wiederverwendet werden, da Ihre Blöcke immer ausgerichtet sind. Möglicherweise möchten Sie einen speziellen Speicherzuweiser dafür (Aufteilen des großen Speichers in kleinere Blöcke). Es gibt verschiedene Arten von Blöcken:
Für jede Art von Block müssen Sie unterschiedliche Informationen in den LSBs speichern. Da Sie Zeichenfolgen mit variabler Länge haben, müssen Sie auch das Ende der Zeichenfolge speichern, und die letzte Art von Block kann nur für die längsten Zeichenfolgen verwendet werden. Die 7 Längenbits sollten durch weniger ersetzt werden, wenn Sie tiefer in die Struktur eindringen.
Dies bietet Ihnen eine relativ schnelle und sehr speichereffiziente Speicherung sortierter Zeichenfolgen. Es wird sich etwas wie ein Versuch verhalten . Stellen Sie sicher, dass Sie genügend Komponententests erstellen, damit dies funktioniert. Sie möchten alle Blockübergänge abdecken. Sie möchten nur mit der zweiten Art von Block beginnen.
Für noch mehr Leistung möchten Sie möglicherweise verschiedene Blocktypen und eine größere Blockgröße hinzufügen. Wenn die Blöcke immer gleich groß und groß genug sind, können Sie noch weniger Bits für die Zeiger verwenden. Mit einer Blockgröße von 16 Zeigern haben Sie bereits ein Byte frei in einem 32-Bit-Adressraum. In der Judy-Baumdokumentation finden Sie interessante Blocktypen. Grundsätzlich fügen Sie Code und Engineering-Zeit für einen Kompromiss zwischen Speicherplatz (und Laufzeit) hinzu
Sie möchten wahrscheinlich mit einem 256 breiten direkten Radix für die ersten vier Zeichen beginnen. Das bietet einen anständigen Kompromiss zwischen Raum und Zeit. In dieser Implementierung erhalten Sie viel weniger Speicheraufwand als bei einem einfachen Versuch. es ist ungefähr dreimal kleiner (ich habe nicht gemessen). O (n) ist kein Problem, wenn die Konstante niedrig genug ist, wie Sie beim Vergleich mit dem Quicksort O (n log n) festgestellt haben.
Interessieren Sie sich für den Umgang mit Doppel? Mit kurzen Sequenzen wird es geben. Das Anpassen der Blöcke an die Anzahl der Zählungen ist schwierig, kann jedoch sehr platzsparend sein.
quelle