5_struktury danych - od drzew do grafów
TRANSCRIPT
Struktury danych w C#
Część 5. Od drzew do grafów
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
i uniwersalnych struktur danych.
Długość dokumentu — około 25 stron drukowanych.
Pobierz kod przykładów — Graphs.msi.
Spis treści
Wprowadzenie
Analiza różnych typów krawędzi
Tworzenie klasy grafu w C#
Często stosowane algorytmy grafowe
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ą
stwierdzenia wypisane poniżej:
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.
3. Liczba krawędzi w drzewie jest mniejsza o jeden od liczby węzłów w drzewie.
W dalszej części trzeciego artykułu omówione zostały drzewa binarne, które są specjalnym rodzajem drzew.
W drzewach binarnych węzeł może mieć najwyżej dwoje dzieci.
W tym artykule zajmiemy się grafami. Grafy — tak jak drzewa — składają się z węzłów (nazywanych też
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ć
jako zbiór połączonych węzłów.
Uwaga Wszystkie drzewa są oczywiście grafami. Drzewo to specjalny przypadek grafu, w którym wszystkie
węzły są dostępne z węzła wyjściowego i w którym nie ma cykli.
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 i wszystkie węzły są
osiągalne. Dlatego graf (c) jest drzewem.
Ilustracja 1. Trzy przykłady grafów
Za pomocą grafów można zaprezentować wiele rzeczywistych problemów. Na przykład wyszukiwarki
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 miasta są
krawędziami grafu.
Analiza różnych typów krawędzi
Zbiór węzłów i krawędzi to najprostsza definicja grafu. Grafy mogą jednak mieć krawędzie kilku typów:
krawędzie skierowane i krawędzie nieskierowane,
krawędzie z wagami i krawędzie bez wag.
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 do
v. Grafy z krawędziami dwukierunkowymi nazywane są grafami nieskierowanymi, ponieważ sposób przejścia po
krawędzi nie jest ograniczony tylko do jednego wyraźnego kierunku.
W niektórych przypadkach w grafie mogą występować jednokierunkowe połączenia pomiędzy węzłami. Na
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
pomiędzy nimi skierowane krawędzie. Krawędź skierowana od u do v oznacza, że na stronie internetowej u
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ę
do strony u, to na ilustracji umieszczane są dwie strzałki — jedna od v do u, a druga od u do v.
Ilustracja 2. Model stron składających się na witrynę
Krawędzie z wagami i bez wag
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 tylko
130 mil.
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,
skierowane bez wag,
nieskierowane z wagami,
nieskierowane bez wag.
Grafy na ilustracji 1. to grafy nieskierowane bez wag. Na ilustracji 2. przedstawiono grafy skierowane bez wag,
a na ilustracji 3. przedstawiono ważony graf nieskierowany.
Grafy rzadkie i grafy gęste
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ść
bliską wartości n2).
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ć
co najwyżej (n – 1) + (n – 2) + ... + 1 krawędzi. Po zsumowaniu tego wyrażenia otrzymamy wynik
[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
krawędzi, nazywany jest grafem gęstym.
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
na grafie zależy w dużym stopniu od liczby krawędzi i liczby węzłów w grafie.
Tworzenie klasy grafu w C#
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ą
zwykle modelowane poprzez:
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.
Przyjrzyjmy się obydwu metodom i poznajmy ich zalety oraz wady.
Przedstawienie grafu w postaci list sąsiedztwa
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:
Value — zmienna typu object, w której przechowywana jest wartość węzła,
Left — referencja do lewego dziecka węzła,
Right — referencja do prawego dziecka węzła.
Klasy Node oraz BinaryTree nie są wystarczająco rozbudowane, by móc służyć za implementację grafu. Po
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ć
możliwość dodania referencji do wszystkich węzłów grafu.
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
listy sąsiedztwa w postaci graficznej.
Ilustracja 4. Reprezentacja grafu w postaci list sąsiedztwa
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
sąsiedztwa węzła b znajduje się węzeł a.
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 grafu
nieskierowanego V + 2E instancji klasy Node.
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.
Na ilustracji 5. przedstawiono reprezentację grafu w postaci macierzy sąsiedztwa.
Ilustracja 5. Reprezentacja grafu w postaci macierzy sąsiedztwa
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
nieskierowanych połowa danych to informacje powtórzone.
Chociaż w tworzonej klasie Graph moglibyśmy zastosować dowolną z form reprezentacji grafu (macierz
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
artykułach tej serii.
Tworzenie klasy Node
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,
utworzymy klasę AdjacencyList.
Klasy AdjacencyList oraz EdgeToNeighbor
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
EdgeToNeighbor powinna zawierać dwie właściwości:
Cost — liczba całkowita będąca wartością wagi danej krawędzi,
Neighbor — referencja do węzła-sąsiada.
Klasa AdjacencyList dziedziczy po klasie System.Collections.CollectionBase i jest silnie typowanym
zbiorem instancji klasy EdgeToNeighbor. Kod klas EdgeToNeighbor i AdjacencyList jest następujący:
public class EdgeToNeighbor{ // prywatne zmienne składowe private int cost; private Node neighbor;
public EdgeToNeighbor(Node neighbor) : this(neighbor, 0) {}
public EdgeToNeighbor(Node neighbor, int cost) { this.cost = cost; this.neighbor = neighbor; }
public virtual int Cost { get { return cost; } }
public virtual Node Neighbor { get { return neighbor; } }}
public class AdjacencyList : CollectionBase{ protected internal virtual void Add(EdgeToNeighbor e) { base.InnerList.Add(e); }
public virtual EdgeToNeighbor this[int index] { get { return (EdgeToNeighbor) base.InnerList[index]; } set { base.InnerList[index] = value; } }}
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.
Dodawanie krawędzi do 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
skierowanej, albo nieskierowanej krawędzi pomiędzy dwoma węzłami.
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:
protected internal virtual void AddDirected(Node n){ AddDirected(new EdgeToNeighbor(n));}
protected internal virtual void AddDirected(Node n, int cost){ AddDirected(new EdgeToNeighbor(n, cost));}
protected internal virtual void AddDirected(EdgeToNeighbor e){
neighbors.Add(e);}
Tworzenie klasy Graph
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
w grafie istnieje węzeł o określonej wartości klucza.
public class NodeList : IEnumerable{ // prywatne zmienne składowe private Hashtable data = new Hashtable();
// metody public virtual void Add(Node n) { data.Add(n.Key, n); }
public virtual void Remove(Node n) { data.Remove(n.Key); }
public virtual bool ContainsKey(string key) { return data.ContainsKey(key); }
public virtual void Clear() { data.Clear(); }
// właściwości... public virtual Node this[string key] { get { return (Node) data[key]; } } // ... niektóre metody i właściwości pominięto // dla zachowania czytelności kodu}
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
fragmenty kodu klasy Graph:
public class Graph{ // prywatne zmienne składowe private NodeList nodes;
public Graph() { this.nodes = new NodeList(); }
public virtual Node AddNode(string key, object data) { // upewniamy się, że klucz jest unikalny if (!nodes.ContainsKey(key)) { Node n = new Node(key, data); nodes.Add(n); return n; } else throw new ArgumentException("W grafie istnieje już węzeł o kluczu " + key); }
public virtual void AddNode(Node n) { // upewniamy się, że węzeł jest unikalny if (!nodes.ContainsKey(n.Key)) nodes.Add(n); else throw new ArgumentException("W grafie istnieje już węzeł o kluczu " + n.Key); }
public virtual void AddDirectedEdge(string uKey, string vKey) { AddDirectedEdge(uKey, vKey, 0); }
public virtual void AddDirectedEdge(string uKey, string vKey, int cost) { // sprawdzamy referencje do węzłów o kluczach uKey i vKey if (nodes.ContainsKey(uKey) && nodes.ContainsKey(vKey)) AddDirectedEdge(nodes[uKey], nodes[vKey], cost); else throw new ArgumentException("Co najmniej jeden z podanych węzłów nie znajduje się w grafie."); }
public virtual void AddDirectedEdge(Node u, Node v) { AddDirectedEdge(u, v, 0); }
public virtual void AddDirectedEdge(Node u, Node v, int cost)
{ // Sprawdzamy, czy u i v należą do grafu if (nodes.ContainsKey(u.Key) && nodes.ContainsKey(v.Key)) // dodajemy krawędź u -> v u.AddDirected(v, cost); else // co najmniej jeden z węzłów nie został znaleziony w grafie throw new ArgumentException("Co najmniej jeden z podanych węzłów nie znajduje się w grafie."); }
public virtual void AddUndirectedEdge(string uKey, string vKey) { AddUndirectedEdge(uKey, vKey, 0); }
public virtual void AddUndirectedEdge(string uKey, string vKey, int cost) { // sprawdzamy referencje do węzłów o kluczach uKey i vKey if (nodes.ContainsKey(uKey) && nodes.ContainsKey(vKey)) AddUndirectedEdge(nodes[uKey], nodes[vKey], cost); else throw new ArgumentException("Co najmniej jeden z podanych węzłów nie znajduje się w grafie."); }
public virtual void AddUndirectedEdge(Node u, Node v) { AddUndirectedEdge(u, v, 0); }
public virtual void AddUndirectedEdge(Node u, Node v, int cost) { // Sprawdzamy, czy u i v należą do grafu if (nodes.ContainsKey(u.Key) && nodes.ContainsKey(v.Key)) { // Dodajemy krawędź u -> v oraz v -> u u.AddDirected(v, cost); v.AddDirected(u, cost); } else // co najmniej jeden z węzłów nie został znaleziony w grafie throw new ArgumentException("Co najmniej jeden z podanych węzłów nie znajduje się w grafie."); }
public virtual bool Contains(Node n) { return Contains(n.Key); }
public virtual bool Contains(string key) { return nodes.ContainsKey(key); }
public virtual NodeList Nodes { get { return this.nodes; } }}
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
podając klucze węzłów, pomiędzy którymi ma zostać ta krawędź wstawiona.
Stosowanie klasy Graph
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.
Graph web = new Graph();web.AddNode("Privacy.htm", null);web.AddNode("People.aspx", null);web.AddNode("About.htm", null);web.AddNode("Index.htm", null);web.AddNode("Products.aspx", null);web.AddNode("Contact.aspx", null);
Następnie należy dodać krawędzie. Tworzymy graf skierowany bez wag, dlatego dodając krawędź z u do v
będziemy stosować metodę AddDirectedEdge(u, v) klasy Graph.
web.AddDirectedEdge("People.aspx", "Privacy.htm"); // People -> Privacy
web.AddDirectedEdge("Privacy.htm", "Index.htm"); // Privacy -> Indexweb.AddDirectedEdge("Privacy.htm", "About.htm"); // Privacy -> About
web.AddDirectedEdge("About.htm", "Privacy.htm"); // About -> Privacyweb.AddDirectedEdge("About.htm", "People.aspx"); // About -> Peopleweb.AddDirectedEdge("About.htm", "Contact.aspx"); // About -> Contact
web.AddDirectedEdge("Index.htm", "About.htm"); // Index -> Aboutweb.AddDirectedEdge("Index.htm", "Contact.aspx"); // Index -> Contactsweb.AddDirectedEdge("Index.htm", "Products.aspx"); // Index -> Products
web.AddDirectedEdge("Products.aspx", "Index.htm"); // Products -> Indexweb.AddDirectedEdge("Products.aspx", "People.aspx");// Products -> People
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
algorytmy, często stosowane do analizy grafów ważonych:
algorytm konstruowania minimalnego drzewa rozpinającego,
algorytm wyszukiwania najkrótszej ścieżki pomiędzy dwoma węzłami.
Często stosowane algorytmy grafowe
Grafy to struktury danych, za pomocą których można odzwierciedlić wiele rzeczywistych problemów — dlatego
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.
Problem minimalnego drzewa rozpinającego
Wyobraźmy sobie, że pracujemy w firmie telefonicznej i mamy poprowadzić linie telefoniczne w wiosce, w której
jest dziesięć domów (przyporządkowano im oznaczenia od H1 do H10). Zadnie to wymaga poprowadzenia 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
odpowiadają możliwym połączeniom pomiędzy poszczególnymi budynkami. Wagi przypisane poszczególnym
krawędziom to odległości dzielące domy. Naszym zadaniem jest połączenie wszystkich domów przy jak
najmniejszym zużyciu kabla telefonicznego.
Ilustracja 6. Graficzne przedstawienie zadania połączenia kablem telefonicznym 10 budynków
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
drzewo rozpinające, które charakteryzuje się najmniejszym kosztem.
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
w budowanym drzewie nie powstały cykle. Rozwiązanie to przedstawiono na ilustracji 8.
Ilustracja 8. Minimalne drzewo rozpinające wykorzystujące krawędzie o najmniejszej wadze
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, są zaznaczone kolorem żółtym.
Ilustracja 9. Wyszukiwanie minimalnego drzewa rozpinającego metodą Prima
Techniki przedstawione na ilustracjach 8. i 9. doprowadziły do znalezienia takiego samego minimalnego 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ą
dać różne wyniki (oczywiście obydwa wyniki będą poprawne).
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.
Wyznaczanie najkrótszej ścieżki z jednym źródłem
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.
Taki graf przedstawiono na ilustracji 10.
Ilustracja 10. Graf przedstawiający dostępne loty i związane z nimi koszty
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
danego węzła, więc tablice kosztu i trasy są odpowiednio aktualizowane.
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
i trasy dla miast Chicago, Denver, Miami i Dallas.
Ilustracja 12. Etap drugi algorytmu ustalania najtańszego połączenia
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
Q po sprawdzeniu lotów z Chicago.
Ilustracja 13. Stan tablic po trzecim etapie procesu
Proces ten jest wykonywany tak długo, jak długo w zbiorze Q istnieją jakieś węzły. Na ilustracji 14.
przedstawiono zawartość tablic po opróżnieniu zbioru Q.
Ilustracja 14. Ostateczne wyniki algorytmu wyszukiwania najtańszego połączenia
Po wyczerpaniu elementów zbioru Q tablice zawierają informacje o najtańszych połączeniach lotniczych
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
znajdowania minimalnego drzewa rozpinającego i algorytmowi wyznaczania najkrótszej ścieżki w ważonym
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 trasy
pomiędzy dwoma miastami.
W następnym artykule — szóstej i ostatniej części tej serii — zajmiemy się problemem efektywnej reprezentacji
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 zbiór
węzłów, które jeszcze nie zostały dołączone do drzewa rozpinającego.
Bibliografia
„Wprowadzenie do algorytmów”, Thomas H. Cormen, Charles E. Leiserson i Ronald L. Rivest,
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 [email protected]. Blog Scotta dostępny jest pod adresem http://ScottOnWriting.NET.