Der std::sort
Algorithmus (und seine Verwandten std::partial_sort
und std::nth_element
) aus der C ++ - Standardbibliothek ist in den meisten Implementierungen eine komplizierte und hybride Zusammenführung elementarerer Sortieralgorithmen wie Auswahlsortierung, Einfügesortierung, Schnellsortierung, Zusammenführungssortierung oder Heap-Sortierung.
Hier und auf Schwesterseiten wie https://codereview.stackexchange.com/ gibt es viele Fragen zu Fehlern, Komplexität und anderen Aspekten der Implementierung dieser klassischen Sortieralgorithmen. Die meisten der angebotenen Implementierungen bestehen aus Rohschleifen, verwenden Indexmanipulation und konkrete Typen und sind im Allgemeinen nicht trivial in Bezug auf Korrektheit und Effizienz zu analysieren.
Frage : Wie können die oben genannten klassischen Sortieralgorithmen mit modernem C ++ implementiert werden?
- Keine Rohschleifen , sondern Kombination der algorithmischen Bausteine der Standardbibliothek aus
<algorithm>
- Iterator-Schnittstelle und Verwendung von Vorlagen anstelle von Indexmanipulation und konkreten Typen
- C ++ 14-Stil , einschließlich der vollständigen Standardbibliothek, sowie syntaktische Rauschunterdrücker wie
auto
Template-Aliase, transparente Komparatoren und polymorphe Lambdas.
Anmerkungen :
- Weitere Referenzen zu Implementierungen von Sortieralgorithmen finden Sie in Wikipedia , Rosetta Code oder http://www.sorting-algorithms.com/.
- Gemäß den Konventionen von Sean Parent (Folie 39) ist eine
for
Rohschleife eine Schleife länger als die Zusammensetzung zweier Funktionen mit einem Operator. Alsof(g(x));
oderf(x); g(x);
oderf(x) + g(x);
sind keine Rohschleifen, und die Schleifen sind auch nicht inselection_sort
undinsertion_sort
unter. - Ich folge der Terminologie von Scott Meyers, um das aktuelle C ++ 1y bereits als C ++ 14 zu bezeichnen, und um C ++ 98 und C ++ 03 beide als C ++ 98 zu bezeichnen, also flamme mich nicht dafür.
- Wie in den Kommentaren von @Mehrdad vorgeschlagen, stelle ich am Ende der Antwort vier Implementierungen als Live-Beispiel bereit: C ++ 14, C ++ 11, C ++ 98 und Boost und C ++ 98.
- Die Antwort selbst wird nur in C ++ 14 dargestellt. Wo relevant, bezeichne ich die syntaktischen und Bibliotheksunterschiede, bei denen sich die verschiedenen Sprachversionen unterscheiden.
Antworten:
Algorithmische Bausteine
Wir beginnen mit dem Zusammenstellen der algorithmischen Bausteine aus der Standardbibliothek:
std::begin()
/std::end()
sowie withstd::next()
sind nur ab C ++ 11 und höher verfügbar. Für C ++ 98 muss man diese selbst schreiben. Es gibt Ersatz von Boost.Range inboost::begin()
/boost::end()
und von Boost.Utility inboost::next()
.std::is_sorted
Algorithmus ist nur für C ++ 11 und höher verfügbar. Für C ++ 98 kann diesstd::adjacent_find
als handgeschriebenes Funktionsobjekt implementiert werden. Boost.Algorithm bietet auch einenboost::algorithm::is_sorted
als Ersatz.std::is_heap
Algorithmus ist nur für C ++ 11 und höher verfügbar.Syntaktische Leckereien
C ++ 14 bietet transparente Komparatoren der Form
std::less<>
, die polymorph auf ihre Argumente einwirken. Dadurch wird vermieden, dass ein Iteratortyp angegeben werden muss. Dies kann in Kombination mit den Standardfunktionsvorlagenargumenten von C ++ 11 verwendet werden , um eine einzelne Überladung für Sortieralgorithmen zu erstellen , die<
als Vergleich dienen, und für Algorithmen mit einem benutzerdefinierten Vergleichsfunktionsobjekt.In C ++ 11 kann ein wiederverwendbarer Vorlagenalias definiert werden , um den Werttyp eines Iterators zu extrahieren, wodurch die Signaturen der Sortieralgorithmen geringfügig überladen werden:
In C ++ 98 müssen zwei Überladungen geschrieben und die ausführliche
typename xxx<yyy>::type
Syntax verwendet werdenauto
Parametern, die wie Argumente für Funktionsvorlagen abgeleitet werden).value_type_t
.std::bind1st
/std::bind2nd
/std::not1
Art der Syntax.boost::bind
und_1
/ oder_2
Platzhaltersyntax.std::find_if_not
, während C ++ 98std::find_if
mit einemstd::not1
um ein Funktionsobjekt benötigt.C ++ - Stil
Es gibt noch keinen allgemein akzeptablen C ++ 14-Stil. Zum Guten oder zum Schlechten verfolge ich Scott Meyers Entwurf Effective Modern C ++ und Herb Sutters überarbeitetes GotW genau . Ich verwende die folgenden Stilempfehlungen:
()
und{}
beim Erstellen von Objekten" und wählen Sie konsequent die Klammerinitialisierung{}
anstelle der guten alten Klammerinitialisierung()
(um alle Probleme mit der Analyse im generischen Code zu umgehen).typedef
Zeit spart Zeit und erhöht die Konsistenz.for (auto it = first; it != last; ++it)
an einigen Stellen ein Muster, um eine Schleifeninvariantenprüfung für bereits sortierte Unterbereiche zu ermöglichen. Im Produktionscode ist die Verwendung vonwhile (first != last)
und++first
irgendwo innerhalb der Schleife möglicherweise etwas besser.Auswahl sortieren
Die Auswahlsortierung passt sich in keiner Weise an die Daten an, daher ist ihre Laufzeit immer
O(N²)
. Die Auswahlsortierung hat jedoch die Eigenschaft, die Anzahl der Swaps zu minimieren . In Anwendungen, in denen die Kosten für den Austausch von Elementen hoch sind, kann die Auswahlsortierung der Algorithmus der Wahl sein.Um es mithilfe der Standardbibliothek zu implementieren, verwenden Sie wiederholt
std::min_element
, um das verbleibende Mindestelement zu finden unditer_swap
es auszutauschen:Beachten Sie, dass
selection_sort
der bereits verarbeitete Bereich[first, it)
als Schleifeninvariante sortiert ist. Die Mindestanforderungen sind Vorwärtsiteratoren im Vergleich zu Iteratoren mitstd::sort
wahlfreiem Zugriff.Details weggelassen :
if (std::distance(first, last) <= 1) return;
(oder für vorwärts- / bidirektionale Iteratoren :) optimiert werdenif (first == last || std::next(first) == last) return;
.[first, std::prev(last))
, da das letzte Element garantiert das minimal verbleibende Element ist und keinen Austausch erfordert.Sortieren durch Einfügen
Obwohl es sich um einen der elementaren Sortieralgorithmen mit
O(N²)
Worst-Case-Zeit handelt, ist die Einfügungssortierung der Algorithmus der Wahl, entweder wenn die Daten nahezu sortiert sind (weil sie adaptiv sind ) oder wenn die Problemgröße klein ist (weil sie einen geringen Overhead hat). Aus diesen Gründen und weil es auch stabil ist , wird die Einfügesortierung häufig als rekursiver Basisfall (wenn die Problemgröße klein ist) für Sortieralgorithmen mit höherem Overhead-Divide-and-Conquer-Sortier verwendet, z. B. Zusammenführungssortierung oder schnelle Sortierung.insertion_sort
Verwenden Siestd::upper_bound
zum Implementieren mit der Standardbibliothek wiederholt die Position, an die das aktuelle Elementstd::rotate
verschoben werden soll , und verschieben Sie die verbleibenden Elemente im Eingabebereich nach oben:Beachten Sie, dass
insertion_sort
der bereits verarbeitete Bereich[first, it)
als Schleifeninvariante sortiert ist. Die Einfügesortierung funktioniert auch mit Vorwärtsiteratoren.Details weggelassen :
if (std::distance(first, last) <= 1) return;
(oder für vorwärts / bidirektionale Iteratoren :)if (first == last || std::next(first) == last) return;
und einer Schleife über das Intervall optimiert werden[std::next(first), last)
, da das erste Element garantiert vorhanden ist und keine Drehung erfordert.std::find_if_not
Algorithmus der Standardbibliothek ersetzt werden.Vier Live-Beispiele ( C ++ 14 , C ++ 11 , C ++ 98 und Boost , C ++ 98 ) für das folgende Fragment:
O(N²)
Vergleich, dies verbessert sich jedoch gegenüberO(N)
Vergleichen für fast sortierte Eingaben. Die binäre Suche verwendet immerO(N log N)
Vergleiche.Schnelle Sorte
Bei sorgfältiger Implementierung ist die schnelle Sortierung robust und hat
O(N log N)
Komplexität erwartet, jedoch imO(N²)
schlimmsten Fall Komplexität, die mit kontrovers ausgewählten Eingabedaten ausgelöst werden kann. Wenn keine stabile Sortierung benötigt wird, ist die schnelle Sortierung eine ausgezeichnete Allzweck-Sortierung.Selbst für die einfachsten Versionen ist die schnelle Sortierung mit der Standardbibliothek etwas komplizierter zu implementieren als die anderen klassischen Sortieralgorithmen. Der Ansatz unten verwendet einige Iterator Dienstprogramme die lokalisieren mittlere Element des Eingangsbereichs
[first, last)
als Drehpunkt, dann zwei Anrufe verwenden , umstd::partition
(dieO(N)
) zu Dreiweg-Partition der Eingangsbereich in Segmente von Elementen , die kleiner sind als gleich, bzw. größer als der ausgewählte Drehpunkt. Schließlich werden die beiden äußeren Segmente mit Elementen, die kleiner und größer als der Drehpunkt sind, rekursiv sortiert:Eine schnelle Sortierung ist jedoch ziemlich schwierig, um korrekt und effizient zu sein, da jeder der oben genannten Schritte sorgfältig geprüft und für Code auf Produktionsebene optimiert werden muss. Insbesondere für die
O(N log N)
Komplexität muss der Pivot zu einer ausgeglichenen Partition der Eingabedaten führen, die im Allgemeinen für einenO(1)
Pivot nicht garantiert werden kann, die jedoch garantiert werden kann, wenn man den Pivot alsO(N)
Median des Eingabebereichs festlegt .Details weggelassen :
O(N^2)
Komplexität für die Eingabe " Orgelpfeife "1, 2, 3, ..., N/2, ... 3, 2, 1
(da die Mitte immer größer als alle anderen Elemente ist).O(N^2)
.std::partition
ist nicht der effizientesteO(N)
Algorithmus, um dieses Ergebnis zu erzielen.O(N log N)
Komplexität durch Auswahl des Median-Pivots erreicht werdenstd::nth_element(first, middle, last)
, gefolgt von rekursiven Aufrufen vonquick_sort(first, middle, cmp)
undquick_sort(middle, last, cmp)
.O(N)
Komplexität vonstd::nth_element
teurer sein kann als der derO(1)
Komplexität eines Median-of-3-Pivots, gefolgt von einemO(N)
Aufruf vonstd::partition
(was eine cachefreundliche einzelne Weiterleitung ist) die Daten).Zusammenführen, sortieren
Wenn die Verwendung von
O(N)
zusätzlichem Speicherplatz keine Rolle spielt, ist die Zusammenführungssortierung eine ausgezeichnete Wahl: Es ist der einzige stabileO(N log N)
Sortieralgorithmus.Die Implementierung mit Standardalgorithmen ist einfach: Verwenden Sie einige Iterator-Dienstprogramme, um die Mitte des Eingabebereichs zu lokalisieren,
[first, last)
und kombinieren Sie zwei rekursiv sortierte Segmente mitstd::inplace_merge
:Die Zusammenführungssortierung erfordert bidirektionale Iteratoren, wobei der Engpass der ist
std::inplace_merge
. Beachten Sie, dass beim Sortieren verknüpfter Listen für die Zusammenführungssortierung nurO(log N)
zusätzlicher Speicherplatz erforderlich ist (für die Rekursion). Der letztere Algorithmus wird vonstd::list<T>::sort
in der Standardbibliothek implementiert .Haufen sortieren
Die Heap-Sortierung ist einfach zu implementieren, führt eine
O(N log N)
direkte Sortierung durch, ist jedoch nicht stabil.Die erste Schleife, die
O(N)
"Heapify" -Phase, versetzt das Array in die Heap-Reihenfolge. Die zweite Schleife, dieO(N log N
"Sortdown" -Phase, extrahiert wiederholt das Maximum und stellt die Heap-Reihenfolge wieder her. Die Standardbibliothek macht dies äußerst einfach:Für den Fall , halten Sie es für „Betrug“ verwenden ,
std::make_heap
undstd::sort_heap
Sie können eine Ebene tiefer gehen und die Funktionen selbst in Bezug auf die schreibenstd::push_heap
undstd::pop_heap
jeweils:Die Standardbibliothek gibt sowohl
push_heap
als auchpop_heap
als Komplexität anO(log N)
. Beachten Sie jedoch, dass die äußere Schleife über den Bereich[first, last)
zuO(N log N)
Komplexität für führtmake_heap
, währendstd::make_heap
sie nurO(N)
Komplexität aufweist. Für die gesamteO(N log N)
Komplexität derheap_sort
es spielt keine Rolle.Details weggelassen :
O(N)
Implementierung vonmake_heap
Testen
Hier sind vier Live-Beispiele ( C ++ 14 , C ++ 11 , C ++ 98 und Boost , C ++ 98 ), in denen alle fünf Algorithmen an einer Vielzahl von Eingaben getestet werden (die nicht erschöpfend oder streng sein sollen). Beachten Sie nur die großen Unterschiede im LOC: C ++ 11 / C ++ 14 benötigt ungefähr 130 LOC, C ++ 98 und Boost 190 (+ 50%) und C ++ 98 mehr als 270 (+ 100%).
quelle
auto
(und viele Leute mit mir nicht einverstanden sind), hat es mir Spaß gemacht, die Standardbibliotheksalgorithmen gut zu verwenden. Ich wollte einige Beispiele für diese Art von Code sehen, nachdem ich Sean Parents Vortrag gesehen hatte. Ich hatte auch keine Ahnungstd::iter_swap
, obgleich es mir seltsam erscheint, dass es drin ist<algorithm>
.if (first == last || std::next(first) == last)
. Ich könnte das später aktualisieren. Die Implementierung der Inhalte in den Abschnitten "Ausgelassene Details" geht über den Rahmen der Frage, IMO, hinaus, da sie Links zu den gesamten Fragen und Antworten selbst enthalten. Das Implementieren von Sortierroutinen für echte Wörter ist schwierig!nth_element
meiner Meinung nach benutzt hast .nth_element
führt bereits eine halbe Quicksortierung durch (einschließlich des Partitionierungsschritts und einer Rekursion der Hälfte, die das n-te Element enthält, an dem Sie interessiert sind).Eine andere kleine und ziemlich elegante, die ursprünglich bei der Codeüberprüfung gefunden wurde . Ich dachte, es lohnt sich zu teilen.
Sortierung zählen
Während es eher spezialisiert ist, das Zählen Art ist eine einfache Integer - Algorithmus sortiert und oft sehr schnell werden kann , um die Werte der ganzen Zahlen zu sortieren sind nicht zu weit voneinander entfernt vorgesehen. Es ist wahrscheinlich ideal, wenn man jemals eine Sammlung von einer Million Ganzzahlen sortieren muss, von denen bekannt ist, dass sie beispielsweise zwischen 0 und 100 liegen.
Um eine sehr einfache Zählsortierung zu implementieren, die sowohl mit vorzeichenbehafteten als auch mit vorzeichenlosen Ganzzahlen funktioniert, müssen die kleinsten und größten Elemente in der Sammlung zum Sortieren gefunden werden. Ihr Unterschied gibt die Größe des zuzuordnenden Zählfelds an. Dann wird ein zweiter Durchlauf durch die Sammlung durchgeführt, um die Anzahl der Vorkommen jedes Elements zu zählen. Schließlich schreiben wir die erforderliche Anzahl jeder Ganzzahl zurück in die ursprüngliche Sammlung.
Während es nur dann nützlich ist, wenn bekannt ist, dass der Bereich der zu sortierenden Ganzzahlen klein ist (im Allgemeinen nicht größer als die Größe der zu sortierenden Sammlung), würde eine allgemeinere Zählsortierung die besten Fälle verlangsamen. Wenn nicht bekannt ist, dass der Bereich klein ist, kann stattdessen ein anderer Algorithmus wie Radix-Sortierung , ska_sort oder Spreadsort verwendet werden.
Details weggelassen :
Wir hätten die Grenzen des vom Algorithmus als Parameter akzeptierten Wertebereichs überschreiten können, um den ersten
std::minmax_element
Durchgang durch die Sammlung vollständig zu beseitigen . Dies macht den Algorithmus noch schneller, wenn eine nützlich kleine Bereichsgrenze auf andere Weise bekannt ist. (Es muss nicht exakt sein. Das Übergeben einer Konstante von 0 bis 100 ist immer noch viel besser als ein zusätzlicher Durchlauf über eine Million Elemente, um herauszufinden, dass die wahren Grenzen 1 bis 95 sind. Sogar 0 bis 1000 wären es wert zusätzliche Elemente werden einmal mit Null geschrieben und einmal gelesen).Das schnelle Wachsen
counts
ist ein weiterer Weg, um einen separaten ersten Durchgang zu vermeiden. Das Verdoppeln dercounts
Größe jedes Mal, wenn es wachsen muss, ergibt eine amortisierte O (1) -Zeit pro sortiertem Element (siehe Analyse der Einfügekosten für Hash-Tabellen für den Beweis, dass exponentiell gewachsen der Schlüssel ist). Das Wachsen am Ende für ein neues Elementmax
ist einfachstd::vector::resize
, wenn neue Elemente mit Nullen hinzugefügt werden. Das Ändernmin
im laufenden Betrieb und das Einfügen neuer Elemente mit Nullen an der Vorderseite kannstd::copy_backward
nach dem Wachstum des Vektors erfolgen. Dannstd::fill
die neuen Elemente auf Null setzen.Die
counts
Inkrementschleife ist ein Histogramm. Wenn sich die Daten wahrscheinlich stark wiederholen und die Anzahl der Fächer gering ist, kann es sich lohnen, über mehrere Arrays zu rollen , um den Engpass bei der Serialisierung der Datenabhängigkeit beim Speichern / erneuten Laden in denselben Behälter zu verringern. Dies bedeutet, dass am Anfang mehr Zählungen auf Null und am Ende mehr Schleifen durchgeführt werden müssen. Dies sollte sich jedoch auf den meisten CPUs für unser Beispiel von Millionen von 0 bis 100 Zahlen lohnen, insbesondere wenn die Eingabe möglicherweise bereits (teilweise) sortiert ist und habe lange Läufe der gleichen Anzahl.Im obigen Algorithmus verwenden wir eine
min == max
Prüfung, um frühzeitig zurückzukehren, wenn jedes Element den gleichen Wert hat (in diesem Fall wird die Sammlung sortiert). Es ist tatsächlich möglich, stattdessen vollständig zu überprüfen, ob die Sammlung bereits sortiert ist, während die Extremwerte einer Sammlung ermittelt werden, ohne dass zusätzliche Zeit verschwendet wird (wenn der erste Durchgang immer noch einen Speicherengpass mit der zusätzlichen Arbeit des Aktualisierens von min und max aufweist). Ein solcher Algorithmus existiert jedoch nicht in der Standardbibliothek, und das Schreiben eines Algorithmus wäre mühsamer als das Schreiben des Restes der Zählsortierung selbst. Es bleibt als Übung für den Leser.Da der Algorithmus nur mit ganzzahligen Werten arbeitet, können statische Zusicherungen verwendet werden, um zu verhindern, dass Benutzer offensichtliche Typfehler machen. In einigen Kontexten kann ein Substitutionsfehler mit
std::enable_if_t
bevorzugt werden.Während modernes C ++ cool ist, könnte zukünftiges C ++ noch cooler sein: Strukturierte Bindungen und einige Teile des Ranges TS würden den Algorithmus noch sauberer machen.
quelle
std::minmax_element
die nur Informationen sammelt). Die verwendete Eigenschaft ist die Tatsache, dass Ganzzahlen als Indizes oder Offsets verwendet werden können und dass sie unter Beibehaltung der letzteren Eigenschaft inkrementierbar sind.counts | ranges::view::filter([](auto c) { return c != 0; })
so dass Sie nicht wiederholt auf Zählwerte ungleich Null innerhalb der testen müssenfill_n
.small
einem gefundenrather
undappart
- darf ich sie bis zur Bearbeitung von reggae_sort behalten?)counts[]
laufenden Betrieb ein Gewinn wäre, anstatt die Eingabeminmax_element
vor dem Histogramm zu durchlaufen . Besonders für den Anwendungsfall, in dem dies ideal ist, mit sehr großen Eingaben mit vielen Wiederholungen in einem kleinen Bereich, da Sie schnellcounts
zu seiner vollen Größe mit wenigen Verzweigungsfehlvorhersagen oder Größenverdopplungen wachsen . (Wenn Sie eine ausreichend kleine Grenze für den Bereich kennen, können Sie natürlich einenminmax_element
Scan vermeiden und die Überprüfung der Grenzen innerhalb der Histogrammschleife vermeiden.)