Introducere în teoria grafurilor

În cele ce urmează vom prezenta o structură de date cu foarte multe aplicații atât în algoritmică, cât și în viața de zi cu zi, acestea fiind grafurile. Problema aflării existenței unor conexiuni sau aflării distanței minime între două noduri reprezintă un punct de plecare pentru majoritatea algoritmilor pe grafuri, teoria folosită în algoritmică fiind una vastă și plină de abordări ce se dovedesc a fi esențiale în foarte multe situații, atât competiționale, cât și în aplicații practice.

Noțiuni introductive

Terminologie

Un graf este o structură care corespunde unui grup de obiecte, în care unele perechi de obiecte sunt într-un anumit sens „legate” reciproc. Obiectele corespund unor abstracții matematice numite într-un graf noduri/vârfuri (numite și puncte) și fiecare legătură dintre perechile de obiecte asociate se numește muchie (numită și arc sau linie, prin care este și reprezentată).

O definiție mai riguroasă ce se va dovedi utilă este prezentată aici:

Noțiunea de graf

Un graf G=(V,E)G = (V, E) este o structură matematică compusă din două mulțimi:

  • VV (mulțimea vârfurilor sau nodurilor), care reprezintă obiectele;

  • EV×VE \subseteq V \times V (mulțimea muchiilor sau arcelor), care reprezintă legăturile între perechi de vârfuri.

Fiecare element vV v \in V este numit vârf (sau nod), iar fiecare element e=(u,v)E e = (u, v) \in E este numit muchie (sau arc). În mod obișnuit, grafurile sunt reprezentate grafic printr-un set de puncte (corespunzătoare vârfurilor) conectate prin linii sau curbe (corespunzătoare muchiilor).

Voi continua prin a defini termeni ce se dovedesc a fi esențiali pentru înțelegerea grafurilor.

Graf orientat și neorientat

Un graf neorientat G=(V,E)G = (V, E) este un graf în care perechile de vârfuri (u,v)E(u, v) \in E sunt neordonate. Aceasta înseamnă că, dacă (u,v)E(u, v)\in E este o muchie de la uu la vv, atunci și (v,u)E(v, u) \in E.

Graf orientat

Prin comparație, un graf orientat este un graf în care perechile de vârfuri (u,v)E(u, v) \in E sunt ordonate. Aceasta înseamnă că, dacă (u,v)E(u, v) \in E, atunci (v,u)∉E(v, u) \not\in E.

Noduri adiacente

Două noduri uu și vv sunt adiacente în graful G=(V,E)G = (V, E) dacă există o muchie (u,v)E(u, v) \in E. Adică, există o legătură directă între uu și vv.

Formal:

u și v sunt adiacente    (u,v)E sau (v,u)E (ıˆn cazul grafurilor neorientate)u \text{ și } v \text{ sunt adiacente} \iff (u, v) \in E \text{ sau } (v, u) \in E \text{ (în cazul grafurilor neorientate)}

Incidență

Folosim noțiunea de incidență pentru a descrie relația dintre noduri și muchii. O muchie (u,v)E(u, v) \in E este incidentă cu nodurile uu și vv.

Gradul unui nod

Definim gradul unui nod vv dintr-un graf G=(V,E)G = (V, E) ca fiind numărul de muchii incidente cu vv.

Într-un graf neorientat, gradul nodului vv, notat deg(v)\deg(v), este numărul de muchii care au vv ca una dintre extremități.

deg(v)={(u,v)E sau (v,u)EuV}\deg(v) = |\{(u, v) \in E \text{ sau } (v, u) \in E \mid u \in V\}|

Într-un graf orientat, se pot defini două tipuri de grad:

  • Gradul intern (numărul de muchii care intră în nodul vv), notat deg(v)\deg^-(v): deg(v)={(u,v)EuV} \deg^-(v) = |\{(u, v) \in E \mid u \in V\}|

  • Gradul extern (numărul de muchii care ies din nodul vv), notat deg+(v)\deg^+(v): deg+(v)={(v,u)EuV} \deg^+(v) = |\{(v, u) \in E \mid u \in V\}|

Observație

Într-un graf neorientat G=(V,E)G = (V, E):

vVdeg(v)=2k,kN\sum_{v \in V} \deg(v) = 2k,\,k \in \mathbb{N}

Explicația este dată de faptul că pentru fiecare muchie adăugată, gradul a două noduri crește cu 1.

Lanț

Numim lanț o secvență de noduri (v1,v2,...,vk)(v_1, v_2, ..., v_k) cu proprietatea că (vi,vi+1)E(v_i, v_{i + 1}) \in E oricare ar fi 1ik1 \leq i \leq k. Un lanț este -elementar* dacă vivjv_i \neq v_j oricare ar fi 1i<jk1 \leq i < j \leq k. Un lanț este simplu dacă (vi,vi+1)(vj,vj+1)(v_i, v_{i + 1}) \neq (v_j, v_{j+1}) oricare ar fi 1i<jk1 \leq i < j \leq k.

Altfel spus, un lanț elementar este un lanț cu nodurile distincte, iar un lanț simplu este un lanț cu muchii distincte.

Ciclu

O secvență de muchii (v1,v2,...,vk,v1)(v_1, v_2, ..., v_k, v_1) formează un ciclu dacă (vi,vi+1)E(v_i, v_{i + 1}) \in E pentru orice 1i<k1 \leq i < k și (vk,v1)E(v_k, v_1) \in E. Un ciclu este simplu dacă vivjv_i \neq v_j pentru orice 1i<j<k1 \leq i < j < k.

Altfel spus, un ciclu reprezintă o secvență de muchii ce nu se repetă, pleacă de la un nod v1v_1 și parcurgând în ordine acele muchii, se ajunge tot la nodul v1v_1. Un ciclu simplu este un ciclu în care nu se repetă noduri.

Lungimea unui lanț

Lungimea unui lanț (v1,v2,...,vk)(v_1, v_2, ..., v_k) este k1k-1 (numărul de muchii). Uneori, aceasta se definește ca fiind numărul de noduri, așadar lungimea acestui lanț este kk.

Graf parțial și subgraf

Definim graf parțial al unui graf dat ca fiind ceea ce rămâne din graful dat păstrând toate nodurile și eliminând eventual unele muchii, fără a adăuga muchii noi.

Formal spus, un graf parțial G=(V,E)G' = (V, E') a grafului G=(V,E)G = (V, E) este un graf unde EEE' \subseteq E.

Subgraf

Definim subgraf al unui graf dat ca fiind ceea ce rămâne din graful dat eliminând unele noduri și doar muchiile incidente lor, deci nu și alte muchii și fără să adăugăm alte muchii.

Formal spus, un subgraf G=(V,E)G' = (V', E') al unui graf G=(V,E)G = (V, E) este un graf unde VVV' \subseteq V și E{(u,v)Eu,vV}E' \subseteq \{(u, v) \in E \mid u, v \in V'\}.

Observație

Numărul de subgrafuri ale unui graf G=(V,E)G = (V, E) este 2V2^{|V|}, iar numărul de grafuri parțiale este 2E2^{|E|}, unde V=n|V| = n este numărul de noduri, iar E=m|E| = m este numărul de muchii al grafului.

Câteva tipuri speciale de grafuri

Se remarcă faptul că în funcție de tipul grafului, mai putem defini următoarele tipuri de grafuri, care se vor folosi în diferite aplicații. De notat ca pentru unele din aceste tipuri, vom avea probleme unde vom explica în detaliu noțiunile și aplicațiile unde folosim aceste concepte.

Graf complet $$K_n$$

Definim un graf complet Kn=(V,E)K_n = (V, E) cu V=n|V| = n ca fiind un graf unde (vi,vj)E 1i<jn(v_i, v_j) \in E\ \forall 1 \leq i < j \leq n. Altfel spus, fiecare nod este conectat cu toate celelalte noduri.

Numărul de muchii ale unui graf complet KnK_n este E=n(n1)2|E| = \frac{n(n-1)}{2}.

Graf bipartit

Definim un graf bipartit G=(A,B,E)G = (A, B, E) ca fiind un graf care poate fi împărțit în două submulțimi V=ABV = A \cup B cu AB=A \cap B = \emptyset, astfel încât, dacă aAa \in A, atunci acesta se poate conecta doar cu bBb \in B și viceversa.

Observație

Are loc următoarea relație pentru un graf bipartit G=(A,B,E)G = (A, B, E): aadeg(a)=bBdeg(b)=E\sum_{a \in a} \deg(a) = \sum_{b \in B} \deg(b) = |E|

Observație

Un graf este bipartit dacă și numai dacă acesta nu conține un ciclu de lungime impară.

Graf planar

Definim un graf planar ca fiind un graf care are proprietatea că poate fi reprezentat grafic fără ca două muchii să se intersecteze.

Graf regulat

Un graf regulat G=(V,E)G = (V, E) este un graf în care deg(v)=k vV\deg(v) = k\ \forall v \in V. Adică, fiecare nod din graf are același număr de muchii incidente.

Un graf regulat cu nodurile de gradul kk se numește graf kk-regulat.

Observație

Condiția necesară și suficientă pentru ca un graf kk-regulat de ordin nn să existe este ca nk+1n \geq k + 1 și că nknk este par.

Numărul de muchii este maxim într-un graf complet KnK_n, acesta fiind n(n1)2\frac{n(n - 1)}{2} cu fiecare nod de gradul n1n - 1. Așadar, k=n1k = n - 1, sau n=k+1n = k + 1 este nn minim pentru un kk anume. De asemenea, după relația de mai sus, avem nk2\frac{nk}{2} muchii, deci nknk trebuie să fie par.

Lucrul cu grafuri. Moduri de reprezentare în memorie

Un concept foarte important în teoria grafurilor reprezintă modul în care parcurgem aceste structuri de date și cum putem verifica proprietățile de care avem nevoie, de la o problemă la alta.

Să considerăm graful neorientat din figura următoare:

Acest graf are 13 noduri și 12 muchii, acestea fiind (1,4)(1, 4), (1,3)(1, 3), (4,9)(4, 9), (9,3)(9, 3), (4,2)(4, 2), (4,6)(4, 6), (2,6)(2, 6), (2,5)(2, 5), (8,12)(8, 12), (8,11)(8, 11), (8,10)(8, 10), (8,7)(8, 7).

Pentru a reprezenta un graf în memorie, există trei moduri principale de a o face, cu distincția că în practică se va folosi doar reprezentarea prin liste de vecini.

Matrice de adiacență

Definim matricea de adiacență a unui graf ca fiind o matrice binară pentru care aij=1a_{ij} = 1 dacă și numai dacă avem muchie de la nodul ii la nodul jj și aij=0a_{ij} = 0 în caz contrar.

Definiție

Pentru un graf neorientat, matricea este mereu simetrică, adică aij=aji i,ja_{ij} = a_{ji}\ \forall i, j.

Pentru graful nostru de mai sus, aceasta este matricea de adiacență la care ajungem.

Listă de vecini

Definim o listă de vecini ca fiind o listă (de regulă, alocată dinamic) pe care o folosim pentru a ține în memorie pentru fiecare nod doar nodurile adiacente cu acesta, această metodă fiind cea mai eficientă din punct de vedere practic pentru a parcurge grafurile.

Exemplu

Pentru graful de mai sus, aceasta este lista de vecini:

NodVecini
1{3,4}\{3,4\}
2{4,5,6}\{4,5,6\}
3{1,9}\{1,9\}
4{1,2,9}\{1,2,9\}
5{2}\{2\}
6{2,4}\{2, 4\}
7{8}\{8\}
8{7,10,11,12}\{7,10,11,12\}
9{3,4}\{3,4\}
10{8}\{8\}
11{8}\{8\}
12{8}\{8\}
13\emptyset

Definiție

Definim o listă de muchii ca fiind o listă pe care o folosim pentru a ține toate muchiile în memorie. Deși nu este o variantă prea practică de a efectua parcurgerile, această metodă poate fi utilă pentru anumiți algoritmi ce se bazează în principal pe prelucrarea muchiilor, un astfel de exemplu fiind arborele parțial de cost minim.

Exemplu

În cazul nostru, lista de muchii este: {1,4}\{1, 4\}, {1,3}\{1, 3\}, {4,9}\{4,9\}, {9,3}\{9,3\}, {4,2}\{4,2\}, {4,6}\{4,6\}, {2,6}\{2,6\}, {2,5}\{2,5\}, {8,12}\{8,12\}, {8,11}\{8,11\}, {8,10}\{8,10\}, {8,7}\{8,7\}.

Conexitate. Parcurgerea DFS

Problema aflării conexității unui graf este una din problemele fundamentale ale teoriei grafurilor, fiind adesea folosită drept un exemplu esențial în explicarea și înțelegerea grafurilor.

Graf conex

Un graf conex este un graf neorientat în care există o cale între oricare două noduri. Cu alte cuvinte, oricare două noduri din graf sunt conectate direct sau indirect printr-o serie de muchii.

Componenta conexă

O componentă conexă a unui graf este un subgraf conex maximal, adică un subgraf în care oricare două noduri sunt conectate printr-o serie de muchii, iar acest subgraf nu poate fi extins adăugând vreun alt nod sau vreo altă muchie fără a pierde proprietatea de conexitate.

Pentru a rezolva problema aflării conexității unui graf, va trebui să parcurgem graful folosind unul din algoritmii consacrați pentru această problemă. În cazul de față, vom continua prin a explica parcurgerea în adâncime a grafului (DFS sau depth-first search), una din parcurgerile optime pentru această problemă.

Parcurgerea în adâncime (DFS)

Parcurgerea în adâncime (DFS) este un algoritm de explorare a grafului care începe de la un nod ales și vizitează cât mai mult posibil din vecinii acestuia înainte de a se întoarce înapoi. Aceasta se realizează printr-o strategie de backtracking recursivă sau prin utilizarea unei stive (stack).

DFS începe de la un nod și vizitează toate nodurile pe care le poate atinge în adâncime înainte de a reveni la nodurile anterioare și verifică dacă un nod a fost deja vizitat pentru a evita bucle infinite.

Observație

Complexitatea parcurgerii în adâncime (DFS) este O(V+E)\mathcal{O}(\lvert V \rvert + \lvert E \lvert), unde V\lvert V \rvert reprezintă numărul de noduri sau vârfuri și E\lvert E \rvert reprezintă numărul de muchii.

Observație

În probleme se notează convențional V\lvert V \rvert cu NN de la noduri, respectiv E\lvert E \rvert cu MM de la muchii.

Observație

Se remarcă faptul că un nod va fi vizitat la un moment dat doar o singură dată, deci dacă avem muchiile (1,2)(1, 2), (1,3)(1, 3) și (2,3)(2, 3), iar DFS-ul pleacă din 1, 2 va fi accesat din 1, iar 3 va fi accesat din 2.

Observație

Se poate remarca faptul că ordinea în care vizităm nodurile în graf depinde de ordinea în care sunt adăugate muchiile în graf, acest lucru înseamnă că nu putem folosi DFS pentru anumite probleme, de exemplu cele la care trebuie aflată distanța minimă în graf.

Ca o recapitulare (sau de fapt comparație) între BFS și DFS, să comparăm fiecare abordare împreună cu o funcție:

=== "DFS"

--8 < --"usor/graphs/dfsbfs.cpp:dfs"

=== "BFS"

--8 < --"usor/graphs/dfsbfs.cpp:bfs"

Problema connected components

Kilonova

View Problem

Kilonova

Could not fetch problem details

Deschide

Se dă un graf neorientat GG cu NN noduri și MM muchii. Să se afle câte componente conexe are graful dat.

Pentru a afla numărul de componente conexe ale unui graf, putem folosi parcurgerea DFS pentru a afla toate nodurile din care apelăm DFS din funcția main, acesta fiind și răspunsul la problema noastră.

--8 < --"usor/graphs/connectedcomponents.cpp"

Drumuri minime. Parcurgerea BFS

Dacă în cazul parcurgerii DFS putem să o aplicăm fără mari probleme pentru o varietate destul de largă de probleme cu grafuri, totuși nu este suficientă pentru problemele ce țin de distanțe. Un exemplu fundamental este acela al aflării drumului minim între două sau mai multe noduri într-un graf dat.

Drum minim

Un drum minim este lungimea minimă a unui lanț care leagă două noduri din graf.

Motivul pentru care nu putem afla drumul minim între două noduri folosind DFS este acela că ordinea în care nodurile sunt parcurse în DFS depinde de ordinea în care sunt date muchiile de la intrare, parcurgerea recursivă făcând aflarea distanțelor minime imposibilă. Astfel, vom introduce un alt mod de a parcurge graful nostru.

Parcurgerea în lățime (BFS)

Parcurgerea în lățime** (BFS, engl. breadth-first search) a unui graf ca fiind o parcurgere iterativă ce pleacă de la unul sau mai multe noduri, iar la fiecare pas, dacă ne aflăm la un nod xx, vom vizita vecinii nevizitați ai nodului xx, adăugându-i într-o coadă, nodurile fiind parcurse în ordinea în care au fost adăugate în coadă.

Observație

Complexitatea parcurgerii în lățime (BFS) este O(V+E)\mathcal{O}(\lvert V \rvert + \lvert E \lvert), unde V\lvert V \rvert reprezintă numărul de noduri sau vârfuri și E\lvert E \rvert reprezintă numărul de muchii.

Observație

Se poate remarca faptul că ordinea în care vizităm nodurile în graf va fi aceeași cu ordinea crescătoare a distanței minime față de nodul sau nodurile inițiale, datorită faptului că ele vor fi inserate în coadă în ordinea în care acestea au fost adăugate.

Problema Simple Shortest Path

Kilonova

View Problem

Kilonova

Could not fetch problem details

Deschide

Se dă un graf neorientat GG cu NN noduri și MM muchii, precum și un nod SS. Să se afle lungimea drumului minim dintre SS și fiecare nod din graf, inclusiv SS.

Pentru a rezolva această problemă, vom pleca cu un BFS din nodul SS și vom afla pe parcurs, distanțele minime față de toate celelalte noduri.

--8 < --"usor/graphs/simpleshortestpath.cpp"

Problema grarb

InfoArena

View Problem

InfoArena
Deschide

Se dă un graf GG neorientat cu NN noduri numerotate de la 1 la NN și MM muchii. Determinați numărul minim de muchii care trebuie eliminate și numărul minim de muchii care trebuie adăugate în graful GG astfel încât acesta sa devina arbore.

Această problemă se împarte în două subprobleme relativ ușor de identificat - aflarea componentelor conexe ale grafului (dacă avem CC componente conexe, va fi nevoie de C1C - 1 muchii pentru a transforma graful într-unul conex), precum și aflarea numărului de muchii care trebuie scoase pentru a transforma graful în arbore (la final, trebuie să ne rămână N1N-1 muchii). Astfel, vom avea nevoie de C1C - 1 muchii noi și va trebui să scoatem M+C1(N1)M + C - 1 - (N - 1) = M+CNM + C - N muchii pentru a avea un arbore.

--8 < --"usor/graphs/grarb.cpp"

Problema graf

Kilonova

View Problem

Kilonova

Could not fetch problem details

Deschide

Se dă un graf neorientat conex cu NN noduri și două noduri XX și YY, să se afle nodurile ce aparțin tuturor lanțurilor optime între XX și YY.

Pentru a rezolva această problemă, va trebui mai întâi să aflăm folosind o parcurgere de tip BFS distanțele minime de la XX și YY spre toate celelalte noduri. Apoi, pentru fiecare distanță dd de la 0 la dist(X,Y)\operatorname{dist}(X, Y), vrem să aflăm câte noduri se află pe unul din drumurile optime de la XX la YY la o distanță dd față de XX. În cele din urmă, vrem să afișăm nodurile situate la distanțele care apar o singură dată în mulțimea nodurilor ce fac parte din cel puțin un drum optim de la XX la YY.

Codul sursă se poate viziona mai jos.

--8 < --"usor/graphs/graf.cpp"

Detectarea unui ciclu simplu - Round Trip

CSES

View Problem

CSES
Deschide

În această problemă, trebuie să găsim un ciclu simplu într-un graf neorientat. Există foarte mulți algoritmi care rezolvă această problemă corect, dar un algoritm care este foarte simplu și ușor de înțeles constă în următorii pași:

  • Pentru fiecare componentă conexă, vom rula un DFS din unul din noduri și vom afla un arbore DFS (pentru fiecare nod din componentă, vom ține prv[i]prv[i] drept nodul de unde am ajuns să vizităm nodul ii, într-un mod similar cu modul în care ținem părinții unui nod în arbore).
  • Dacă la un pas dăm de un nod care a fost deja vizitat și nu este părintele nodului curent, înseamnă că avem un ciclu între cele două noduri în cauză.
  • Pentru cele două noduri găsite, vom folosi vectorul prv creat anterior pentru a merge înapoi prin nodurile găsite, iar acest drum va fi ciclul pe care îl vom obține.

Mai jos găsiți implementarea C++ a soluției descrise mai sus.

--8 < --"usor/graphs/roundtrip.cpp"

Probleme suplimentare

Lectură suplimentară