Ich denke, dass in diesem Thema mehrere Fragen verborgen sind:
- Wie implementieren Sie
buildHeap
so läuft es in O(n) Zeit?
- Wie können Sie zeigen, dass
buildHeap
läuft ein O(n) Zeit, wenn sie richtig umgesetzt wird?
- Warum funktioniert dieselbe Logik nicht, um Heap Sort in O(n) Zeit statt O(n log n) ?
Wie implementieren Sie buildHeap
so läuft es in O(n) Zeit?
Häufig konzentrieren sich die Antworten auf diese Fragen auf den Unterschied zwischen siftUp
et siftDown
. Die richtige Wahl treffen zwischen siftUp
et siftDown
ist entscheidend für den Erhalt O(n) Leistung für buildHeap
aber es hilft nicht, den Unterschied zu verstehen zwischen buildHeap
et heapSort
im Allgemeinen. In der Tat sind ordnungsgemäße Implementierungen von sowohl buildHeap
et heapSort
wird nur verwenden. siftDown
. En siftUp
Operation wird nur benötigt, um Einfügungen in einen bestehenden Heap vorzunehmen, so dass sie z. B. zur Implementierung einer Prioritätswarteschlange mit einem binären Heap verwendet werden könnte.
Ich habe dies geschrieben, um zu beschreiben, wie ein Max Heap funktioniert. Diese Art von Heap wird typischerweise für Heap-Sortierung oder für eine Prioritätswarteschlange verwendet, bei der höhere Werte eine höhere Priorität angeben. Ein Min-Heap ist ebenfalls nützlich, z. B. beim Abrufen von Elementen mit ganzzahligen Schlüsseln in aufsteigender Reihenfolge oder von Zeichenketten in alphabetischer Reihenfolge. Die Prinzipien sind genau die gleichen; man muss nur die Sortierreihenfolge ändern.
En Haufeneigenschaft legt fest, dass jeder Knoten in einem binären Heap mindestens so groß sein muss wie seine beiden Kinder. Dies bedeutet insbesondere, dass sich das größte Element im Heap an der Wurzel befindet. Sifting down und Sifting up sind im Wesentlichen derselbe Vorgang in entgegengesetzter Richtung: Verschieben eines angreifenden Knotens, bis er die Heap-Eigenschaft erfüllt:
siftDown
tauscht einen zu kleinen Knoten mit seinem größten Unterknoten aus (und verschiebt ihn damit nach unten), bis er mindestens so groß ist wie die beiden darunter liegenden Knoten.
siftUp
tauscht einen zu großen Knoten mit seinem Elternteil aus (und verschiebt ihn dadurch nach oben), bis er nicht mehr größer ist als der darüber liegende Knoten.
Die Anzahl der Vorgänge, die für siftDown
et siftUp
ist proportional zu der Entfernung, die der Knoten möglicherweise zurücklegen muss. Für siftDown
ist die Entfernung zum unteren Ende des Baumes, also siftDown
ist für Knoten an der Spitze des Baums teuer. Mit siftUp
ist die Arbeit proportional zum Abstand zur Spitze des Baumes, also siftUp
ist für Knoten am unteren Ende des Baums teuer. Obwohl beide Operationen O(log n) Im schlimmsten Fall befindet sich in einem Haufen nur ein Knoten an der Spitze, während die Hälfte der Knoten in der unteren Schicht liegt. Also sollte es nicht allzu überraschend sein, dass wir, wenn wir eine Operation auf jeden Knoten anwenden müssen, die siftDown
über siftUp
.
En buildHeap
Funktion nimmt ein Array unsortierter Elemente und verschiebt sie, bis sie alle die Heap-Eigenschaft erfüllen, wodurch ein gültiger Heap entsteht. Es gibt zwei Ansätze, die man für buildHeap
unter Verwendung der siftUp
et siftDown
Operationen, die wir beschrieben haben.
-
Beginnen Sie am oberen Ende des Heaps (dem Anfang des Arrays) und rufen Sie siftUp
für jeden Artikel. Bei jedem Schritt bilden die zuvor gesiebten Elemente (die Elemente vor dem aktuellen Element im Array) einen gültigen Haufen, und das Sieben des nächsthöheren Elements platziert dieses an einer gültigen Position im Haufen. Nach der Sichtung jedes Knotens erfüllen alle Elemente die Eigenschaft des Haufens.
-
Oder gehen Sie in die entgegengesetzte Richtung: Beginnen Sie am Ende des Feldes und bewegen Sie sich rückwärts nach vorne. Bei jeder Iteration sortieren Sie ein Element nach unten, bis es sich an der richtigen Stelle befindet.
Welche Implementierung für buildHeap
effizienter ist?
Beide Lösungen ergeben einen gültigen Heap. Es überrascht nicht, dass die effizientere Lösung die zweite Operation ist, bei der siftDown
.
Sea h = log n stellen die Höhe des Haufens dar. Der Arbeitsaufwand für die siftDown
Ansatz ist gegeben durch die Summe
(0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (h * 1).
Jeder Term in der Summe hat die maximale Entfernung, die ein Knoten in der gegebenen Höhe zurücklegen muss (Null für die unterste Schicht, h für die Wurzel), multipliziert mit der Anzahl der Knoten in dieser Höhe. Im Gegensatz dazu ist die Summe für den Aufruf von siftUp
auf jedem Knoten ist
(h * n/2) + ((h-1) * n/4) + ((h-2)*n/8) + ... + (0 * 1).
Es sollte klar sein, dass die zweite Summe größer ist. Der erste Term allein ist hn/2 = 1/2 n log n Dieser Ansatz ist also bestenfalls komplex. O(n log n) .
Wie lässt sich die Summe für die siftDown
Ansatz ist in der Tat O(n) ?
Eine Methode (es gibt auch andere Analysen, die funktionieren) besteht darin, die endliche Summe in eine unendliche Reihe umzuwandeln und dann Taylorreihen zu verwenden. Wir können den ersten Term ignorieren, der Null ist:
Wenn Sie sich nicht sicher sind, warum jeder dieser Schritte funktioniert, finden Sie hier eine Begründung für den Prozess in Worten:
- Die Terme sind alle positiv, so dass die endliche Summe kleiner sein muss als die unendliche Summe.
- Die Reihe ist gleich einer Potenzreihe, die bei x=1/2 .
- Diese Potenzreihe ist gleich (eine Konstante mal) der Ableitung der Taylor-Reihe für f(x)=1/(1-x) .
- x=1/2 innerhalb des Konvergenzintervalls dieser Taylor-Reihe liegt.
- Daher können wir die Taylor-Reihe ersetzen durch 1/(1-x) , differenzieren und auswerten, um den Wert der unendlichen Reihe zu finden.
Da die unendliche Summe genau n schließen wir, dass die endliche Summe nicht größer ist, und ist daher, O(n) .
Warum erfordert die Heap-Sortierung O(n log n) Zeit?
Wenn es möglich ist, die buildHeap
in linearer Zeit, warum erfordert die Haufensortierung O(n log n) Zeit? Nun, die Haufensortierung besteht aus zwei Schritten. Erstens, wir rufen buildHeap
auf dem Array, was eine O(n) Zeit, wenn sie optimal umgesetzt wird. Der nächste Schritt besteht darin, das größte Element im Heap wiederholt zu löschen und es an das Ende des Arrays zu setzen. Da wir ein Element aus dem Heap löschen, gibt es immer einen freien Platz direkt hinter dem Ende des Heaps, wo wir das Element speichern können. Die Haufensortierung erreicht also eine sortierte Reihenfolge, indem sie nacheinander das nächstgrößere Element entfernt und in das Array einfügt, wobei sie an der letzten Position beginnt und sich nach vorne bewegt. Es ist die Komplexität dieses letzten Teils, die bei Heap-Sortierung dominiert. Die Schleife sieht wie folgt aus:
for (i = n - 1; i > 0; i--) {
arr[i] = deleteMax();
}
Offensichtlich läuft die Schleife O(n) mal ( n - 1 um genau zu sein, ist der letzte Punkt bereits vorhanden). Die Komplexität der deleteMax
für einen Heap ist O(log n) . In der Regel wird die Wurzel (das größte Element im Heap) entfernt und durch das letzte Element im Heap ersetzt, das ein Blatt und damit eines der kleinsten Elemente ist. Diese neue Wurzel wird mit ziemlicher Sicherheit die Heap-Eigenschaft verletzen, daher müssen Sie siftDown
bis Sie ihn wieder in eine akzeptable Position bringen. Dies hat auch zur Folge, dass das nächstgrößere Element an die Wurzel verschoben wird. Beachten Sie, dass im Gegensatz zu buildHeap
wobei wir für die meisten Knotenpunkte siftDown
vom unteren Ende des Baumes aus, rufen wir nun siftDown
von der Spitze des Baumes bei jeder Iteration! Obwohl der Baum schrumpft, schrumpft er nicht schnell genug : Die Höhe des Baumes bleibt konstant, bis Sie die erste Hälfte der Knoten entfernt haben (wenn Sie die unterste Schicht vollständig entfernt haben). Für das nächste Viertel ist die Höhe dann h - 1 . Die Gesamtarbeit für diese zweite Stufe beträgt also
h*n/2 + (h-1)*n/4 + ... + 0 * 1.
Beachten Sie den Wechsel: Der Null-Arbeitsfall entspricht jetzt einem einzelnen Knoten und die h Der Arbeitsfall entspricht der Hälfte der Knotenpunkte. Diese Summe ist O(n log n) genau wie die ineffiziente Version von buildHeap
die mit siftUp implementiert wird. Aber in diesem Fall haben wir keine Wahl, da wir versuchen, zu sortieren und wir verlangen, dass das nächstgrößere Element als nächstes entfernt wird.
Zusammenfassend lässt sich sagen, dass die Arbeit für die Haufensortierung die Summe der beiden Stufen ist: O(n) Zeit für buildHeap und O(n log n), um jeden Knoten der Reihe nach zu entfernen Die Komplexität ist also O(n log n) . Sie können (mit Hilfe einiger Ideen aus der Informationstheorie) beweisen, dass für eine vergleichsbasierte Sortierung, O(n log n) ist ohnehin das Beste, was man sich erhoffen kann. Es gibt also keinen Grund, davon enttäuscht zu sein oder zu erwarten, dass Heap Sort die O(n)-Zeitgrenze erreicht, die buildHeap
tut.