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

Transkrypt

RKI — Zajęcia 14 — Przeszukiwanie grafu w głąb
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 startowy oraz wierzchołek szukany. Naszym celem będzie stwierdzić czy ze startowego da się dojść do szukanego 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 startowego 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).
1
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 rekurencyjnych) 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 — oczywiś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.
2
#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 ++);
rek ( arg ) ;
}
// r e k ( )
i n t main ( i n t a r g c , char ∗∗ a r g v ) {
rek ( 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ą niejawny 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 startowego, 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.
3
Dla list sąsiedztwa Obie wersje algorytmu (tj. iteracyjna i rekurencyjna) wykonują po jednym obrocie 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 definiowany 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ć wszystkich 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
4
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 ( ) {
// wczytujemy 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 . push back ( v ) ;
}
// f o r i
int 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 . at ( a ) . push back ( 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 >> szukany ;
// 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 odwiedzony
czyOdwiedzony [ s t a r t o w y ] = true ;
// z n a l e z l i s m y
i f ( szukany == 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 ( ) ;
int s a s i a d = s a s i e d z i . at ( startowy ) . at ( i ) ;
}
// j e z e l i s a s i a d j e s z c z e n i e odwiedzony
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
5
i ++){
}
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 ( ) {
// wczytujemy 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 . push back ( v ) ;
}
// f o r i
int 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 . at ( a ) . push back ( 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 >> szukany ;
// 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 . top ( ) ;
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 == szukany ) {
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
int s a s i a d = s a s i e d z i . at ( w i e r z c h o l e k ) . at ( 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
6
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 Vector<Vector<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 ) {
// wczytujemy g r a f
S c a n n e r s = new S c a n n e r ( System . i n ) ;
int n = s . nextInt ( ) ;
int m = s . nextInt ( ) ;
k r a w e d z i e = new Vector<Vector<I n t e g e r > >();
f o r ( i n t i =0; i <n ; i ++){
Vector<I n t e g e r > v = new Vector<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 ++){
int a = s . nextInt ( ) ;
int b = s . nextInt ( ) ;
// 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
int startowy = s . nextInt ( ) ;
i n t szukany = 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 , szukany ) ;
System . out . 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 szukany ) {
// 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 == szukany ) {
return true ;
}
// i f
}
}
// c l a s s
// 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 ++){
int s a s i a d = krawedzie . get ( startowy ) . get ( i ) ;
// j e z e l i j e s z c z e n i e odwiedzony 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 , szukany ) ;
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 ( )
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 ;
7
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 ) ;
int n = s . nextInt ( ) ;
i n t m =s . n e x t I n t ( ) ;
Vector<Vector<I n t e g e r >> k r a w e d z i e = new Vector<Vector<I n t e g e r > >();
f o r ( i n t i =0; i <n ; i ++){
Vector<I n t e g e r > v = new Vector<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 ++){
int a = s . nextInt ( ) ;
int b = s . nextInt ( ) ;
k r a w e d z i e . g e t ( a ) . add ( b ) ;
}
// f o r
int startowy = s . nextInt ( ) ;
i n t szukany = 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
Stack<I n t e g e r > s t o s = new Stack<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 odpowiedz = 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 == szukany ) {
odpowiedz = true ;
stos . clear ();
break ;
}
// i f
}
4
}
// c l a s s
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 ++){
int s a s i a d = krawedzie . get ( w i e r z c h o l e k ) . get ( 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 . out . f o r m a t ( ”%s \n” , odpowiedz ? ”TAK” : ”NIE” ) ;
// main
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ą wierzchoł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.
8
Rysunek 1: Cykl w grafie nieskierowanym (po lewej), graf skierowany bez cyklu (środkowy), graf skierowany zawierający cykl (po prawej).
Uwaga: Nie zachodzi implikacja w przeciwną stronę. Ścieżki w grafie skierowanym mogą być jednoznaczne 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 potomkiem. 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 potomkó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:
9
• 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 ∗ odwiedzony ;
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 . push back ( v ) ;
}
// f o r i
int 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 . at ( a ) . push back ( 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
int s t a r t = 0 ;
odwiedzony = new i n t [ n ] ;
f o r ( i n t i =0; i <n ; i ++){
odwiedzony [ i ] = 0 ;
}
// f o r
bool wynik = f a l s e ;
while ( s t a r t < n ) {
i f ( odwiedzony [ s t a r t ] ! = 0 ) {
s t a r t ++;
} else {
bool w = d f s 3 a ( s t a r t ) ;
wynik = wynik | | w ;
}
//
if
}
// 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 ( odwiedzony [ s t a r t ] == 1 ) {
return true ;
}
e l s e i f ( odwiedzony [ s t a r t ] == 2 ) {
return f a l s e ;
}
// i f
odwiedzony [ 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 ( ) ;
int s a s i a d = s a s i e d z i . at ( s t a r t ) . at ( 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
10
i ++){
}
// 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 srawdzony
odwiedzony [ s t a r t ] = 2 ;
}
5.2
return f a l s e ;
// d f s ( )
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 przeszukiwać. 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:
11
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∈V v ∈ 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,
12
• 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
0
1
2
3
1
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.
13

Podobne dokumenty