Professional Documents
Culture Documents
Scott Mitchell
4GuysFromRolla.com
marzec 2004
Streszczenie:
Graf — podobnie jak drzewo — jest zbiorem węzłów i krawędzi. Jednak w przypadku grafów nie ma żadnych
zasad dotyczących sposobu połączenia poszczególnych węzłów za pomocą krawędzi. W tym artykule — piątym
już z serii poświęconej strukturom danych — zaprezentowano grafy, należące do najbardziej wszechstronnych
Spis treści
Wprowadzenie
Podsumowanie
Bibliografia
Wprowadzenie
Pierwszy i drugi artykuł tej serii poświęciliśmy liniowym strukturom danych — tablicy, klasie ArrayList, kolejce,
stosowi i tablicy z haszowaniem. W trzecim artykule rozpoczęliśmy analizę struktur drzewiastych. Jak
pamiętamy, drzewa składają się ze zbioru węzłów i wszystkie węzły są ze sobą połączone. Połączenia pomiędzy
węzłami nazywane są krawędziami. Połączenia w drzewach muszą spełniać wiele różnych warunków. Na
przykład wszystkie węzły w drzewie (poza korzeniem) muszą posiadać dokładnie jednego rodzica, a każdy
węzeł może posiadać dowolną liczbę dzieci. Dzięki tym prostym regułom, dla każdego drzewa prawdziwe są
1. Rozpoczynając chodzenie po drzewie od dowolnego węzła, można dotrzeć do każdego innego węzła
w drzewie. Oznacza to, że nie istnieje węzeł, do którego nie prowadzi żadna ścieżka.
2. W drzewie nie ma cykli. Cykl istnieje wtedy, gdy zaczynamy chodzenie po drzewie od pewnego węzła v
i idąc ścieżką prowadzącą przez zbiór węzłów v1, v2, ..., vk z powrotem trafiamy do węzła v.
W dalszej części trzeciego artykułu omówione zostały drzewa binarne, które są specjalnym rodzajem drzew.
wierzchołkami) i krawędzi, ale — w przeciwieństwie do drzew — węzły mogą być połączone w dowolny sposób.
W przypadku grafów nie istnieje pojęcie węzła-korzenia ani pojęcie węzłów-rodziców. Graf można raczej opisać
Uwaga Wszystkie drzewa są oczywiście grafami. Drzewo to specjalny przypadek grafu, w którym wszystkie
Na ilustracji 1. przedstawiono trzy przykłady grafów. Warto zwrócić uwagę, że w przeciwieństwie do drzew,
w grafach mogą istnieć zbiory węzłów, które nie są powiązane z pozostałymi zbiorami węzłów. Na przykład
w grafie (a) istnieją dwa odrębne zbiory węzłów. W grafach mogą także występować cykle — w grafie (b)
istnieje nawet kilka cykli. Jeden cykl to ścieżka idąca od węzła v1 przez v2 do v4 i z powrotem do węzła v1.
Kolejny cykl to ścieżka łącząca węzły v1, v2, v3, v5, v4 i idąca z powrotem do v1. Cykle istnieją także w grafie (a).
W grafie (c) nie ma żadnych cykli, liczba krawędzi w tym grafie jest o jeden mniejsza od liczby węzłów
internetowe takie jak Google modelują Internet w postaci grafu — strony internetowe są węzłami grafu,
a łączące strony odnośniki są krawędziami grafu. Programy typu Microsoft MapPoint, generujące trasy
przejazdu z miasta do miasta, także wykorzystują grafy — miasta odpowiadają węzłom grafu, a drogi łączące
Zbiór węzłów i krawędzi to najprostsza definicja grafu. Grafy mogą jednak mieć krawędzie kilku typów:
Opisując sposób wykorzystania grafu do przedstawienia jakiegoś problemu trzeba wskazać typ zastosowanego
grafu — czy jest to graf skierowany ważony, czy też jest to graf nieskierowany, ale również z wagami.
W następnej sekcji omówimy, czym różnią się krawędzie skierowane i nieskierowane oraz krawędzie z wagami
i bez wag.
Krawędzie skierowane i nieskierowane
Krawędzie grafu są połączeniami pomiędzy węzłami. Domyślnie krawędź jest dwukierunkowa. Oznacza to, że
jeśli pomiędzy węzłami v i u istnieje krawędź, to można nią przejść zarówno od węzła v do u, jak i od węzła u
przykład przy tworzeniu modelu Internetu w postaci grafu, odnośnik ze strony internetowej v do strony u
stanowiłby jednokierunkową krawędź od węzła v do węzła u. Oznacza to, że można przejść z v do u, ale nie
można przejść w drugą stronę — z u do v. Graf, w którym krawędzie są jednokierunkowe, nazywany jest
grafem skierowanym.
Gdy rysujemy graf, krawędzie dwukierunkowe kreślone są jako zwykłe linie (tak jak na ilustracji 1.). Krawędzie
jednokierunkowe rysowane są jako strzałki, których grot wskazuje kierunek krawędzi. Na ilustracji 2.
przedstawiono graf skierowany, w którym strony internetowe witryny przedstawiono jako węzły i poprowadzono
znajduje się odnośnik do strony internetowej v. Jeśli i strona u odwołuje się do strony v, i strona v odwołuje się
Zazwyczaj grafy służą do przedstawienia zbioru elementów oraz relacji pomiędzy tymi elementami. Na przykład
graf z ilustracji 2. przedstawia zbiór stron składających się na witrynę oraz odnośniki umożliwiające poruszanie
się pomiędzy tymi stronami. Czasami jednak ważne jest, by połączeniu dwóch węzłów przypisać jakiś koszt.
Mapę można w bardzo prosty sposób przedstawić jako graf — miasta to węzły, a drogi łączące miasta to
krawędzie. Jeśli chcemy ustalić najkrótszą trasę przejazdu z jednego miasta do drugiego, to do poszczególnych
krawędzi musimy przyporządkować koszt przejazdu z miasta do miasta. Logicznym rozwiązaniem jest
przypisanie do każdej krawędzi wagi, która może być na przykład równa odległości dzielącej dwa miasta.
Na ilustracji 3. widoczny jest graf przedstawiający kilka miast południowej Kalifornii. Koszt związany z trasą
prowadzącą od jednego miasta do drugiego jest sumą kosztów przypisanych do krawędzi składających się na
daną trasę. Najkrótsza trasa to taka, z którą związany jest najmniejszy koszt. Korzystając z przykładowej
ilustracji, możemy wyznaczyć długość trasy z San Diego do Santa Barbara. Może ona wynosić 210 mil, jeśli
zamierzamy przejechać przez Riverside i Barstow. Jednak najkrótsza trasa prowadzi przez Los Angeles i ma
Ilustracja 3. Graf, w którym węzły są miastami stanu Kalifornia, a waga krawędzi równa jest liczbie
mil
Kierunek i waga krawędzi nie zależą od siebie, dlatego graf może mieć krawędzie jednego z czterech
następujących typów:
• skierowane z wagami,
• nieskierowane z wagami,
Grafy na ilustracji 1. to grafy nieskierowane bez wag. Na ilustracji 2. przedstawiono grafy skierowane bez wag,
Graf może nie mieć wcale krawędzi lub może mieć ich wiele, jednak typowy graf ma więcej krawędzi niż
węzłów. Jaka jest maksymalna liczba wszystkich krawędzi w grafie o n węzłach? Zależy to od tego, czy graf jest
skierowany. Jeśli graf jest skierowany, to każdy węzeł może być połączony krawędzią z każdym innym węzłem.
Oznacza to, że każdy z n węzłów posiada n – 1 krawędzi, co w sumie daje n * (n – 1) krawędzi (czyli wartość
Uwaga W artykule tym przyjmuję, że węzeł nie może posiadać krawędzi prowadzących do samego siebie.
Jednak w teorii grafów dopuszcza się istnienie krawędzi prowadzących od węzła v do węzła v (tzw. pętli). Jeśli
w grafie dopuszcza się istnienie pętli, to maksymalna liczba wszystkich krawędzi w grafie skierowanym
wynosi n2.
Jeśli graf nie jest skierowany, to jeden węzeł — nazwijmy go v1 — może mieć krawędzie do wszystkich
pozostałych węzłów, czyli możne wychodzić z niego n – 1 krawędzi. Kolejny węzeł — nazwijmy go v2 — może
mieć najwyżej n – 2 krawędzie, ponieważ istnieje już krawędź łącząca ten węzeł z węzłem v1. Trzeci węzeł — v3
— może mieć co najwyżej n – 3 krawędzie i tak dalej. Dlatego dla n węzłów w grafie nieskierowanym może być
[n * (n - 1)] / 2. Czyli w grafie skierowanym może być co najwyżej dwa razy więcej krawędzi niż w grafie
nieskierowanym.
Graf rzadki to graf, w którym jest znacznie mniej niż n2 krawędzi. Na przykład graf o n węzłach i n krawędziach
lub nawet 2n krawędziach to graf rzadki. Graf, w którym liczba krawędzi jest bliska maksymalnej liczbie
Planując wykorzystanie grafu w algorytmie dobrze jest znać stosunek liczby węzłów do liczby krawędzi. Jak
dowiemy się z dalszej części tego artykułu, asymptotyczna złożoność obliczeniowa operacji przeprowadzanych
Grafy są strukturą danych powszechnie stosowaną w rozwiązaniach bardzo wielu różnych problemów, jednak
nie ma takiej struktury w środowisku .NET Framework. Przyczyną tego jest między innymi to, że efektywna
implementacja klasy grafów zależy od wielu czynników związanych z rozwiązywanym problemem. Grafy są
• listę sąsiedztwa,
• macierz sąsiedztwa.
Te dwie metody różnią się sposobem wewnętrznej reprezentacji węzłów i krawędzi grafu w klasie grafu.
W trzecim artykule opisałem tworzenie w języku C# klasy drzew binarnych o nazwie BinaryTree. Każdy węzeł
w drzewie binarnym był instancją klasy Node. Klasa Node zawierała trzy właściwości:
pierwsze, klasa Node drzewa binarnego dopuszcza tylko dwie krawędzie wychodzące z danego węzła — do
lewego i do prawego dziecka. Po drugie, w klasie BinaryTree można ustawić referencję tylko do jednego węzła
— korzenia drzewa. Niestety w przypadku grafu jest to niewystarczające — w klasie grafu musi istnieć
Jednym z rozwiązań jest utworzenie klasy Node zawierającej tablicę obiektów typu Node, w której będą
zapisywani sąsiedzi danego węzła. Klasa Graph także musiałaby zawierać tablicę instancji klasy Node, w której
zapisane byłyby wszystkie węzły grafu. Rozwiązanie takie nazywane jest listami sąsiedztwa, ponieważ każdy
węzeł zawiera listę sąsiednich węzłów (z którymi jest bezpośrednio połączony). Na ilustracji 4. przedstawiono
W przypadku grafu nieskierowanego, na listach sąsiedztwa znajdują się zduplikowane dane o krawędziach. Na
przykład w reprezentacji grafu (b) z ilustracji 4., w liście sąsiedztwa węzła a jest wpisany węzeł b, a w liście
W liście sąsiedztwa każdego węzła znajduje się dokładnie tyle węzłów, z iloma węzłami dany węzeł jest
powiązany. Dlatego lista sąsiedztwa to bardzo wydajna pod względem pamięciowym forma przedstawienia
grafu — przechowywane są w niej tylko potrzebne dane. W szczególności dla grafu z V wierzchołkami
i E krawędziami potrzebnych jest V + E instancji klasy Node w przypadku grafu skierowanego, a w przypadku
Chociaż nie wynika to z ilustracji czwartej, listę sąsiedztwa można także wykorzystać do przedstawienia grafu
z wagami. Jedyną różnicą jest to, że w liście sąsiedztwa dla każdego węzła n każda instancja klasy Node musi
także zawierać informację o koszcie związanym z przejściem krawędzi z węzła n.
Wadą listy sąsiedztwa jest to, że chcąc sprawdzić, czy istnieje krawędź z węzła u do węzła v, trzeba przeszukać
listę sąsiedztwa węzła u. W przypadku gęstych grafów lista sąsiedztwa węzła u będzie długa — ustalenie, czy
istnieje krawędź pomiędzy dwoma węzłami, ma liniową złożoność obliczeniową. Na szczęście przy korzystaniu
z grafów rzadko istnieje potrzeba sprawdzenia, czy istnieje krawędź pomiędzy dwoma konkretnymi węzłami.
Częściej konieczne będzie raczej wypisanie wszystkich krawędzi wychodzących z danego węzła.
Przedstawienie grafu jako macierzy sąsiedztwa
Innym sposobem przedstawienia grafu jest zastosowanie macierzy sąsiedztwa. W przypadku grafu z n węzłami,
macierz sąsiedztwa jest dwuwymiarową tablicą o rozmiarze n × n. Jeśli macierz ma reprezentować graf
ważony, to element macierzy o współrzędnych (u, v) ma wartość wagi krawędzi od u do v (lub na przykład -1,
jeśli nie istnieje krawędź z u do v). W macierzy sąsiedztwa dla grafu bez wag, macierz może zawierać wartości
boolowskie — wartość True na pozycji (u, v) oznacza, że istnieje krawędź z u do v, a wartość False oznacza,
że taka krawędź nie istnieje.
W przypadku grafów nieskierowanych macierz sąsiedztwa jest symetryczna względem głównej przekątnej.
Oznacza to, że jeśli w grafie nieskierowanym istnieje krawędź pomiędzy węzłami u i v, to w tablicy macierzy
sąsiedztwa znajdą się dwa odpowiadające sobie wpisy na pozycjach (u, v) oraz (v, u).
Ponieważ ustalenie, czy istnieje krawędź pomiędzy danymi dwoma węzłami, polega na zwykłym sprawdzeniu
wartości w tablicy, operacja ta przeprowadzana jest w stałym czasie. Wadą macierzy sąsiedztwa jest
zajmowanie dużej ilości pamięci. Macierz sąsiedztwa jest zapisywana w postaci tablicy zawierającej
n2 elementów, a więc w przypadku rzadkich grafów wiele pozycji w tablicy będzie pustych. W przypadku grafów
sąsiedztwa lub listy sąsiedztwa), zdecydowałem się na zastosowanie modelu list sąsiedztwa. Wybrałem to
rozwiązanie, ponieważ jest ono logicznym rozszerzeniem klas Node i BinaryTree, utworzonych w poprzednich
Klasa Node reprezentuje pojedynczy węzeł grafu. W grafach węzły zazwyczaj reprezentują jakiś obiekt. Dlatego
klasa Node zawiera właściwość Data o typie danych Object. We właściwości tej mogą być zapisane dowolne
dane skojarzone z węzłem. Trzeba także umożliwić prostą identyfikację poszczególnych węzłów, dlatego do
klasy węzła dodamy właściwość typu string o nazwie Key, która będzie unikalnym identyfikatorem każdego
węzła.
Zdecydowaliśmy się na reprezentację grafu poprzez listy sąsiedztwa, dlatego każdy egzemplarz klasy Node
musi mieć listę swoich sąsiadów. Jeśli przedstawiamy graf ważony, lista sąsiedztwa musi także zawierać
informacje o wadze poszczególnych krawędzi. Aby umożliwić stosowanie i zarządzanie listami sąsiedztwa,
Węzeł Node zawiera egzemplarz klasy AdjacencyList, który przechowuje informacje o krawędziach
prowadzących do sąsiadów danego węzła. Skoro klasa AdjacencyList przechowuje zbiór krawędzi, to najpierw
musimy utworzyć klasę reprezentującą krawędź. Klasa ta będzie reprezentować krawędź do węzła-sąsiada,
nazwijmy ją więc EdgeToNeighbor. Do każdej krawędzi możemy chcieć przypisać jakąś wagę, dlatego klasa
zbiorem instancji klasy EdgeToNeighbor. Kod klas EdgeToNeighbor i AdjacencyList jest następujący:
Właściwość Neighbors klasy Node umożliwia dostęp do wewnętrznej składowej AdjacencyList. Metoda
Add() klasy AdjacencyList jest oznaczona jako internal, co oznacza, że krawędzie do listy sąsiedztwa
węzła mogą być dopisywane wyłącznie przez klasy znajdujące się w tym samym podzespole. Klasy zostały
opracowane tak, że programista korzystający z klasy Graph może modyfikować strukturę grafu wyłącznie za
pośrednictwem metod składowych klasy Graph, a nie poprzez właściwość Neighbors węzła.
Oprócz właściwości Key, Data i Neighbors klasa Node musi zawierać także metodę, umożliwiającą
programiście zajmującemu się klasą Graph dodanie krawędzi prowadzącej od danego węzła do sąsiada. Jak
pamiętamy z opisu podejścia wykorzystującego listy sąsiedztwa, jeśli istnieje nieskierowana krawędź pomiędzy
węzłami u i v, to u w swojej liście sąsiedztwa będzie miało referencję do v, natomiast v będzie miało w swojej
liście sąsiedztwa referencję do u. Każdy egzemplarz klasy Node powinien być odpowiedzialny za
przechowywanie wyłącznie listy sąsiedztwa reprezentowanego przez siebie węzła, a nie list sąsiedztwa innych
węzłów w grafie. Jak za chwilę zobaczymy, klasa Graph zawiera metody umożliwiające dodanie albo
Aby ułatwić implementację metody klasy Graph, służącej do wstawiania krawędzi pomiędzy dwa węzły, klasa
Node zawiera metodę dodającą skierowaną krawędź, prowadzącą od danego węzła do określonego sąsiada.
Metoda AddDirected(), przyjmuje jako argument instancję klasy Node oraz opcjonalny parametr wagi,
następnie tworzy instancję klasy EdgeToNeighbor i dodaje ją do listy sąsiedztwa danego węzła. Opisanemu
powyżej procesowi odpowiada następujący kod:
Jak pamiętamy, metoda list sąsiedztwa wymaga, by w klasie grafu była zapisana lista wszystkich węzłów tego
grafu. Z kolei każdy węzeł przechowuje listę węzłów sąsiednich. A więc w klasie Graph musimy umieścić listę
wszystkich węzłów. Moglibyśmy je zapisać w tablicy ArrayList, jednak lepszym rozwiązaniem będzie tablica
z haszowaniem. Argumentem za zastosowaniem tablicy z haszowaniem jest to, że metody klasy Graph,
wykorzystywane do dodawania krawędzi, muszą sprawdzić, czy w grafie na pewno znajdują się węzły, które
mają zostać połączone krawędzią. Jeśli zapisywalibyśmy węzły w tablicy ArrayList, to musielibyśmy przeszukać
całą tablicę (złożoność liniowa!). W przypadku tablicy z haszowaniem sprawdzenie, czy dane węzły istnieją,
wykonywane jest w stałym czasie. Więcej informacji na temat tablicy z haszowaniem i charakteryzującej ją
złożoności obliczeniowej poszczególnych operacji można znaleźć w drugim artykule tej serii.
Pokazana poniżej klasa NodeList zawiera silnie typowane metody Add() i Remove(), służące do dodawania
i usuwania węzłów z grafu. Klasa ta zawiera także metodę ContainsKey(), która umożliwia sprawdzenie, czy
// metody
public virtual void Add(Node n)
{
data.Add(n.Key, n);
}
// właściwości...
public virtual Node this[string key]
{
get
{
return (Node) data[key];
}
}
Klasa Graph zawiera publiczną właściwość Nodes typu NodeList. Co więcej, klasa Graph zawiera kilka
metod służących do dodawania krawędzi skierowanych lub nieskierowanych, z wagami lub bez wag pomiędzy
dwa istniejące w grafie węzły. Metoda AddDirectedEdge() przyjmuje jako parametry wejściowe dwa obiekty
typu Node oraz opcjonalny parametr wagi, a następnie tworzy krawędź skierowaną od pierwszego węzła do
drugiego. Podobnie metoda AddUndirectedEdge() przyjmuje jako parametry wejściowe dwa obiekty typu
Node oraz opcjonalny parametr wagi, a następnie dodaje krawędź skierowaną od pierwszego węzła do
drugiego, a także krawędź skierowaną od drugiego węzła do pierwszego.
Oprócz metod służących do dodawania krawędzi, klasa Graph zawiera także metodę Contains(), która zwraca
wartość boolowską wskazującą, czy dany węzeł istnieje w grafie. Poniżej zostały zamieszczone najważniejsze
public Graph()
{
this.nodes = new NodeList();
}
Zarówno metoda AddDirectedEdge(), jak i metoda AddUndirectedEdge(), sprawdzają, czy wskazane węzły
istnieją w grafie. Jeśli węzły te nie istnieją w grafie, zgłaszany jest wyjątek ArgumentException. Każda
z tych metod jest także przeciążona. Krawędź można dodać przekazując referencje do dwóch węzłów lub
Utworzyliśmy wszystkie klasy potrzebne dla naszej struktury danych grafu. Za chwilę zajmiemy się
powszechnie stosowanymi algorytmami grafowymi, takimi jak tworzenie minimalnego drzewa rozpinającego
i znajdowanie najkrótszej ścieżki z jednego węzła do innych. Ale zanim przejdziemy do tych zagadnień,
przyjrzymy się sposobowi stosowania utworzonej klasy Graph w prostej aplikacji C#.
Najpierw należy utworzyć egzemplarz klasy Graph. Następnie należy dodać do grafu węzły. Wiąże się to
z wywołaniem metody AddNode() klasy Graph dla każdego dodawanego do grafu węzła. Odtwórzmy graf
z ilustracji 2. Musimy dodać do grafu sześć węzłów. Niech kluczem Key każdego z tych węzłów będzie nazwa
pliku strony internetowej. Właściwości Data każdego z węzłów zamiast danych przypiszemy pustą referencję,
chociaż moglibyśmy podać zawartość pliku lub zbiór słów kluczowych opisujących zawartość danej strony.
Następnie należy dodać krawędzie. Tworzymy graf skierowany bez wag, dlatego dodając krawędź z u do v
Wynikiem wykonania powyższych poleceń jest obiekt web, reprezentujący graf przedstawiony na ilustracji 2.
Mamy już graf, możemy więc spróbować udzielić odpowiedzi na kilka pytań. W przypadku grafu z naszego
przykładu może nas na przykład interesować odpowiedź na pytanie, jaką najmniejszą liczbę odnośników musi
kliknąć użytkownik, aby ze strony głównej (Index.htm) dostać się do jakiejś innej strony. Udzielenie
odpowiedzi na takie pytanie wymaga skorzystania z algorytmów grafowych. W następnej sekcji poznamy dwa
istnieje tak wiele algorytmów grafowych stosowanych do rozwiązywania często spotykanych problemów. Aby
poszerzyć naszą wiedzę o grafach, przyjrzyjmy się dwóm najważniejszym ich zastosowaniom.
Wyobraźmy sobie, że pracujemy w firmie telefonicznej i mamy poprowadzić linie telefoniczne w wiosce, w której
kabla, łączącego wszystkie domy. Kabel musi dotrzeć do domu H1, H2 i tak dalej aż po dom H10. Ze względu
na przeszkody geograficzne, takie jak wzgórza, drzewa, rzeki i inne, nie można poprowadzić kabla bezpośrednio
od domu do domu.
Na ilustracji 6. przedstawiono model tego zadania w postaci grafu. Każdy węzeł to dom, a krawędzie
krawędziom to odległości dzielące domy. Naszym zadaniem jest połączenie wszystkich domów przy jak
W przypadku spójnego, nieskierowanego grafu istnieje pewien podzbiór krawędzi, które łączą wszystkie węzły
i nie tworzą cyklu. Taki podzbiór krawędzi tworzy drzewo — liczba zawartych w nim krawędzi jest o jeden
mniejsza od liczby wierzchołków, a skonstruowany z tych krawędzi graf jest acykliczny. Drzewo takie nazywane
jest drzewem rozpinającym. Dla jednego grafu może istnieć wiele drzew rozpinających. Na ilustracji 7.
przedstawiono dwa poprawne drzewa rozpinające dla grafu z ilustracji 6. (krawędzie tworzące drzewo
rozpinające są pogrubione).
Ilustracja 7. Drzewa rozpinające grafu z ilustracji szóstej
W przypadku grafów ważonych z różnymi drzewami rozpinającymi związane są różne koszty. Koszt drzewa
rozpinającego to suma wag krawędzi składających się na to drzewo. Minimalne drzewo rozpinające to takie
Istnieją dwa podstawowe sposoby rozwiązania problemu minimalnego drzewa rozpinającego. Pierwszy sposób
polega na budowaniu drzewa rozpinającego poprzez wybieranie krawędzi o minimalnej wadze w taki sposób, by
Inny sposób wyznaczania minimalnego drzewa rozpinającego polega na podzieleniu węzłów grafu na dwa zbiory
rozłączne — zbiór węzłów znajdujących się już w drzewie i zbiór węzłów, które nie zostały jeszcze dołączone do
drzewa. W każdej iteracji do drzewa rozpinającego jest dodawana krawędź o najmniejszej wadze, łącząca węzeł
należący już do drzewa z węzłem, który nie należy jeszcze do drzewa. Pierwszy krok algorytmu polega na
losowym wybraniu pierwszego węzła. Ten sposób rozwiązania przedstawiono na ilustracji dziewiątej, gdzie jako
węzeł początkowy wybrano węzeł H1. Węzły, które zostały już dodane do zbioru węzłów należących do drzewa
rozpinającego. Jeśli w grafie istnieje tylko jedno minimalne drzewo rozpinające, to te dwa algorytmy dają takie
samo rozwiązanie. Jeśli jednak w grafie jest więcej minimalnych drzew rozpinających, to te dwie metody mogą
Uwaga Pierwszy z przedstawionych sposobów rozwiązania został opracowany przez Josepha Kruskala w 1956
roku w laboratoriach Bella. Drugi sposób rozwiązania został opracowany w 1957 roku przez Roberta Prima —
innego naukowca z laboratoriów Bella. W Internecie można znaleźć mnóstwo informacji na temat tych
algorytmów, także aplety Java demonstrujące działanie algorytmów w sposób graficzny (na przykład algorytm
Kruskala i algorytm Prima). Dostępne są również kody źródłowe w różnych językach programowania.
Gdy planujemy podróż samolotem, to jednym z trapiących nas problemów jest znalezienie trasy o najmniejszej
liczbie przesiadek. Raczej nikt nie lubi lecieć z Nowego Jorku do Los Angeles z przesiadkami w Chicago i Denver.
Większość osób wybrałaby samolot bezpośredni, lecący prosto z Nowego Jorku do Los Angeles — bez żadnych
przesiadek po drodze.
Wyobraźmy sobie jednak, że bardziej cenimy pieniądze niż swój czas i jesteśmy zainteresowani znalezieniem
najtańszej trasy przelotu bez względu na liczbę przesiadek. Może to oznaczać lot z Nowego Jorku do Miami,
gdzie przesiądziemy się do samolotu lecącego do Dallas, skąd z kolei polecimy do Phoenix, następnie
przesiądziemy się na samolot do San Diego, skąd w końcu polecimy do Los Angeles.
Problem ten można rozwiązać przedstawiając dostępne loty i ich ceny w postaci grafu skierowanego z wagami.
Interesuje nas wyszukanie „najkrótszej” ścieżki z Nowego Jorku do Los Angeles. Patrząc na ilustrację szybko
możemy ustalić, że najkrótsze (czyli najtańsze) połączenie prowadzi przez Chicago i San Francisco. Aby jednak
zadanie takie mogło być rozwiązane przez komputer, musimy sformułować odpowiedni algorytm.
Edgar Dijkstra — jeden z najbardziej uznanych autorytetów w dziedzinie informatyki — opracował najczęściej
wykorzystywany algorytm wyszukiwania najkrótszej ścieżki z węzła źródłowego do wszystkich innych węzłów
w skierowanym grafie z wagami. Algorytm ten — zwany algorytmem Dijkstry — działa z wykorzystaniem dwóch
tablic. W każdej tablicy istnieje rekord dla każdego węzła grafu. Te dwie tablice to:
• tablica kosztu — zapisane są w niej aktualne informacje o najniższym koszcie (najkrótszej ścieżce)
pokonania trasy od źródła do każdego innego węzła w grafie,
• tablica tras — dla każdego węzła n wskazuje, przez który węzeł prowadzi najkrótsza ścieżka do
węzła n.
Początkowo w tablicy kosztu na wszystkich pozycjach — oprócz pozycji węzła startowego z wpisaną wartością 0
— wpisane są bardzo duże wartości (na przykład nieskończoność). Na wszystkich pozycjach w tablicy tras
wpisana jest wartość null. Algorytm pamięta także zbiór Q, zawierający węzły, które należy jeszcze
sprawdzić. Początkowo do zbioru Q należą wszystkie węzły grafu.
Algorytm wybiera (i usuwa) ze zbioru Q węzeł, dla którego w tablicy kosztu wpisana jest najmniejsza wartość.
Wybrany węzeł oznaczmy literą n, a literą d oznaczmy wartość w tablicy odległości dla węzła n. Dla każdej
krawędzi węzła n sprawdzane jest, czy suma d i kosztu przejścia z n do sąsiada jest mniejsza niż wartość
wpisana w tablicy kosztu dla tego sąsiada. Jeśli wartość ta jest mniejsza, to znaleziona została lepsza trasa do
Aby lepiej wyjaśnić działanie tego algorytmu, zastosujmy go do grafu z ilustracji 10. Chcemy poznać najtańsze
połączenie z Nowego Jorku do Los Angeles, więc jako węzeł źródłowy wybieramy Nowy Jork. Tablica kosztu na
początku działania algorytmu na pozycji Nowy Jork ma wpisaną wartość 0, a na wszystkich pozostałych
pozycjach ma wpisaną nieskończoność. Tablica tras na wszystkich pozycjach ma wpisane puste referencje,
a zbiór Q zawiera wszystkie węzły grafu (sytuacja została przedstawiona na ilustracji 11.).
Ilustracja 11. Tablice kosztu i trasy, wykorzystywane do wyznaczenia najtańszego połączenia
Ze zbioru Q wybieramy miasto, do którego w tablicy kosztu przypisana jest najmniejsza wartość — w naszym
przykładzie jest to Nowy Jork. Następnie sprawdzamy, z jakimi miastami Nowy Jork posiada bezpośrednie
połączenie lotnicze i czy koszt przelotu z Nowego Jorku do tych miast jest niższy niż koszt wpisany dla tych
miast w tablicy odległości. Następnie ze zbioru Q usuwamy Nowy Jork i uaktualniamy dane w tablicach kosztu
W następnej iteracji miastem ze zbioru Q z wpisem o najmniejszej wartości w tablicy odległości jest Chicago.
Sprawdzamy, czy istnieje tańsze połączenie do sąsiadów Chicago. Mniejsze wartości otrzymujemy dla San
Francisco i Denver. Kosz przelotu do San Francisco przez Chicago wynosi 75 USD +25 USD, co daje wartość
mniejszą niż nieskończoność, więc uaktualniamy wpisy dla San Francisco. Także lot przez Chicago do Denver
jest tańszy niż bezpośredni przelot z Nowego Jorku do Denver (75 USD + 20 USD < 100 USD), więc
uaktualniamy wpisy dla Denver. Na ilustracji 13. przedstawiono wartości wpisów w tablicach i zawartość zbioru
Proces ten jest wykonywany tak długo, jak długo w zbiorze Q istnieją jakieś węzły. Na ilustracji 14.
z Nowego Jorku do pozostałych miast. Aby ustalić trasę przelotu do Los Angeles, należy sprawdzić wpis dla L.A.
w tablicy tras i cofać się przez kolejne miasta aż do osiągnięcia Nowego Jorku. A więc, jeśli w tablicy tras na
pozycji L.A. wpisane jest San Francisco, to ostatnia przesiadka miała miejsce w San Francisco. Z wpisu
w tablicy tras dla San Francisco wynika, że najtańszy lot do San Francisco odbywa się przez Chicago. W tablicy
tras dla Chicago widnieje Nowy Jork. Jeśli złożymy te wszystkie informacje razem, okaże się, że najtaniej
z Nowego Jorku do Los Angeles można lecieć przez Chicago i San Francisco.
Uwaga Implementację algorytmu Dijkstry w języku C# można znaleźć w pliku z przykładami dla tego
artykułu. Plik zawiera testową aplikację dla klasy Graph, która ustala najkrótszą trasę z jednego miasta do
drugiego z zastosowaniem algorytmu Dijkstry.
Podsumowanie
Grafy są często stosowaną strukturą danych, ponieważ można za ich pomocą przedstawić wiele rzeczywistych
problemów. Graf składa się ze zbioru węzłów i dowolnej liczby połączeń pomiędzy tymi węzłami, nazywanych
krawędziami. Krawędzie mogą być skierowane lub nieskierowane i mogą (ale nie muszą) mieć przypisane wagi.
W tym artykule przedstawione zostały podstawowe informacje o grafach. Utworzyliśmy także klasę Graph.
Klasa ta jest podobna do utworzonej w trzecim artykule klasy BinaryTree. Różnica polega na tym, że węzły
w klasie Graph mogą mieć dowolną liczbę krawędzi, a węzły drzew binarnych mogą mieć maksymalnie dwie
krawędzie. Podobieństwo to nie powinno dziwić, ponieważ drzewa są specjalnym przypadkiem grafów.
Po utworzeniu klasy Graph przyjrzeliśmy się dwóm popularnym algorytmom grafowym — algorytmowi
grafie skierowanym. W artykule nie przedstawiłem kodu źródłowego z implementacją tych algorytmów, ale
w Internecie znajduje się wiele takich przykładów. Również plik, dostępny do pobrania z tym artykułem,
zawiera aplikację testową dla klasy Graph, która wykorzystuje algorytm Dijkstry do wyznaczenia najkrótszej
zbiorów rozłącznych. Zbiory rozłączne to kolekcja co najmniej dwóch zbiorów, nie posiadających żadnych
wspólnych elementów. Na przykład w algorytmie Prima wyznaczania minimalnego drzewa rozpinającego, węzły
są rozdzielane na dwa zbiory rozłączne — zbiór węzłów, które znajdują się już w drzewie rozpinającym oraz
Bibliografia
Wydawnictwa Naukowo-Techniczne
Scott Mitchell — autor 5 książek i założyciel witryny 4GuysFromRolla.com. Od 5 lat zajmuje się w Microsoft
technologiami internetowymi. Scott pracuje jako niezależny konsultant, szkoleniowiec i autor artykułów,
a niedawno zdobył dyplom z informatyki na Uniwersytecie Kalifornijskim w San Diego. Jego adres e-mail to
mitchell@4guysfromrolla.com. Blog Scotta dostępny jest pod adresem http://ScottOnWriting.NET.