• Nie Znaleziono Wyników

RKI — Zajęcia 14 — Przeszukiwanie grafu w głąb Piersa Jarosław 2010-05-09

N/A
N/A
Protected

Academic year: 2021

Share "RKI — Zajęcia 14 — Przeszukiwanie grafu w głąb Piersa Jarosław 2010-05-09"

Copied!
13
0
0

Pełen tekst

(1)

RKI — Zajęcia 14 — Przeszukiwanie grafu w głąb

Piersa Jarosław 2010-05-09

1 Wprowadzenie

Natenczas Wojski chwycił na taśmie przypięty Swój róg bawoli, długi, cętkowany, kręty Jak wąż boa, oburącz do ust go przycisnął, Wzdął policzki jak banię, w oczach krwią zabłysnął, Zasunął wpół powieki, wciągnął w głąb pół brzucha

I do płuc wysłał z niego cały zapas ducha, I zagrał (...)

Adam Mickiewicz

Na poprzednich zajęciach omawialiśmy grafy oraz jedną z metod szukania w tej strukturze danych

— przeszukiwanie wszerz. Na dzisiejszej lekcji poznamy drugi ważny algorytm jakim jest przeszukiwanie grafu w głąb.

Wymagania wstępne:

• grafy, sposoby reprezentacji grafu,

• stos,

• algorytm BFS.

2 Algorytm przeszukiwania grafu w głąb

2.1 Idea

Podobnie jak poprzednio, w najbardziej podstawowej formie problemu dany jest graf, wierzchołek star- towy oraz wierzchołek szukany. Naszym celem będzie stwierdzić czy ze startowego da się dojść do szuka- nego chodząc tylko po krawędziach grafu.

Wspomniany algorytm BFS, poszukując celu w grafie zachowywał się w sposób, który można by wręcz określić jako pedantyczny. Tj. sprawdzał kolejno wszystkie wierzchołki w kolejności ich odległości od startowego np. zanim sprawdził wierzchołek leżący w odległości 3 pracowicie przeszukał wszystkie wierzchołki oddalone od startowego od dwie krawędzie. Nie negujemy, że szczypta samodyscypliny zawsze będzie w cenie, czasami jednak warto pozwolić algorytmowi na „odrobinę szaleństwa”.

Idea przeszukiwania w głąb zakłada możliwie szybką ucieczkę z przeszukiwaniem jak najdalej od star- towego wierzchołka. To jak zestawienie smerfa Pracusia, który po kolei wykonuje wszystkie powierzone mu zadania, oraz Marzyciela; ten drugi zostawiony sam sobie po chwili „odfrunie” ku obłokom.

Tak jak Pracuś, również i Marzyciel powinien pamiętać by:

• nie szukać dwa razy w tym samym miejscu,

• wrócić (kiedyś) do pominiętych wcześniej wierzchołków, trzeba przeszukać cały graf.

Tu podobieństwa się kończą. Marzyciel po dojściu do jakiegokolwiek wierzchołka sprawdza, czy jest to ten, który ma znaleźć. Jeżeli nie, to wybiera pierwszego sąsiada, którego jeszcze nie odwiedził (wybór musi skonsultować ze swoją podręczną listą) a następnie... robi dokładnie to samo. Można by rzec, że rekurencyjnie wywołuje swoje poszukiwanie z tegoż sąsiedniego wierzchołka. Ponieważ odwiedzone wierzchołki zapisuje na liście nie zacznie krążyć w kółko.

Obrana strategia ucieczki w głąb grafu jest źródłem nazwy „przeszukiwanie grafu w głąb” lub DFS (ang. Depth First Search).

(2)

2.2 Algorytm

Zapiszmy algorytm poszukiwania w pseudokodzie:

int main(){

(...)

int wierzcholekSzukany;

int wierzcholekStartowy;

bool czyOdwiedzony[] = {false, .., false};

dfs(wierzcholekSzukany, wierzcholekStartowy, czyOdwiedzony);

(...) } // main() // algorytm dfs

bool dfs(int szukany, int startowy, bool czyOdwiedzony[]){

// oznaczmy wierzchołek jako odwiedzony czyOdwiedzony[startowy] = true;

// znaleźliśmy

if (szukany == startowy){

return true;

} // if

// dla każdego sąsiada

for (int v = sasiedziWezla(startowy)){

if (czyOdwiedzony[v] == false){

// przeszukujemy graf z wybranego sąsiada bool ret = DFS(szukany, v, czyOdwiedzony);

// przeszukiwanie zakończone sukcesem if (ret == true){

return true;

} // if } // if } // for

// nie znaleźliśmy return false;

} // dfs

2.3 Stos wywołań

W tej wersji algorytmu jawnie skorzystaliśmy z rekurencji. To bardzo potężne narzędzie służyło nam już nie raz w lekcjach cyklu pierwszego, w zadaniu o wieżach Hanoi czy sortowaniu przez scalanie.

Skorzystamy z okazji by wyjaśnić kilka reguł rządzących wywołaniami funkcji (nie tylko rekurencyj- nych) w programach. System operacyjny, wykonując program napisany w C++ lub Javie, wykonuje tak naprawdę instrukcje zawarte w funkcji main(). Gdy w jej treści napotka wywołanie innej funkcji — oczy- wiście musi ją wykonać, ale musi również pamiętać stan funkcji main() z przed wywołania tak by móc do niej powrócić. Aby uniknąć nadpisywania zmiennych, które nie powinny być widoczne w wewnętrznej funkcji stan funkcji main() zostaje zapisany zaś nowa funkcja dostaje swój własny fragment pamięci do przechowywania zmiennych, argumentów, miejsca programie do którego należy powrócić itp. Co więcej, jeżeli z wnętrza tej funkcji zostanie wywołana jeszcze jedna funkcja, to ponownie system operacyjny musi zapamiętać stan tej niższej. Do pamiętania tych wywołań wykorzystywany jest stos wywołań. Gdy nowa funkcja jest wywoływana, na stos jest dodawany nowy kontekst wywołania i właśnie na nim wykonywane są obliczenia. Gdy funkcja się kończy (poprzez return lub dochodząc do jej końca) górny kontekst zostaje zdjęty, a obliczenia są przenoszone na ten leżący poniżej.

Rosnący stos wywołań zajmuje miejsce w pamięci operacyjnej. Może się zdarzyć, że pamięci tej zabraknie i system operacyjny musi przerwać działanie programu. Ten typ błędu zwykło się nazywać przepełnieniem stosu (ang. stack overflow). Poniżej podany jest mały program, w którym funkcja rek() wywołuje się rekurencyjnie bez końca. Dokładniej — wywoływałaby się, gdyby jej na to pozwolić — po pewnym czasie system operacyjny przerwie działanie.

(3)

#include <c s t d i o >

void r e k ( i n t a r g ) {

p r i n t f ( ”%d\n” , a r g ++);

r e k ( a r g ) ;

} // r e k ( )

i n t main ( i n t a r g c , char ∗∗ a r g v ) { r e k ( 0 ) ;

return 0 ;

} // main

2.4 Algorytm DFS — wersja druga

Po krótkim wyjaśnieniu możemy przepisać algorytm bez wykorzystania rekurencji. Ukryty za nią nie- jawny stos wywołań zamienimy na jak najbardziej jawny stos oczekujących na odwiedzenie wierzchołków.

// dane:

int start;

int szukanyElement;

bool czyJuzOdwiedzony[n] = {false, ..., false};

stos = pustyStos();

czyJuzOdwiedzony[start] = true;

stos.push(start);

while (stos.empty() == false){

int w = stos.top();

stos.pop()

if (w == szukanyElement){

stos.clear();

return "znalazlem";

} // if

for (int v = sasiedziWezla(w)){

if (czyJuzOdwiedzony[v] == false){

czyJuzOdwiedzony[v] = true;

stos.push(v);

} // if } // for v } // while

return "nie znalazlem";

2.5 Drzewo DFS

Podobnie jak w algorytmie BFS również i tu możemy zbudować drzewo osiągalności lub drzewo DFS poprzez zapamiętywanie rodzica tj. tego wierzchołka, z którego dotarliśmy do aktualnie odwiedzonego.

Drzewo budujemy z krawędzi pomiędzy wierzchołkami a ich rodzicami. wierzchołek startowy rodzica nie ma, ale sam jest rodzicem innych, więc również należy do drzewa.

Podobnie jak w algorytmie BFS wszystkie wierzchołki należące do tego drzewa są osiągalne ze star- towego, nie jest zaskoczeniem że są to te same zbiory wierzchołków, choć krawędzie być inne.

Należy jednak zauważyć, że drogi między korzeniem a wierzchołkami w drzewie DFS nie są najkrótsze (tj. liczą najmniej krawędzi) spośród dróg istniejących w oryginalnym grafie.

2.6 Analiza złożoności

Przyjmijmy oznaczenia n — liczba wierzchołków w grafie, m — liczba krawędzi.

(4)

Dla list sąsiedztwa Obie wersje algorytmu (tj. iteracyjna i rekurencyjna) wykonują po jednym ob- rocie pętli while / jednym wywołaniu funkcji dla każdego wierzchołka. Wewnętrzna pętla odwiedzająca sąsiadów wykona się w sumie liczbę krawędzi przemnożoną przez 2 razy, ponieważ każdy sąsiad jest de- finiowany poprzez krawędź. W grafie nieskierowanym jest to sąsiedztwo obustronne: najpierw z A do B, ale później zostanie również sprawdzone połączenie z B do A. Stąd mnożenie przez 2

Złożoność czasowa algorytmu wynosi O(m + n).

Dla macierzy sąsiedtwa W obu wersjach algorytmu dla każdego wierzchołka trzeba wyszukać wszyst- kich jego sąsiadów, co wymaga sprawdzenia całego wiersza w macierzy.

Złożoność czasowa wynosi O(n2).

Złożoność pamięciowa Algorytm jawnie wykorzystuje tablicę rozmiaru n pamiętającą czy wierzchołek był już odwiedzony. Obie wersje wykorzystują również (jawnie lub niejawnie) stos. Na stos dodawane są wierzchołki, przy czym każdy może zostać dodany co najwyżej jeden raz. Co za tym idzie, złożoność pamięciowa algorytmu (w obu wersjach) skaluje się wraz liczbą wierzchołków grafu tj. O(n).

Uwaga: w tej analizie nie jest uwzględniony rozmiar danych wejściowych.

3 Ćwiczenie

Napisz program, który wczyta graf skierowany:

• n — liczba wierzchołków w grafie,

• m — liczba krawędzi w grafie,

• m par liczb a b rozdzielonych spacjami — lista krawędzi, wierzchołki są indeksowane liczbami od 0 do n − 1,

• wierzchołek startowy,

• wierzchołek szukany,

oraz wypisze „TAK” jeżeli poszukiwany wierzchołek jest osiągalny w grafie ze startowego lub „NIE” w przeciwnym wypadku. Oczywiście należy wykorzystać algorytm DFS.

Przykład 1:

3 3 0 1 0 2 1 2 0 1

Odpowiedź:

TAK

Przykład 2:

3 2 1 0 2 0 0 1

Odpowiedź:

NIE

(5)

3.1 Rozwiązanie w C++

Rozwiązanie rekurencyjne

#include <i o s t r e a m >

#include <v e c t o r >

// zmienne g l o b a l n e i n t n , m;

s t d : : v e c t o r <s t d : : v e c t o r <int> > s a s i e d z i ; bool ∗ czyOdwiedzony ;

bool d f s ( int , i n t ) ;

i n t main ( ) {

// w c z y t u j e m y g r a f s t d : : c i n >> n >> m;

f o r ( i n t i =0; i <n ; i ++){

s t d : : v e c t o r <int> v ; s a s i e d z i . p u s h b a c k ( v ) ;

} // f o r i

i n t a , b ;

f o r ( i n t i =0; i <m; i ++){

s t d : : c i n >> a >> b ;

s a s i e d z i . a t ( a ) . p u s h b a c k ( b ) ;

// g r a f j e s t s k i e r o w a n y w i e c n i e dodajemy s y m e t r y c z n i e

} // f o r

i n t szukany , s t a r t o w y ;

s t d : : c i n >> s t a r t o w y >> s z u k a n y ;

// t a b l i c a pomocnicza

czyOdwiedzony = new bool [ n ] ; f o r ( i n t i =0; i <n ; i ++){

czyOdwiedzony [ i ] = f a l s e ;

} // f o r

// a l g o r y t m d f s

bool wynik = d f s ( szukany , s t a r t o w y ) ; s t d : : c o u t << ( wynik ? ”TAK\n” : ”NIE\n” ) ;

return 0 ;

} // main

bool d f s ( i n t szukany , i n t s t a r t o w y ) { // oznaczamy j a k o o d w i e d z o n y czyOdwiedzony [ s t a r t o w y ] = true ;

// z n a l e z l i s m y

i f ( s z u k a n y == s t a r t o w y ) { return true ;

} // i f

// d l a k a z d e g o s a s i a d a

f o r ( i n t i =0; i < ( i n t ) s a s i e d z i . a t ( s t a r t o w y ) . s i z e ( ) ; i ++){

i n t s a s i a d = s a s i e d z i . a t ( s t a r t o w y ) . a t ( i ) ;

// j e z e l i s a s i a d j e s z c z e n i e o d w i e d z o n y i f ( czyOdwiedzony [ s a s i a d ] == f a l s e ) {

// t o go sprawdzamy

bool wynik = d f s ( szukany , s a s i a d ) ; i f ( wynik == true ) {

// zwracamy wynik return true ;

} // i f

} // i f

} // f o r

// n i e z n a l e z l i s m y

(6)

return f a l s e ;

} // d f s ( )

Rozwiązanie iteracyjne:

#include <i o s t r e a m >

#include <v e c t o r >

#include <s t a c k >

using namespace s t d ;

i n t n , m;

s t d : : v e c t o r <s t d : : v e c t o r <int> > s a s i e d z i ; bool ∗ czyOdwiedzony ;

i n t main ( ) {

// w c z y t u j e m y g r a f s t d : : c i n >> n >> m;

f o r ( i n t i =0; i <n ; i ++){

s t d : : v e c t o r <int> v ; s a s i e d z i . p u s h b a c k ( v ) ;

} // f o r i

i n t a , b ;

f o r ( i n t i =0; i <m; i ++){

s t d : : c i n >> a >> b ;

s a s i e d z i . a t ( a ) . p u s h b a c k ( b ) ;

// g r a f j e s t s k i e r o w a n y w i e c n i e dodajemy s y m e t r y c z i e

} // f o r

i n t szukany , s t a r t o w y ;

s t d : : c i n >> s t a r t o w y >> s z u k a n y ;

// t a b l i c a pomocnicza

czyOdwiedzony = new bool [ n ] ; f o r ( i n t i =0; i <n ; i ++){

czyOdwiedzony [ i ] = f a l s e ;

} // f o r

// dodajemy s t a r t o w y w e z e na s t o s s t d : : s t a c k <int> s t o s ;

s t o s . push ( s t a r t o w y ) ;

czyOdwiedzony [ s t a r t o w y ] = true ; bool wynik = f a l s e ;

// d o p o k i s t o s j e s t n i e p u s t y while ( s t o s . empty ( ) == f a l s e ) {

// zdejmujemy w i e r z c h o e k z e s t o s u i n t w i e r z c h o l e k = s t o s . t o p ( ) ; s t o s . pop ( ) ;

// z n a l e z l i s m y

i f ( w i e r z c h o l e k == s z u k a n y ) { wynik = true ;

} // i f

// d l a k a z d e g o s a s i a d a

f o r ( i n t i =0; i < ( i n t ) s a s i e d z i . a t ( w i e r z c h o l e k ) . s i z e ( ) ; i ++){

// j e z e l i s a s i a d b y l n i e o d w i e d z o n y t o dodajemy go na s t o s i n t s a s i a d = s a s i e d z i . a t ( w i e r z c h o l e k ) . a t ( i ) ;

i f ( czyOdwiedzony [ s a s i a d ] == f a l s e ) { czyOdwiedzony [ s a s i a d ] = true ; s t o s . push ( s a s i a d ) ;

} // i f

} // f o r i

} // w h i l e

s t d : : c o u t << ( wynik ? ”TAK\n” : ”NIE\n” ) ; return 0 ;

} // main

(7)

3.2 Rozwiązanie w Javie

Rozwiązanie rekurencyjne:

import j a v a . u t i l . S c a n n e r ; import j a v a . u t i l . V e c t o r ;

public c l a s s RKI DFS REK { // zmienne g l o b a l n e

s t a t i c V e c t o r<V e c t o r<I n t e g e r >> k r a w e d z i e ; s t a t i c boolean czyOdwiedzony [ ] ;

public s t a t i c void main ( S t r i n g [ ] a r g s ) { // w c z y t u j e m y g r a f

S c a n n e r s = new S c a n n e r ( System . i n ) ; i n t n = s . n e x t I n t ( ) ;

i n t m = s . n e x t I n t ( ) ;

k r a w e d z i e = new V e c t o r<V e c t o r<I n t e g e r > >();

f o r ( i n t i =0; i <n ; i ++){

V e c t o r<I n t e g e r > v = new V e c t o r<I n t e g e r > ( ) ; k r a w e d z i e . add ( v ) ;

} // f o r

f o r ( i n t i =0; i <m; i ++){

i n t a = s . n e x t I n t ( ) ; i n t b = s . n e x t I n t ( ) ;

// g r a f j e s t n i e s k i e r o w a n y w i e c n i e dodajemy s y m e t r y c z n i e k r a w e d z i e . g e t ( a ) . add ( b ) ;

} // f o r

i n t s t a r t o w y = s . n e x t I n t ( ) ; i n t s z u k a n y = s . n e x t I n t ( ) ;

// t a b l i c a pomocnicza

czyOdwiedzony = new boolean [ n ] ; f o r ( i n t i =0; i <n ; i ++){

czyOdwiedzony [ i ] = f a l s e ;

} // f o r

// DFS

boolean wynik = d f s ( s t a r t o w y , s z u k a n y ) ;

System . o u t . f o r m a t ( ”%s \n” , ( wynik ? ”TAK” : ”NIE” ) ) ; return ;

} // main

public s t a t i c boolean d f s ( i n t s t a r t o w y , i n t s z u k a n y ) { // z n a l e z l i s m y

czyOdwiedzony [ s t a r t o w y ] = true ; i f ( s t a r t o w y == s z u k a n y ) {

return true ;

} // i f

// d l a k a z d e g o s a s i a d a . . .

f o r ( i n t i =0; i <k r a w e d z i e . g e t ( s t a r t o w y ) . s i z e ( ) ; i ++){

i n t s a s i a d = k r a w e d z i e . g e t ( s t a r t o w y ) . g e t ( i ) ; // j e z e l i j e s z c z e n i e o d w i e d z o n y t o sprawdzamy go i f ( czyOdwiedzony [ s a s i a d ] == f a l s e ) {

boolean wynik = d f s ( s a s i a d , s z u k a n y ) ; i f ( wynik == true ) {

return true ;

} // i f

} // i f

} // f o r i

// n i e z n a l e z l i s m y return f a l s e ;

} // d f s ( )

} // c l a s s

Rozwiązanie iteracyjne:

import j a v a . u t i l . S c a n n e r ; import j a v a . u t i l . S t a c k ; import j a v a . u t i l . V e c t o r ;

(8)

public c l a s s RKI DFS ITER {

public s t a t i c void main ( S t r i n g [ ] a r g s ) { S c a n n e r s = new S c a n n e r ( System . i n ) ;

i n t n = s . n e x t I n t ( ) ; i n t m =s . n e x t I n t ( ) ;

V e c t o r<V e c t o r<I n t e g e r >> k r a w e d z i e = new V e c t o r<V e c t o r<I n t e g e r > >();

f o r ( i n t i =0; i <n ; i ++){

V e c t o r<I n t e g e r > v = new V e c t o r<I n t e g e r > ( ) ; k r a w e d z i e . add ( v ) ;

} // f o r

f o r ( i n t i =0; i <m; i ++){

i n t a = s . n e x t I n t ( ) ; i n t b = s . n e x t I n t ( ) ; k r a w e d z i e . g e t ( a ) . add ( b ) ;

} // f o r

i n t s t a r t o w y = s . n e x t I n t ( ) ; i n t s z u k a n y = s . n e x t I n t ( ) ;

boolean czyOdwiedzony [ ] = new boolean [ n ] ; f o r ( i n t i =0; i <n ; i ++){

czyOdwiedzony [ i ] = f a l s e ;

} // f o r

S ta ck<I n t e g e r > s t o s = new S ta ck<I n t e g e r > ( ) ; s t o s . add ( s t a r t o w y ) ;

czyOdwiedzony [ s t a r t o w y ] = true ;

boolean o d p o w i e d z = f a l s e ; while ( s t o s . s i z e ( ) > 0 ) {

i n t w i e r z c h o l e k = s t o s . pop ( ) ;

i f ( w i e r z c h o l e k == s z u k a n y ) { o d p o w i e d z = true ; s t o s . c l e a r ( ) ; break ;

} // i f

f o r ( i n t i =0; i < k r a w e d z i e . g e t ( w i e r z c h o l e k ) . s i z e ( ) ; i ++){

i n t s a s i a d = k r a w e d z i e . g e t ( w i e r z c h o l e k ) . g e t ( i ) ; i f ( czyOdwiedzony [ s a s i a d ] == f a l s e ) {

czyOdwiedzony [ s a s i a d ] = true ; s t o s . add ( s a s i a d ) ;

} // i f

} // f o r

} // w h i l e

System . o u t . f o r m a t ( ”%s \n” , o d p o w i e d z ? ”TAK” : ”NIE” ) ;

} // main

} // c l a s s

4 Cykl w grafie

Cykl w grafie nieskierowanym jest drogą (listą wierzchołków) postaci: A1− A2− ... − Ak− A1, gdzie wszystkie krawędzie A1− A2, ... , Ak−1− Ak, Ak− A1 należą do zbioru krawędzi w grafie i się nie powtarzają.

Cykl w grafie skierowanym definiuje się podobnie, z tym że korzystamy z krawędzi skierowanych i wymagamy zachowania orientacji krawędzi. Czyli jest to droga postaci: A1 → A2 → ... → Ak → A1, gdzie wszystkie krawędzie A1→ A2, ... , Ak−1→ Ak, Ak → A1 należą do zbioru krawędzi w grafie.

Cykl jest zatem możliwością przejścia po różnych wierzchołkach grafu i powrotu do samego siebie.

Ważną własnością grafów nieskierowanych, które nie mają cykli jest to, że jeżeli między parą wierz- chołków istnieje ścieżka, to jest ona jedyna.

Powyższy fakt nie przenosi się bezpośrednio na grafy skierowane, co widać na przykładzie. Natomiast jeżeli w grafie powstałym ze skierowanego poprzez zapomnienie orientacji krawędzi (czyli nieskierowanej wersji grafu) nie ma cyklu, to ścieżki w oryginalnym grafie skierowanym są jednoznaczne.

(9)

Rysunek 1: Cykl w grafie nieskierowanym (po lewej), graf skierowany bez cyklu (środkowy), graf skiero- wany zawierający cykl (po prawej).

Uwaga: Nie zachodzi implikacja w przeciwną stronę. Ścieżki w grafie skierowanym mogą być jedno- znaczne i jednocześnie graf po zapomnieniu orientacji krawędzi może posiadać cykl nieskierowany.

4.1 Wyszukiwanie cykli w grafach nieskierowanych

W grafie nieskierowanym wystarczy zliczać odwiedziny w wierzchołkach. Jeżeli trafimy do wierzchołka raz już odwiedzonego oznacza to, że w grafie jest cykl. Jedyna uwaga jest taka, że zawsze wracając przez krawędź, którą do aktualnego wierzchołka doszliśmy, trafimy do wierzchołka odwiedzonego. Dlatego raz użyta krawędź musi również być oznaczona i nie dopuszczona do dalszego przechodzenia po grafie.

4.2 Wyszukiwanie cykli w grafach skierowanych

Cykle w grafie skierowanym są trudniejsze do znalezienia. Na rysunku zaprezentowany jest graf, w którym para wierzchołków jest połączona dwiema różnymi ścieżkami, ale mimo to graf nie posiada cyku. Naiwnie przeniesiony algorytm z poprzedniej sekcji niepoprawnie stwierdził by obecność cyklu w grafie.

Zauważmy, że jeżeli w grafie skierowanym jest cykl to któryś z wierzchołków jest swoim własnym po- tomkiem. Kolejno będziemy przeglądali wierzchołki w grafie. Nadal będziemy oznaczać fakt odwiedzenia wierzchołka, ale będą nam potrzebne dodatkowe oznaczenia:

• oznaczenie wierzchołka nieodwiedzonego — na ilustracjach kolor zielony, w kodach programów liczba 0,

• oznaczenie dla wierzchołka odwiedzonego, ale nie wszystkie jego potomki zostały odwiedzone — kolor czerwony, liczba 1. Jeżeli dojdziemy do tak oznaczonego wierzchołka przeglądając jego po- tomków to istnieje cykl w grafie.

• oznaczenie dla wierzchołka, który został już odwiedzony i wszystkie wierzchołki potomne również zostały odwiedzone — kolor niebieski, liczba 2.

Na początku wszystkie wierzchołki oznaczamy na zielono. Zaczynamy od dowolnego wierzchołka startowego. Odwiedzony wierzchołek oznaczamy jako czerwony i kolejno przechodzimy jego sąsiadów wychodzących szukając w głąb. Po odwiedzeniu wszystkich potomków (pośrednich i bezpośrednich) zmieniamy kolor na niebieski. Dojście do wierzchołka czerwonego oznacza, że przeglądając jego potomków wróciliśmy do niego samego — czyli znaleźliśmy cykl w grafie.

5 Ćwiczenie

Napisz program, który wczyta kolejno:

• n liczbę wierzchołków w grafie,

• m liczbę krawędzi w grafie

• m par liczb a b rozdzielone spacjami, które będą reprezentowały listę krawędzi w grafie skierowanym.

A następnie wypisze „TAK”, jeżeli w graf zawiera cykl, lub „NIE” w przeciwnym wypadku.

Wskazówki:

(10)

• Graf może być niespójny, wtedy przeszukiwania mogą zakończyć się w składowej, która nie ma cyklu, choć graf może cykl zawierać. Należy wówczas ponownie rozpocząć algorytm w innym nieodwiedzonym wierzchołku.

• Graf jest skierowany, wierzchołki indeksowane są liczbami od 0 do n − 1 włącznie.

5.1 Rozwiązanie w C++

#include <i o s t r e a m >

#include <v e c t o r >

i n t n , m;

s t d : : v e c t o r <s t d : : v e c t o r <int> > s a s i e d z i ; bool ∗ czyOdwiedzony ;

i n t ∗ o dw ie dz on y ; bool d f s ( ) ;

i n t main ( ) {

s t d : : c i n >> n >> m;

f o r ( i n t i =0; i <n ; i ++){

s t d : : v e c t o r <int> v ; s a s i e d z i . p u s h b a c k ( v ) ;

} // f o r i

i n t a , b ;

f o r ( i n t i =0; i <m; i ++){

s t d : : c i n >> a >> b ;

s a s i e d z i . a t ( a ) . p u s h b a c k ( b ) ;

// g r a f j e s t s k i e r o w a n y w i e c n i e dodajemy s y m e t r y c z n i e

} // f o r

i n t s t a r t = 0 ;

o dw ie dz on y = new i n t [ n ] ; f o r ( i n t i =0; i <n ; i ++){

od w ie dz on y [ i ] = 0 ;

} // f o r

bool wynik = f a l s e ; while ( s t a r t < n ) {

i f ( od wi ed z on y [ s t a r t ] ! = 0 ) { s t a r t ++;

} e l s e {

bool w = d f s 3 a ( s t a r t ) ; wynik = wynik | | w ;

} // i f

} // w h i l e

s t d : : c o u t << ( wynik ? ”TAK\n” : ”NIE\n” ) ;

return 0 ;

} // main ( )

bool d f s ( i n t s t a r t ) {

i f ( od wi ed zo n y [ s t a r t ] == 1 ) { return true ;

} e l s e i f ( od wi ed z on y [ s t a r t ] == 2 ) { return f a l s e ;

} // i f

o dw ie dz on y [ s t a r t ] = 1 ; // d l a k a z d e g o s a s i a d a

f o r ( i n t i =0; i < ( i n t ) s a s i e d z i . a t ( s t a r t ) . s i z e ( ) ; i ++){

i n t s a s i a d = s a s i e d z i . a t ( s t a r t ) . a t ( i ) ; // p r z e s z u k u j e m y g l a f z s a s i a d a

bool r e s = d f s 3 a ( s a s i a d ) ;

// p r z e k a z u j e m y i n f o r m a c j e o c y k l u i f ( r e s == true ) {

return true ;

} // i f

(11)

} // f o r i

// w s z y s c y s a s i e d z i s p r a w d z e n i

// oznaczamy w i e r z c h o l e k j a k o s r a w d z o n y o dw ie dz on y [ s t a r t ] = 2 ;

return f a l s e ;

} // d f s ( )

5.2 Rozwiązanie w Javie

. . .

6 DFS vs BFS

W poprzedniej i bieżącej lekcji poznaliśmy dwa algorytmy, które wykonują podobne zadania choć różnią- cymi się strategiami.

Algorytm przeszukiwania wszerz jest „krótkowzroczny”, przegląda szeroko po listach sąsiadujących wierzchołków. Powoduje to, że sprawdza wierzchołki „warstwami”, to tych leżących najbliżej startowego (oddalonych o mniejszą ilość krawędzi) do tych najdalszych.

Algorytm przeszukiwania w głąb na odwrót — koncentruje się na wybranym sąsiedzie i podąża gałęzią wychodzącą z danego sąsiada. Po sprawdzeniu całego pod-grafu osiągalnego z pierwszego sąsiada dopiero zagląda do drugiego, trzeciego itd.

Dobór właściwego przeszukiwania zależy od natury problemu i struktury grafu, który chcemy prze- szukiwać. Jeżeli problem zawsze będzie wymakał sprawdzenia wszystkich wierzchołków, to wybór DFS czy BFS ma wpływ znikomy.

Jeżeli jednak algorytm można przerwać po znalezieniu konkretnego wierzchołka, właściwie dobrana strategia może zaoszczędzić wielu obliczeń.

Przykładem jest szukanie cyklu w nieregularnych grafach, jeżeli najkrótszy cykl liczy wiele wierzchoł- ków. Algorytm BFS zanim znajdzie cykl długości np. 10 krawędzi musi pracowicie przeliczyć wszystkie wierzchołki leżące w odległości 1, potem 2, i tak dalej aż do odległości 9 od startowego, by mieć szansę znaleźć wierzchołek, który zamyka cykl. Jeżeli graf jest ma dużo wierzchołków to te poszukiwania mogą trwać bardzo długo. Natura algorytmu DFS jest nastawiona na intensywne szukanie w jednym kierunku i istnieje duża szansa, że uda się znaleźć zamykający wierzchołek bez przeszukiwania dużej części grafu.

Kontrprzykładem jest wyszukiwanie najkrótszej drogi w grafie. Tu z kolei algorytm DFS niemal zawsze zwróci najgorszą możliwą odpowiedź, tj liczącą bardzo dużo elementów drogę. Natomiast algorytm BFS zawsze zwróci najkrótszą istniejącą.

Zdążyć się może, że dany graf będzie bardzo duży, lub wręcz nieskończony, wówczas nie może być mowy o sprawdzeniu wszystkich wierzchołków. Przykładem może być graf możliwych ruchów w grze w szachy:

wierzchołkiem startowym jest aktualny stan szachownicy, pozostałe wierzchołki to stany po wykonaniu ruchów, krawędzie oznaczają możliwość dotarcia do danej sytuacji poprzez naprzemienne ruchy graczy.

Zastosowanie algorytmu BFS do przeszukania takiego grafu, w celu znalezienia optymalnej strategii, daje możliwość sprawdzenia wszystkich możliwych zagrań, ale tylko takich, które uwzględniają kilka ruchów do przodu. Na więcej nie starczy czasu. Czasami jednak liczba ta okaże się wystarczająca. Algorytm DFS bez problemu obliczy co się może stać w tysięcznym ruchu, ale może przeoczyć inne bardzo groźne posunięcie przeciwnika w następnym ruchu, gdyż z braku czasu nie będzie w stanie do niego wrócić.

Stosowany bywa algorytm DFS z ograniczeniem na ilość ruchów jaka może zostać sprawdzona. Taka odmiana jest w stanie „myśleć” na wiele ruchów do przodu, a jednocześnie są duże szanse, że przejrzy wystarczająco dużo możliwości by nie dać się złapać w pułapkę.

Ogólnie należy stosować algorytm BFS, gdy mamy powody oczekiwać, że poszukiwany wierzchołek znajduje się niezbyt głęboko w grafie, lub zależy nam na rozwiązaniu niezbyt odległym od wierzchołka startowego. Powinniśmy natomiast korzystać z algorytmu DFS jeżeli zależy nam na szybkiej eksploracji w głąb grafu.

7 Graf dwudzielny

Graf nazywamy dwudzielnym kiedy wszystkie jego wierzchołki da się podzielić na dwa zbiory A i B, takie że:

(12)

Rysunek 2: Graf dwudzielny (po lewej). Po dodaniu krawędzi 7 → 8 powstały graf nie jest dwudzielny, wierzchołek 8 nie może zostać przypisany do kategorii lewej ze względu na krawędź 4 → 8, ani do kategorii prawej ze względu na krawędź 7 → 8.

• każdy z wierzchołków należy albo do A, albo do B,

v∈Vv ∈ A ∨ v ∈ B

• żaden wierzchołek grafu nie należy do obu jednocześnie, A ∩ B = ∅

• żadna para wierzchołków ze zbioru A nie jest połączona krawędzią w grafie, podobnie żadna para wierzchołków ze zbioru B również nie jest połączona krawędzią.

(u,v)∈E(u ∈ A ∧ v ∈ B) ∨ (u ∈ B ∧ v ∈ A) Dwudzielność określamy identycznie dla grafów skierowanych i nieskierowanych.

Przykładem grafu dwudzielnego jest taktyka krycia zawodników przeciwnej drużyny na meczu piłki nożnej. Wierzchołkami będą piłkarze, zaś ich podział będzie się pokrywał z podziałem na drużyny.

Członków własnej drużyny kryć nie trzeba, stąd brak krawędzi w obrębie jednej drużyny. Zauważmy, że drużyny mogą mieć odmienne taktyki krycia, więc ten graf jest niesymetryczny.

Innym przykładem może być przydział grup na sprawdzianie. Sprawdzian został przygotowany w dwóch zestawach. Uczniowie losowo usadowili się w ławkach, nauczyciel chciałby mieć pewność, że osoby siedzące blisko dostaną różne zestawy. Czy jest to możliwe?

Spójrzmy na problem nauczyciela jak na problem grafowy. Wierzchołkami w grafie oczywiście będą uczniowie. Krawędź pomiędzy uczniami oznacza, że uczniowie ci siedzą na tyle blisko siebie, że mogą rozczytać swoje prace. Co więcej jeżeli jeden z uczniów widzi pracę drugiego, to również i praca pierwszego ucznia jest w zasięgu wzroku drugiego z nich. Co za tym idzie, krawędzie są symetryczne, czyli graf jest nieskierowany. Jeżeli graf jest dwudzielny, to nauczyciel możne tak rozdać zestawy, że żadne dwie osoby, które siedzą za blisko siebie nie dostaną tego samego sprawdzianu.

Zastanówmy się jak można stwierdzić czy graf jest dwudzielny.

Strategia zachłanna okazuje się być w tym przypadku jak najbardziej skuteczna. Przeszukując graf w głąb kolejno przydzielamy wierzchołkom oznaczenie ich zbioru. Wszyscy sąsiedzi wierzchołka oznaczonego jako A muszą otrzymać oznaczenie B i na odwrót — sąsiedzi wierzchołka B otrzymują oznaczenie A. Jeżeli w trakcie poszukiwań natrafimy na wierzchołek, który powinien otrzymać jednocześnie oba oznaczenia, to oznacza, że graf nie jest dwudzielny. Jeżeli graf jest dwudzielny to wyżej wykonane oznaczenia są przykładowym podziałem zbioru wierzchołków grafu. Może być więcej niż jeden poprawny podział.

8 Zadanie

Napisz program. który wczyta kolejno:

• n liczbę wierzchołków w grafie n < 10000,

(13)

• m liczbę krawędzi w grafie,

• m par liczb a oraz b reprezentujących skierowane krawędzie z wierzchołka a do b, wierzchołki będą numerowane liczbami od 0 do n − 1 włącznie.

A następnie program powinien wypisać jako wynik „TAK”, jeżeli wczytany graf jest dwudzielny, lub

„NIE” w przeciwnym wypadku.

Przykładowe dane:

3 2 0 1 1 2

Odpowiedź:

TAK

Przykładowe dane:

3 3 0 1 1 2 2 1

Odpowiedź:

NIE

Wskazówki

• podstawowe przeszukiwanie grafu może nie dotrzeć do wszystkich wierzchołków w grafie, jeżeli ten nie jest spójny,

• graf jest skierowany — dla każdego wierzchołka wszyscy jego sąsiedzi muszą mieć inne oznaczenie:

zarówno ci połączeni krawędzią wchodzącą jak i wychodzącą,

• pamiętaj, że macierz sąsiedztwa zajmuje miejsce w pamięci rosnące kwadratowo z liczbą wierzchoł- ków w grafie.

Cytaty

Powiązane dokumenty

Błąd wyznaczania odległości w funkcji odległości między kamerami oraz rozdzielczości matrycy, dla zadanej odległości obiektu 5 m.. Porównanie błędu wyznaczania odległości

Napisz skrypt w perlu, który będzie prostym (by nie rzec: prymitywnym) tłumaczem tekstu z języka polskiego na angielski (lub w drugą stronę, wybór języka jest dowolny, ale

• (*) Skonstruuj dane jednowymiarowe składające się z trzech przykładów uczących, którego nie będzie wstanie rozwiązać perceptron, a który rozwiąże sieć.. Podaj

Przeformułuj hipotezy w powyższych testach tak aby błąd o poważniejszych konsekwen- cjach był błędem pierwszego rodzaju.. (*) — Zadanie dla chętnych, może paść na egzaminie

Jak sieć będzie działać dla problemu rozpoznawania małych liter na matrycy dużej rozdzielczości.. (*) — Zadanie dla chętnych, może paść na egzaminie na

Graf jest spójny, jeżeli z każdego wierzchołka da się dojść do wszystkich pozostałych. W przeciwnym wypadku graf jest określany jako

W tej sytuacji kodo- wanie sprowadza się do czytania jednego tekstu litery, sprawdzania w słowniku jej kodu i wpisywania go w drugim tekście.. Gdyby wszystkie prace domowe były

W tej sytuacji, jest zrozumiałe, że pojawia się coraz więcej opracowań z tego obszaru oświetlających to zjawisko z wielu punktów widzenia: psychologicznego – doszukującego