Algorytmy i struktury danych/Algorytmy grafowe - najlżejsze ścieżki: Różnice pomiędzy wersjami
m (ASD Moduł 11 moved to Algorytmy i struktury danych/ASD Moduł 11) |
|
(Brak różnic)
|
Wersja z 15:17, 21 wrz 2006
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 będziemy oznaczali liczbę wierzchołków w grafie, a przez liczbę jego krawędzi. Dla każdego grafu mamy zawsze .
Niech będzie grafem, a funkcją przypisująca krawędziom dodatnie liczby całkowite zwane wagami krawędzi.
Ciąg wierzchołków nazywamy ścieżką w G wtedy i tylko wtedy, gdy Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle v_i–v_{i+1}} jest krawędzią w , dla każdego . Liczbę nazywamy długością ścieżki .
Wagą ścieżki nazywamy sumę wag jej krawędzi i oznaczamy także przez . Przyjmujemy, że waga ścieżki o długości 0 wynosi 0.
Przez będziemy oznaczali zbiór sąsiadów wierzchołka w grafie .
Problem (wag) najlżejszych ścieżek z jednym źródłem definiuje się nastepująco.
Dane: – spójny, nieskierowany graf – funkcja przypisująca krawędziom dodatnie liczby całkowite – wyróżniony wierzchołek w G, zwany źródłem Wynik: dla każdego wierzchołka waga 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 będzie najlżejszą ścieżką z wierzchołka , do źródła . Wówczas .
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 różnego od źródła, sąsiad na najlżejszej ścieżce z do 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 powiększona o wagę krawędzi łączącej go z jest najmniejsza.
Możemy teraz przystąpić do schematu algorytmu. Algorytm obliczania najlżejszych ścieżek będzie działał w fazach. W trakcie działania algorytmu będzie zbiorem tych wierzchołków dla których wagi najlżejszych scieżek prowadzące do zostały już policzone. Przez będziemy oznaczali zbiór pozostałych wierzchołków. Podczas obliczeń z kazdym wierzchołkiem będziemy mieli związana nieujemną wagę . Dla każdego wierzchołka waga w będzie wagą najlżejszej scieżki łączacej z , czyli . Dla każdego wierzchołka z , będzie równe wadze najlżejszej ścieżki z do , na której wszystkie wierzchołki poza v muszą należeć do . Może się zdarzyć, że taka ścieżka nie istnieje. Wówczas przyjmujemy, że 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 wierzchołka z o najmniejszej wadze , a nstępnie poprawieniu wag dla wierzchołków, które pozostały w . Zauważmy, że wagi mogą się zmienić tylko sąsiadom w grafie wierzchołka przeniesionego do , którzy pozostali w . Oto schemat algorytmu obliczania najlżejszych ścieżek. Nazwiemy go metodą Dijkstry.
Algorytm Metoda Dijkstry
1 //Inicjacja 2 ; ; 3 ; 4 for each v \in R do 5 if then 6 7 else 8 ; 9 //właściwe obliczenia 10 for to do 11 begin 12 wierzchołek z o minimalnym ; 13 ; 14 ; 15 for each do 16 if then 17 ; 18 end; 20 //dla każdego ,
Dlaczego powyższy opis obliczania wag najlzejszych ś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 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ę 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 zadawać graf jako daną dla algorytmu. Zawyczaj przyjmuje się, że wierzchołki grafu utożsamiamy z liczbami , 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 , gdzie Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\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.
Macierz sąsiedztwa
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 -tego wiersza i -tej kolumny mówi, że w grafie jest krawędź o końcach w raz , 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 -tego wiersza i -tej kolumny wstawiamy wagę , 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ą taką,że
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 . Nie jest to źle dla grafów gęstych, w których , 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.
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 , gdzie jest listą sasiadztwa wierzchołka . W przypadku grafu z wagami, jeśli wierzchołek występuje na liście wierzchołka , to z wystąpieniem wierzchołka związana jest waga krawędzi . Kolejność wierzchołków na liście może być dowolna. Zauważmy, że suma długości list sąsiedztw wynosi , a stąd rozmiar struktury danych pamiętającej graf wynosi 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 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 . Zbiór R jest rerezentowany za pomocą tablicy (wektora charakterystycznego) r[1..n] o wartościach 0-1:
Tablica służy do obliczania wag najlżejszych ścieżek. Oznaczmy przez funkcję, której wartością jest wierzchołek w R onajmniejszej wadze w. Jeśli jest więcej niż jeden taki wierzchołek, to dowolny z nich. Oto jedna z możliwych implementacji tej funkcji.
function ; begin ; for to do if then ; return end
Czas działania tej funkcji wynosi .
Możemy teraz przedstawić pierwszą implementację metody Dijkstry.
Algorytm Metoda Dijkstry - implementacja tablicowa
1 //Inicjacja 2 for to do 3 begin 4 ; 5 6 end; 7 ; 8 9 //właściwe obliczenia 10 for to do 11 begin 12 ; 13 ; 14 15 for to do if then 16 if then 17 ; 18 end; 20 //dla każdego ,
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 powyzszego algorytmu nie nastręcza żadnych trudności. Inicjacja zajmuje czas każdy obrót pętli for z wiersza 10 zajmuje także czas , a zatem cały algorytm działa w czasie . Dla grafów gęstych, czyli zawierających rzędu 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 to do 3 begin 4 ; 5 6 end; 7 ; 8 for each do ; 9 //właściwe obliczenia 10 for to do 11 begin 12 ; 13 ; 14 15 for each do if then 16 if then 17 ; 18 end; 20 //dla każdego ,
Dokonajmy analizy złożoności obliczeniowej tego algorytmu. Inicjacja, tak jak poprzednio zajmuje czas . Rozważmy teraz jeden obrót pętli for z wiersza 10. Niestety wiersz 12 nie uległ zmianie i tak jak poprzednio zajmuje czas . To skutkuje, ze cały algorytm działa w czasie . 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 . Przyjrzyjmy się jakiego rodzaju operacje wykonujemy w naszym algorytmie. Nietrudno spostrzec, że kluczowe dla wydajności naszego algorytmu są operacje na zbiorze . Zbiór R jest zbiorem zmieniającym się dynamicznie podczas wykonywania algorytmu i składa się z wierzchołków grafu wraz z wagami. Na zbiorze R wykonywane są następujące operacje:
- : zbuduj zbió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 , gdy takie krawędzie nie istnieją - wiersze 1-7
- : znajdź w wierzchołek o minimalnej wadze - wiersz 11
- : usuń z R znaleziony wierzchołek o minimalnej wadze - wiersz 12
- : zmień wagę wierzchołka na mniejszą wagę
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:
- -
- -
- -
- -
Szczegóły implementacyjne metody Dijkstry z wykorzystaniem kopca zwyczajnego zostawiamy jako zadanie dla Czytelnika. Tak zaimplemntowany algorytm obliczania najlżejszych ścieżek działa w czasie i jest on asymptotycznie szybszy od algorytmu tablicowego dla każdego rzędu mniejszego niż . Zuważmy, że opracją mającą decydujący wpływ na taką właśnie złożoność jest operacja zmnieszenia wagi . Wiemy jedna, ż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 jest kosztem zamortyzowanym) jak w kopcu zwyczajnym. Zatem jeśli do implementacji zbioru wykorzystamy kopiec Fibonacciego, to czas działania metody Dijkstry wyniesie . Na praktyczne zachowanie się tego algorytmu duży wpływ ma jednak skomplikowana budowa kopców Fibonacciego.