1. Einleitung
In diesem Lernprogramm erfahren Sie, wie Heap Sort funktioniert, und implementieren es in Java.
-
Die Heap-Sortierung basiert auf der Heap-Datenstruktur. ** Um die Heap-Sortierung richtig zu verstehen, werden wir zunächst die Heaps und ihre Implementierung untersuchen.
2. Heap-Datenstruktur
Ein Heap ist eine spezialisierte Baumstruktur . Daher besteht es aus Knoten. Wir weisen die Elemente Knoten zu: Jeder Knoten enthält genau ein Element.
Knoten können auch Kinder haben. Wenn ein Knoten keine Kinder hat, nennen wir es blatt.
Was Heap auszeichnet, sind zwei Dinge:
-
Der Wert jedes Knotens muss ** kleiner oder gleich allen in ihm gespeicherten Werten sein
Kinder . es ist ein vollständiger Baum ** , dh es hat die geringstmögliche Höhe
Aufgrund der 1. Regel befindet sich das kleinste Element immer im Stamm des Baums .
Wie wir diese Regeln durchsetzen, hängt von der Implementierung ab.
Heaps werden normalerweise zur Implementierung von Prioritätswarteschlangen verwendet, da Heap eine sehr effiziente Implementierung des Extrahierens des kleinsten (oder größten) Elements ist.
2.1. Heap-Varianten
Heap hat viele Varianten, die sich in einigen Implementierungsdetails unterscheiden.
Zum Beispiel ist das, was wir oben beschrieben haben, ein Min-Heap, da ein Elternteil immer weniger als alle seine Kinder ist . Alternativ hätten wir Max-Heap definieren können. In diesem Fall ist ein übergeordnetes Element immer größer als das untergeordnete Element. Daher befindet sich das größte Element im Wurzelknoten.
Wir können aus vielen Baumimplementierungen auswählen. Am einfachsten ist ein binärer Baum. In einem binären Baum kann jeder Knoten höchstens zwei Kinder haben. Wir nennen sie linkes Kind und rechtes Kind .
Die zweite Regel lässt sich am besten durchsetzen, wenn Sie einen vollständigen binären Baum verwenden. Ein vollständiger binärer Baum folgt einigen einfachen Regeln:
-
Wenn ein Knoten nur ein Kind hat, sollte dies sein linkes Kind sein
-
Nur der ganz rechte Knoten auf der tiefsten Ebene kann genau einen Knoten haben
Kind . Blätter können nur auf der tiefsten Ebene sein
Sehen wir uns diese Regeln mit einigen Beispielen an:
1 2 3 4 5 6 7 8 9 10
() () () () () () () () () ()
/ \ / \ / \ / \ / \ / / / \
() () () () () () () () () () () () () ()
/ \ / \ / \ / / \
() () () () () () () () ()
/ ()
Die Bäume 1, 2, 4, 5 und 7 folgen den Regeln.
Baum 3 und 6 verstoßen gegen die 1. Regel, 8 und 9 gegen die 2. Regel und 10 gegen die 3. Regel.
In diesem Lernprogramm konzentrieren wir uns auf Min-Heap mit einer Binary Tree-Implementierung.
2.2. Elemente einfügen
Wir sollten alle Operationen so implementieren, dass die Heap-Invarianten erhalten bleiben. Auf diese Weise können wir den Heap mit wiederholten Einfügungen erstellen ** , so dass wir uns auf den Vorgang des Einfügens konzentrieren.
Wir können ein Element mit den folgenden Schritten einfügen:
-
Erstellen Sie ein neues Blatt, das sich ganz rechts am tiefsten verfügbaren Platz befindet
Ebene und speichern Sie den Artikel in diesem Knoten . Wenn das Element weniger als das übergeordnete Element ist, tauschen wir sie
-
Fahren Sie mit Schritt 2 fort, bis das Element weniger als das übergeordnete Element oder es ist
wird die neue Wurzel
Beachten Sie, dass der zweite Schritt nicht gegen die Heap-Regel verstößt. Wenn Sie den Wert eines Knotens durch einen niedrigeren Wert ersetzen, ist er immer noch kleiner als der untergeordnete Wert.
Wir sehen ein Beispiel! Wir möchten 4 in diesen Heap einfügen:
2
/\
/ \
3 6
/\
5 7
Der erste Schritt besteht darin, ein neues Blatt zu erstellen, das 4 speichert:
2
/\
/ \
3 6
/\ / 5 7 4
Da 4 weniger ist als das übergeordnete, 6, tauschen wir sie aus:
2
/\
/ \
3 4
/\ / 5 7 6
Jetzt prüfen wir, ob 4 weniger als das übergeordnete Element ist oder nicht. Da das Elternteil 2 ist, hören wir auf. Der Heap ist noch gültig und wir haben Nummer 4 eingefügt.
Einfügen 1:
2
/\
/ \
3 4
/\ /\
5 7 6 1
Wir müssen 1 und 4 tauschen:
2
/\
/ \
3 1
/\ /\
5 7 6 4
Jetzt sollten wir 1 und 2 tauschen:
1
/\
/ \
3 2
/\ /\
5 7 6 4
Da 1 die neue Wurzel ist, hören wir auf.
-
Heap-Implementierung in Java
Da wir einen Full Binary Tree verwenden, können wir ihn mit einem Array implementieren. Wir markieren jeden Knoten mit den Array-Indizes von links nach rechts und von oben nach unten auf folgende Weise:
0
/\
/ \
1 2
/\ / 3 4 5
Das einzige, was wir brauchen, ist zu verfolgen, wie viele Elemente wir im Baum speichern. Auf diese Weise hat der Index des nächsten Elements, das wir einfügen möchten, die Größe des Arrays.
Mit dieser Indizierung können wir den Index der übergeordneten und untergeordneten Knoten berechnen:
-
Elternteil: (Index - 1)/2
-
linkes Kind: 2 ** Index 1
-
rechtes Kind: 2 ** Index 2
Da wir uns nicht mit der Array-Neuzuordnung befassen wollen, vereinfachen wir die Implementierung noch mehr und verwenden eine ArrayList .
Eine grundlegende Implementierung von Binary Tree sieht folgendermaßen aus:
class BinaryTree<E> {
List<E> elements = new ArrayList<>();
void add(E e) {
elements.add(e);
}
boolean isEmpty() {
return elements.isEmpty();
}
E elementAt(int index) {
return elements.get(index);
}
int parentIndex(int index) {
return (index - 1)/2;
}
int leftChildIndex(int index) {
return 2 ** index + 1;
}
int rightChildIndex(int index) {
return 2 ** index + 2;
}
}
Der Code oben fügt das neue Element nur am Ende der Baumstruktur hinzu.
Daher müssen wir das neue Element ggf. nach oben verschieben. Wir können es mit dem folgenden Code machen:
class Heap<E extends Comparable<E>> {
//...
void add(E e) {
elements.add(e);
int elementIndex = elements.size() - 1;
while (!isRoot(elementIndex) && !isCorrectChild(elementIndex)) {
int parentIndex = parentIndex(elementIndex);
swap(elementIndex, parentIndex);
elementIndex = parentIndex;
}
}
boolean isRoot(int index) {
return index == 0;
}
boolean isCorrectChild(int index) {
return isCorrect(parentIndex(index), index);
}
boolean isCorrect(int parentIndex, int childIndex) {
if (!isValidIndex(parentIndex) || !isValidIndex(childIndex)) {
return true;
}
return elementAt(parentIndex).compareTo(elementAt(childIndex)) < 0;
}
boolean isValidIndex(int index) {
return index < elements.size();
}
void swap(int index1, int index2) {
E element1 = elementAt(index1);
E element2 = elementAt(index2);
elements.set(index1, element2);
elements.set(index2, element1);
}
//...
}
Beachten Sie, dass die Elemente java.util.Comparable implementieren müssen, da wir die Elemente vergleichen müssen.
4. Heap Sort
Da die Wurzel des Heap immer das kleinste Element enthält, ist die Idee hinter Heap Sort ziemlich einfach: Entfernen Sie den Stammknoten, bis der Heap leer wird.
Das einzige, was wir brauchen, ist eine Remove-Operation, die den Heap in einem konsistenten Zustand hält. Wir müssen sicherstellen, dass wir nicht gegen die Struktur des Binary Tree oder der Heap-Eigenschaft verstoßen.
-
Um die Struktur beizubehalten, können wir kein Element löschen, außer dem rechten Blatt. ** Die Idee besteht also darin, das Element aus dem Wurzelknoten zu entfernen und das rechte Blatt im Wurzelknoten zu speichern.
Diese Operation wird jedoch höchstwahrscheinlich die Heap-Eigenschaft verletzen. Wenn also der neue Stamm größer als einer seiner untergeordneten Knoten ist, tauschen wir ihn mit seinem untersten untergeordneten Knoten aus. Da der unterste Knoten weniger als alle anderen untergeordneten Knoten ist, verletzt er nicht die Heap-Eigenschaft.
Wir tauschen so lange aus, bis das Element zu einem Blatt wird oder weniger als alle untergeordneten Elemente.
Löschen wir die Wurzel aus diesem Baum:
1
/\
/ \
3 2
/\ /\
5 7 6 4
Zuerst platzieren wir das letzte Blatt in der Wurzel:
4
/\
/ \
3 2
/\ / 5 7 6
Dann, da es mehr als seine beiden Kinder ist, tauschen wir es gegen das kleinste Kind ein, das 2 ist:
2
/\
/ \
3 4
/\ / 5 7 6
4 ist weniger als 6, also hören wir auf.
5. Implementierung der Heap-Sortierung in Java
Bei allem, was wir haben, sieht das Entfernen der Wurzel (Popping) folgendermaßen aus:
class Heap<E extends Comparable<E>> {
//...
E pop() {
if (isEmpty()) {
throw new IllegalStateException("You cannot pop from an empty heap");
}
E result = elementAt(0);
int lasElementIndex = elements.size() - 1;
swap(0, lasElementIndex);
elements.remove(lasElementIndex);
int elementIndex = 0;
while (!isLeaf(elementIndex) && !isCorrectParent(elementIndex)) {
int smallerChildIndex = smallerChildIndex(elementIndex);
swap(elementIndex, smallerChildIndex);
elementIndex = smallerChildIndex;
}
return result;
}
boolean isLeaf(int index) {
return !isValidIndex(leftChildIndex(index));
}
boolean isCorrectParent(int index) {
return isCorrect(index, leftChildIndex(index)) && isCorrect(index, rightChildIndex(index));
}
int smallerChildIndex(int index) {
int leftChildIndex = leftChildIndex(index);
int rightChildIndex = rightChildIndex(index);
if (!isValidIndex(rightChildIndex)) {
return leftChildIndex;
}
if (elementAt(leftChildIndex).compareTo(elementAt(rightChildIndex)) < 0) {
return leftChildIndex;
}
return rightChildIndex;
}
//...
}
Wie bereits erwähnt, wird beim Sortieren nur ein Heap erstellt und der Stamm wiederholt entfernt:
class Heap<E extends Comparable<E>> {
//...
static <E extends Comparable<E>> List<E> sort(Iterable<E> elements) {
Heap<E> heap = of(elements);
List<E> result = new ArrayList<>();
while (!heap.isEmpty()) {
result.add(heap.pop());
}
return result;
}
static <E extends Comparable<E>> Heap<E> of(Iterable<E> elements) {
Heap<E> result = new Heap<>();
for (E element : elements) {
result.add(element);
}
return result;
}
//...
}
Wir können überprüfen, dass es mit dem folgenden Test funktioniert:
@Test
void givenNotEmptyIterable__whenSortCalled__thenItShouldReturnElementsInSortedList() {
//given
List<Integer> elements = Arrays.asList(3, 5, 1, 4, 2);
//when
List<Integer> sortedElements = Heap.sort(elements);
//then
assertThat(sortedElements).isEqualTo(Arrays.asList(1, 2, 3, 4, 5));
}
Beachten Sie, dass wir eine Implementierung bereitstellen könnten, die vor Ort sortiert ist, was bedeutet, dass wir das Ergebnis in demselben Array angeben, in dem wir die Elemente erhalten haben.
Außerdem benötigen wir auf diese Weise keine Zwischenspeicherzuordnung.
Diese Implementierung wäre jedoch etwas schwieriger zu verstehen.
6. Zeitkomplexität
Die Heap-Sortierung besteht aus zwei Schlüsselschritten , Einfügen eines Elements und Entfernen des Wurzelknotens. Beide Schritte haben die Komplexität O (log n) .
Da wir beide Schritte n-mal wiederholen, ist die Gesamtsortierkomplexität O (n log n) . **
Beachten Sie, dass wir die Kosten für die Neuzuordnung von Arrays nicht erwähnt haben. Da dies jedoch O (n) ist, hat dies keinen Einfluss auf die Gesamtkomplexität. Wie bereits erwähnt, ist es auch möglich, eine Direktsortierung zu implementieren, sodass keine Neuzuordnung von Arrays erforderlich ist.
Erwähnenswert ist auch, dass 50% der Elemente Blätter sind und 75% der Elemente auf den untersten Ebenen liegen. Daher erfordern die meisten Einfügevorgänge nicht mehr als zwei Schritte.
-
Beachten Sie, dass Quicksort bei realen Daten normalerweise leistungsfähiger ist als Heap-Sort. Das Silberne ist, dass Heap Sort immer eine O (n log n) Zeitkomplexität im schlechtesten Fall hat. **
7. Schlussfolgerung
In diesem Tutorial haben wir eine Implementierung von Binary Heap und Heap Sort gesehen.
-
Obwohl die zeitliche Komplexität O (n log n) ist, ist dies in den meisten Fällen nicht der beste Algorithmus für Daten aus der realen Welt.
Die Beispiele sind wie üblich verfügbar: https://github.com/eugenp/tutorials/tree/master/algorithms-sorting [over on GitHub