You are on page 1of 25

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 -> Index


web.AddDirectedEdge("Privacy.htm", "About.htm"); // Privacy -> About

web.AddDirectedEdge("About.htm", "Privacy.htm"); // About -> Privacy


web.AddDirectedEdge("About.htm", "People.aspx"); // About -> People
web.AddDirectedEdge("About.htm", "Contact.aspx"); // About -> Contact

web.AddDirectedEdge("Index.htm", "About.htm"); // Index -> About


web.AddDirectedEdge("Index.htm", "Contact.aspx"); // Index -> Contacts
web.AddDirectedEdge("Index.htm", "Products.aspx"); // Index -> Products

web.AddDirectedEdge("Products.aspx", "Index.htm"); // Products -> Index


web.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
mitchell@4guysfromrolla.com. Blog Scotta dostępny jest pod adresem http://ScottOnWriting.NET.

You might also like