Myślę, że w tym temacie jest kilka pytań:
- Jak wdrożyć,
buildHeap
aby działało w czasie O (n) ?
- Jak pokazać, że
buildHeap
działa w czasie O (n), gdy jest poprawnie zaimplementowany?
- Dlaczego ta sama logika nie działa, aby sortowanie sterty działało w czasie O (n) zamiast O (n log n) ?
Jak wdrożyć, buildHeap
aby działało w czasie O (n) ?
Często odpowiedzi na te pytania koncentrują się na różnicy między siftUp
i siftDown
. Właściwy wybór między siftUp
i siftDown
jest krytyczny dla uzyskania wydajności O (n)buildHeap
, ale nie robi nic, aby pomóc zrozumieć różnicę pomiędzy buildHeap
i heapSort
ogólnie. Rzeczywiście, odpowiednie implementacje obu buildHeap
i heapSort
będą korzystać tylkosiftDown
. Ta siftUp
operacja jest potrzebna tylko do wstawiania do istniejącej sterty, więc może być wykorzystana do zaimplementowania kolejki priorytetowej przy użyciu na przykład sterty binarnej.
Napisałem to, aby opisać, jak działa maksymalna sterty. Jest to typ sterty zwykle używany do sortowania sterty lub do kolejki priorytetowej, w której wyższe wartości wskazują wyższy priorytet. Przydatna jest również sterta min; na przykład podczas wyszukiwania elementów z kluczami całkowitymi w porządku rosnącym lub ciągami znaków w kolejności alfabetycznej. Zasady są dokładnie takie same; wystarczy zmienić kolejność sortowania.
Właściwość sterta określa, że każdy węzeł w stercie binarnym musi być co najmniej tak duży, jak oba jego elementy potomne. W szczególności oznacza to, że największy element na stosie znajduje się w katalogu głównym. Przesiewanie w dół i przesiewanie w górę to w zasadzie ta sama operacja w przeciwnych kierunkach: przesuń naruszający węzeł, aż spełni właściwości sterty:
siftDown
zamienia zbyt mały węzeł z jego największym dzieckiem (przesuwając go w dół), aż będzie co najmniej tak duży, jak oba węzły pod nim.
siftUp
zamienia węzeł, który jest zbyt duży z rodzicem (tym samym przesuwając go w górę), aż nie będzie większy niż węzeł nad nim.
Liczba operacji wymaganych siftDown
i siftUp
jest proporcjonalna do odległości, jaką może pokonać węzeł. Ponieważ siftDown
jest to odległość do dolnej części drzewa, więc siftDown
jest kosztowna dla węzłów na szczycie drzewa. Dzięki siftUp
, praca jest proporcjonalna do odległości do szczytu drzewa, więc siftUp
jest kosztowna dla węzłów na dole drzewa. Chociaż obie operacje są w najgorszym przypadku O (log n) , w stercie tylko jeden węzeł znajduje się na górze, podczas gdy połowa węzłów leży na dolnej warstwie. Nie powinno więc dziwić, że gdybyśmy musieli zastosować operację do każdego węzła, wolelibyśmy siftDown
więcej siftUp
.
buildHeap
Funkcja przyjmuje tablicę nieposortowane przedmiotów i przenosi je do momentu spełnienia wszystkich właściwość stos, tworząc w ten sposób poprawny sterty. Istnieją dwa podejścia można by przyjąć za buildHeap
wykorzystaniem siftUp
i siftDown
operacje Opisaliśmy.
Rozpocznij na górze stosu (na początku tablicy) i wywołaj siftUp
każdy element. Na każdym etapie poprzednio przesiane elementy (elementy przed bieżącym elementem w tablicy) tworzą prawidłową stertę, a przesiewanie następnego elementu w górę umieszcza go w prawidłowej pozycji na stercie. Po przesianiu każdego węzła wszystkie elementy spełniają właściwość sterty.
Lub idź w przeciwnym kierunku: zacznij od końca tablicy i cofnij się do przodu. Przy każdej iteracji przesiewasz przedmiot w dół, aż znajdzie się we właściwej lokalizacji.
Które wdrożenie buildHeap
jest bardziej wydajne?
Oba te rozwiązania wygenerują prawidłową stertę. Nic dziwnego, że bardziej wydajna jest druga operacja siftDown
.
Niech h = log n reprezentuje wysokość stosu. Praca wymagana do tego siftDown
podejścia jest sumą
(0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (h * 1).
Każdy termin w sumie ma maksymalną odległość, którą musi przesunąć węzeł na danej wysokości (zero dla dolnej warstwy, h dla korzenia) pomnożony przez liczbę węzłów na tej wysokości. Natomiast suma za wywołanie siftUp
w każdym węźle wynosi
(h * n/2) + ((h-1) * n/4) + ((h-2)*n/8) + ... + (0 * 1).
Powinno być jasne, że druga suma jest większa. Sam pierwszy termin to hn / 2 = 1/2 n log n , więc to podejście ma w najlepszym razie złożoność O (n log n) .
Jak udowodnimy, że suma dla tego siftDown
podejścia jest rzeczywiście O (n) ?
Jedną z metod (istnieją inne analizy, które również działają) jest przekształcenie skończonej sumy w szereg nieskończony, a następnie użycie szeregu Taylora. Możemy zignorować pierwszy termin, który wynosi zero:
Jeśli nie masz pewności, dlaczego każdy z tych kroków działa, oto uzasadnienie tego procesu w słowach:
- Wszystkie warunki są dodatnie, więc suma skończona musi być mniejsza niż suma nieskończona.
- Szereg jest równy szeregowi mocy oszacowanemu przy x = 1/2 .
- Ta seria mocy jest równa (stałemu czasowi) pochodnej szeregu Taylora dla f (x) = 1 / (1-x) .
- x = 1/2 mieści się w przedziale zbieżności tej serii Taylora.
- Dlatego możemy zamienić szereg Taylora na 1 / (1-x) , różnicować i oceniać, aby znaleźć wartość szeregu nieskończonego.
Ponieważ suma nieskończona wynosi dokładnie n , dochodzimy do wniosku, że suma skończona nie jest większa, a zatem wynosi O (n) .
Dlaczego sortowanie sterty wymaga czasu O (n log n) ?
Jeśli możliwe jest uruchomienie buildHeap
w czasie liniowym, dlaczego sortowanie sterty wymaga czasu O (n log n) ? Sortowanie sterty składa się z dwóch etapów. Najpierw wywołujemy buildHeap
tablicę, która wymaga O (n) czasu, jeśli jest zaimplementowana optymalnie. Następnym etapem jest wielokrotne usuwanie największego elementu ze sterty i umieszczanie go na końcu tablicy. Ponieważ usuwamy element ze sterty, tuż za końcem sterty zawsze jest otwarte miejsce, w którym możemy przechowywać przedmiot. Tak więc sortowanie sterty osiąga uporządkowany porządek poprzez sukcesywne usuwanie następnego największego przedmiotu i umieszczanie go w tablicy, zaczynając od ostatniej pozycji i przesuwając się do przodu. To złożoność tej ostatniej części dominuje w rodzaju sterty. Pętla wygląda następująco:
for (i = n - 1; i > 0; i--) {
arr[i] = deleteMax();
}
Oczywiście, pętla działa O (n) razy ( n - 1, by być dokładnym, ostatni element jest już na miejscu). Złożoność deleteMax
stosu wynosi O (log n) . Zwykle jest to realizowane przez usunięcie korzenia (największego elementu pozostawionego na stercie) i zastąpienie go ostatnim elementem na stercie, który jest liściem, a zatem jednym z najmniejszych elementów. Ten nowy root prawie na pewno naruszy właściwość sterty, więc musisz zadzwonić, siftDown
dopóki nie przeniesiesz go z powrotem do akceptowalnej pozycji. To również powoduje przeniesienie następnego największego przedmiotu do korzenia. Zauważ, że w przeciwieństwie do tego, buildHeap
gdzie w przypadku większości węzłów wzywamy siftDown
od dołu drzewa, teraz wzywamy siftDown
od góry drzewa podczas każdej iteracji!Chociaż drzewo kurczy się, nie kurczy się wystarczająco szybko : wysokość drzewa pozostaje stała, dopóki nie usuniesz pierwszej połowy węzłów (po całkowitym usunięciu dolnej warstwy). Następnie przez następny kwartał wysokość wynosi h - 1 . Tak więc całkowita praca dla tego drugiego etapu wynosi
h*n/2 + (h-1)*n/4 + ... + 0 * 1.
Zwróć uwagę na przełącznik: teraz zerowy przypadek roboczy odpowiada jednemu węzłowi, a przypadek roboczy h odpowiada połowie węzłów. Ta suma wynosi O (n log n), podobnie jak nieefektywna wersja buildHeap
tego jest implementowana za pomocą siftUp. Ale w tym przypadku nie mamy wyboru, ponieważ próbujemy sortować i wymagamy następnego następnego największego elementu do usunięcia.
Podsumowując, praca dla sortowania sterty jest sumą dwóch etapów: O (n) czas na buildHeap i O (n log n) do usunięcia każdego węzła w kolejności , więc złożoność wynosi O (n log n) . Możesz udowodnić (korzystając z niektórych pomysłów z teorii informacji), że dla sortowania opartego na porównaniu O (n log n) jest najlepszym, na co możesz liczyć, więc nie ma powodu, aby być rozczarowanym tym lub oczekiwać, że sortowanie sterty pozwoli osiągnąć O (n) czas, który buildHeap
spełnia.