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

Podobne dokumenty
RKI Zajęcia 13 Przeszukiwanie grafu wszerz

Struktury danych i złożoność obliczeniowa Wykład 5. Prof. dr hab. inż. Jan Magott

Grafem nazywamy strukturę G = (V, E): V zbiór węzłów lub wierzchołków, Grafy dzielimy na grafy skierowane i nieskierowane:

Ogólne wiadomości o grafach

Przykłady grafów. Graf prosty, to graf bez pętli i bez krawędzi wielokrotnych.

Algorytmy grafowe. Wykład 2 Przeszukiwanie grafów. Tomasz Tyksiński CDV

Reprezentacje grafów nieskierowanych Reprezentacje grafów skierowanych. Wykład 2. Reprezentacja komputerowa grafów

Porównanie algorytmów wyszukiwania najkrótszych ścieżek międz. grafu. Daniel Golubiewski. 22 listopada Instytut Informatyki

Algorytmy i Struktury Danych.

Wstęp do programowania

8. Wektory. Przykłady Napisz program, który pobierze od użytkownika 10 liczb, a następnie wypisze je w kolejności odwrotnej niż podana.

1. Algorytmy przeszukiwania. Przeszukiwanie wszerz i w głąb.

a) 7 b) 19 c) 21 d) 34

Wstęp do Programowania potok funkcyjny

Digraf o V wierzchołkach posiada V 2 krawędzi, zatem liczba różnych digrafów o V wierzchołkach wynosi 2 VxV

6. Pętle while. Przykłady

AiSD zadanie trzecie

Algorytm selekcji Hoare a. Łukasz Miemus

Rozwiązywanie problemów metodą przeszukiwania

. Podstawy Programowania 2. Algorytmy dfs i bfs. Arkadiusz Chrobot. 2 czerwca 2019

Algorytmiczna teoria grafów

Sortowanie topologiczne skierowanych grafów acyklicznych

Algorytm DFS Wprowadzenie teoretyczne. Algorytm DFS Wprowadzenie teoretyczne. Algorytm DFS Animacja. Algorytm DFS Animacja. Notatki. Notatki.

7. Pętle for. Przykłady

Wstęp do informatyki- wykład 7

Temat: Struktury danych do reprezentacji grafów. Wybrane algorytmy grafowe.

Wykład 7. Algorytmy grafowe

Wstęp do sieci neuronowych, wykład 12 Łańcuchy Markowa

Zofia Kruczkiewicz, Algorytmu i struktury danych, Wykład 14, 1

ĆWICZENIE 1: Przeszukiwanie grafów cz. 1 strategie ślepe

Algorytmy i struktury danych. Drzewa: BST, kopce. Letnie Warsztaty Matematyczno-Informatyczne

Programowanie - wykład 4

Programowanie obiektowe

Programowanie obiektowe

Wykład 10 Grafy, algorytmy grafowe

Podstawy programowania 2. Temat: Drzewa binarne. Przygotował: mgr inż. Tomasz Michno

Opis zagadnieo 1-3. Iteracja, rekurencja i ich realizacja

Matematyka dyskretna

Wstęp do sieci neuronowych, wykład 11 Łańcuchy Markova

Analiza algorytmów zadania podstawowe

Wstęp do Informatyki zadania ze złożoności obliczeniowej z rozwiązaniami

Instrukcje dla zawodników

Kurs programowania. Wykład 9. Wojciech Macyna. 28 kwiecień 2016

MATEMATYKA DYSKRETNA - MATERIAŁY DO WYKŁADU GRAFY

Algorytmy grafowe. Wykład 1 Podstawy teorii grafów Reprezentacje grafów. Tomasz Tyksiński CDV

WYKŁAD 9. Algorytmy sortowania elementów zbioru (tablic) Programy: c4_1.c... c4_3.c. Tomasz Zieliński

Znajdowanie wyjścia z labiryntu

operacje porównania, a jeśli jest to konieczne ze względu na złe uporządkowanie porównywanych liczb zmieniamy ich kolejność, czyli przestawiamy je.

Wstęp do Programowania potok funkcyjny

Wstęp do programowania

Dynamiczny przydział pamięci w języku C. Dynamiczne struktury danych. dr inż. Jarosław Forenc. Metoda 1 (wektor N M-elementowy)

Wstęp do programowania

Podstawy Programowania 2 Grafy i ich reprezentacje. Plan. Wstęp. Teoria grafów Graf skierowany. Notatki. Notatki. Notatki. Notatki.

. Podstawy Programowania 2. Grafy i ich reprezentacje. Arkadiusz Chrobot. 9 czerwca 2016

Wstęp do programowania

Algorytmy i Struktury Danych.

Strategia "dziel i zwyciężaj"

Pole wielokąta. Wejście. Wyjście. Przykład

4. Funkcje. Przykłady

Algorytmy i str ruktury danych. Metody algorytmiczne. Bartman Jacek

Złożoność obliczeniowa klasycznych problemów grafowych

Pomorski Czarodziej 2016 Zadania. Kategoria C

E: Rekonstrukcja ewolucji. Algorytmy filogenetyczne

Podstawy programowania skrót z wykładów:

Algorytmy i złożoności. Wykład 3. Listy jednokierunkowe

Zasady programowania Dokumentacja

Podstawy Programowania C++

Pętle i tablice. Spotkanie 3. Pętle: for, while, do while. Tablice. Przykłady

Kurs programowania. Wykład 9. Wojciech Macyna

Algorytm Dijkstry znajdowania najkrótszej ścieżki w grafie

EGZAMIN - Wersja A. ALGORYTMY I STRUKTURY DANYCH Lisek89 opracowanie kartki od Pani dr E. Koszelew

Matematyczne Podstawy Informatyki

Wstęp do Sztucznej Inteligencji

Wykład X. Programowanie. dr inż. Janusz Słupik. Gliwice, Wydział Matematyki Stosowanej Politechniki Śląskiej. c Copyright 2016 Janusz Słupik

Rozwiązanie. #include <cstdlib> #include <iostream> using namespace std;

Lista 0. Kamil Matuszewski 1 marca 2016

Algorytmy i Struktury Danych

Podstawy programowania 2. Temat: Funkcje i procedury rekurencyjne. Przygotował: mgr inż. Tomasz Michno

Literatura. 1) Pojęcia: złożoność czasowa, rząd funkcji. Aby wyznaczyć pesymistyczną złożoność czasową algorytmu należy:

Podstawy algorytmiki i programowania - wykład 2 Tablice dwuwymiarowe cd Funkcje rekurencyjne

Algorytmy z powracaniem

Wysokość drzewa Głębokość węzła

Wstęp do informatyki- wykład 12 Funkcje (przekazywanie parametrów przez wartość i zmienną)

WYŻSZA SZKOŁA INFORMATYKI STOSOWANEJ I ZARZĄDZANIA

do instrukcja while (wyrażenie);

Wstęp do programowania

Podstawowe własności grafów. Wykład 3. Własności grafów

3. Instrukcje warunkowe

Uniwersytet Zielonogórski Wydział Elektrotechniki, Informatyki i Telekomunikacji Instytut Sterowania i Systemów Informatycznych

Wykład 8. Drzewo rozpinające (minimum spanning tree)

TEORETYCZNE PODSTAWY INFORMATYKI

Informacje wstępne #include <nazwa> - derektywa procesora umożliwiająca włączenie do programu pliku o podanej nazwie. Typy danych: char, signed char

Wstęp do programowania

Informatyka II. Laboratorium.

Sztuczna Inteligencja i Systemy Doradcze

Algorytmy i Struktury Danych.

Teoria grafów podstawy. Materiały pomocnicze do wykładu. wykładowca: dr Magdalena Kacprzak

5. Rekurencja. Przykłady

Wprowadzenie do maszyny Turinga

Zajęcia nr 2 Programowanie strukturalne. dr inż. Łukasz Graczykowski mgr inż. Leszek Kosarzewski Wydział Fizyki Politechniki Warszawskiej

Transkrypt:

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 rek ( int arg ){ p r i n t f ( %d\n, arg ++); rek ( arg ) ; } // rek ( ) int main ( int argc, char argv ){ 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(n 2 ). 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 3 2 1 0 2 0 0 1 Przykład 2: Odpowiedź: NIE 4

3.1 Rozwiązanie w C++ Rozwiązanie rekurencyjne #include <iostream > #include <vector > // zmienne g l o b a l n e int n, m; std : : vector <std : : vector <int> > s a s i e d z i ; bool czyodwiedzony ; bool d f s ( int, int ) ; int main ( ) { // wczytujemy g r a f std : : c i n >> n >> m; std : : vector <int> v ; s a s i e d z i. push back ( v ) ; i int a, b ; for ( int i =0; i <m; i ++){ std : : 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 skierowany wiec nie dodajemy s y m e t r y c z n i e int szukany, startowy ; std : : c i n >> startowy >> szukany ; // t a b l i c a pomocnicza czyodwiedzony = new bool [ n ] ; czyodwiedzony [ i ] = f a l s e ; // algorytm d f s bool wynik = d f s ( szukany, startowy ) ; std : : cout << ( wynik? TAK\n : NIE\n ) ; return 0 ; } // main bool d f s ( int szukany, int startowy ){ // oznaczamy j a k o odwiedzony czyodwiedzony [ startowy ] = true ; // z n a l e z l i s m y i f ( szukany == startowy ){ return true ; // d l a kazdego s a s i a d a for ( int i =0; i < ( int ) s a s i e d z i. at ( startowy ). s i z e ( ) ; i ++){ 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 nie odwiedzony i f ( czyodwiedzony [ s a s i a d ] == f a l s e ){ // to go sprawdzamy bool wynik = d f s ( szukany, s a s i a d ) ; i f ( wynik == true ){ // zwracamy wynik return true ; // nie z n a l e z l i s m y 5

return f a l s e ; } // d f s ( ) Rozwiązanie iteracyjne: #include <iostream > #include <vector > #include <stack > using namespace std ; int n, m; std : : vector <std : : vector <int> > s a s i e d z i ; bool czyodwiedzony ; int main ( ) { // wczytujemy g r a f std : : c i n >> n >> m; std : : vector <int> v ; s a s i e d z i. push back ( v ) ; i int a, b ; for ( int i =0; i <m; i ++){ std : : 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 skierowany wiec nie dodajemy s y m e t r y c z i e int szukany, startowy ; std : : c i n >> startowy >> szukany ; // t a b l i c a pomocnicza czyodwiedzony = new bool [ n ] ; czyodwiedzony [ i ] = f a l s e ; // dodajemy s t a r t o w y w e z e na s t o s std : : stack <int> s t o s ; s t o s. push ( startowy ) ; czyodwiedzony [ startowy ] = true ; bool wynik = f a l s e ; // dopoki 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 ze s t o s u int 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 ; // d l a kazdego s a s i a d a for ( int i =0; i < ( int ) s a s i e d z i. at ( 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 nieodwiedzony to 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 } // w h i l e std : : cout << ( wynik? TAK\n : NIE\n ) ; return 0 ; } // main 6

3.2 Rozwiązanie w Javie Rozwiązanie rekurencyjne: import java. u t i l. Scanner ; import java. u t i l. Vector ; public class 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 >> krawedzie ; 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 Scanner s = new Scanner ( System. i n ) ; int n = s. n e x t I n t ( ) ; int m = s. n e x t I n t ( ) ; krawedzie = new Vector<Vector<I n t e g e r >>(); Vector<I n t e g e r > v = new Vector<I n t e g e r >(); krawedzie. add ( v ) ; for ( int i =0; i <m; i ++){ int a = s. n e x t I n t ( ) ; int b = s. n e x t I n t ( ) ; // g r a f j e s t nieskierowany wiec nie dodajemy syme try czn ie krawedzie. get ( a ). add ( b ) ; int startowy = s. n e x t I n t ( ) ; int szukany = s. n e x t I n t ( ) ; // t a b l i c a pomocnicza czyodwiedzony = new boolean [ n ] ; czyodwiedzony [ i ] = f a l s e ; // DFS boolean wynik = d f s ( startowy, szukany ) ; System. out. format ( %s \n, ( wynik? TAK : NIE ) ) ; return ; } // main public s t a t i c boolean d f s ( int startowy, int szukany ){ // z n a l e z l i s m y czyodwiedzony [ startowy ] = true ; i f ( startowy == szukany ){ return true ; // d l a kazdego s a s i a d a... for ( int i =0; i <krawedzie. get ( startowy ). 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 nie odwiedzony to 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 // nie 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 java. u t i l. Scanner ; import java. u t i l. Stack ; import java. u t i l. Vector ; 7

public class RKI DFS ITER { public s t a t i c void main ( S t r i n g [ ] a r g s ) { Scanner s = new Scanner ( System. i n ) ; int n = s. n e x t I n t ( ) ; int m =s. n e x t I n t ( ) ; Vector<Vector<I n t e g e r >> krawedzie = new Vector<Vector<I n t e g e r >>(); Vector<I n t e g e r > v = new Vector<I n t e g e r >(); krawedzie. add ( v ) ; for ( int i =0; i <m; i ++){ int a = s. n e x t I n t ( ) ; int b = s. n e x t I n t ( ) ; krawedzie. get ( a ). add ( b ) ; int startowy = s. n e x t I n t ( ) ; int szukany = s. n e x t I n t ( ) ; boolean czyodwiedzony [ ] = new boolean [ n ] ; czyodwiedzony [ i ] = f a l s e ; 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 ( startowy ) ; czyodwiedzony [ startowy ] = true ; boolean odpowiedz = f a l s e ; while ( s t o s. s i z e ( ) > 0){ int 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 ; s t o s. c l e a r ( ) ; break ; for ( int i =0; i < krawedzie. get ( 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 ) ; } // w h i l e System. out. format ( %s \n, odpowiedz? TAK : NIE ) ; } // main } // c l a s s 4 Cykl w grafie Cykl w grafie nieskierowanym jest drogą (listą wierzchołków) postaci: A 1 A 2... A k A 1, gdzie wszystkie krawędzie A 1 A 2,..., A k 1 A k, A k A 1 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: A 1 A 2... A k A 1, gdzie wszystkie krawędzie A 1 A 2,..., A k 1 A k, A k A 1 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 <iostream > #include <vector > int n, m; std : : vector <std : : vector <int> > s a s i e d z i ; bool czyodwiedzony ; int odwiedzony ; bool d f s ( ) ; int main ( ) { std : : c i n >> n >> m; std : : vector <int> v ; s a s i e d z i. push back ( v ) ; i int a, b ; for ( int i =0; i <m; i ++){ std : : 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 skierowany wiec nie dodajemy s y m e t r y c z n i e int s t a r t = 0 ; odwiedzony = new int [ n ] ; odwiedzony [ i ] = 0 ; 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; } // w h i l e std : : cout << ( wynik? TAK\n : NIE\n ) ; return 0 ; } // main ( ) bool d f s ( int s t a r t ){ i f ( odwiedzony [ s t a r t ] == 1){ return true ; } else i f ( odwiedzony [ s t a r t ] == 2){ return f a l s e ; odwiedzony [ s t a r t ] = 1 ; // d l a kazdego s a s i a d a for ( int i =0; i < ( int ) s a s i e d z i. at ( s t a r t ). s i z e ( ) ; i ++){ int s a s i a d = s a s i e d z i. at ( s t a r t ). at ( i ) ; // przeszukujemy 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 ) ; // przekazujemy informacje o c y k l u i f ( r e s == true ){ return true ; 10

i // wszyscy s a s i e d z i sprawdzeni // oznaczamy w i e r z c h o l e k j a k o srawdzony odwiedzony [ 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 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 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. 13