Algorytmy i struktury danych/Kolejki priorytetowe

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania

Kolejki priorytetowe

Złączalna kolejka priorytetowa

Kolejka priorytetowa to jedna z podstawowych abstrakcyjnych struktur danych, wykorzystywana między innymi w takich zastosowaniach jak:

  • algorytm Dijkstry wyznaczania najkrótszych ścieżek w grafach;
  • algorytm Prima znajdowania minimalnego drzewa rozpinającego;
  • symulacja sterowana zdarzeniami;
  • metoda zamiatania w geometrii obliczeniowej;
  • kodowanie Huffmana;
  • sortowanie (algorytm Heapsort).

Oferuje ona następujące operacje:

  • MakePQ(): tworzy nową, pustą kolejkę;
  • Insert(H,x): wstawia element x (o kluczu z pewnego liniowo uporządkowanego uniwersum) do kolejki H;
  • FindMin(H): zwraca element o najmniejszym kluczu w kolejce H;
  • DelMin(H): zwraca element o najmniejszym kluczu w kolejce H, usuwając go przy tym z H.

Ten zestaw operacji często rozszerza się o:

  • DecreaseKey(H,x,y): nadaje kluczowi elementu x w kolejce H nową, mniejszą wartość y;
  • Delete(H,x): usuwa element x z kolejki H.

Jeśli struktura danych udostępnia dodatkowo operację łączenia kolejek

  • Meld(H1,H2): zwraca nową kolejkę, zawierającą wszystkie elementy z kolejek H1 i H2, niszcząc je przy tym;

to nazywamy ją złączalną kolejką priorytetową.


Najprostszą, choć niezbyt efektywną implementację kolejki priorytetowej stanowi zwykła lista. Znacznie efektywniejszy jest kopiec binarny (wykorzystywany w algorytmie HeapSort), nie pozwala on jednak na szybkie łączenie kolejek. Przedstawione na tym wykładzie struktury danych: kopiec dwumianowy i kopiec Fibonacciego, umożliwiają efektywne wykonywanie wszystkich operacji złączalnej kolejki priorytetowej.

Oto tabela kosztów poszczególnych operacji w wymienionych implementacjach, przy założeniu, że w kolejce(ach) jest aktualnie elementów:

Tabela kosztów poszczególnych operacji
Operacja Lista Kopiec Binarny Kolejka Dwumianowa Fibonacciego(*)
MakePQ 1 1 1 1
Insert 1 1
FindMin 1 1
DelMin
DecreaseKey 1 1
Delete 1
Meld 1 1

(*) - koszt zamortyzowany

Uwaga: wyszukiwanie elementu nie należy do zestawu operacji (złączalnej) kolejki priorytetowej, dlatego w przypadku operacji DecreaseKey i Delete zakładamy, że jako parametr przekazywane jest dowiązanie do elementu, którego ma dotyczyć operacja.

Drzewa i kolejki dwumianowe

Wynaleziona w roku 1978 przez J. Vuillemina [V] kolejka dwumianowa to kolekcja drzew dwumianowych o następującej rekurencyjnej definicji: jest drzewem jednowęzłowym, a korzeń drzewa (drzewa dwumianowego stopnia ) ma synów stanowiących korzenie drzew ,..., (w tej właśnie kolejności, idąc od prawej do lewej).

Drzewa dwumianowe.png

Poniższy lemat dotyczy podstawowych własności drzew dwumianowych:


Lemat [Lemat 1]

Drzewo dwumianowe

(a) ma węzłów;

(b) ma wysokość ;

(c) ma dokładnie węzłów na poziomie , dla (stąd nazwa 'drzewo dwumianowe');

(d) można otrzymać dołączając do drzewa drugie drzewo jako skrajnie lewego syna korzenia.

Dowód lematu pozostawiamy jako ćwiczenie.

Kolejka dwumianowa to lista drzew dwumianowych uporządkowana ściśle rosnąco względem stopni (idąc od prawej do lewej), przy czym każde drzewo spełnia warunek kopca: klucz w ojcu jest niewiększy niż klucze w synach. Z lematu 1(a) wynika, że w skład kolejki dwumianowej zawierającej kluczy drzewo wchodzi wtedy i tylko wtedy, gdy -tym bitem rozwinięcia binarnego liczby jest 1; w szczególności łączna liczba drzew to .

Operacje na kolejce dwumianowej

Ponieważ z warunku kopca wynika, że w każdym drzewie wchodzącym w skład kolejki element najmniejszy znajduje się w korzeniu, operacja FindMin wymaga jednokrotnego przejścia listy drzew. Jej koszt to dla -elementowej kolejki.

Najważniejszą operacją na kolejce dwumianowej, za pomocą której definiuje się większość pozostałych, jest Meld (łączenie kolejek).Jej działanie przypomina mechanizm dodawania liczb binarnych, przy czym sumowaniu jedynek na pozycji w dodawanych liczbach odpowiada łączenie dwóch drzew dwumianowych stopnia (zobacz lemat 1(d)): korzeń z mniejszym kluczem (w celu zachowania warunku kopca) zostaje korzeniem wynikowego drzewa ('przeniesienia'), a drugi korzeń zostaje jego skrajnie prawym synem. Łączenie dwóch kolejek polega na przejściu obydwu list drzew i złączeniu drzew jednakowych stopni. Jego koszt jest proporcjonalny do sumy długości list.

<flash>file=Dwumianowe.swf|width=600|height=446</flash>


Operacja Insert (wstawienie węzła) stanowi w zasadzie szczególny przypadek Meld (łączenie z kolejką jednoelementową).Operacja DelMin(H) polega na znalezieniu i usunięciu z listy H drzewa z najmniejszym kluczem, odcięciu jego korzenia (ten klucz jest wynikiem całej operacji) i połączeniu listy jego synów (stanowiącej poprawną kolejkę dwumianową) z H. Łączny koszt to dla -elementowej kolejki.Operację DecreaseKey wykonuje się podobnie jak poprawianie kopca binarnego przy wstawianiu elementu: zmniejszony klucz wędruje w górę swojego drzewa dwumianowego, zamieniając się miejscami z ojcem dopóty, dopóki nie zostanie odtworzony warunek kopca. W myśl lematu 1(b) maksymalna wysokość drzewa w -elementowej kolejce to , więc koszt takiej operacji jest logarytmiczny.Operacja Delete sprowadza się do przesunięcia klucza, który mamy usunąć, do korzenia (jak w DecreaseKey), a potem usunięcia tego korzenia (jak w DelMin).

Kopce Fibonacciego

Ta struktura danych, wynaleziona przez Fredmana i Tarjana w roku 1984 [FT], stanowi ulepszenie kolejki dwumianowej, które pozwala uzyskać stały (w sensie zamortyzowanym) koszt operacji DecreaseKey, dominującej w algorytmie Dijkstry i jemu pokrewnych. Podstawowy pomysł polega tu na leniwym wykonywaniu operacji, tzn. odkładaniu pracy związanej z zarządzaniem strukturą danych do momentu, kiedy jest to naprawdę niezbędne. Podobnie jak kolejka dwumianowa, kopiec Fibonacciego to lista drzew, z których każde spełnia warunek kopca. Drzewa te nie są już jednak drzewami dwumianowymi (chociaż są im na tyle bliskie, że mają zbliżone własności) i nie są uporządkowane względem stopni korzeni.

Operacja Meld (łączenie kolejek) to po prostu sklejenie dwóch list drzew, bez prób porządkowania względem stopni korzeni czy eliminacji powtórzeń. Jej koszt to oczywiście O(1). Tak jak poprzednio, Insert stanowi szczególny przypadek Meld (łączenie z kolejką jednoelementową). Podczas operacji DelMin przychodzi czas na wykonanie odkładanej wcześniej pracy. Najpierw usuwany jest korzeń zawierający najmniejszy klucz, a jego synowie są dołączani do listy korzeni. Następnie odbywa się konsolidacja listy drzew, mająca na celu doprowadzenie do sytuacji, w której wszystkie korzenie na liście będą miały różne stopnie. Polega ona na przejściu przez listę korzeni i łączeniu drzew jednakowego stopnia - tak samo, jak łączyło się drzewa dwumianowe - a przy okazji uaktualnieniu wskaźnika do korzenia zawierającego najmniejszy klucz. Można ją zrealizować w czasie proporcjonalnym do liczby konsolidowanych drzew, jeśli skorzystamy z pomocniczej tablicy indeksowanej stopniami korzeni: pod indeksem przechowujemy w niej wskaźnik do (jedynego) korzenia stopnia w przetworzonej części listy, albo NULL, jeśli w tym fragmencie listy korzenia o stopniu nie ma.


<flash>file=Fibo_delmin.swf|width=600|height=446</flash>


Oto oszacowanie kosztu zamortyzowanego operacji DelMin: Każdemu drzewu w kopcu Fibonacciego w chwili jego pojawienia się na liście przypisujemy jednostkę kredytu. Niech oznacza liczbę drzew w kopcu przed wykonaniem operacji, - stopień korzenia zawierającego najmniejszy klucz, zaś - liczbę wykonań operacji łączenia drzew. Żeby wykonać Delmin, musimy wykonać pracę proporcjonalną do . Ponieważ przy każdym połączeniu drzew uwalniana jest jednostka kredytu, uzyskujemy w ten sposób jednostek. Jak pokażemy później (Tw. 2), rozmiar drzewa w kopcu Fibonacciego jest wykładniczy względem stopnia korzenia. Wynika z tego, że po konsolidacji, kiedy wszystkie drzewa w kopcu będą miały różne stopnie, ich liczba będzie , gdzie to rozmiar kopca po operacji. Tak więc do wykonania koniecznej pracy i utrzymania niezmiennika "jedna jednostka kredytu związana z każdym drzewem w kopcu" wystarczy dodatkowych jednostek kredytu - i taki właśnie jest koszt zamortyzowany operacji DelMin.

Operacja DecreaseKey mogłaby polegać na zmniejszeniu wartości klucza oraz - o ile został naruszony warunek kopca - odcięciu poddrzewa o korzeniu i dołączeniu go do listy korzeni. To jednak, zbyt gwałtownie zmniejszając rozmiary poddrzew, powodowałoby, że twierdzenie 2 nie byłoby prawdziwe, a nasza analiza operacji DelMin przestałaby działać. Bedziemy zatem odcinać poddrzewa, ale w sposób kontrolowany: każdy nie będący korzeniem węzeł może stracić co najwyżej jednego syna. Jeśli sytuacja wymaga odcięcia drugiego syna, to taki węzeł sam zostaje odcięty od swojego ojca (co może oczywiście spowodować dalsze rekurencyjne odcięcia). Do utrzymywania informacji o stanie węzła wystarcza znacznik logiczny, który ma wartość TRUE wtedy i tylko wtedy, gdy dany węzeł stracił dokładnie jednego syna od czasu, kiedy sam stał się synem innego węzła (w wyniku łączenia drzew).

<flash>file=Deckey.swf|width=600|height=400</flash>

Argumentacja, że koszt zamortyzowany operacji DecreaseKey to , jest następująca: Oprócz kredytu przypisywanego do drzew będziemy dodatkowo przypisywać dwie jednostki kredytu każdemu węzłowi , który traci jednego syna, kosztem kredytu obciążając operację, która spowodowała odcięcie (dla każdej operacji jest co najwyżej jeden taki węzeł). Kiedy następuje odcięcie drugiego syna (a więc także i odcięcie samego węzła od ojca), koszt tego zabiegu pokrywa jedna jednostka kredytu, a druga pozostaje przypisana do nowo wstawionego na listę drzewa o korzeniu (w celu utrzymania niezmiennika "jedna jednostka kredytu związana z każdym drzewem w kopcu"). Podczas jednej operacji DecreaseKey może nastąpić cała seria odcięć, ale wszystkie (oprócz być może ostatniego) są już wcześniej z góry opłacone. Operacja Delete polega na odcięciu węzła od ojca (o ile nie jest korzeniem) - jak w DecreaseKey - a następnie usunięciu , bedącego teraz korzeniem - jak w Delmin. Koszt zamortyzowany tej operacji to dla -elementowego kopca.

Aby nasza analiza operacji na kopcach Fibonacciego była kompletna, pozostaje jeszcze udowodnić:

Twierdzenie [Twierdzenie 2]

Rozmiar drzewa o korzeniu w kopcu Fibonacciego jest wykładniczy względem stopnia .


Dowód: Niech będą aktualnymi synami , w kolejności ich przyłączania do . Pokażemy, że dla węzeł ma stopień co najmniej . W chwili przyłączania węzła węzeł miał co najmniej synów (węzły oraz być może jeszcze jakieś, które zostały później odcięte). Ponieważ przyłączane węzły mają zawsze taki sam stopień, miał wtedy stopień co najmniej , a potem mógł stracić co najwyżej jednego syna (bo inaczej sam zostałby odcięty od ). Oznaczmy przez najmniejszą możliwa liczbe węzłów w drzewie stopnia z kopca Fibonacciego. Z powyższych rozważań wynikają zależności:, (liczba 2 po prawej stronie nierówności bierze sie z uwzględnienia węzłów oraz ). Z łatwej do udowodnienia przez indukcję tożsamości , gdzie to -ta liczba Fibonacciego (stąd nazwa tej struktury danych!), zdefiniowana rekurencyjnie: , wynika, że. Ogólnie znany (również łatwy do udowodnienia przez indukcję) fakt, że , gdzie , kończy dowód twierdzenia.

Literatura

[CLRS] Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein, 'Wprowadzenie do algorytmów', WNT, Warszawa 2004.

[FT] Michael L. Fredman, Robert E. Tarjan, 'Fibonacci heaps and their uses in improved network optimization algorithms', Journal of the ACM 34(3), 1987, 596-615.

[V] Jean Vuillemin, 'A data structure for manipulating priority queues', Communications of the ACM 21(4), 1978, 309-315.