Grafurile sunt utile pentru a modela diverse probleme și au numeroase aplicații practice:
Puteți consulta capitolul “Elementary Graph Algorithms” din “Introduction to Algorithms” [0] pentru mai multe definiții formale. Această secțiune sumarizează principalele notații folosite în laboratoarele de PA.
Un graf G se definește ca fiind o pereche (V, E), unde V = {node / un nod oarecare din graf}, iar E = {(x, y) / (x, y) muchie in graf}.
Un graf este neorientat dacă relațiile dintre noduri sunt bidirecționale: oricare ar fi $(x, y)$ în $E$, există și $(y, x)$ în $E$. Relațiile se numesc muchii.
Un graf este orientat dacă relațiile dintre noduri sunt unidirecționale: $(x, y)$ este în $E$ nu implică neapărat $(y, x)$ în $E$. Relațiile se numesc arce.
O componentă conexă (CC) este o submulțime maximală de noduri, cu proprietatea că oricare ar fi două noduri x și y din aceasta, există drum de la x la y. Pentru grafuri orientate, o componentă conexă se numește componentă tare conexă (CTC).
Un graf aciclic este un graf (orientat/neorientat) care nu conține cicluri.
Problemele care se modelează folosind grafuri, de obicei, presupun explorarea spațiului. O parcurgere explorează fiecare nod al grafului, exact o singură dată, pornind de la un nod ales, numit în continuare nod sursă (EN: source). Modul de reprezentare ar grafului, poate influența performanța unei parcurgeri/unui algoritm.
Un graf poate fi modelat în mai multe moduri (folosind mai multe notații):
Reprezentarea în memorie a grafurilor se face, de obicei, cu liste de adiacență. Se pot folosi însă și alte structuri de date, care vor fi introduse pe parcurs.
Cele mai uzuale notații din laboratoarele de grafuri sunt descrise în Precizări laboratoare 07-12 (ex. $n$, $m$, $adj$, $adj\_trans$, $(x, y)$, etc).
Algoritmii de parcugere se pot folosi de o colorare a nodurilor:
Problemă: Să se parcurgă un graf dat. Fiecare nod se parcuge (exact) o singură dată. Algoritmi:
Parcurgerea în lățime (Breadth-first Search - BFS) este un algoritm de căutare în graf, în care, atunci când se ajunge într-un nod oarecare node, nevizitat, se vizitează toate nodurile nevizitate adiacente lui (notate pe rand cu neigh), apoi toate vârfurile nevizitate adiacente vârfurilor adiacente lui node, etc.
Atenție! BFS depinde de nodul de start source. Plecând din acest nod, se vor vizita toate nodurile accesibile. De exemplu, într-un graf neorientat, aceste noduri accesibile formează o componentă conexă; în urma aplicării algoritmului BFS asupra fiecărei componente conexe a grafului, se obține un arbore de acoperire a întregului graf (prin eliminarea muchiilor pe care nu le folosim la parcurgere). Pentru a putea reconstitui acest arbore, se păstrează pentru fiecare nod dat identitatea părintelui său. În cazul în care nu exista o funcție de cost asociată muchiilor, BFS va determina și drumurile minime de la rădăcină la oricare nod.
Pentru implementarea BFS se folosește o coadă.
// do a BFS traversal from source // // source = the source for the BFS traversal // nodes = list of all nodes from G // adj[node] = the adjacency list of node // example: adj[node] = {..., neigh, ...} => edge (node, neigh) BFS(source, G=(nodes, adj)) { // STEP 0: initialize results // d[node] = distance from source to node // p[node] = parent of node in the BFS traversal started from source // [optional] color[node] = white/gray/black // * white = not yet visited // * gray = visit in progress // * black = visited foreach (node in nodes) { d[node] = +oo; // distance not yet computed p(node) = null; // parent not yet found // [optional] color[node] = white; } // STEP 1: initialize a queue q = {} // STEP 2: add the source(s) into q d[source] = 0; // distance from source to source p[source] = null; // the source never has a parent (because it's the root of the traversal) q.push(source); // [optional] color[source] = gray; // STEP 3: start traversal using the node(s) from q while (!q.empty()) { // while still have nodes to explore // STEP 3.1: extract the next node from queue node = q.pop(); // [optional] STEP 3.2: print/use the node // STEP 3.3: expand/visit the node foreach (neigh in adj[node]) { // for each neighbour if (d[node] + 1 < d[neigh]) { // a smaller distance <=> color[neigh] == white d[neigh] = d[node] + 1; // update distance p[neigh] = node; // save parent q.push(neigh); // add neigh to the queue of nodes to be visited // [optional] color[neigh] = gray; } } // [optional] color[node] = black; } }
Parcurgerea în adâncime (Depth-First Search - DFS) pornește de la un nod dat (node), care este marcat ca fiind în curs de procesare. Se alege primul vecin nevizitat al acestui nod (neigh), se marchează și acesta ca fiind în curs de procesare, apoi și pentru acest vecin se caută primul vecin nevizitat, și așa mai departe. În momentul în care nodul curent nu mai are vecini nevizitati, se marchează că fiind deja procesat și se revine la nodul anterior. Pentru acest nod se caută primul vecin nevizitat. Algoritmul se repetă până când toate nodurile grafului au fost procesate.
În urma aplicării algoritmului DFS asupra fiecărei componente conexe a grafului, se obține pentru fiecare dintre acestea câte un arbore de acoperire (prin eliminarea muchiilor pe care nu le folosim la parcurgere). Pentru a putea reconstitui acest arbore, păstram pentru fiecare nod dat identitatea părintelui sau.
Pentru fiecare nod se vor reține:
Spre deosebire de BFS, pentru implementarea DFS se folosește o stivă (abordare LIFO în loc de FIFO). În practică, stiva nu va fi reținută explicit - ci ne vom baza pe recursivitate.
// do a DFS traversal from all nodes // // nodes = list of all nodes from G // adj[node] = the adjacency list of node // example: adj[node] = {..., neigh, ...} => edge (node, neigh) // DFS(G=(nodes, adj)) { // STEP 0: initialize results // p[node] = parent of node in the BFS traversal started from source // start[node] = the timestamp (the order) when we started visiting the node subtree // finish[node] = the timestamp (the order) when we finished visiting the node subtree // [optional] color[node] = white/gray/black // * white = not yet visited // * gray = visit in progress // * black = visited foreach (node in nodes) { p[node] = null; // parent not yet found // [optional] color[node] = white; } timestamp = 0; // the first timestamp before the DFS traversal foreach (node in nodes) { if (p[node] == null) { // or [optional] color[node] == white DFS_RECURSIVE(node, G, p, timestamp) } } } DFS_RECURSIVE(node, G=(node, adj), p, ref timestamp) { start[node] = ++timestamp; // start visiting its subtree // [optional] color[node] = gray; for (neigh in adj[node]) { // for each neighbour if (p[neigh] == null) { // or [optional] color[neigh] = white; p[neigh] = node; // save parent DFS_RECURSIVE(neigh, G, p, timestamp); // continue traversal } } finish[node] = ++timestamp; // finish visiting its subtree // [optional] color[node] = black; }
Arborele de parcurgere DFS cuprinde toate nodurile din graf împreună cu muchiile/arcele pe vizitate de DFS. Dacă graful nu este conex / tare conex, se obțin mai mulți arbori care formează o pădure de arbori DFS.
Exemplu:
Putem folosi o parcurgere DFS pentru a clasifica tipurile de muchii (toate muchiile din graf) relativ la arborele DFS curent.
tree-edge (T) / muchie de arbore = muchie (x, y) care conectează un nod x de copilul său y din arbore.
back-edge (B) / muchie înapoi = muchie (x, y) care conectează un nod x de un strămoș y (ambele noduri sunt în curs de vizitare).
Pentru graf orientat, mai există încă 2 tipuri de muchii (arce):
forward-edge (F) / muchie înainte /muchie de înaintare = muchie (x, y) care nu este tree-edge și care conecteză un nod x de un descendent y .
Într-un graf orientat muchiile (x, y) și (y, x) sunt diferite, deci pot avea tipuri diferite!
cross-edge (C) / muchie de traversare = muchie (x, y) care conectează un nod x de un nod y din alt subarbore.
Într-un graf orientat muchiile (x, y) și (y, x) sunt diferite, deci pot avea tipuri diferite!
În acest laborator vom studia doar problema sortare topologică.
O sortare topologică într-un graf orientat aciclic reprezintă o aranjare/permutare a nodurilor din graf care ține cont de arce.
Orientarea muchiilor corespunde unei relatii de ordine de la nodul sursa catre cel destinație: dacă $(x,y$) este un arc, $x$ trebuie să apară înaintea lui $y$ în inșiruire.
Sunt doi algoritmi cunoscuti pentru sortarea topologică.
Optimizare: Pentru a evita sortarea nodurilor in functie de timpul de finalizare, se poate folosi o stiva ce retine aceste noduri in ordinea terminarii parcurgerii (sau un vector care la final este inversat).
Optimizare: Pentru a evita ștergerea propriu-zisă a muchiilor din graf, se poate modifica gradul intern al fiecărui nod (care poate fi reținut într-un vector $in\_degree[node]$).
Ambele variante au aceeasi complexitate.
Prin citirea acestor precizari vă asigurați ca:
Se dă un graf neorientat cu n noduri și m muchii. Se mai dă un nod special source, pe care îl vom numi sursa.
Se cere să se găsească numărul minim de muchii ce trebuie parcurse de la source la toate celelalte noduri.
Convenție:
Se dă un graf orientat aciclic cu n noduri și m arce. Se cere să se găsească o sortare topologica validă.
Vectorul topsort va reprezenta o permutare a multimii ${1, 2, 3,..., n}$ reprezentand sortarea topologica gasita.
B1 Determinați componentele conexe ale unui graf neorientat. Puteți testa implementarea pe infoarena la problema dfs.
B2 Rezolvați problema muzeu pe infoarena.
[0] Chapter Elementary Graph Algorithms, “Introduction to Algorithms”, Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest and Clifford Stein
[1] https://en.wikipedia.org/wiki/Breadth-first_search