www.agora.ro

Noduri, muchii, arce ... (4)

Introducere în teoria GRAFURILOR

Cristian Cadar

Parcurgerea DF, prezentată într-un episod anterior al acestui serial are numeroase aplicații. Vă prezentăm acum două dintre ele: determinarea conexității unui graf și sortarea topologică.

Printr-un graf conex înțelegem un graf în care între oricare două noduri ale sale există cel puțin un drum. Determinarea conexității unui graf este o problemă deosebit de importantă, pentru a cărei rezolvare există mai mulți algoritmi. O primă variantă ar fi de exemplu crearea matricei drumurilor, așa cum s-a arătat în articolul despre determinarea drumurilor minime (algoritmul Roy-Warshall). Având construită această matrice, știm că graful este conex dacă și numai dacă toate elementele matricei sunt egale cu 1 (deci există drum între oricare două noduri ale grafului). De remarcat că această metodă se poate aplica atât grafurilor neorientate cât și celor orientate. Algoritmul în pseudocod este următorul:

boolean Conex(graf G)

D Ź A // A este matricea de adiacență

pentru k=1,n

pentru i=1,n

pentru j=1,n

dacă D(i,k)=1 și D(k,j)=1

D(i,j) Ź 1

c Ź adevărat

pentru i=1,n

pentru j=1,n

dacă D(i,j)=0

c Ź fals

Conex Ź c

Complexitatea acestui algoritm este O(n3), complexitate total ineficientă, dată de algoritmul Roy-Warshall.

În ceea ce privește grafurile neorientate, determinarea conexității unui graf se poate face foarte simplu și foarte eficient printr-o singură parcurgere DF sau BF (de obicei se alege DF). Parcurgerea se face dintr-un nod oarecare al grafului, iar graful va fi conex dacă și numai dacă toate nodurile acestuia au fost vizitate. Dacă acest lucru se întâmplă, vom avea un drum de la orice nod la rădăcina arborelui DF, și prin urmare, vom avea drum între oricare două noduri ale grafului, trecând prin rădăcină. Algoritmul în pseudocod este:

DF(nod k)

Vizitat(k) Ź adevărat

i Ź primul_vecin(k)

cât timp nu am parcurs toată lista vecinilor lui k

dacă nu Vizitat(i)

DF(i)

i Ź următorul_vecin(k)

boolean Conex(graf G)

pentru i=1,n

Vizitat(i) Ź fals

DF(1)

c Ź adevărat

pentru i=1,n

dacă nu Vizitat(i)

c Ź fals

Conex Ź c

Algoritmul are bineînțeles complexitatea O(n+m), complexitate optimă.

În cazul grafurilor neconexe, ne poate interesa aflarea componentelor sale conexe. Printr-o componentă conexă a unui graf, înțelegem o mulțime maximală de noduri ale grafului, cu proprietatea că între oricare două noduri ale sale există cel puțin un drum. Pentru a afla componentele conexe ale unui graf neorientat se procedează în felul următor: mai întâi se face o parcurgere DF din nodul 1, determinându-se componenta conexă în care se află acest nod, apoi se face o nouă parcurgere dintr-un nod nevizitat încă, determinându-se componenta conexă din care acesta face parte, și tot așa până când au fost vizitate toate nodurile:

DF(nod k)

afișează k

Vizitat(k) Ź adevărat

i Ź primul_vecin(k)

cât timp nu am parcurs toată lista vecinilor lui k

dacă nu Vizitat(i)

DF(i)

i Ź următorul_vecin(k)

Componente_Conexe(graf G)

pentru i=1,n

Vizitat(i) Ź fals

pentru i=1,n

dacă nu Vizitat(i)

afișează "O nouă componentă conexă:"

DF(i)

În ceea ce privește grafurile orientate, determinarea conexității (care în acest caz se numește tare conexitate) este ceva mai dificilă. Așa cum am amintit mai sus, algoritmul Roy-Warshall rămâne valabil și în acest caz, însă complexitatea lui (O(n3)) este mult prea mare.

Un algoritm mai eficient de determinare a componentelor tare conexe ale unui graf orientat (și implicit de determinare a tare conexității grafului) este algoritmul plus-minus, care acționează în felul următor: se face o parcurgere DF dintr-un nod oarecare al grafului (alegem de obicei nodul 1). Se marchează cu plus toate nodurile la care s-a ajuns în această parcurgere. Apoi, se face o nouă parcurgere DF, plecând din același nod, avansându-se acum pe muchiile care intră în noduri. De această dată, toate nodurile la care s-a ajuns în această parcurgere se marchează cu minus. De fapt, a doua parcurgere se face pe graful transpus al lui G. Acest graf se notează GT și se obține din graful G prin inversarea sensurilor muchiilor. De exemplu, figura 2 conține graful transpus al grafului din figura 1.

Revenind la algoritmul plus-minus, după ce realizăm cele două parcurgeri, putem afla componenta tare conexă din care face parte nodul 1; aceasta este alcătuită din nodurile care au fost marcate atât cu plus cât și cu minus. Explicația este simplă: cu plus sunt marcate nodurile la care se poate ajunge din nodul 1, iar cu minus sunt marcate nodurile din care se poate ajunge în nodul 1. Bineînțeles, componenta tare conexă a nodului 1 este reprezentată de intersecția celor două mulțimi, deoarece astfel vom avea drum între oricare două noduri, în ambele sensuri, trecând prin nodul 1.

Pentru a afla și celelalte componente tare conexe ale grafului, repetăm pașii de mai sus pentru nodurile care nu au fost incluse încă în nici o componentă tare conexă. Iată descrierea în pseudocod a algoritmului plus-minus:

DF_Plus_Minus(nod k, graf G, marcaj)

marchează cu 'marcaj' nodul k

i Ź primul_vecin(k)

cât timp nu am parcurs toata lista vecinilor lui k

dacă nu Vizitat(i)

DF_Plus_Minus(i,G,marcaj)

i Ź următorul_vecin(k)

Plus_Minus(graf G)

pentru i=1,n

v(i) Ź fals

pentru i=1,n

dacă nu v(i)

afișează "O nouă componentă tare conexă:"

Vizitat Ź fals

DF_Plus_Minus(i,G,+)

Vizitat Ź fals

DF_Plus_Minus(i,GT,-)

pentru i=1,n

dacă i este marcat cu plus și cu minus

v(i) Ź adevărat

afișează i

Acest algoritm are, în cazul mediu, complexitatea O(n+m), însă în cazul cel mai defavorabil atinge o complexitate de O(n2). În figura 4 este ilustrat un astfel de caz:

Algoritmul optim de aflare a componentelor tare conexe ale unui graf are complexitatea O(n+m) și se bazează pe algoritmul plus-minus. Acest algoritm realizează, de asemenea, două parcurgeri DF, una pe graful G inițial, iar cealaltă pe graful transpus GT. Diferența față de algoritmul anterior este că cea de-a doua parcurgere se face în funcție de timpii finali din prima parcurgere, asigurându-se astfel că fiecare nod este parcurs înaintea succesorilor săi. Acești timpi reprezintă timpii la care am terminat prelucrarea fiecărui nod. Determinarea lor se face în felul următor:

DF1(nod k)

Vizitat(k) Ź adevărat

i Ź primul_vecin(k)

cât timp nu am parcurs toată lista vecinilor lui k

dacă nu Vizitat(i)

DF1(i)

i Ź următorul_vecin(k)

timp Ź timp+1

// Variabila timp este inițializată cu 0

// înainte de apelarea procedurii

Final(k) Ź timp

Având timpii finali memorați în vectorul Final, parcurgerile DF pe graful transpus se vor face începând cu vârful cu cel mai mare timp final și continuând în ordine descrescătoare cu celelalte noduri care nu au fost incluse încă într-o componentă tare conexă. La fiecare dintre aceste parcurgeri, toate nodurile marcate formează o componentă tare conexă.

Spre exemplificare, să considerăm din nou graful din figura 1. Mai întâi, printr-o parcurgere DF, aflăm timpii finali pentru fiecare nod. Aceștia sunt:

Nod Final

1 12

2 11

3 10

4 7

5 9

6 6

7 1

8 2

9 5

10 3

11 4

12 8

Parcurgerea pe graful transpus se va face în ordine descrescătoare a timpilor finali. Se începe cu nodul 1; nodurile la care se ajunge din nodul 1 în graful GT sunt 1, 4, 3 și 2 care formează o primă componentă tare conexă. Din vârfurile rămase, îl alegem pe cel cu cel mai mare timp final, și anume vârful 5. Din acest vârf nu putem ajunge în nici un alt vârf neinclus încă într-o componentă tare conexă, deci el formează singur o componentă tare conexă. Similar pentru nodul 6. Apoi începem o nouă parcurgere din nodul 9 și aflăm componenta tare conexă formată din nodurile 9, 11și 10. Apoi pornim din nodul 8, aflând componenta tare conexă formată numai din acest nod și în final din nodul 7, aflând componenta tare conexă formată de asemenea numai din acest nod. Algoritmul descris în pseudocod va fi:

DF(nod k, graf G)

afișează_nodul_k

v(k) Ź adevărat

i Ź primul_vecin(k)

cât timp nu am parcurs toată lista vecinilor lui k

dacă nu V(i)

atunci DF(i,G)

i Ź următorul_vecin(k)

Tare_conexe(graf G)

pentru i=1,n

Vizitat(i) Ź fals

timp Ź 0

pentru i=1,n

dacă nu Vizitat(i)

DF(i,G)

pentru i=1,n

v(i) Ź fals

// v(i) indică dacă nodul i a fost inclus

// într-o componentă tare conexă

pentru fiecare nod k din graf

(parcurse în ordine descrescătoare a timpilor finali)

dacă nu v(i)

afișează "O nouă componentă tare conexă:"

DF(i,GT)

În cele ce urmează, vom prezenta încă o aplicație a parcurgerii DF, și anume sortarea topologică. Sortarea topologică reprezintă o sortare a nodurilor unui graf orientat cu proprietatea că un nod i se află înaintea unui nod j dacă și numai dacă nu există un drum de la nodul j la nodul i. Bineînțeles, dacă respectivul graf este ciclic, acesta nu poate fi sortat topologic. Pentru a înțelege mai bine conceptul de sortare topologică, să presupunem că avem de îndeplinit un număr de activități, cu următoarele restricții: pot exista activități care trebuie realizate neapărat înainte sau după alte activități. Problema cere furnizarea unei ordini de realizare a acestor activități care să respecte restricțiile cerute. Vom modela această problemă folosind un graf orientat în care nodurile sunt reprezentate de activitățile respective. În acest graf avem arc de la nodul i la nodul j dacă și numai dacă activitatea i trebuie îndeplinită înaintea activității j. Problema se reduce astfel la aflarea unei sortări topologice a acestui graf.

În primul rând trebuie să determinăm dacă graful este sau nu aciclic. Acest fapt presupune ca în parcurgerea DF să nu existe muchii de întoarcere, adică muchii care să ajungă într-un nod deja vizitat.

Dacă graful este aciclic, atunci există sigur o sortare topologică, aceasta fiind dată chiar de sortarea nodurilor în ordine descrescătoare a timpilor finali. Pentru a păstra complexitatea O(n+m), vectorul de noduri sortate topologic va fi realizat prin adăugarea fiecărui nod la începutul unei cozi. Algoritmul în pseudocod este următorul:

DF(nod k)

afișează_nodul_k

Vizitat(k) Ź adevărat

i Ź primul_vecin(k)

cât timp nu am parcurs toată lista vecinilor lui k

dacă nu Vizitat(i)

DF(i)

altfel afișează "Graful este ciclic"

i Ź următorul_vecin(k)

c Ź i // introduc nodul i în coada c

Topologic (graf G)

pentru i=1,n

Vizitat(i) Ź fals

timp Ź 0

pentru i=1,n

dacă nu Vizitat(i)

DF(i)

afișează_coada_C

Corectitudinea acestui algoritm este ușor de intuit: într-un graf orientat aciclic un nod i are un timp final mai mare decât timpul final al unui alt nod j, dacă și numai dacă prelucrarea nodului j a fost terminată înainte de a fi terminată prelucrarea nodului i, adică de la nodul j nu există arce spre nodul i.

De remarcat că ordinea în care sunt parcurse nodurile pentru determinarea tare conexității (algoritmul O(n+m)) este chiar ordinea furnizată de sortarea topologică.

O altă metodă de realizare a sortării topologice este următoarea:

- afișăm nodurile de grad interior 0 (deci nodurile în care nu intră nici un arc);

- eliminăm aceste noduri și arcurile incidente din graf;

- reactualizăm gradele nodurilor grafurilor;

- repetăm pașii anteriori fie până când am afișat toate nodurile, fie până când nu mai există noduri de grad interior 0 (graful conține cicluri și nu poate fi sortat topologic).

O primă variantă de implementare ar fi următoarea:

Topologic(graf G)

pentru k=1,n

d(k) Ź 0

// vectorul d reține gradele vârfurilor

pentru k=1,m

d(i) Ź d(i)+1

// muchia a k-a este muchia (i,j)

d(j) Ź d(j)+1

nr Ź 0

cât timp nr<n // nu am afișat toate nodurile

găsit Ź fals

pentru i=1,n

dacă d(i)=0

găsit Ź adevărat

afișează i

nr Ź nr+1

j Ź primul_vecin(i)

cât timp

nu am parcurs toată

lista vecinilor lui i

d(j) Ź d(j)-1

// reactualizez gradele

j Ź următorul_vecin(i)

// nodurilor grafului

dacă nu găsit

afișează "Eroare! - Graful este ciclic !!!"

Se remarcă ușor că algoritmul de mai sus are complexitatea O(n2), complexitate dată de căutarea nodurilor cu gradul interior 0. Pentru a-l aduce la complexitatea O(n+m), este suficient să folosim o structură de coadă în care să reținem nodurile cu gradul interior 0. Algoritmul în pseudocod devine:

Topologic(graf G)

pentru k=1,n

d(k) Ź 0

// vectorul d reține gradele vârfurilor

pentru k=1,m

d(i) Ź d(i)+1

// muchia a k-a este muchia (i,j)

d(j) Ź d(j)+1

pentru i=1,n

dacă d(i)=0

c Ź i

// se introduc în coadă nodurile cu gradul

// interior 0

nr Ź 0

cât timp c nu este vidă și nr<n

k Ź primul nod din c

afișează k

nr Ź nr+1

i Ź primul_vecin(k)

cât timp nu am parcurs toată lista vecinilor lui k

d(i) Ź d(i)-1 // se reactualizează

dacă d(i)=0 // gradele nodurilor

c Ź i // grafului și coada

următorul_vecin(k)

dacă nr<n

afișează "Eroare! - Graful este ciclic !!!"

Cristian Cadar este student în anul II la Universitatea Politehnică din București. Poate fi contactat la adresa ccadar@pcnet.pcnet.ro.

[cuprins]