Ich denke, in diesem Thema sind mehrere Fragen begraben:
- Wie implementieren Sie,
buildHeap
damit es in O (n) Zeit ausgeführt wird?
- Wie zeigen Sie, dass bei korrekter Implementierung
buildHeap
in O (n) -Zeit ausgeführt wird?
- Warum funktioniert dieselbe Logik nicht, um die Heap-Sortierung in O (n) -Zeit und nicht in O (n log n) auszuführen ?
Wie implementieren Sie, buildHeap
damit es in O (n) Zeit ausgeführt wird?
Oft konzentrieren sich die Antworten auf diese Fragen auf den Unterschied zwischen siftUp
und siftDown
. Die richtige Wahl zwischen siftUp
und siftDown
zu treffen ist entscheidend, um die O (n) -Leistung zu erhalten buildHeap
, trägt jedoch nicht dazu bei, den Unterschied zwischen buildHeap
und heapSort
im Allgemeinen zu verstehen . Tatsächlich richtige Implementierungen von beiden buildHeap
und heapSort
wird nur verwendet werden siftDown
. Die siftUp
Operation wird nur benötigt, um Einfügungen in einen vorhandenen Heap durchzuführen, sodass sie beispielsweise zum Implementieren einer Prioritätswarteschlange unter Verwendung eines binären Heaps verwendet wird.
Ich habe dies geschrieben, um zu beschreiben, wie ein maximaler Heap funktioniert. Dies ist der Heap-Typ, der normalerweise für die Heap-Sortierung oder für eine Prioritätswarteschlange verwendet wird, bei der höhere Werte eine höhere Priorität anzeigen. Ein kleiner Haufen ist ebenfalls nützlich. Zum Beispiel beim Abrufen von Elementen mit Ganzzahlschlüsseln in aufsteigender Reihenfolge oder Zeichenfolgen in alphabetischer Reihenfolge. Die Prinzipien sind genau die gleichen; Wechseln Sie einfach die Sortierreihenfolge.
Die Heap-Eigenschaft gibt an, dass jeder Knoten in einem Binärheap mindestens so groß sein muss wie seine beiden untergeordneten Knoten. Dies bedeutet insbesondere, dass sich das größte Element im Heap im Stammverzeichnis befindet. Das Absenken und Aufwärtssieben ist im Wesentlichen dieselbe Operation in entgegengesetzte Richtungen: Verschieben Sie einen fehlerhaften Knoten, bis er die Heap-Eigenschaft erfüllt:
siftDown
tauscht einen zu kleinen Knoten mit seinem größten Kind aus (wodurch er nach unten verschoben wird), bis er mindestens so groß ist wie beide Knoten darunter.
siftUp
tauscht einen zu großen Knoten mit seinem übergeordneten Knoten aus (wodurch er nach oben verschoben wird), bis er nicht größer als der darüber liegende Knoten ist.
Die Anzahl der Operationen , die erforderlich für die siftDown
und siftUp
ist proportional zum Abstand der Knoten bewegen müssen können. Denn siftDown
es ist der Abstand zum unteren Rand des Baums, der siftDown
für Knoten am oberen Rand des Baums teuer ist. Mit siftUp
ist die Arbeit proportional zum Abstand zum oberen siftUp
Rand des Baums, daher ist sie für Knoten am unteren Rand des Baums teuer. Obwohl beide Operationen im schlimmsten Fall O (log n) sind , befindet sich in einem Heap nur ein Knoten oben, während die Hälfte der Knoten in der unteren Schicht liegt. So sollte es nicht allzu überraschend sein , dass , wenn wir eine Operation an jeden Knoten anzuwenden haben, würden wir es vorziehen , siftDown
über siftUp
.
Die buildHeap
Funktion nimmt ein Array von unsortierten Elementen und verschiebt sie, bis sie alle die Heap-Eigenschaft erfüllen, wodurch ein gültiger Heap erzeugt wird. Es gibt zwei Ansätze, um buildHeap
die von uns beschriebenen Operationen siftUp
und zu siftDown
verwenden.
Beginnen Sie am oberen Rand des Heaps (am Anfang des Arrays) und rufen Sie siftUp
jedes Element auf. Bei jedem Schritt bilden die zuvor gesiebten Elemente (die Elemente vor dem aktuellen Element im Array) einen gültigen Heap, und das Sieben des nächsten Elements platziert es an einer gültigen Position im Heap. Nach dem Durchsieben jedes Knotens erfüllen alle Elemente die Heap-Eigenschaft.
Oder gehen Sie in die entgegengesetzte Richtung: Beginnen Sie am Ende des Arrays und bewegen Sie sich rückwärts nach vorne. Bei jeder Iteration wird ein Element gesiebt, bis es sich an der richtigen Stelle befindet.
Welche Implementierung für buildHeap
ist effizienter?
Beide Lösungen erzeugen einen gültigen Heap. Es ist nicht überraschend, dass die effizientere die zweite Operation ist, die verwendet wird siftDown
.
Sei h = log n die Höhe des Heaps. Die für den siftDown
Ansatz erforderliche Arbeit ergibt sich aus der 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 angegebenen 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 siftUp
beträgt die Summe für den Aufruf jedes Knotens
(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 , so dass dieser Ansatz bestenfalls eine Komplexität von O (n log n) aufweist .
Wie beweisen wir, dass die Summe für den siftDown
Ansatz tatsächlich O (n) ist ?
Eine Methode (es gibt andere Analysen, die ebenfalls funktionieren) besteht darin, die endliche Summe in eine unendliche Reihe umzuwandeln und dann die Taylor-Reihe zu verwenden. Wir können den ersten Term ignorieren, der Null ist:
Wenn Sie 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, daher muss die endliche Summe kleiner sein als die unendliche Summe.
- Die Reihe entspricht einer Potenzreihe, die mit x = 1/2 bewertet wird .
- Diese Potenzreihe ist gleich (eine konstante Zeit) der Ableitung der Taylorreihe für f (x) = 1 / (1-x) .
- x = 1/2 liegt innerhalb des Konvergenzintervalls dieser Taylor-Reihe.
- Daher können wir die Taylor-Reihe durch 1 / (1-x) ersetzen , differenzieren und bewerten, um den Wert der unendlichen Reihe zu ermitteln.
Da die unendliche Summe genau n ist , schließen wir, dass die endliche Summe nicht größer ist und daher O (n) ist .
Warum benötigt die Heap-Sortierung O (n log n) Zeit?
Wenn es möglich ist, buildHeap
in linearer Zeit zu laufen , warum erfordert die Heap-Sortierung O (n log n) Zeit? Nun, die Heap-Sortierung besteht aus zwei Stufen. Zuerst rufen wir buildHeap
das Array auf, das bei optimaler Implementierung O (n) Zeit benötigt . Der nächste Schritt besteht darin, das größte Element im Heap wiederholt zu löschen und am Ende des Arrays zu platzieren. Da wir ein Element aus dem Heap löschen, gibt es direkt nach dem Ende des Heaps immer eine freie Stelle, an der wir das Element speichern können. Die Heap-Sortierung erreicht also eine sortierte Reihenfolge, indem das nächstgrößere Element nacheinander entfernt und an der letzten Position in das Array eingefügt wird und sich nach vorne bewegt. Es ist die Komplexität dieses letzten Teils, die bei der Heap-Sortierung dominiert. Die Schleife sieht folgendermaßen aus:
for (i = n - 1; i > 0; i--) {
arr[i] = deleteMax();
}
Es ist klar, dass die Schleife O (n) Mal ausgeführt wird ( n - 1 um genau zu sein, das letzte Element ist bereits vorhanden). Die Komplexität deleteMax
für einen Heap ist O (log n) . Es wird normalerweise implementiert, indem der Stamm (das größte im Heap verbleibende Element) entfernt und durch das letzte Element im Heap ersetzt wird, bei dem es sich um ein Blatt handelt und daher eines der kleinsten Elemente ist. Diese neue Wurzel verletzt mit ziemlicher Sicherheit die Heap-Eigenschaft. Sie müssen also aufrufen, siftDown
bis Sie sie wieder in eine akzeptable Position bringen. Dies hat auch den Effekt, dass das nächstgrößere Element an die Wurzel verschoben wird. Beachten Sie, dass buildHeap
wir im Gegensatz zu den meisten Knoten, die wir siftDown
vom unteren Rand des Baums siftDown
aus aufrufen, jetzt bei jeder Iteration vom oberen Rand des Baums aus aufrufen !Obwohl der Baum schrumpft, schrumpft er nicht schnell genug : Die Höhe des Baums bleibt konstant, bis Sie die erste Hälfte der Knoten entfernt haben (wenn Sie die untere Ebene vollständig entfernt haben). Dann ist für das nächste Quartal die Höhe h - 1 . Die Gesamtarbeit für diese zweite Stufe ist also
h*n/2 + (h-1)*n/4 + ... + 0 * 1.
Beachten Sie den Schalter: Jetzt entspricht der Null-Arbeitsfall einem einzelnen Knoten und der h- Arbeitsfall der Hälfte der Knoten. Diese Summe ist O (n log n), genau wie die ineffiziente Version buildHeap
, die mit siftUp implementiert wird. In diesem Fall haben wir jedoch keine Wahl, da wir versuchen zu sortieren und das nächstgrößere Element als nächstes entfernt werden muss.
Zusammenfassend ist die Arbeit für die Heap-Sortierung die Summe der beiden Stufen: O (n) Zeit für buildHeap und O (n log n), um jeden Knoten der Reihe nach zu entfernen , sodass die Komplexität O (n log n) ist . Sie können (unter Verwendung einiger Ideen aus der Informationstheorie) beweisen, dass für eine vergleichsbasierte Sortierung O (n log n) das Beste ist, auf das Sie hoffen können. Es gibt also keinen Grund, davon enttäuscht zu sein oder zu erwarten, dass die Heap-Sortierung das erreicht O (n) zeitgebunden buildHeap
.