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

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania

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

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 n1mn(n1)/2.

Niech G=(V,E) będzie grafem, a w:E{1,2,...} funkcją przypisująca krawędziom dodatnie liczby całkowite zwane wagami krawędzi.

Ciąg wierzchołków p=<v0,v1,,vk> nazywamy ścieżką w G wtedy i tylko wtedy, gdy Parser nie mógł rozpoznać (błąd składni): {\displaystyle v_i–v_{i+1}} jest krawędzią w E, dla każdego i=1,2,,k1. 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 𝒩 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ę nastepująco.

Dane:
  G=(V,E) – spójny, nieskierowany graf
  w:E{1,2,} – 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.

W tym wykładzie zajmiemy się tylko wyznaczaniem wag najlżejszych ścieżek. Znalezienie sposobu wyznaczania takich ścieżek pozstawimy jako ćwiczenie dla czytelników. 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 ważniejsze 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 szczegtó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ępujace proste spostrzeżenie:

Niech <v=v0,v1,,vk=s> będzie najlżejszą ścieżką z wierzchołka v, do źródła s. Wówczas w*(v)>w*(v1)>>w*(vk)=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ć wiecej 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 n1 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ż policzone. Przez R będziemy oznaczali zbiór pozostałych wierzchołków. Podczas obliczeń z kazdym wierzchołkiem v będziemy mieli związana nieujemną wagę w[v]. Dla każdego wierzchołka vL waga w 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 muszą należeć do L. Może się zdarzyć, że taka ścieżka nie istnieje. Wówczas przyjmujemy, że w[v] jest równe - 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 nstę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 w grafie wierzchołka przeniesionego do L, którzy pozostali w R. Oto schemat algorytmu obliczania najlżejszych ścieżek. Nazwiemy go schematem Dijkstry.

Algorytm Schemat Dijkstry


 1  //Inicjacja
 2  L:={s}; R:=V{s};
 3  w[s]:=0; 
 4  for each v \in R do
 5    if vsE then
 6      w[v]:=w(vs)
 7    else
 8      w[v]:=;
 9  //właściwe obliczenia
 10 for i:=1 to n1 do 
 11 begin
 12   u:= wierzchołek z R o minimalnym w[u];
 13   R:=R{u}; 
 14   L:=L+{u};
 15   for each vin𝒩(𝓊) do
 16     if w[u]+w(uv)<w[v] then
 17       w[v]:=w[u]+w(uv);
 18 end;
 20 //dla każdego vV, w[v]=w*(v)

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

- implementacja grafu - implementacja zbiorów L i P i 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ę schematu, 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 zadawać graf jako daną dla algorytmu. Zawyczaj przyjmuje się, że wierzchołki grafu utożsamiamy z liczbami 1,2,,n, natomiast krawędzie opisujemy jako pary liczb odpowiadające końcom krawędzi. Gdy dodatkowo, tak jak w prolemie 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 nie jest w pełni izolowany, to rozmiar grafu wynosi O(m), gdzie Parser nie mógł rozpoznać (błąd składni): {\displaystyle 0 < 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.

Implementacja tablicowa

W implementacji tablicowej zakłada się, że graf jest reprezentowany przez zwaną macierz sąsiedztwa. W najczystszej postaci macierz sąsiedztwa jest macierzą kwadratową indeksowaną wierzchołkami grafu o wartościach 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 raz j, zaś zero (0) oznacza, ze takiej krawędzi nie ma. W przypadku, gdy krawędzie mają wagi, to można macierz sąsiedztwa 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(ij), natomiast, gdy takiej krawędzi nie ma w grafie, to w to miejsce wstawiamy . 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]={w(ij)ijEij∉E oraz ij0i=j

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, zawsze rozmiar tej reprezentacji jest 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 Ω(n2). Nie jest to źle dla grafów gęstych, w których m=Θ(n2), 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 jego reprezentacji. Taką reprezentacją jest reprezentacja listowa.

Implementacja listowa

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ą sasiadztwa 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 ij. Kolejność wierzchołków na liście może być dowolna. Zauważmy, że suma długości list sąsiedztw wynosi 2m, a stąd rozmiar struktury danych pamiętającej graf wynosi O(n+m) i jest jest on liniowy ze względu na rozmiar danych. Listy L niezwykle łatwo zbudować w czasie liniowym. Tak więc jeśli 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, to należy reprezentować graf przez Bold textlisty sąsiedztwa.