Algorytmy i struktury danych/Algorytmy grafowe - najlżejsze ścieżki

From Studia Informatyczne

Ten wykład poświęcimy wprowadzeniu do algorytmów grafowych. W tym celu rozwiążemy następujący problem ścieżkowy.

Spis treści

Najlżejsze ścieżki z jednym źródłem

W tym wykładzie pisząc graf będziemy mieli zawsze na uwadze graf spójny i nieskierowany. Tradycyjnie przez n będziemy oznaczali liczbę wierzchołków w grafie, a przez m liczbę jego krawędzi. Dla każdego grafu mamy zawsze n-1 \le m \le n(n-1)/2.

Niech G=(V,E) będzie grafem, a w: E \rightarrow \{1,2,...\} funkcją przypisująca krawędziom dodatnie liczby całkowite zwane wagami krawędzi. Krawędzie (u,w)\in E będziemy oznaczali przez u-w.

Ciąg wierzchołków p=< v_0, v_1,\ldots , v_k > nazywamy ścieżką w G wtedy i tylko wtedy, gdy v_i - v_{i+1} jest krawędzią w E, dla każdego i = 0, 1,\ldots,k-1. Liczbę k nazywamy długością ścieżki p.

Wagą ścieżki p nazywamy sumę wag jej krawędzi i oznaczamy także przez w(p). Przyjmujemy, że waga ścieżki o długości 0 wynosi 0.

Przez N(v) będziemy oznaczali zbiór sąsiadów wierzchołka v w grafie G.

Problem (wag) najlżejszych ścieżek z jednym źródłem definiuje się następująco (uwaga: w literaturze przyjęło się też wagę ścieżki nazywać długością i dlatego często mówi się o problemie najkrótszych ścieżek).


Dane:

G=(V,E) – spójny, nieskierowany graf

w: E\rightarrow \{1,2,\ldots\} – funkcja przypisująca krawędziom dodatnie liczby całkowite

s – wyróżniony wierzchołek w G, zwany źródłem

Wynik:

dla każdego wierzchołka v, waga w^*(v) najlżejszej ścieżki łączącej v z s.


Na tym wykładzie zajmiemy się tylko wyznaczaniem wag najlżejszych ścieżek. Znalezienie sposobu wyznaczania takich ścieżek pozostawiamy jako ćwiczenie dla czytelnika. Problem najlżejszych scieżek jest problemem bardzo naturalnym. Jeśli mapę drogową potraktować jako graf, w którym wagi to długości odcinków drogowych na mapie, a źródło jest wyróżnionym miastem na mapie (na przykład stolicą państwa), to rozwiązanie problemu najlżejszych ścieżek da nam długości najkrótszych dróg od miasta-źródła do pozostałych miast na mapie.

Znaczenie problemu najlżejszych ścieżek jest dużo większe niż by to wynikało z jego zastosowań. Poszukując wydajnego algorytmu dla rozwiązania tego problemu, postaramy się przedstawić cały proces projektowania, a następnie implementowania algorytmu ze szczególnym uwzględnieniem doboru odpowiednich struktur danych w celu osiągnięcia lepszej wydajności implementacji.

Nasze rozważania rozpoczniemy od schematu algorytmu poszukiwania najlżejszych ścieżek, a następnie zajmiemy się implementacją poszczególnych elementów tego schematu. Rozwiązanie, które zaproponujemy pochodzi od wybitnego holenderskiego informatyka Edgara Dijkstry.

Zastanówmy się, w jaki sposób obliczać wagi najlżejszych ścieżek. Pomoże nam w tym następujące proste spostrzeżenie:


Niech <v=v_0,v_1,\ldots,v_k=s> będzie najlżejszą ścieżką z wierzchołka v do źródła s. Wówczas w^*(v) > w^*(v_1)>\ldots > w^*(v_k)=0.


Spostrzeżenie to mówi, że gdybyśmy obliczali wagi najlżejszych ścieżek od najlżejszej do najcięższej, to dla każdego wierzchołka v różnego od źródła, sąsiad v na najlżejszej ścieżce z v do s miałby swoją wagę już obliczoną i tym sąsiadem byłby wierzchołek (może być więcej niż jeden wybór), dla którego waga najlżejszej ścieżki do s, powiększona o wagę krawędzi łączącej go z v, jest najmniejsza.

Możemy teraz przystąpić do schematu algorytmu. Algorytm obliczania najlżejszych ścieżek będzie działał w n-1 fazach. W trakcie działania algorytmu L będzie zbiorem tych wierzchołków, dla których wagi najlżejszych scieżek prowadzące do s zostały już obliczone. Przez R będziemy oznaczali zbiór pozostałych wierzchołków. Podczas obliczeń z każdym wierzchołkiem v będziemy mieli związana nieujemną wagę w[v]. Dla każdego wierzchołka v \in L waga w[v] będzie wagą najlżejszej scieżki łączacej v z s, czyli w[v] = w^*(v). Dla każdego wierzchołka v z R, w[v] będzie równe wadze najlżejszej ścieżki z v do s, na której wszystkie wierzchołki poza v należą do L. Może się zdarzyć, że taka ścieżka nie istnieje. Wówczas przyjmujemy, że w[v] jest równe \infty - umownej wartości większej od wszystkich wag występujących w obliczeniach. Jedna faza algorytmu będzie polegała na przeniesieniu do L wierzchołka z R o najmniejszej wadze w, a następnie poprawieniu wag w dla wierzchołków, które pozostały w R. Zauważmy, że wagi w mogą się zmienić tylko sąsiadom wierzchołka przeniesionego do L, którzy pozostali w R. Oto schemat algorytmu obliczania najlżejszych ścieżek. Nazwiemy go metodą Dijkstry.

Algorytm Metoda Dijkstry


 1  //Inicjacja
 2  L := \{s\}; R := V-\{s\};
 3  w[s] := 0; 
 4  for each v \in R do
 5    if v-s \in E then
 6      w[v] := w(v-s)
 7    else
 8      w[v] := \infty;
 9  //właściwe obliczenia
 10 for i := 1 to n-1 do 
 11 begin
 12   u := wierzchołek z R o minimalnej wadze w[u];
 13   R := R - \{u\}; 
 14   L := L + \{u\};
 15   for each v \in N(u)\cap R do
 16     if w[u] + w(u-v) < w[v] then
 17       w[v] := w[u] + w(u-v);
 18 end;
 20 //dla każdego v \in V, w[v] = w^*(v)

Dlaczego powyższy opis obliczania wag najlżejszych ścieżek nazwaliśmy metodą Dijkstry, a nie algorytmem Dijkstry? Zauważmy, że żeby powyższą metodę móc zaimplementować na komputerze, a także dokonać analizy jej złożoności obliczeniowej, musimy doprecyzować wiele elementów schematu. Należą do nich

  • implementacja grafu;
  • implementacje zbiorów L i P oraz operacji na nich (inicjacja, usuwanie i dodawanie wierzchołka, znajdowanie wierzchołka o minimalnej wadze, zmiana wagi wierzchołka).

Dopiero ścisłe określenie tych elementów da nam pełną implementację metody, a tym samym algorytm, który będzie mógł być poddany analizie.

Zaczniemy od implementacji, w której wszystkie struktury danych są tablicami. Zanim to jednak zrobimy, zastanówmy się, w jaki sposób wygodnie zadać graf jako daną dla algorytmu. Zawyczaj przyjmuje się, że wierzchołki grafu utożsamiamy z liczbami naturalnymi 1,2,\ldots, n, natomiast krawędzie opisujemy jako pary liczb odpowiadające końcom krawędzi. Gdy dodatkowo, tak jak w problemie najlżejszych ścieżek, z każdą krawędzią związana jest waga, to z każdą parą liczb opisującą końce krawędzi podajemy trzecią liczbę - wagę tej krawędzi. Tak więc graf możemy zadać w następujący sposób:

  • wiersz pierwszy: para liczb n, m - odpowiednio liczba wierzchołków i krawędzi grafu
  • m wierszy, z których każdy zawiera parę (trójkę) liczb reprezentujących końce jednej krawędzi (i jej wagę).

Zauważmy, że jeśli graf jest spójny, jego rozmiar wynosi O(m), gdzie n-1 < m \le \frac{n(n-1)}{2}.

Taki sposób reprezentacji grafu nie jest wygodny w obliczeniach, ponieważ nie daje on prawie żadnej informacji o strukturze grafu. Dlatego w algorytmach grafowych przyjmuje się, że graf jest implementowany na jeden z dwóch sposobów.

Macierz sąsiedztwa

W implementacji tablicowej zakłada się, że graf jest reprezentowany przez tak zwaną macierz sąsiedztwa. W najczystszej postaci macierz sąsiedztwa jest macierzą kwadratową indeksowaną wierzchołkami grafu, której elementy przyjmują wartości 0 lub 1. Jedynka (1) na przecięciu i-tego wiersza i j-tej kolumny mówi, że w grafie jest krawędź o końcach w i oraz j, zaś zero (0) oznacza, że takiej krawędzi nie ma. W przypadku, gdy krawędzie mają wagi, macierz sąsiedztwa można zmodyfikować w następujący sposób. Jeśli i-j jest krawędzią w grafie, to na przecięciu tej i-tego wiersza i j-tej kolumny wstawiamy wagę w(i-j). Gdy takiej krawędzi nie ma w grafie, to w to miejsce wstawiamy \infty. W takim przypadku elementy macierzy traktujemy jako wagi bezpośrednich połączeń pomiędzy wierzchołkami w grafie. Żeby być w zgodzie z tą konwencją przyjmujemy, że na przekątnej macierzy sąsiedztwa zawsze stawiamy zera. Podsumowując, w implementacji tablicowej przyjmujemy, że graf jest reprezentowany przez macierz kwadratową A[1..n,1..n] taką,że

A[i,j] = \left\{\begin{array}{ll}   w(i-j) & i-j \in E\\ \infty & i-j \not \in E \mbox{ oraz } i \ne j\\ 0 & i = j \end{array} \right.

Głównymi zaletami tej reprezentacji są jej prostota i to, że łatwo sprawdzać w czasie stałym, czy dwa wierzchołki są połączone krawędzią w grafie. Wadą jest to, że niezależnie od rozmiaru grafu, rozmiar tej reprezentacji jest zawsze kwadratowy. Tyle też czasu zajmuje jej inicjacja. Nawet jeśli byśmy przyjęli, że taka reprezentacja jest z góry zadana, to okazuje się, że przy tej reprezentacji grafu algorytmy dla znakomitej większości naturalnych problemów grafowych działają w czasie \Omega(n^2). Nie jest to źle dla grafów gęstych, w których m=\Theta(n^2), ale gdy chcemy uzyskiwać szybsze algorytmy dla grafów rzadszych, w szczególności liniowe ze względu na rozmiar grafu, to musimy myśleć o innej ich reprezentacji. Taką reprezentacją jest reprezentacja listowa.

Listy sąsiedztw

W tej implementacji dla każdego wierzchołka w grafie pamiętamy listę jego sąsiadów zwaną listą sąsiedztwa. Innymi słowy, graf jest pamiętany w tablicy list L[1..n], gdzie L[i] jest listą sasiedztwa wierzchołka i. W przypadku grafu z wagami, jeśli wierzchołek j występuje na liście wierzchołka i, to z wystąpieniem wierzchołka j związana jest waga krawędzi i-j. Kolejność wierzchołków na liście może być dowolna. Zauważmy, że suma długości list sąsiedztw wynosi 2m, a zatem rozmiar struktury danych reprezentującej graf wynosi O(n+m) i jest on liniowy ze względu na rozmiar danych. Listy L można łatwo zbudować w czasie liniowym. Jeśli więc chcemy uzyskiwać algorytmy, których złożoności jak najlepiej zależą od rozmiaru grafu, a w szczególności są liniowe ze względu na ten rozmiar, należy reprezentować graf przez listy sąsiedztw.

Możemy teraz powrócić do problemu najlżejszych ścieżek i zająć się implementacją schematu Dijkstry.

Implementacja tablicowa

W tej implementacji wszystkie struktury danych są tablicami. Przyjmujemy, że graf jest zadany przez macierz sąsiedztwa A[1..n,1..n]. Zbiór R jest rerezentowany za pomocą tablicy (wektora charakterystycznego) r[1,\ldots,n] o wartościach 0-1:

r[i] = \left\{ \begin{array}{ll}   1 & i \in R\\   0 & i \in L = V-R \end{array} \right.
Tablica w[1..n] służy do obliczania wag najlżejszych ścieżek. Oznaczmy przez MinR funkcję, której wartością jest wierzchołek w R o najmniejszej wadze w. Jeśli jest więcej niż jeden taki wierzchołek, to wynikiem funkcji może być dowolny z nich. Oto jedna z możliwych implementacji tej funkcji.

1  function MinR;
2  begin
3    min := 0;
4    for i := 1 to n do  
5      if w[i] < min then min\_v := i; 
6    return min\_v
7  end

Czas działania tej funkcji wynosi O(n).

Możemy teraz przedstawić pierwszą implementację metody Dijkstry.

Algorytm Metoda Dijkstry - implementacja tablicowa


 1  //Inicjacja
 2  for v := 1 to n do  
 3  begin 
 4    r[v] := 1;
 5    w[v] := A[v,s]
 6  end;
 7  r[s] := 0;
 8  
 9  //właściwe obliczenia
 10 for i := 1 to n-1 do 
 11 begin
 12   u := MinR;
 13   r[u] := 0; 
 14   
 15   for v := 1 to n do if r[v] = 1 then
 16     if w[u] + A[u,v] < w[v] then
 17       w[v] := w[u] + A[u,v]
 18 end;
 20 //dla każdego v \in V, w[v] = w^*(v)

W powyższej implementacji grupy wierszy 2-8, 12-14 oraz 15-18 odpowiadają dokładnie tym samym grupom wierszy w opisie metody Dijkstry. Analiza złożoności czasowej powyższego algorytmu nie nastręcza żadnych trudności. Inicjacja zajmuje czas O(n), każdy obrót pętli for z wiersza 10 zajmuje także czas O(n), a zatem cały algorytm działa w czasie \Theta(n^2). Dla grafów gęstych, czyli zawierających rzędu n^2 wierzchołków, jest to algorytm optymalny. Zwróćmy także uwagę na jego wyjątkowo prosty i elegancki zapis. Co jednak, gdy zadany graf nie jest grafem gęstym? Czy wówczas możemy obliczać wagi najlżejszych ścieżek szybciej?

Implementacja listowa

Pierwszym krokiem, jaki podejmiemy, jest przyjęcie reprezentacji grafu przez listy sąsiedztw. Załóżmy, że jest to jedyna zmiana w implementacji. Oto zapis metody Dijsktry przy tej zmianie.

Algorytm Metoda Dijkstry - implementacja listowa


 1  //Inicjacja
 2  for v := 1 to n do  
 3  begin
 4    r[v] := 1;
 5    w[v] := \infty
 6  end;
 7  r[s] := 0; w[s] := 0;
 8  for each v \in L[s] do w[v] := w(v-s);
 9  //właściwe obliczenia
 10 for i := 1 to n-1 do 
 11 begin
 12   u := MinR;
 13   r[u] := 0; 
 14   
 15   for each v \in L[u] do if r[v] = 1 then
 16     if w[u] + w(u-v) < w[v] then
 17       w[v] := w[u] + w(u-v);
 18 end;
 20 //dla każdego v \in V, w[v] = w^*(v)

Dokonajmy analizy złożoności obliczeniowej tego algorytmu. Inicjacja, tak jak poprzednio, zajmuje czas O(n+m). Rozważmy teraz jeden obrót pętli for z wiersza 10. Niestety wiersz 12 nie uległ zmianie i tak jak poprzednio zajmuje czas \Theta(n). To powoduje, że cały algorytm działa w czasie \Theta(n^2). Mamy jednak pewien zysk w wierszach 15-17. Zauważmy, że w tych wierszach, dla każdego wierzchołka różnego od źródła przeglądamy dokładnie raz jego listę sąsiedztwa, a obsługa jednego wierzchołka na liście zajmuje stały czas. Tak więc łączny koszt wykonania pętli for z wiersza 15 wynosi O(m). Przyjrzyjmy się jakiego rodzaju operacje wykonujemy w naszym algorytmie. Nietrudno spostrzec, że kluczowe dla wydajności naszego algorytmu są operacje na zbiorze R. Zbiór R jest zbiorem zmieniającym się dynamicznie podczas wykonywania algorytmu i składa się z wierzchołków grafu z przypisanymi im wagami. Na zbiorze R wykonywane są następujące operacje:

  • MakeR: zbuduj zbiór R, na który składają się wszystkie wierzchołki poza źródłem i których wagami są wagi krawędzi łączące je ze źródłem lub \infty, gdy takie krawędzie nie istnieją - wiersze 1-7
  • MinR: znajdź w R wierzchołek o minimalnej wadze - wiersz 11
  • DeleteMinR: usuń z R znaleziony wierzchołek o minimalnej wadze - wiersz 12
  • DecreaseKeyR(v,w'): zmień wagę wierzchołka v na mniejszą wagę w' - wiersz 17

Powyższe operacje są operacjami kolejki priorytetowej. Powstaje pytanie, którą z licznych implementacji kolejki priorytetowej tu wykorzystać. Skupimy się na dwóch implementacjach.

Kopiec zwyczajny

W tej implementacji zbiór reprezentujemy za pomocą kopca zwyczajnego, o którym była mowa przy okazji sortowania kopcowego. Koszty wykonania poszczególnych operacji wynoszą w tym przypadku:

  • MakeR - O(n)
  • MinR - O(1)
  • DeleteMinR - O(\log n)
  • DecreaseKeyR - O(\log n)

Szczegóły implementacyjne metody Dijkstry z wykorzystaniem kopca zwyczajnego zostawiamy jako zadanie dla Czytelnika. Tak zaimplementowany algorytm obliczania najlżejszych ścieżek działa w czasie O(m\log n) i jest on asymptotycznie szybszy od algorytmu tablicowego dla każdego m rzędu mniejszego niż \frac{n^2}{\log n}.

Kopiec Fibonacciego

Zauważmy, że operacją mającą decydujący wpływ na taką właśnie złożoność jest operacja zmniejszenia wagi DecreaseKeyR. Wiemy jednak, że w kopcach Fibonacciego zamortyzowany koszt tej operacji jest stały. To jest wystarczające do naszych celów, tym bardziej, że koszty pozostałych operacji są takie same (koszt DeleteMinR jest kosztem zamortyzowanym) jak w kopcu zwyczajnym. Zatem jeśli do implementacji zbioru R wykorzystamy kopiec Fibonacciego, czas działania metody Dijkstry wyniesie O(n\log n + m). Na praktyczne zachowanie się tego algorytmu duży wpływ ma jednak skomplikowana budowa kopców Fibonacciego.