Sunteți pe pagina 1din 516

Cuprins

CUPRINS
Prefa ..................................................................................vii
1. Introducere .......................................................................... 11
1.1.
1.2.

Noiuni despre limbaj ........................................................................13


Noiuni despre notaia asimptotic..................................................14

2. Algoritmi de sortare............................................................. 21
2.1.
2.2.
2.3.
2.4.
2.5.
2.6.
2.7.
2.8.

Bubble sort .........................................................................................23


Insertion sort ......................................................................................24
Quicksort ............................................................................................27
Merge sort ..........................................................................................33
Heapsort .............................................................................................37
Counting sort......................................................................................45
Radix sort............................................................................................48
Concluzii .............................................................................................55

3. Tehnici de programare......................................................... 57
3.1.
3.2.
3.3.
3.4.
3.5.

Recursivitate ......................................................................................59
Backtracking .......................................................................................68
Divide et impera ................................................................................82
Greedy ................................................................................................89
Programare dinamic ........................................................................96

4. Algoritmi matematici ......................................................... 109


4.1.
4.2.
4.3.
4.4.
4.5.
4.6.
4.7.
4.8.
4.9.

Noiuni despre aritmetica modular ............................................. 111


Algoritmul lui Euclid........................................................................ 112
Algoritmul lui Euclid extins............................................................. 114
Numere prime ................................................................................. 116
Algoritmul lui Gauss........................................................................ 130
Exponenierea logaritmic ............................................................. 136
Inveri modulari, funcia totenial ............................................... 143
Teorema chinez a resturilor ......................................................... 145
Principiul includerii i al excluderii ................................................ 150
iii

Algoritmic
4.10. Formule i tehnici folositoare ........................................................ 151
4.11. Operaii cu numere mari ................................................................ 154

5. Algoritmi backtracking ....................................................... 167


5.1.
5.2.
5.3.
5.4.
5.5.

Problema labirintului ...................................................................... 169


Problema sriturii calului ............................................................... 173
Generarea submulimilor ............................................................... 175
Problema reginelor ......................................................................... 177
Generarea partiiilor unei mulimi ................................................ 180

6. Algoritmi generali .............................................................. 183


6.1.
6.2.

Algoritmul K.M.P. (Knuth Morris Pratt)................................... 185


Evaluarea expresiilor matematice ................................................. 190

7. Introducere n S.T.L. ........................................................... 197


7.1.
7.2.
7.3.
7.4.

Containere secveniale ................................................................... 199


Containere adaptoare .................................................................... 205
Containere asociative ..................................................................... 210
Algoritmi S.T.L. ................................................................................ 220

8. Algoritmi genetici............................................................... 227


8.1.
8.2.
8.3.

Descrierea algoritmilor genetici .................................................... 229


Problema gsirii unei expresii ........................................................ 236
Rezolvarea sistemelor de ecuaii ................................................... 241

9. Algoritmi de programare dinamic.................................... 245


9.1.
9.2.
9.3.
9.4.
9.5.
9.6.
9.7.
9.8.
9.9.
9.10.
9.11.
9.12.
9.13.
9.14.

Problema labirintului algoritmul lui Lee..................................... 247


Problema subsecvenei de sum maxim .................................... 258
Problema subirului cresctor maximal ........................................ 262
Problema celui mai lung subir comun ......................................... 269
Problema nmulirii optime a matricelor ...................................... 273
Problema rucsacului 1 .................................................................... 276
Problema rucsacului 2 .................................................................... 279
Problema plii unei sume 1 .......................................................... 280
Problema plii unei sume 2 .......................................................... 283
Numrarea partiiilor unui numr ................................................. 284
Distana Levenshtein ...................................................................... 286
Determinarea strategiei optime ntr-un joc ................................. 289
Problema R.M.Q. (Range Minimum Query) .................................. 292
Numrarea parantezrilor booleane............................................. 296
iv

Cuprins
9.15. Concluzii .......................................................................................... 300

10. Algoritmi de geometrie computaional ......................... 301


10.1.
10.2.
10.3.
10.4.
10.5.
10.6.
10.7.

Convenii de implementare ........................................................... 303


Reprezentarea punctului i a dreptei ............................................ 304
Panta i ecuaia unei drepte .......................................................... 305
Intersecia a dou drepte ............................................................... 306
Intersecia a dou segmente ......................................................... 308
Calculul ariei unui poligon .............................................................. 311
Determinarea nfurtorii convexe (convex hull) ....................... 313

11. Liste nlnuite ................................................................. 323


11.1.
11.2.
11.3.
11.4.
11.5.

Noiuni introductive ....................................................................... 325


Tipul abstract de date list simplu nlnuit ............................... 327
Aplicaii ale listelor nlnuite ........................................................ 339
Tipul abstract de date list dublu nlnuit ................................. 343
Dancing Links .................................................................................. 354

12. Teoria grafurilor ............................................................... 355


12.1. Noiuni teoretice............................................................................. 357
12.2. Reprezentarea grafurilor n memorie ........................................... 360
12.3. Probleme introductive ................................................................... 364
12.4. Parcurgerea n adncime ............................................................... 369
12.5. Parcurgerea n lime ..................................................................... 380
12.6. Componente tare conexe .............................................................. 388
12.7. Determinarea nodurilor critice ...................................................... 391
12.8. Drum i ciclu eulerian ..................................................................... 394
12.9. Drum i ciclu hamiltonian............................................................... 399
12.10. Drumuri de cost minim n grafuri ponderate ............................... 404
12.11. Reele de transport......................................................................... 423
12.12. Arbore parial de cost minim ......................................................... 438
12.13. Concluzii .......................................................................................... 445

13. Structuri avansate de date............................................... 447


13.1.
13.2.
13.3.
13.4.
13.5.
13.6.

Skip lists (liste de salt) .................................................................... 449


Tabele de dispersie (Hash tables) .................................................. 455
Arbori de intervale problema L.C.A. ........................................... 464
Arbori indexai binar....................................................................... 474
Arbori de prefixe (Trie) ................................................................... 481
Arbori binari de cutare (Binary Search Trees) ............................ 488
v

Algoritmic
13.7. Arbori binari de cutare cutare echilibrai ................................. 504
13.8. Concluzii .......................................................................................... 514

Bibliografie ..................................................................... 515

vi

Prefa

Prefa
Aceast carte este util tuturor celor care doresc s studieze
conceptele fundamentale ce stau la baza programrii calculatoarelor,
mbinnd principalele direcii de cercetare pe care un viitor programator sau
absolvent al domeniului informatic ar trebui s le parcurg i s le
cunoasc.
Cartea este conceput ca o colecie de probleme demonstrative a
cror rezolvare acoper elemente de programare procedural, tehnici de
programare, algoritmi i structuri de date, inteligen artificial i nu n
ultimul rnd programare dinamic.
Pentru fiecare problem n parte sunt construii algoritmii clasici de
rezolvare, completai cu explicaia funcionrii acestora, iar n completare,
acolo unde este necesar, problemele dispun i de prezentarea noiunilor
teoretice, a conceptelor generale i particulare aferente construirii unui
algoritm optimizat.

Organizare
Cartea este structurat pe 13 capitole, fiecare dintre acestea tratnd
una dintre temele specifice ale algoritmicii:
Capitolul 1 cuprinde noiunile generale referitoare la modul n care trebuie
citit aceast carte, ce cuprinde aceast carte i ce trebuie avut n vedere n
evaluarea algoritmilor.
Capitolul 2 trateaz algoritmii de sortare cei mai cunoscui (reprezentativi).
Fiecare dintre acetia a fost prezentat din punct de vedere al complexitii
asimptotice, eficienei, memoriei suplimentare folosite, stabilitii i a
optimizrilor suportate.
Capitolul 3 descrie tehnicile de programare i principalele probleme
asociate acestora: recursivitate cu dezvoltarea complet a problemei
turnurile din Hanoi, backtracking cu generarea permutrilor,
aranjamentelor, combinrilor, ... divide et impera, cutarea binar, tehnica

vii

Algoritmic
greedy cu problema spectacolelor..., noiuni i tehnici de programare
dinamic.
Capitolul 4 prezint o serie de algoritmi care au la baz noiuni elementare
de matematic i teoria numerelor dintre care amintim cei mai cunoscui:
algoritmul lui Euclid, algoritmii de determinare a numerelor prime,
algoritmul lui Gauss i ali algoritmi mai puin cunoscui cum ar fi teorema
chinez a resturilor. Am completat acest capitol cu un paragraf destinat
numerelor mari i operaiile asociate acestora.
Capitolul 5 trateaz problemele clasice asociate tehnicii de programare
backtracking: problema labirintului, problema sriturii calului, generarea
submulimilor, problema reginelor, generarea partiiilor unei mulimi...(n
general, acele probleme care apar n examenele asociate cu materia tehnici
de programare, n.a.)
Capitolul 6 dezvolt algoritmul K.M.P. (Knuth Morris Pratt) i
algoritmul de evaluare a expresiilor matematice. Am folosit un capitol
special destinat acestor algoritmi deoarece acetia nu pot fi ncadrai ntr-o
categorie aparte i sunt totui algoritmi necesari i cu aplicabilitate teoretic
vast.
Capitolul 7 prezint pe scurt principalele containere i algoritmi din
Biblioteca S.T.L. i modul de folosire a acestora n cteva situaii concrete.
Capitolul 8 prezint algoritmii genetici, modul de construire a acestora,
conceptele de evoluie i optimizare ce stau la baza construirii unui astfel de
algoritm, de asemenea i implementarea, att din punct de vedere
demonstrativ, n analogie cu o problem clasic, ct i implementarea n
probleme a cror rezolvare se preteaz la aceste clase de algoritmi.
Capitolul 9 prezint mai multe aplicaii ale programrii dinamice. Tot n
acest capitol se insist mai mult pe facilitile limbajului C++.
Capitolul 10 prezint metode de rezolvare a unor probleme de geometrie
computaional. Aceast ramur a informaticii are aplicaii practice
importante n programe de grafic, aplicaii CAD, aplicaii de modelare,
proiectarea circuitelor integrate i altele.

viii

Prefa
Capitolul 11 prezint noiunile elementare despre liste nlnuite, att la
nivel teoretic, modul de construire, tipuri, ct i implementarea acestora.
Dei listele nlnuite exist deja implementate n cadrul librriei S.T.L.
(containerul list), este important pentru orice programator s cunosc modul
de construire al unui tip abstract de date de tip list, tipurile de date derivate
(stiv, coad, list circular), respectiv domeniul de aplicabilitate al
acestora.
Capitolul 12 trateaz n detaliu principalele structuri de date folosite n
teoria grafurilor i algoritmii cei mai des folosii pentru rezolvarea
problemelor cu grafuri.
Capitolul 13 prezint, n ncheiere, structurile avansate de date, deoarece
acestea necesit noiuni de grafuri, liste, operaii pe bii, recursivitate,
tehnici de programare, matematic i S.T.L., aa c recomandm stpnirea
tuturor capitolelor anterioare nainte de parcurgerea acestui capitol final.

Convenii utilizate
Liniile de cod surs prezentate n aceast carte respect standardele
C++ n vigoare la data publicrii. Au fost testate pe compilatoarele g++ i
Visual Studio Express (versiunea minim testat este 2005) i pe sistemul de
operare Windows (XP i 7) pe 32 de bii.
Programele prezentate sunt scrise n aa fel nct s fie uor de
neles pentru cineva care cunoate bine bazele limbajului de programare
C++.
Fragmentele de cod vor fi scrise cu italic i colorate sintactic pentru
a fi uor de recunoscut.

ix

Algoritmic

Despre Autori
La data publicrii acestei cri
Laslo E. Eugen este asistent la Facultatea de tiine a Universitii din
Oradea pe laboratoarele i seminariile de Algoritmi i structuri de date,
Tehnici de programare i Inteligen artificial. Masterat n domeniul
matematicii cu specializarea Analiz Real i Complex. A fost inginer de
software la SC SoftParc, unde a ajutat la proiectarea i realizarea produselor
software ale acestei firme. Laslo Eugen locuiete n Oradea mpreun cu
soia i fiica lui.
Ionescu Vlad Sebastian este student n anul II la Facultatea de tiine a
Universitii din Oradea. ncepnd cu clasa a X-a a obinut diverse premii i
meniuni la olimpiade i concursuri judeene i naionale de informatic.
Este pasionat de informatic nc din clasele primare. Domeniile sale
principale de interes sunt optimizarea algoritmilor, programarea funcional
i inteligena artificial.

Introducere

1. Introducere
Acest prim capitol are ca scop familiarizarea cititorului cu
elementele constructive ale crii, cunotiinele iniiale necesare nelegerii
materialul de fa, conveniile de scriere i prezentare a secvenelor de cod,
tot aici sunt cuprinse noiunile generale privind analiza complexitii
algoritmilor prin studiul timpului de execuie i cantitii de memorie
utilizat de ctre acetia (notaia asimptotic).

11

Capitolul 1

CUPRINS
1.1. Noiuni despre limbaj ...............................................................................13
1.2. Noiuni despre notaia asimptotic .........................................................14

12

Introducere

1.1. Noiuni despre limbaj


Secvenele de cod prezentate n aceast carte respect standardele
C++ n vigoare la data publicrii. Acestea au fost testate pe compilatoarele
g++ (versiuni mai mari de 3) i Visual Studio Express (versiunea minim
testat este 2005) i pe sistemul de operare Windows (XP i 7) pe 32 de bii.
Dei este aproape imposibil testarea codului pe toate compilatoarele
C++ existente, programele prezentate ar trebui s funcioneze pe orice
compilator care respect standardele limbajului C++.
Atenie: programele prezentate nu vor funciona pe compilatoarele
de DOS Borland din anii 80. Acele compilatoare sunt vechi, nu mai au
niciun folos practic i nu respect standardele moderne, motiv pentru care
am ales folosirea unor compilatoare mai noi.
Programele prezentate sunt scrise n aa fel nct s fie uor de
neles pentru cineva care cunoate relativ bine bazele limbajului de
programare C++. Nu se va pune accent pe explicarea limbajului, ci pe
nelegerea algoritmilor, aa c sunt necesare cunotiine despre limbajul
C++.
Implementrile fiecrui algoritm respect, n mare parte, nescrise de
calitate a codului. Am ncercat clarificarea implementrilor prin evitarea
variabilelor globale, ceea ce este o marc a calitii codului, dar totodat am
numerotat tablourile ncepnd de la 1, nu de la 0 aa cum este normal n
contextul limbajelor din familia C. Aceast decizie a fost luat din dou
motive: n primul rnd calculele devin mai naturale i mai uor de neles,
programele devenind mai apropiate de modul natural de rezolvare a
problemelor i de pseudocodul prezentat, cu att mai mult cu ct poziia 0
este de multe ori un caz particular pentru probleme de programare dinamic,
deci dac am ncepe numerotarea de la 0, am scrie mai mult cod tratnd
aceste cazuri particulare.
n al doilea rnd, numerotarea de la 0 servete ca un exerciiu
permanent pentru cititorii acestei cri: s modifice fiecare implementare
prezentat astfel nct numerotarea s se fac de la 0 i nu de la 1. Uneori
acest lucru nu este foarte uor.
De cele mai multe ori, implementrile ncap pe o singur pagin,
astfel nct s fie uor de urmrit i de neles. Mai mult, vom evita uneori
prezentarea unor lucruri care se consider cunoscute, cum ar fi fiierele antet
care trebuie incluse, a modului de apelare a unor funcii etc.

13

Capitolul 1
Implementrile unor subalgoritmi, care se repet des, nu vor fi
ntotdeauna prezentate exact la fel ca nainte. Pot s difere nume de variabile
i chiar modul de implementare (structurile de date folosite, funciile, stilul
de scriere a codului etc.). Acest lucru se datoreaz faptului c scopul acestei
lucrri este s dezvolte o gndire algoritmic liber i deschis la nou. Nu
trebuie niciodat nvat pe de rost o anumit metod de rezolvare, ci
trebuie neles un algoritm, care apoi poate fi implementat n mai multe
moduri. Considerm c prin diversificarea implementrilor contribuim la
educarea cititorului n acest scop.
Se va evita, pe ct posibil, folosirea conceptelor avansate de
programare orientat pe obiecte. Implementrile prezentate vor folosi, n
general, doar partea procedural a limbajului C++. Unele programe care
sunt simplificate prin folosirea claselor sau structurilor vor folosi aceste
faciliti, dar nu sunt necesare dect cunotiine de baz a programrii
orientate pe obiecte pentru nelegerea acestora.
Fragmentele de cod vor fi scrise cu italic i colorate sintactic pentru
a fi uor de recunoscut.

1.2. Noiuni despre notaia asimptotic


n matematic, notaia asimptotic (cunoscut i sub denumirile de
notaia Landau i notaia O-mare) descrie comportamentul unei funcii
atunci cnd argumentele sale tind ctre anumite valori sau ctre infinit,
folosind alte funcii mai simple.
n informatic, aceast notaie ne permite s exprimm eficiena unui
algoritm (timpul su de execuie i cantitatea de memorie folosit de ctre
acesta) fr a ine cont de resursele unui anumit sistem de referin. Aadar,
este o modalitate de a exprima eficiena teoretic (sau estimativ) a unui
algoritm. Analiza asimptotic a algoritmilor ne poate ajuta n alegerea unui
anumit algoritm optim pentru rezolvarea unei probleme care poate fi
rezolvat prin mai multe metode.
Rezultatele obinute folosind notaia asimptotic vor fi exprimate n
funcie de dimensiunile datelor de intrare cnd acestea tind la infinit. Notaia
asimptotic ne ofer o funcie care reprezint o limit superioar numrului
de operaii efectuate de ctre algoritmul analizat.
Formal, fie f o funcie definit pe mulimea numerelor naturale, cu
valori n aceeai mulime, iar f(N) numrul exact de operaii efectuate de
14

Introducere
ctre un algoritm dac dimensiunea datelor de intrare este N. Putem scrie
f(N) este O(g(N)), f(N) = O(g(N)) sau f(N) O(g(N)) i citi complexitatea
algoritmului este de ordinul g(N), dac i numai dac exist un numr real
pozitiv C i un numr natural N0 astfel nct:
|f(N)| C|g(N)|, N N 0, unde g este o funcie care (de obicei) nu
conine constante.
Cnd funcia f este o constant, complexitatea algoritmului se scrie
O(1).
Pentru a nelege mai bine aceast notaie i pentru a evidenia modul
de folosire al acesteia n aceast carte, vom prezenta cteva secvene de cod
pentru care vom calcula complexitatea.
Secvena 1
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
cout << i << " * " << j << " = " << i * j << '\n';
Analiza complexitii aestei secvene este foarte uoar: pentru
fiecare din cele N valori ale lui i, variabila j va lua la rndul ei tot N valori,
afindu-se aadar N2 linii, fiecare linie coninnd 6 atomi lexicali (i, * ,
j, = , i * j, \n). Aadar, f(N) = 6N2. Pentru C = 6, obinem
complexitatea algoritmului O(N2). Nu ntotdeauna putem preciza cu
exactitate numrul de operaii efectuate de ctre algoritm. Chiar i pe acest
exemplu, nu putem fi siguri c instruciunea cout efectueaz exact 6
operaii, deoarece nu tim cum este implementat aceast funcie (sau, mai
corect spus, obiect). n orice caz, nu ne intereseaz dect termenul care l
conine pe N la puterea cea mai mare, aa cum va reiei din secvena
urmtoare.
Secvena 2
for ( int i = 1; i <= N; ++i )
for ( int j = i + 1; j <= N; ++j )
cout << i << " * " << j << " = " << i * j << '\n';
De aceast dat vom ignora numrul de operaii introduse de cout,
deoarece acest numr este oricum constant i nu va influena n niciun fel
rezultatul, deoarece constantele nu au nicio semnificaie atunci cnd N tinde
15

Capitolul 1
la infinit. Vom numra doar numrul de incrementri ale variabilelor din
cadrul celor dou for-uri.
Cnd i = 1, j se va incrementa de N 1 ori.
Cnd i = 2, j se va incrementa de N 2 ori.
...
Cnd i = N, j se va incrementa de 0 ori.
Se observ ca i se va incrementa de N ori.
Aadar, numrul de incrementri ale ambelor variabile este egal cu
N + (N 1) + (N 2) + ... + 2 + 1, sum egal cu
()

= 0.5 2 0.5 .

Cnd N tinde la infinit, singurul termen care prezint interes este


0.5N , aa c este de ajuns s gsim o funcie care, nmulit cu o constant,
s mrgineasc superior doar acest termen. Aceast funcie poate fi chiar N 2 ,
iar constanta 1. Aadar, complexitatea acestui algoritm este tot O(N2).
2

Secvena 3
...
int st[maxn], k = 1;
...
for ( int i = 1; i <= N; ++i )
{
while ( st[k] >= A[i] )
--k;
st[++k] = A[i];
}
i de aceast dat avem o structur repetitiv n cadrul altei structuri
repetitive, aa c am putea fi tentai s spunem c i acest algoritm are
complexitatea O(N2 ). Structura while nu se va executa de fiecare dat de N
ori, ci n total de N ori, aa c acest algoritm are complexitatea O(N). O alt
modalitate de a argumenta aceast complexitate este prin faptul c n tabloul
st se rein valori din tabloul A, iar la fiecare pas i se scot elemente din st
atta timp ct valoarea acestora este mai mare dect A[i]. Aadar, fiecare
element va fi introdus n st i ters din st cel mult o singur dat, deci se vor
efectua cel mult 2N operaii.

16

Introducere
Fcnd o analogie cu timpul de execuie, putem spune c memoria
folosit de ctre algoritm este de ordinul lui N, sau c memoria folosit
este O(N), deoarece tabloul st va conine N elemente n cel mai ru caz.
Secvena 4
for ( int i = 1; i * i <= N; ++i )
cout << i << '\n';
Complexitatea este O(sqrt(N)), unde sqrt(x) reprezint radical din
x, deoarece i * i <= N poate fi rescris ca i <= (int)sqrt(N).
Secvena 5
for ( int i = 1; i <= N; i *= 2 )
cout << i << '\n';
Complexitatea este O(log N), deoarece i se dubleaz la fiecare pas.
Am putea fi tentai s scriem complexitatea ca O(log2 N), dar acest lucru ar
fi o greeal, deoarece tim c:
=


, , 1 , > 0

Aadar, orice logaritm difer de un un logaritm n alt baz printr-o


contant, iar n notaia asimptotic nu se trec constante, aa c, dac apar
logaritmi n notaia asimptotic, se va folosi logaritmul nedefinit,
semnificnd un logaritm ntr-o baz oarecare.
Mai trebuie menionat c, din modul n care am definit notaia
asimptotic deducem c am putea spune c toi algoritmii prezentai au
complexitatea O(N3) sau O(N4) sau chiar O(N2010). Putem ntr-adevr s
facem acest lucru, deoarece notaia asimptotic reprezint o limit
superioar oarecare i nu o limit strns. Pentru limite inferioare i limite
strnse exist dou notaii diferite, dar mai puin folosite:
1. Notaia Theta: spunem c f(N) = (g(N)) dac are loc, pentru
nite constante pozitive C1 i C2 , dubla inegalitate:
C1|g(N)| |f(N)| C2 |g(N)|, N N0
2. Notaia Omega: spunem c f(N) = (g(N)) dac are loc, pentru
o constant pozitiv C, inegalitatea:
|f(N)| C|g(N)|, N N0
17

Capitolul 1
Cu alte cuvinte, notaia Theta nseamn c funcia f este mrginit
att superior ct i inferior de funcia g, iar notaia Omega nseamn c
funcia f este mrginit inferior de ctre funcia g. Notaia O-mare nseamn
c funcia f este mrginit superior de ctre funcia g.
n aceast carte vom folosi, pentru simplitate, doar notaia O-mare,
dar vom da de fiecare dat limite superioare strnse (exacte) n cadrul
acesteia. Aceast notaie are avantajul de a fi mai uor de calculat dect
notaia Theta, deoarece de multe ori este mai uor de gsit o limit
superioar oarecare dect o limit superioar exact. Totui, pentru
algoritmii prezentai n aceast carte gsirea unei limite exacte nu va fi un
lucru foarte dificil, aa c recomandm cititorilor s exprime toate
complexitile prezentate att n notaia Theta ct i n notaia Omega, pe
lng notaia asimptotic oferit.
Exerciii precizai complexitile urmtoarelor secvene de cod,
folosind toatecele trei notaii prezentate
1.
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= M; ++j )
++k;

2.
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j + i <= N; ++j )
++k;

3.
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
if ( (i + j) % 2 == 0 )
for ( int k = 1; k <= j; ++k )
cout << i + j + k <<'\n';

4.
for ( int i = 1; i * i <= N; ++i )
for ( int j = 1; j <= N; j *= 2 )
cout << i + j << '\n';

5.
for ( int i = 1; i <= N*N; i *= 2 ) cout << i << endl;

18

Introducere
6.
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j * j <= N; j++ )
cout << i + j << '\n';

7.
int f(int k)
{
if ( !k ) return 1;
return k * f(k - 1);
}

8.
for ( int i = 1; i <= 2010; ++i )
for ( int j = 1; j <= N; ++j )
for ( int k = 2010; k; --k )
if ( i + j > j + k )
cout << "Ok!\n";

9.
while ( st <= dr )
{
if ( A[st] == A[dr] )
{
for ( int i = st; i <= dr; ++i )
cout << A[i] << ' ';
cout << endl;
}
++st;
--dr;
}

10.
void f(int N)
{
if ( !N ) return;
for ( int i = 1; i <= N; ++i )
cout << i << ' ';
cout << endl;
f(N / 2);
}

19

Capitolul 1
Fiecare algoritm prezentat n continuare pe parcursul crii va fi
nsoit de complexitatea sa asimptotic, uneori fr a mai prezenta
deducerea acesteia!
Tabelul de mai jos prezint cteva categorii asimptotice, denumirile
acestora i exemple de algoritmi pentru fiecare categorie. Majoritatea
algoritmilor menionai se vor regsi n capitolele urmtoare.
Tabelul 1.2.1. Principalele categorii de complexitate a algoritmilor
Denumirea
Complexitate
Exemple de algoritmi
complexitii
Determinarea minimului dintr-un
liniar
ir, afiarea unui ir, problema
O(N)
majoritii votului.
Sortri naive, prelucrarea
ptratic
matricelor, generarea tuturor
O(N2)
subsecvenelor unui ir.
Algoritmul
Roy-Floyd, nmulirea
cubic
O(N3)
optim a matricelor.
fracional
Determinarea primalitii.
O(sqrt(N))
Cutarea binar, cutarea ntr-un
arbore binar de cutare echilibrat,
logaritmic
O(log N)
aproximarea unor funcii
matematice.
supraliniar,
Majoritatea algoritmilor divide et
liniaritmic,
O(Nlog N)
impera.
pseudoliniar
Determinarea tuturor
N
submulimilor, gsirea tuturor
O(c ), c
exponenial
ieirilor dintr-un labirint, probleme
constant
la care se cer toate soluiile.
Accesarea unui element dintr-un
tablou, interschimbarea a dou
valori, apelarea unei funcii,
constant
O(1)
efectuarea unei operaii de un
numr finit de ori care nu depinde
de N.
inversa
Operaii optime pe mulimi
funciei
O((N))
disjuncte.
Ackermann
Ciurul lui Eratosthenes
O(Nlog(log N))
20

Algoritmi de sortare

2. Algoritmi de
sortare
Problema sortrii unor date dup un anumit criteriu este una dintre
cele mai vechi probleme care face obiectul de studiu al informaticii. Exist o
gam foarte larg de algoritmi care rezolv aceast problem, ct i nite
rezultate teoretice importante cu privire la corectitudinea i eficiena acestor
algoritmi.
Acest capitol prezint detaliat o serie de algoritmi de sortare
reprezentativi pentru clasele din care acetia fac parte. Fiecare algoritm este
prezentat din punct de vedere al complexitii asimptotice, al eficienei
practice, al memoriei suplimentare folosite, al stabilitii, al
optimizrilor suportate i este nsoit de o implementare n limbajul C++.
Demonstraiile unor rezultate la care se face referire nu vor fi prezentate,
punndu-se accentul pe ntelegerea modului de funcionare al algoritmilor i
al implementrii acestora ntr-un limbaj de programare.
Prin memorie suplimentar nelegem memoria necesar execuiei
algoritmului, fr s lum n considerare vectorul ce reine numerele ce
trebuiesc sortate.
Prin stabilitate nelegem proprietatea unui algoritm de sortare de a
pstra ordinea relativ a dou elemente cu chei de sortare identice. De
exemplu, dac ar trebui s sortm perechile (2, 3), (1, 4), (2, 5), (1, 2) dup
prima component, un algoritm care ar produce sortarea: (1, 4), (1, 2), (2, 3),
(2, 5) ar putea fi stabil, pe cnd un algoritm care ar produce orice alt sortare
sigur nu ar fi stabil.
Implementrile algoritmilor vor fi prezentate sub forma unei funcii
ce poart numele algoritmului descris, funcie care, dac nu se precizeaz
alfel, accept ca parametri un ir de numere ntregi A, reprezentnd irul
care trebuie sortat cresctor i un numr natural N, reprezentnd numrul de
elemente ale irului A. Funcia sorteaz cresctor elementele irului A. Pot
exista i alte funcii ajuttoare, dar nu va fi prezentat un program ntreg,
considernd acest lucru inutil pentru problema de fa.
21

Capitolul 2

CUPRINS
2.1. Bubble sort ................................................................................................23
2.2. Insertion sort .............................................................................................24
2.3. Quicksort ....................................................................................................27
2.4. Merge sort .................................................................................................33
2.5. Heapsort ....................................................................................................37
2.6. Counting sort .............................................................................................45
2.7. Radix sort ...................................................................................................48
2.8. Concluzii .....................................................................................................55

22

Algoritmi de sortare

2.1. Bubble sort


Bubble sort, sau sortarea bulelor, este probabil cel mai simplu
algoritm de sortare, fiind deseori folosit pentru a introduce conceptul de
algoritm de sortare. Din pcate, acesta este totodat i unul dintre cei mai
ineficieni algoritmi, chiar i comparndu-l cu algoritmi de aceiai
complexitate asimptotic. Bubble sort suport ns nite optimizri care l
fac s se comporte destul de bine pe seturi de date generate aleator.
Numele de Bubble sort vine de la modul de funcionare al
algoritmului, care este similar cu modul n care bulele dintr-un pahar de ap
urc ntotdeauna la suprafa: la fiecare pas al algoritmului, elementul de
valoare maxim din ir va urca, comparnd mereu dou elemente adiacente,
n poziia sa final, adic la sfritul vectorului. Acest lucru ne permite s
parcurgem la fiecare pas tot mai puine elemente, deoarece tim c avem
attea elemente aflate pe poziia lor final ci pai avem deja efectuai.
Mai putem face o optimizare i anume s oprim algoritmul dac la
un anumit pas nu s-a mai fcut nicio interschimbare. Acest lucru nseamn
c vectorul a fost deja sortat i nu mai are rost s continum parcurgerile.
Obinem astfel o optimizare care aduce mbuntire substaniale
algoritmului pentru date de intrare deja aproape sortate.
void Bubble_sort(int A[], int N)
{
bool sortat = true;
for ( int i = 1; i < N && sortat == false; ++i )
{
sortat = true;
for ( int j = 1; j < N - i + 1; ++j ) // prima optimizare
if ( A[j] > A[j+1] )
{
sortat = false; // a doua optimizare
int temp = A[j]; A[j] = A[j+1]; A[j+1] = temp;
}
}
}

Tabelul 2.1.1. Proprietile algoritmului Bubble Sort


Caz favorabil Caz mediu Caz defav.
Timp de execuie
O(N)
O(N2)
O(N2)
Memorie suplimentar
O(1)
Stabil
DA
23

Capitolul 2

2.2. Insertion sort


Insertion sort, sau sortarea prin inserie, este unul dintre cei mai
rapizi algoritmi de sortare de complexitate O(N2), depind cu mult n
practic ali algoritmi precum bubble sort i selection sort.
Paii algoritmului sunt urmtorii:
Pentru fiecare element i al vectorului, ncepnd de la al doilea,
execut
o Deplaseaz toate elementele cu indici mai mici dect i
care sunt mai mari ca A[i] cu o poziie ctre dreapta.
o Insereaz elementul i n locul rmas liber.
Returneaz vectorul sortat.
S lum ca exemplu urmtorul vector:
i 1 2 3 4 5
A 6 4 3 8 7
Algoritmul ncepe parcurgerea de la elementul cu indice 2:
V = A[2] = 4. Urmeaz s deplasm toate elementele cu indici mai mici ca 2
i a cror valoare este mai mare ca V = 4 cu o poziie ctre dreapta. n cazul
acesta, elementul de pe poziia 1, A[1] = 6, va trece pe poziia 2, iar pe
poziia 1 va fi inserat fostul element de pe poziia 2, adic V = 4. Observm
c primele dou elemente sunt deja sortate cresctor. Precizm c, pentru a
efectua eficient deplasrile, vom reine elementul care trebuie inserat ntr-o
variabil auxiliar V, dup care vom suprascrie acest element efectund
deplasrile necesare.
Vectorul arat acum n felul urmtor (elementul proaspt inserat
apare cu rou):
i 1 2 3 4 5
A 4 6 3 8 7
Se trece la elementul cu indice 3 i se procedeaz similar. Vom
reine V = A[3], adic V = 3, i vom deplasa primele dou elemente cu o
poziie spre dreapta, tergnd astfel valoarea din A[3]. Dup deplasarea
elementelor, vectorul va arta astfel (elementele deplasate apar n albastru):
i 1 2 3 4 5
A 4 4 6 8 7
24

Algoritmi de sortare
Observm c, practic, deplasarea const n operaia de atribuire
fiecrui element a valorii elementului precedent. Valoarea iniial a lui A[3]
a fost reinut n variabila V. Se insereaz V pe poziia 1, rezultnd
urmtorul vecctor:
i 1 2 3 4 5
A 3 4 6 8 7
Se trece la elementul de pe poziia 4, care se insereaz tot pe poziia
4, rezultnd urmtorul vector:
i 1 2 3 4 5
A 3 4 6 8 7
Se trece la ultimul element, cel de pe pozia 5, care va fi inserat pe
poziia 4:
i 1 2 3 4 5
A 3 4 6 7 8
Vectorul final este astfel sortat.
Trebuie precizat c algoritmul de sortare prin inserie este cel mai
natural algoritm de sortare i cel mai des folosit n viaa de zi cu zi. De
exemplu, dac avem de sortat nite cri de joc, probabil c vom folosi
(chiar i fr s ne dm seama) sortarea prin inserie.
Alt aspect interesant al algoritmului este faptul c nu efectueaz
nicio interschimbare. Acesta este i motivul superioritii sale fa de ali
algoritmi de aceiai complexitate. Performana sa pentru vectori de
dimensiune mic poate fi exploatat de algoritmul Quicksort, care poate
folosi sortarea prin inserie cnd ajunge la intervale foarte mici, eliminnd
astfel apeluri recursive.

25

Capitolul 2
void Insertion_sort(int A[], int N)
{
for ( int i = 2; i <= N; ++i )
{
int V = A[i];
// elementele A[1], A[2], ..., A[i-1] sunt deja sortate, deci
// caut pozitia in care trebuie sa inserez elementul V = A[i]
// astfel incat vectorul sa ramana in continuare sortat
int j = i - 1;
while ( j > 0 && A[j] > V )
{
A[j+1] = A[j];
--j;
}
A[j+1] = V;
}
}

Tabelul 2.2.1. Proprietile algoritmului Insertion sort


Caz favorabil Caz mediu Caz defav.
Timp de execuie
O(N)
O(N2)
O(N2)
Memorie suplimentar
O(1)
Stabil
DA
Acest algoritm este folositor pentru sortarea irurilor de dimensiuni
mici sau care sunt parial sortate.
Mai mult, modul de parcurgere al irului de date confer
algoritmului posibilitatea de a sorta date pe msur ce acestea devin
accesibile. De exemplu, dac avem de sortat notele unor elevi la nite
examene naionale, ne putem atepta s nu primim rezultatele din toate
judeele n acelai timp, ci cu decalri de cteva ore sau chiar zile. n acest
caz, poate fi mai eficient s aplicm algoritmul de sortare prin inserie de
fiecare dat cnd primim date dintr-un jude, dect s aplicm unul dintre
algoritmii mai performani ce urmeaz a fi prezentai.
Dac avem ns de sortat un volum foarte mare de date, este de
preferat s folosim un algoritm mai eficient.

26

Algoritmi de sortare

2.3. Quicksort
Quicksort, sau sortarea rapid, este cel mai eficient algoritm de
sortare, comparativ cu ceilali algoritmi de aceiai complexitate. Din pcate,
pentru a fi cu adevrat performant att pe cazul mediu ct i pe cazul cel mai
defavorabil, algoritmul necesit anumite optimizri care complic puin
codul, rezultnd un program mai complex dect pentru celelalte sortri.
Sortarea rapid este un algoritm de tip divide et impera i
funcioneaz astfel:
Fie Quicksort(A, st, dr) o funcie care sorteaz intervalul [x, y]
al vectorului A.
Fie Partitie(A, st, dr) o funcie care reordoneaz intervalul [x, y]
al vectorului A astfel nct toate elementele mai mici sau egale
cu A[st] s se afle la nceputul vectorului i toate elementele mai
mari sau egale cu A[st] s se afle la sfritul vectorului.
Elementul A[st] se numete element pivot.
Funcia Quicksort(A, st, dr) este implementat astfel:
o Dac st < dr execut
P = Partitie(A, st, dr)
Apeleaz recursiv Quicksort(A, st, P)
Apeleaz recursiv Quicksort(A, P + 1, dr)
La finalul algoritmului, vectorul A va fi sortat.
Ne punem aadar problema implementrii funciei Partitie. Eficiena
i corectitudinea algoritmului depind n cea mai mare parte de aceast
funcie.
Funcia Partitie(A, st, dr) poate fi implementat, ntr-o prim
form, astfel:
Fie V = A[st]
st = st 1 i dr = dr + 1
Ct timp st < dr execut
o Execut
dr = dr 1
o Ct timp st < dr i A[dr] > V
o Execut
st = st + 1
o Ct timp st < dr i A[st] < V
o Dac st < dr execut
Interschimb A[st] cu A[dr]
Returneaz poziia pivotului, adic dr.
27

Capitolul 2
Deja devine clar ideea din spatele algoritmului: la fiecare pas se
mparte subsecvena [st, dr] n alte dou subsecvene (pe care le vom numi
subsecvena stng respectiv subsecvena dreapt): prima cu elemente mai
mici sau egale cu pivotul, iar cealalt cu elemente mai mari sau egale cu
pivotul. Acest lucru este fcut de ctre funcia Partitie, care returneaz la
sfrit poziia care delimiteaz mprirea menionat mai sus. Atenie:
algoritmul nu ofer nicio informaie folositoare despre poziia pe care
ajunge elementul pivot!
Dup ce funcia Partitie returneaz poziia ce delimiteaz mprirea
fcut n funcie de pivot, funcia Quicksort se autoapeleaz pentru
subsecvena stng i pentru subsecvena dreapt. Datorit recursivitii, i
aceste subsecvene vor trece prin funcia Partitie, fapt ce va duce n final la
sortarea vectorului.
Funcia Partitie are complexitatea O(N), iar complexitatea
ntregului algoritm depinde de pivotul ales. Pe cazul mediu i pe cazul
favorabil, complexitatea algoritmului de sortare rapid este de O(Nlog N),
elementul pivot mprind o subsecven n dou subsecvene de dimensiuni
relativ apropiate. Dar dac elementul pivot mparte fiecare subsecven
[st, dr] ntr-o subsecven de dimensiune 1 i o subsecven de dimensiune
dr st? n cazul acesta, complexitatea algoritmului este de O(N2), cu nimic
mai bun dect cea a algoritmului de sortare prin inserie! Mai mult, i
memoria suplimentar folosit este O(N), mai mult dect a algoritmilor
ptratici.
Pentru a v convinge de complexitatea ptratic a algoritmului
Quicksort n cazul n care partiionarea irului se face ntotdeauna
dezechilibrat, urmrii modul de funcionare al algoritmului pe vectorul:
i 1 2 3 4
A 1 2 3 4
La primul pas, se apeleaz funcia Quicksort(A, 1, 4), care va apela
Partitie(A, 1, 4). Funcia Partitie va alege ca pivot pe V = A[1] = 1, iar st i
dr se vor iniializa cu 0 respectiv 5. Acum, se va executa prima bucl
execut ... ct timp, ajungndu-se n final la dr = 1. Elementele marcate cu
rou n tabelele ce urmeaz se compar cu V = 1. La primul pas, se
decrementeaz dr, lund valoarea 4.
i 1 2 3 4
A 1 2 3 4

28

Algoritmi de sortare
st = 0 < dr = 4 i A[dr] = 4 > A[V] = 1, deci se ajunge la dr = 3.
i 1 2 3 4
A 1 2 3 4
st = 0 < dr = 3 i A[dr] = 3] > A[V] = 1, deci se ajunge la dr = 2.
i 1 2 3 4
A 1 2 3 4
st = 0 < dr = 2 i A[dr] = 2 > A[V] = 1, deci se ajunge la dr = 1.
i 1 2 3 4
A 1 2 3 4
st = 0 < dr = 1, dar A[dr] = A[V] = 1, deci se va iei din prima bucl
execut ... ct timp. A doua bucl nu va apuca dect s incrementeze
variabila st, ajungndu-se la st = dr i ieindu-se i din aceast bucl.
Condiia st < dr nu se verific, deci nu se efectueaz nicio interschimbare i
se iese i din bucla principal ct timp ... execut. Se returneaz dr = 1.
Dup ieirea din funcia Partitie, se atribuie valoarea returnat
variabilei P, dup care se efectueaz dou apeluri recursive ale funciei
Quicksort. Mai nti se apeleaz Quicksort(A, 1, 1), funcie din care se va
iei foarte rapid, deoarece nu va fi respectat condiia st < dr. Se reveni la
pasul precedent i se apeleaz funcia Quicksort(A, 2, 4). Aceast apel se va
comporta similar cu apelul iniial. Lsm desluirea tuturor pailor efectuai
pe seama cititorului.
Exemplul prezentat ascunde o deficien major a algoritmului. Se
poate observa c, pentru un ir care este deja sortat, funcia partiie va
mpri ntotdeauna o subsecven [st, dr] n dou subsevene de lungime 1
respectiv dr st. Cum am spus mai devreme, acest lucru duce la o
complexitate ptratic, adic O(N2). Se va returna ntotdeauna un pivot care
va mpri subsecvena curent ntr-o subsecven de dimensiune 1, care
poate fi considerat sortat (orice ir cu un singur element este gata sortat) i
subsecvena iniial, mai puin un singur element. Acest lucru se repet de N
ori. Se efectueaz aadar N + (N 1) + + 1 operaii. Aceast sum este o
progresie aritmetic clasic, avnd valoarea N * (N + 1) / 2. Complexitatea
timp este aadar O(N2). Memoria suplimentar este O(N), deoarece avem N
niveluri de recursivitate, iar informaia memorat pe fiecare nivel este O(1).
29

Capitolul 2
Vom prezenta forma clasic a algoritmului, aa cum a fost prezentat
n pseudocod, dup care vom prezenta metode de mbuntire a timpului de
execuie i a memoriei folosite pe orice ir de intrare posibil.
Metoda clasica poate fi implementat astfel:
int Partitie(int A[], int st, int dr)
{
int V = A[st];
--st; ++dr;
while ( st < dr )
{
do
--dr;
while ( st < dr && A[dr] > V );
do
++st;
while ( st < dr && A[st] < V );
if ( st < dr )
{
int tmp = A[st];
A[st] = A[dr];
A[dr] = tmp;
}

void Quicksort(int A[], int st, int dr)


{
if ( st < dr )
{
int P = Partitie(A, st, dr);
Quicksort(A, st, P);
Quicksort(A, P+1, dr);
}
}

Execiiu: modificai funcia Partitie


astfel nct aceasta s returneze, n
O(N), al k-lea cel mai mic element al
vectorului A. De exemplu, dac
A = {1, 7, 5, 2, 4} i k = 3, se va
returna 4.

}
return dr;
}

Aa cum am mai spus, algoritmul suport diverse optimizri,


reducnd complexitatea timp la O(Nlog N) pentru marea majoritate a
datelor de intrare, iar memoria la O(log N) pentru toate datele de intrare
posibile. Acest lucru poate fi fcut folosind generarea de numere aleatoare.
O prim idee ar fi s folosim o funcie care amestec vectorul A, dup
care s aplicm algoritmul de sortare rapid. Aceast metod nu este ns
att de eficient precum alegearea aleatoare a pivotului folosit la fiecare pas
al algoritmului.
Dac alegem aleator la fiecare pas un element pivot din subsecvena
[st, dr], probabilitatea ca acesta s fie o alegere proast este att de mic
nct putem considera c algoritmul are complexitatea O(Nlog N) pentru
toate datele de intrare posibile. Trebuie menionat ns c aceast
30

Algoritmi de sortare
complexitate este una probabilist i c, n cazul n care cineva cu intenii
maliioase ar avea acces la generatorul de numere aleatoare folosit n
alegerea pivotului, acesta ar putea teoretic genera date de intrare pentru care
complexitatea algoritmului s fie O(N2). Pentru toate scopurile practice ns,
putem considera c algoritmul are complexitatea O(Nlog N).
Aa cum am artat mai devreme, memoria suplimentar folosit de
algoritm este O(N) pe cazuri defavorabile. Putem reduce memoria la
O(log N) pentru toate cazurile, schimbnd ordinea apelurilor recursive i
reducnd numrul acestora. Observm ca apelurile recursive se fac la
sfritul algoritmului, neexistnd nicio instruciune care s se execute dup
acestea. Acest lucru ne permite s folosim, atunci cnd este posibil, tehnici
iterative n loc de apeluri recursive.
Primul lucru pe care l vom face este s apelm funcia recursiv
pentru subsecvena mai mic. Al doilea lucru este s renunm la al doilea
apel recursiv, pentru cea de-a doua secven, i s rezolvm aceast
subsecven iterativ. Astfel, memoria folosit va fi ntotdeauna O(log N).
Precizm c pentru a folosi funcia rand() trebuie inclus fiierul
antet <cstdlib>, iar nainte de apelarea funciei Quicksort, trebuie iniializat
generatorul de numere aleatoare folosind apelul srand((unsigned)time(0)).
Algoritmul optimizat arat acuma n felul urmtor:

31

Capitolul 2
int Partitie(int A[], int st, int dr)
{ // numar aleator din [st, dr]
int poz = st + rand() % (dr-st+1);
int tmp = A[poz];
A[poz] = A[st];
A[st] = tmp;
int V = A[st];
--st; ++dr;
while ( st < dr )
{
do
--dr;
while ( st < dr && A[dr] > V );
do
++st;
while ( st < dr && A[st] < V );

void Quicksort(int A[], int st, int dr)


{
while ( st < dr )
{
int P = Partitie(A, st, dr);
if ( P - st < dr - P - 1 )
{
Quicksort(A, st, P);
st = P + 1;
}
else
{
Quicksort(A, P + 1, dr);
dr = P;
}
}
}

if ( st < dr )
{
int tmp = A[st];
A[st] = A[dr];
A[dr] = tmp;
}
}
return dr;
}

Tabelul 2.3.1. Proprietile algoritmului Quicksort


Caz favorabil Caz mediu Caz defav.
Timp de execuie
O(Nlog N)
Memorie suplimentar
O(1)
O(log N)
O(log N)
Stabil
NU n forma dat.
Trebuie menionat faptul c mai exist i alte optimizri posibile. De
exemplu, Quicksort se poate combina cu algoritmul Heapsort, rezultnd
timp de execuie i mai buni n practic. Acest algoritm se numete
Introsort i este abordat mai n detaliu n cadrul algoritmului Heapsort.

32

Algoritmi de sortare

2.4. Merge sort


Merge sort, sau sortarea prin interclasare, este un algoritm de
sortare de tip divide et impera, similar cu algoritmul de sortare rapid
prezentat anterior. Complexitatea timp este ntotdeauna O(Nlog N).
Aceast complexitate se obine pentru orice date de intrare i nu este
condiionat de numere aleatoare. Dei, teoretic, algoritmul pare s fie mai
bun dect Quicksort, n practic se obin timpi de execuie mai mari dect
n cazul sortrii rapide. Mai mult, memoria folosit este ntotdeauna O(N),
variantele care folosesc memorie constant fiind i mai dificil de
implementat i mai puin eficiente n practic.
Algoritmul folosete dou funcii: o funcie principal,
Merge_sort(A, st, dr), care este responsabil de ordonarea subsecvenei [st,
dr] a vectorului A i o funcie secundar Interclasare(A, st, m, dr) care are
rolul de a interclasa subsecvenele [st, m] i [m+1, dr] a vectorului A,
subsevene care sunt deja sortate. Interclasarea a dou subsecvene nseamn
formarea unei singure secvene care conine toate elementele din ambele
subsecvene astfel nct acestea s fie la rndul lor sortate.
Funcia Merge_sort(A, st, dr) poate fi implementat astfel:
Dac st < dr execut
o Fie m = (st + dr) / 2
o Apeleaz recursiv Merge_sort(A, st, m)
o Apeleaz recursiv Merge_sort(A, m+1, dr)
o Apeleaz Interclasare(A, st, m, dr)
Funcia Interclasare(A, st, m, dr) poate fi implementat astfel:
Declar un vector auxiliar B de dimensiune dr st + 1.
Fie i = st, j = m + 1 i k = 1
Ct timp i m i j dr execut
o Dac A[i] A[j] execut
B[k] = A[i]
k = k + 1 i i = i + 1
o Altfel execut
B[k] = A[j]
k = k + 1 i j = j + 1
Dac exist un element fie n secvena [st, m] sau [m+1, dr] care
s nu fi fost adugat n vectorul B, se adaug i acesta.
Suprascrie coninutul vectorului auxiliar B peste subsecvena [st,
dr] a vectorului A.

33

Capitolul 2
Funcia Interclasare efectueaz ntotdeauna dr st + 1 operaii,
deci complexitatea sa este O(N) pe nivel de recursivitate. Funcia
Merge_sort mparte ntotdeauna subsecvena curent n dou subsecvene
de dimensiuni egale sau care difer prin cel mult 1. Aadar, arborele
apelurilor recursive va avea O(log N) niveluri, rezultnd timpul O(Nlog N).
Se observ c, pentru a interclasa dou subsecvene, avem nevoie de
un vector auxiliar, pe care l-am notat cu B. Acest lucru nseamn c
memoria auxiliar folosit de algoritm este ntotdeauna O(N), mai mult
dect memoria folosit de orice alt algoritm prezentat pn acum. Totui,
asta nu nseamn c sortarea prin interclasare este ntotdeauna inferioar
algoritmilor precedeni. De multe ori, dac ne permitem timpul de execuie
pentru a sorta N obiecte, ne permitem i memoria auxiliar.
Pentru a nelege mai bine cum am dedus complexitatea algoritmului
i pentru a vizualiza modul de funcionare al acestuia, urmrii apelurile
funciilor n urmtorul exemplu. Acesta se execut conform cu numerotarea
de pe desen. Apelurile recursive i secvenele pe care acestea se fac sunt
marcate cu rou, iar apelurile funciei de interclasare, intervalele asociate
acesteia i ordinea elementelor rezultat dup interclasare sunt marcate cu
albastru.

Fig. 2.4.1. Modul de execuie a sortrii prin interclasare


34

Algoritmi de sortare
Dei este mai dificil de dedus numrul de operaii efectuate folosind
acest exemplu, putem observa c arborele rezultat n urma aplicrii
algoritmului de sortare prin interclasare are doar dou niveluri complete pe
care se apeleaz funcia Interclasare. Astfel, avem dou niveluri pe care se
execut O(N) operaii, unde N = 5 n cazul de fa, iar 2 5 = 2, deci
putem folosi acest exemplu pentru a intui complexitatea algoritmului ca
fiind O(Nlog N), iar memoria auxiliar folosit ca fiind O(N).
Am putea fi tentai s argumentm c se efectueaz, de fapt, patru
apeluri ale funciei de interclasare, fiecare executnd O(N) operaii,
rezultnd astfel o complexitate timp de O(N2). Acest lucru este fals ns,
deoarece, dei se fac ntr-adevr patru apeluri ale acestei funcii, doar apelul
de pe primul nivel efectueaz N operaii. Apelurile de pe un nivel oarecare
efectueaz mpreun N operaii. Deoarece algoritmul mparte ntotdeauna
subsecvena curent n dou subsecvene egale, adncimea arborelui va fi
ntotdeauna direct proporional cu 2 ( + 1), rezultnd complexitatea
menionat. Trebuie ns inut cont de faptul c, n practic, acest algoritm
este mai puin eficient dect Quicksort.
Pentru a observa mai bine numrul de operaii efectuate, construii
un arbore similar cu cel prezentat anterior, dar pentru N o putere a lui 2, de
exemplu 32 sau 64. Aa se va observa mult mai clar complexitatea
algoritmului.
Sortarea prin interclasare are aplicabiliti i n unele probleme de
numrare. Un exemplu clasic este aflarea numrului de inversiuni ale unei
permutri. O inversiune a unei permutri
=

1
(1)

2
(2)

()

este o pereche (i, j), 1 i < j N, cu proprietatea P(i) > P(j).


Folsind sortarea prin interclasare putem numra, n cadrul funciei
Interclasare, numrul de inversiuni ale unei permutri n felul urmtor: ne
intereseaz s numrm, pentru fiecare element j din subsecvena [m+1, dr]
cte elemente exist n subsecvena [st, m] care sunt mai mari dect A[j],
numr pe care l vom aduna la numrul total de inversiuni. Acest lucru se
poate face atunci cnd A[i] > A[j], adunnd la soluie numrul m i + 1,
deoarece, dac A[i] > A[j], orice A[k] unde i < k m va fi mai mare dect
A[j].
n implementarea ce urmeaz, sortarea prin interclasare poate fi
optimizat declarnd vectorul B naintea rulrii algoritmului i scpnd de
alocrile de memorie din cadrul funciei de interclasare.
35

Capitolul 2
void Interclasare(int A[],
int st, int m, int dr)
{
// folosim numerotare de la 1,
// deci trebuie declarat un element
// in plus
int *B = new int[dr - st + 2];

void Merge_sort(int A[],


int st, int dr)
{
if ( st < dr )
{
int m = (st + dr) / 2;
Merge_sort(A, st, m);
Merge_sort(A, m+1, dr);
Interclasare(A, st, m, dr);
}
}

int i = st, j = m + 1, k = 0;
while ( i <= m && j <= dr )
if ( A[i] <= A[j] )
B[++k] = A[i++];
else
B[++k] = A[j++];

Numrarea inversiunilor poate


fi facut modificnd primul
while astfel:

// copiaza ce a mai ramas


while ( i <= m )
B[++k] = A[i++];
while ( j <= dr )
B[++k] = A[j++];

while ( i <= m && j <= dr )


if ( A[i] <= A[j] )
B[++k] = A[i++];
else
{
B[++k] = A[j++];
NrInv += m - i + 1;
}

// copiaza la loc secventa sortata


for ( i = 1; i <= k; ++i )
A[st + i - 1] = B[i];
// sterge memoria auxiliara folosita
delete []B;

Unde NrInv este transmis prin


referin sau global.

Tabelul 2.4.1. Proprietile algoritmului Merge sort


Caz favorabil Caz mediu Caz defav.
Timp de execuie
O(Nlog N)
Memorie suplimentar
O(N)
Stabil
DA

36

Algoritmi de sortare

2.5. Heapsort
Heapsort, cunoscut i sub numele de sortare prin ansamble, este
un algoritm de sortare cu timpul de execuie O(Nlog N) i memorie
auxiliar O(1). Algoritmul este, practic, o optimizare a algoritmului de
sortare prin selecie (Selection sort), algoritm care funcioneaz
determinnd la fiecare pas elementul de valoarea maxim i mutndu-l pe
ultima poziie liber a vectorului. Deoarece trebuie s determinm N
maxime, iar determinarea unui maxim implic verificarea tuturor
elementelor vectorului, complexitatea acestui algoritm este O(N2). Heapsort
folosete structura de date numit heap pentru a determina cele N maxime,
rezultnd un timp de execuie de O(Nlog N). Pentru a nelege mai bine ce
este acela un heap, vom ncepe prin prezentarea unor noiuni teoretice.
Definiia 1: Un arbore binar este un arbore n care fiecare nod are
cel mult doi descendeni direci.
Definiia 2: Un arbore binar complet este un arbore binar n care
fiecare nivel al arborelui, eventual mai puin ultimul, are numr maxim de
noduri (nivelul h al arborelui are 2h noduri, numerotarea ncepnd de la
zero). n cazul n care ultimul nivel nu are numr maxim de noduri,
completarea cu noduri a ultimului nivel trebuie s se fac de la stnga spre
dreapta.
Definiia 3-4: Un max-heap este un arbore binar complet n care
orice nod are asociat o valoare mai mare sau egal (nu neaprat n sensul
clasic) cu valorile asociate descendenilor acestui nod, dac acest nod are
descendeni. Dac orice nod are asociat o valoare mai mic sau egal cu
valorile asociate descendenilor si, structura de date poart numele de
min-heap. De exemplu, desenul urmtor reprezint un max-heap:

Fig. 2.5.1. Un max-heap oarecare


37

Capitolul 2
Aceast structur de date suport operaiile de inserare a unui nod i
de tergere a rdcinii n compexitate O(log N), unde N este numrul
elementelor din heap. Operaia de aflare a maximului (sau a minimului) are
complexitatea O(1), deoarece tot ce trebuie s facem este s verificm nodul
rdcin.
Un heap poate fi reprezentat foarte uor folosind un vector A cu N
elemente, fiecare element reprezentnd valoarea unui nod al heap-ului.
Rdcina va fi reinut n A[1], iar descendenii direci ai acesteia n A[2]
pentru fiul stng i A[3] pentru fiul drept. n cazul general, fiii unui nod
reprezentat prin A[k] se vor afla n A[2k] pentru fiul stng i A[2k+1]
pentru fiul drept. Tatl unui nod A[k] se va afla pe poziia A[k / 2] (se ia
ntotdeauna partea ntreag a rezultatului mpririi).
De exemplu, heap-ul din figura precedent poate fi reprezentat
printr-un vector n modul urmtor:
i
1 2 3 4 5 6 7
19
13 15 5 6 12 14
A
Pentru a putea implementa algoritmul Heapsort, avem nevoie de
urmtoarele trei operaii: inserarea unei valori n heap, tergerea rdcinii
din heap i transformarea unui vector oarecare ntr-un vector care reprezint
un heap valid.
Inserarea unei valori x ntr-un max-heap se face n felul urmtor:
Se creaz un nod cu valoarea x la sfritul heap-ului.
Ct timp x este mai mare dect valoarea tatlui nodului asociat
lui x execut
o Interschimb tatl nodului lui x cu nodul lui x.
De exemplu, dac vrem s adugm n heap-ul prezentat anterior
valoarea 18, se procedeaz ca n desenul de mai jos.

Fig. 2.5.2. Adugarea unei valori n heap

38

Algoritmi de sortare
Sau, folosind un vector:
i
1 2 3 4 5 6 7 8
A 19 13 15 5 6 12 14 18
Se compar A[8] = 18 cu A[8 / 2] = A[4] = 5. 18 > 5 deci se
interschimb A[8] cu A[5] i rezult vectorul:
i
1 2 3 4 5 6 7 8
A 19 13 15 18 6 12 14 5
Se compar A[4] = 18 cu A[4 / 2] = A[2] = 13. 18 > 13 deci se
interschimb A[4] cu A[2] i rezult vectorul:
i
1 2 3 4 5 6 7 8
A 19 18 15 13 6 12 14 5
Se compar A[2] = 18 cu A[2 / 2] = A[1] = 19. 18 < 19, deci algoritmul se
ncheie i valoarea inserat se afl pe poziia pe care trebuie.
Inserarea unui element ntr-un heap are complexitatea O(log N),
deoarece nlimea unui heap este dat de logaritmul binar al lui N.
tergerea rdcinii se poate face folosind urmtorul algoritm:
Se nlocuiete rdcina cu ultimul nod al heap-ului i se terge
ultimul nod. Fie x noua rdcin.
Ct timp valoarea lui x este mai mic dect cel puin unul dintre
fiii si execut
o Interschimb x cu fiul care are valoarea cea mai mare.
Este important s efectum interschimbarea cu fiul care
are valoarea cea mai mare, deoarece n caz contrar am
obine un heap invalid, existnd un nod care este printe
pentru un nod cu valoare mai mare. Astfel, se reface
structura de heap.
Datorit nlimii unui heap, aceast operaie are complexitatea tot
O(log N).
n figura urmtoare putei vizualiza modul n care se terge rdcina
unui heap.

39

Capitolul 2

Fig. 2.5.3. tergerea rdcinii unui heap


Sau, folosind reprezentarea cu ajutorul unui vector:
i
1 2 3 4 5 6 7
A 19 13 15 5 6 12 14
Elementul marcat cu rou trece pe prima poziie, devenind rdcin:
i
1 2 3 4 5 6
A 14 13 15 5 6 12
Acum se compar valoarea elementului marcat cu rou, adic
A[1] = 14 cu acel fiu al su care are valoarea maxim. Fiii elementului 1 se
afl pe poziiile 2 i 3, iar A[2] = 13 i A[3] = 15. A[1] = 14 < A[3] = 15,
deci se interschimb A[1] cu A[3], rezultnd vectorul:
i
1 2 3 4 5 6
A 15 13 14 5 6 12
Se compar valoarea A[3] = 14 cu A[6] (teoretic i cu A[7], dar
acest element nu exist). A[3] = 14 > A[6] = 12, deci nu mai trebuie
efectuat nicio interschimbare, iar refacerea structurii de heap este gata.
Prin tergerea rdcinii extragem practic elementul de valoare
maxim din heap. Astfel, noua rdcin va avea a doua cea mai mare
valoare din heap-ul iniial. Dac tergem i aceast rdcin, elementul care
n va lua locul va avea a treia cea mai mare valoare din heap-ul iniial i aa
mai departe pentru restul elementelor. Deja am putea implementa o variant
a algoritmului Heapsort, dar aceasta ar folosi O(N) memorie suplimentar.
Pentru a reduce memoria suplimentar folosit de algoritm la O(1),
vom transforma vectorul care trebuie sortat ntr-un heap. Avnd vectorul
transformat ntr-un heap, putem s extragem la fiecare pas maximul i s l
poziionm pe ultima poziie, adic N, iar noul heap s fie format din
40

Algoritmi de sortare
primele N 1 poziii ale vectorului. Procednd n acest fel pn ce am
extras N maxime, vectorul va ajunge s fie sortat.
Pentru a transforma un vector oarecare ntr-un heap, vom considera
o funcie Downheap(A, poz, N) care aplic algoritmul de tergere a
rdcinii, prezentat anterior, subarborelui cu rdcina pe poziia poz a
vectorului A. N reprezint numrul de noduri ale heap-ului.
Avnd aceast funcie, putem construi o alt funcie,
Transformare(A, N), care transform vectorul A (cu N elemente) ntr-un
heap. Aceast funcie poate fi implementat n felul urmtor:

Pentru fiecare i de la
pn la 1 execut
2
o Apeleaz Downheap(A, i, N)
Funcia este foarte simplu de implementat, dar poate s nu fie clar
cum am ajuns la aceast metod sau de ce aceasta este corect.
Algoritmul de transformare a unui vector oarecare ntr-un vector

care reprezint un heap se bazeaz pe faptul c elementele


+ 1,
+ 2,
2
2
, N sunt frunze, deci pot fi considerate ca reprezentnd heap-uri formate
dintr-un singur element.
S demonstrm c aceste elemente sunt frunze: vom folosi metoda

reducerii la absurd i vom presupune c elementul


+ 1 nu este frunz,
2
adic are cel puin un fiu. Asta nseamn c acest fiu se afl pe poziia

2
+1 = 2
+ 2 > . Dar asta ar nsemna c fiul se afl n afara
2
2
vectorului, deci presupunerea fcut este fals, elementul analizat fiind deci
frunz. Rezult de aici c i celelalte elemente sunt frunze, avnd indici i
mai mari.
O proprietate exploatat de acest algoritm este aceea c orice
subarbore al unui heap este la rndul su heap. Acest afirmaie se poate
demonstra prin inducie. Astfel, ne propunem s transform vectorul dat ntrun heap considernd c acesta reprezint la nceput un arbore binar complet
oarecare i transformnd pe rnd fiecare subarbore ntr-un heap. Pentru
acest lucru, apelm funcia Downheap pentru fiecare element care nu
reprezint o frunz. Aa ajungem la algoritmul prezentat anterior n
pseudocod. Complexitatea acestei funcii este O(N), dei am putea fi tentai
s spunem c este O(Nlog N). Demonstraia acestei afirmaii este lsat pe
seama cititorului.

41

Capitolul 2
Avem acum toate noiunile necesare pentru a implementa eficient
algoritmul Heapsort. Dei teoria din spatele algoritmului poate fi mai greu
de neles dect teoria din spatele algoritmilor prezentai anterior, merit
fcut efortul necesar nelegerii acesteia, Heapsort fiind un algoritm care,
dei este mai ncet n practic dect Quicksort, are avantajul de a folosi
memorie suplimentar constant i de a nu folosi funcii recursive. Aceste
lucruri pot fi foarte importante dac avem nevoie de un algoritm de
complexitate O(Nlog N) pe cel mai ru caz i care s foloseasc memorie
ct mai puin.
Funcia Heapsort(A, N) poate fi implementat n felul urmtor:
Apeleaz Transformare(A, N)
Pentru fiecare i de la N la 1 execut
o Interschimb A[i] cu A[1]
o Apeleaz Downheap(A, 1, i 1)
Am menionat la algoritmul Quicksort existena unui algoritm care
combin Quicksort cu Heapsort, rezultnd un algoritm foarte eficient numit
Introsort. Ideea din spatele acestui algoritm este s ncepem prin a folosi
Quicksort, dar numai pn cnd nivelul recursivitii nu depete o anumit
limit, egal, de exemplu, cu 2 , unde N este numrul de elemente ale
vectorului care trebuie sortat. Dac nivelul recursivitii depete aceast
valoare, vom folosi Heapsort pentru a sorta subsecvena curent.
Pentru cele mai bune rezultate, este recomandat s se testeze mai
multe limite pe ct mai multe date de intrare.
Dac se ajunge la subsecvene de dimensiuni mici, dar adncimea
recursivitii nu a depit limita impus, Introsort poate folosi algoritmul de
sortare prin inserie pentru a sorta aceste subsecvene, eliminndu-se i mai
multe apeluri recursive.
Un fapt ce merit menionat este c funcia std::sort din fiierul
antet algorithm este, pe majoritatea compilatoarelor, implementat folosind
algoritmul Introsort. Un model de implementare este dat n seciunea
urmtoare.

42

Algoritmi de sortare
void Downheap(int A[], int poz, int N)
{
int FiuSt, FiuDr, Schimb;
while ( 2*poz <= N )
{
FiuSt = 2*poz;
FiuDr = 2*poz + 1;
Schimb = FiuSt;
if ( FiuDr <= N )
if ( A[FiuDr] > A[Schimb] )
Schimb = FiuDr;
if ( A[Schimb] > A[poz] )
{
swap(A[Schimb], A[poz]); // poate fi folosita prin includerea
// <fstream> sau <iostream>
// si a namespace-ului std
poz = Schimb;
}
else
break;
}
}
void Transformare(int A[], int N)
{
for ( int i = N / 2; i >= 1; --i )
Downheap(A, i, N);
}
void Heapsort(int A[], int N)
{
Transformare(A, N);
for ( int i = N; i >= 1; --i )
{
swap(A[i], A[1]);
Downheap(A, 1, i - 1);
}
}

Algoritmul Introsort poate fi implementat n felul urmtor. Trebuie


modificat algoritmul Heapsort astfel nct s sorteze o subsecven
transmis prin doi parametri, la fel ca la Quicksort. Acest lucru este lsat pe
43

Capitolul 2
seama cititorului. Partitie este funcia prezentat n cadrul algoritmului
Quicksort, iar apelul initial este:
Introsort(A, N, 1, N, 0);
void Introsort(int A[], int N, int st, int dr, int Adanc)
{
if ( st < dr )
{
if ( (1 << Adanc) > N )
Heapsort(A, st, dr);
else
{
int P = Partitie(A, st, dr);
Introsort(A, N, st, P, Adanc + 1);
Introsort(A, N, P+1, dr, Adanc + 1);
}
}
}

Deoarece numrul de apeluri recursive este limitat la O(log N), nu


mai este att de important s aplicm optimizrile de reducere a memoriei
prezentate anterior. ntr-o implementare cu adevrat eficient, am elimina de
tot apelurile recursive, implementnd manual o stiv n care se depun
subsecvenele ce trebuiesc sortate. n practic, Introsort este cel mai rapid
algoritm prezentat pn acum. Acest lucru se poate testa comparnd
algoritmii prezentai cu funcia std::sort din antetul algorithm. Aceast
funcie se poate folosi n felul urmtor pentru a sorta secvena [1, N] a
vectorului A: std::sort(A + 1, A + N + 1);
Proprietile algoritmului Heapsort se regsesc i n algoritmul
Introsort, singura diferen fiind memoria suplimentar folosit de
Introsort, care este O(log N). n practic ns, acest lucru nu prezint un
dezavantaj foarte mare, deoarece dac ne permitem s reinem N elemente
pentru a le sorta, ne permitem s reinem i memoria auxiliar.
Tabelul 2.5.4. Proprietile algoritmului Heapsort
Caz favorabil Caz mediu Caz defav.
Timp de execuie
O(Nlog N)
Memorie suplimentar
O(1)
Stabil
NU

44

Algoritmi de sortare
Am prezentat pn acum doi algoritmi de complexitate ptratic i
patru de complexitate liniar-logaritmic, acetia patru fiind cei mai des
folosii algoritmi n practic. Aa cum probabil c ai observat,
complexitatea acestor algoritmi nu a sczut niciodat sub O(Nlog N) pe
cazurile medii i defavorabile. Alt asemnare a algoritmilor prezentai pn
n acest moment este c fiecare se bazeaz pe comparaii ntre elemente.
Un algoritm de sortare bazat pe comparaii trebuie s efectueze ntotdeauna
minim O(Nlog N) operaii pe cazul defavorabil, deci putem considera
algoritmii Quicksort, Merge sort, Heapsort i Introsort ca fiind optimi.
Aceti algoritmi sunt ns optimi doar n cadrul clasei de algoritmi
bazai pe comparaii. Aa cum vom vedea, putem obine algoritmi mai
eficieni dac folosim alte tehnici de sortare care nu compar elementele.
Dezavantajul acestor tehnici este c se bazeaz pe anumite
particulariti ale datelor ce trebuiesc sortate i ale criteriului dup care
acestea trebuiesc sortate. Sortrile bazate pe comparaii sunt uor de
modificat pentru a sorta tipuri de date neelementare, singura schimbare
major ce trebuie fcut este nlocuirea operatorilor de comparare cu funcii
care compar tipurile de date ce trebuiesc sortate. O alt posibilitate este
suprancrcarea acestor operatori pentru tipurile date necesare.
Pentru algoritmii ce urmeaz a fi prezentai, Counting sort i Radix
sort, modificrile necesare pentru a sorta orice altceva n afar de numere
naturale sunt mai dificil de realizat, sau chiar imposibile n unele situaii.
Aceti doi algoritmi au ns avantajul de a fi mult mai rapizi dac datele ce
trebuiesc sortate au anumite particulariti.

2.6. Counting sort


Counting sort, sau sortarea prin numrare, este un algoritm ce
sorteaz un vector de numere naturale A n timp O(N + MaxV) i memorie
O(MaxV), unde N are semnificaia sa obinuit, iar MaxV reprezint
valoarea maxim a unui element din A. Algoritmul este aadar eficient doar
dac avem de sortat un numr foarte mare de elemente, dar a cror valoare
numeric tim c este foarte mic. Dac MaxV nu este mult mai mic dect
N, nu are rost s folosim aceast sortare.
Modul de funcionare al algoritmului este urmtorul:
Se declar un vector V cu MaxV+1 elemente, care se
iniializeaz cu 0.
Pentru fiecare i de la 1 la N execut
o V[ A[i] ] = V[ A[i] ] + 1
Golete vectorul A.
45

Capitolul 2
Pentru fiecare i de la 0 la MaxV execut
o Pentru fiecare j de la 1 la V[i] execut
Adaug-l pe i la sfritul vectorului A.
La finalul execuiei acestor instruciuni, vectorul A va conine
elemente iniiale n ordine cresctoare. Algoritmul numr practic cte
elemente exist din fiecare valoare posibil, dup care parcurge n ordine
fiecare valoare posibil i o adaug n vectorul soluie de cte ori este
nevoie. V[i] reprezint aadar numrul de elemente egale cu i din vectorul
A.
De exemplu, daca avem vectorul:
i 1 2 3 4 5 6
A 4 7 2 2 1 3
Vectorul V ar trebui s aib dimensiunea 7+1=8, deoarece 7 este
valoarea maxim a unui element din A. V se iniializeaz cu 0 i se
construiete n felul urmtor: primul element analizat este A[1], deci
V[ A[1] ] primee valoarea V[ A[1] ] + 1. A[1] = 4, deci V[4] pimete
valoarea V[4] + 1 = 0 + 1 = 1. Al doilea element analizat este A[2], deci,
procednd ca i la primul element, V[7] va primi tot valoarea 1.
n final, V va arta n felul urmtor:
i 0 1 2 3 4 5 6 7
V 0 1 2 1 1 0 0 1
Ceea ce nseamn c avem zero elemente cu valoarea 0, un element
cu valoarea 1, dou elemente cu valoarea 2, un element cu valoarea 3
.a.m.d.
Acum, pentru a sorta efectiv vectorul A, parcurgem toate poziiile i
ale vectorului V i punem n A valoarea i de V[i] ori.
Este clar c sortarea prin numrare este foarte eficient atunci cnd
avem un numr mare de valori mici ce trebuiesc sortate. Dezavantajele
acestei metode sunt c nu putem sorta dect numere naturale.
Pentru a extinde metoda la numere ntregi din intervalul
[-MaxV, MaxV], putem aduna fiecrui element valoarea MaxV,
transformnd astfel toate numerele ntregi n umere naturale. Astfel se
dubleaz ns memoria folosit.
Pentru a putea sorta numere reale pozitive, despre care tim c au un
anumit numr X de cifre dup virgul, putem s le nmulim pe fiecare cu
10X, dup care s le sortm ca fiind numere naturale. Dac numerele pot fi i
46

Algoritmi de sortare
negative, se pot combina cele dou metode. Pentru numere reale ns,
algoritmul devine destul de ineficient, deoarece memoria folosit crete de
10X ori, iar timpul de execuie devine mai mare. Pentru alte tipuri de date,
folosirea acestei metode poate fi mult mai dificil, sau chiar imposibil. De
exemplu, cum putem sorta alfabetic nite iruri de caractere folosind aceast
metod? Dar nite perechi de numere dup prima component?
const int MaxVal = 1000;
void Counting_sort(int A[], int N)
{
int *V = new int[MaxVal + 1];
for ( int i = 0; i <= MaxVal; ++i )
V[i] = 0;
for ( int i = 1; i <= N; ++i )
++V[ A[i] ];
for ( int i = 0, k = 0; i <= MaxVal; ++i )
for ( int j = 1; j <= V[i]; ++j )
A[++k] = i;
delete []V;
}

Deoarece lucrm cu numere naturale, am putea folosi tipul de date


unsigned int, dar acest lucru nu este obligatoriu ci ine, n acest caz, de
preferinele personale ale programatorului.
Putem optimiza algoritmul dac avem de sortat numai elemente
distincte sau dac prin sortare dorim s eliminm elementele care se repet.
Acest lucru l putem face lucrnd pe bii, vectorul V avnd n acest caz
semnificaia: V[i] = true dac exist valoarea i n vectorul A i false n
caz contrar. Putem reduce astfel dimensiunea vectorului V la

+1
8 ()
unde sizeof(int) reprezint numrul de bytes (octei) folosii de tipul de date
int pe sistemul pe care se lucreaz. Deoarece 1 byte = 8 bii, iar pentru
reprezentarea valorilor 0 i 1 (echivalente cu false respectiv true) avem
nevoie de un singur bit, putem gestiona mai eficient memoria, chiar dac
vom avea un cod mai greu de neles. Detalii despre efecutarea operaiilor pe
bii putei gsi n seciunea urmtoare, la algoritmul Radix sort.
47

Capitolul 2
Tabelul 2.6.1. Proprietile algoritmului de sortare prin numrare
Caz favorabil Caz mediu Caz defav.
Timp de execuie
O(N+MaxVal)
Memorie suplimentar
O(MaxVal)
Stabil
DA
Dei am considerat algoritmul ca fiind optim pe toate cazurile,
trebuie inut cont de faptul ca acest lucru nu se aplic dect dac MaxVal
este cu mult mai mic dect N.

2.7. Radix sort


Radix sort poate fi considerat o optimizare a sortrii prin
numrare, deoarece algoritmul are la baz acelai mod de funcionare, dar
aplicat de mai multe ori n aa fel nct memoria folosit s fie constant i
timpul de execuie s fie aproximativ la fel de bun ca al sortrii prin
numrare. Pentru a putea fi aplicat altor tipuri de date dect numerelor
naturale, Radix sort necesit cel puin aceleai modficri ca sortarea prin
numrare.
Ideea din spatele algoritmului este s sortm mai nti numerele dup
cea mai puin semnificativ cifr (cifra unitilor), dup care dup cifra
zecilor, cifra sutelor .a.m.d. Dac numerele nu au toate acelai numr de
cifre, vom considera c numerele cu cifre mai puine au zerouri n fa.
De exemplu, dac ne propunem s sortm urmtorul vector:
i
1
2
3
4
5
6
7
8
9
10
A 430 027 325 088 145 111 034 932 353 007
Vom ncepe prin a sorta mai nti numerele dup cifra unitilor. Va
rezulta urmtorul vector:
i
1
2
3
4
5
6
7
8
9
10
A 430 111 932 353 034 325 145 027 007 088
Se poate observa uor c numerele sunt ordonate cresctor dup cifra
unitilor. Urmtorul pas este s sortm aceste numere dup cifra zecilor.
Rezult vectorul:
i
1
2
3
4
5
6
7
8
9
10
A 007 111 325 027 430 932 034 145 353 088
48

Algoritmi de sortare
Ultimul pas este s sortm numerele dup cifra sutelor:
i
1
2
3
4
5
6
7
8
9
10
A 007 027 034 088 111 145 325 353 430 932
Astfel, vectorul ajunge s fie sortat. Deja am putea implementa
algoritmul n aceast form fr prea mari complicaii. Ar trebui doar s
modificm sortarea prin numrare pentru a sorta numerele dup o anumit
cifr, lucru care va fi explicat n detaliu dup ce vom prezenta o optimizare
care va reduce cu mult timpul de execuie. Dac am implementa algoritmul
n forma sa actual, timpul de execuie ar fi O(Nlog MaxVal), unde
MaxVal reprezint valoarea maxim a numerelor din vector. Acest
complexitate se datoreaz faptului c aplicm sortarea prin numrare de
NrCif ori, unde NrCif reprezint numrul de cifre ale celui mai mare numr
din vector, iar = 1 + 10 . Deoarece sortarea prin
numrare se aplic ntotdeauna unor numere de o singur cifr, putem
considera timpul de execuie al acesteia ca fiind O(N). Memoria
suplimentar folosit este O(N), avnd nevoie, aa cum vom vedea, de un
vector auxiliar de dimensiune N.
Avem deja un algoritm eficient chiar i n cazul n care avem
MaxVal > N, deoarece baza logaritmului este 10, nu doi aa cum este n
cazul algoritmilor prezentai pn acum.
Putem ns obine un algoritm i mai rapid. Pentru acest lucru, vom
prezenta mai nti cteva noiuni teoretice despre reprezentarea numerelor n
sistemul zecimal i sistemul binar cu ajutorul cror vom putea optimiza
numrul de apeluri ale sortrii prin numrare.
Definiia 1: Un numr natural care are cea mai mare cifr C poate fi
considerat un numr n toate bazele mai mari dect C. De exemplu, numrul
5213 poate fi considerat un numr n toate bazele mai mari dect 5. Sistemul
(baza) n care un numr este scris se marcheaz prin trecerea bazei ca indice
al numrului.
Definiia 2: O cifr a unui numr natural n baza 2 se numete bit. 8
bii = 1 byte.
Proprietatea 1: Orice numr natural notat n felul urmtor:
= 1 2 , {0,1, ,9}
este scris n sistemul zecimal (baza 10), n urmtoarea form:
49

Capitolul 2
10 = 1 10 1 + 2 10 2 + + 100
De exemplu, numrul 362 este scris n felul urmtor:
36210 = 3102 + 610 + 2
Proprietatea 2: Orice numr natural
= 1 2 , {0,1, ,9}
care reprezint un numr n sistemul binar (baza 2) poate fi
transformat n echivalentul su din baza 10 n felul urmtor:
2 = (1 21 + 2 22 + + 20 )10
.
De exemplu, 10112 = 1110
Proprietatea 3: Orice numr natural notat n felul urmtor:
= 1 2 , {0,1, ,9}
care reprezint un numr n baza 10, poate fi transformat n baza 2
prin urmtorul algoritm:
Ct timp X diferit de 0 execut
o Noteaz restul mpririi lui X la 2.
o X=X/2
Resturile notate, citite de la dreapta la stnga, reprezint
numrul transformat din baza 10 n baza 2.
De exemplu, dac vrem s transformm numrul 1110 n baza 2, vom
proceda n felul urmtor: 11 este diferit de 0, deci notm restul mpririi
sale la 2, acesta fiind 1. l mprim pe 11 la 2 i reinem partea ntreag a
mpririi, adic 5. 5 este diferit de 0, deci notm restul mpirii sale la 2,
care este 1. Reinem partea ntreag a mpririi lui 5 la 2, adic 2. Restul
mpririi lui 2 la 2 este 0, care se noteaz. 2 mprit la 2 este 1, iar restul
mpririi lui 1 la 2 este 1. Partea ntreag a mpririi lui 1 la 2 este 0, deci
am terminat. Reprezentarea binar este dat de citirea resturilor obinute: 1,
1, 0, 1 de la dreapta la stnga 10112 = 1110.
Cele dou transformri prezentate se pot aplica i altor baze, dar de
cele mai multe ori ne intereseaz doar baza 2.
50

Algoritmi de sortare
Pentru a implementa eficient algoritmul Radix sort vom avea nevoie
s efectum anumite operaii logice pe reprezentarea binar a numerelor
date spre sortare. Calculatorul reine automat numerele n baza 2, deci nu va
trebui s efectum vreo transformare, ci doar s aplicm operaiile necesare.
Aceste operaii sunt:
Operaia I (AND):
Operaia I (operatorul &) se aplic asupra a dou valori numere
naturale (unsigned int). Rezultatul este un numr natural obinut prin
conjuncia tuturor biilor primului numr cu biii de pe aceleai poziii ai
celui de-al doilea numr. Tabelul de adevr al conjunciei este:
p
1
1
0
0

q
1
0
1
0

p&q
1
0
0
0

Operaia SAU (OR):


Operaia SAU (operatorul |) funcioneaz la fel ca operaia I,
doar c n loc de conjuncie se aplic disjuncia. Tabelul de adevr este
urmtorul:
p
1
1
0
0

q
1
0
1
0

p|q
1
1
1
0

Operaiile de deplasare (operatorii >> i <<)


Operaiile de deplasare au ca efect deplasarea tuturor biilor de
valoare 1 a unui numr cu una sau mai multe poziii ctre stnga, n cazul
operatorului <<, sau ctre dreapta, n cazul operatorului >>. Biii rmai
liberi se nlocuiesc cu bii de valoare 0, iar biii care ies din numrul de bii
alocai reprezentrii se pierd.
Exemple: 1011 << 1 = 10110, 10011 >> 1001, 11001 << 3 =
11001000, 110 >> 2 = 1. Dac impunem, de exemplu, o limit de 4 bii
reprezentrii, atunci 0110 << 2 = 1000.
n cele ce urmeaz vom lucra cu tipul de date unsigned int, care
vom considera c poate reine numere naturale din intervalul [0, 232 1],
51

Capitolul 2
deci este reprezentat pe 32 de bii. De fiecare dat cnd avem de gnd s
efectum operaii pe bii este de preferat s lucrm cu tipuri de date fr
semn (unsigned), pentru a nu avea grija bitului de semn i pentru a putea
profita astfel de toi cei 32 de bii ai tipului de date int.
Operaiile pe bii ne ajut s optimizm algoritmul Radix sort,
aducndu-l la complexitatea timp O(N) pentru numere naturale
reprezentabile pe 32 de bii. Nu vom mai sorta numerele dup valorile
fiecarei cifre, ci dup valorile fiecrei grupe de bii. Vom alege un numr
natural MaxG care va reprezenta numrul de bii dintr-o grup. Algoritmul
prezentat anterior rmne nemodificat, doar c de data aceasta vom
considera o cifr ca fiind format din MaxG bii. Vom avea deci nevoie
de un vector de dimensiune 2MaxG n cadrul sortrii prin numrare, dar i de
un vector auxiliar de dimensiune N. Memoria folosit va fi astfel O(N).
n implementarea prezentat vom considera MaxG = 16, deci pentru
numere naturale din intervalul [0, 232 1] va trebui s aplicm sortarea prin
numrare de dou ori. n cazul acesta, timpul de execuie poate fi considerat
O(N), dar n cazul general, cnd nu tim ct de mari sunt numerele pe care
trebuie s le sortm, timpul de execuie este O(kN), unde k reprezint de
cte ori trebuie apelat procedura de sortare prin numrare.
De exemplu, pe urmtorul vector, n care numerele sunt date n baza
2, reprezentate pe 8 bii, iar MaxG = 4:
i
A

1
01101110

2
00010111

3
11101001

Sortm mai nti numerele dup ultimii MaxG = 4 bii, rezultnd


urmtorul vector:
i
A

1
00010111

2
11101001

3
01101110

Deoarece 01112 = 710, 10012 = 910 i 11102 = 1410. Vom sorta acum
numerele dup urmtoarea grup de de patru bii:
i
A

1
00010111

2
01101110

3
11101001

Deoarece 00012 = 110, 01102 = 610 i 11102 = 1410. Vectorul a fost


sortat n doi pai. Numerele n baza 10 sunt: 23, 110, 233.

52

Algoritmi de sortare
Mai trebuie s rezolvm doar subproblema sortrii numerelor dup o
anumit grup de bii. Pentru aceasta vom folosi o funcie ajuttoare numit
Sortare(A, N, T, Gr, V, Poz) care sorteaz numerele din vectorul A, de
dimensiune N, dup grupa de bii cu numrul Gr i care reine rezultatul n
vectorul T. Funcia va folosi doi vectori de caracterizare de dimensiune
MaxG: un vector V, unde V[i] reprezint cte numere exist n vectorul A
care au grupa Gr de bii egal cu i i un vector Poz, unde Poz[i] reprezint
poziia pe care trebuie pus n vectorul T primul numr pentru care grupa Gr
are valoarea i.
Poz[0] = 1, iar Poz[i] = Poz[i 1] + V[i 1], pentru orice i > 0.
De exemplu, dac avem urmtorul vector V:
i 0 1 2 3 4 5 6
A 3 4 3 2 1 1 7
Atunci vectorul Poz va fi urmtorul:
i 0 1 2 3 4 5 6
A 1 4 8 11 13 14 15
Ceea ce nseamn c cele trei numere care au grupa Gr egal cu 0
vor fi puse n vectorul T pe poziiile 1, 2 i 3. Cele patru numere cu grupa
Gr egal cu 1 vor fi puse n vectorul T pe poziiile 4, 5, 6 i 7. Se
procedeaz la fel i cu celelalte numere.
Pentru a afla valoarea unei grupe, vom numerota grupele de la
dreapta spre stnga ncepnd de la 0 i vom folosi operaiile i operatorii pe
bii prezentate anterior pentru a afla valoarea grupei curente. Mai exact, vom
folosi o funcie AflaGrupa(Nr, Gr) care va returna valoarea X grupei Gr a
numrului Nr. Aceast valoare poate fi calculat folosind urmtoarea
formul: X = ((Nr >> (Gr * MaxG)) & (MaxVal - 1)); unde MaxVal este
egal cu 2MaxG 1 = 216 1.
Formula rezult din faptul c orice putere a lui doi se reprezint n
sistemul binar ca un ir de bii cu un singur bit de valoare 1 urmat numai de
bii de valoare 0. Astfel, sczndu-l pe 1 dintr-o putere a lui 2, reprezentarea
binar a rezultatului va fi alctuit numai din bii de valoare 1. Efectund
operaia I ntre un numr X i un alt numr care are toi biii de valoare 1,
rezultatul va fi ntotdeauna numrul X.

53

Capitolul 2
De exemplu, dac ne propunem s aflm valoarea primilor cinci bii
(de la dreapta spre stnga) ai numrului 11010110110 2, tot ce trebuie s
facem este s aplicm operaia I ntre acest numr i numrul 11111 2:
110101101102 &
000000111112
000000101102 = 2210
Pentru a afla valoarea urmtorilor cinci bii, vom deplasa mai nti
numrul iniial cu cinci poziii ctre dreapta i vom aplica operaia I pe
numrul rezultat prin deplasare:
110101101102 >>
000001101012 &
5
000000111112
000001101012
000000101012 = 2110
const int MaxG = 16;
const unsigned int MaxVal = 1 << 16; // 2 la puterea 16
unsigned int AflaGrupa(unsigned int Nr, unsigned int Gr)
{
// se afla valoarea grupei Gr a numarului Nr
return ((Nr >> (Gr * MaxG)) & (MaxVal - 1));
}
void Sortare(unsigned int A[], int N, unsigned int T[], int Gr, int V[],
int Poz[])
{
for ( int i = 0; i < MaxVal; ++i ) V[i] = 0;
for ( int i = 1; i <= N; ++i ) ++V[ AflaGrupa(A[i], Gr) ];
Poz[0] = 1;
for ( int i = 1; i < MaxVal; ++i ) Poz[i] = Poz[i - 1] + V[i - 1];
for ( int i = 1; i <= N; ++i ) T[ Poz[ AflaGrupa(A[i], Gr) ]++ ] = A[i];
}
void Radix_sort(unsigned int A[], int N)
{
unsigned int *T = new unsigned int[N + 1];
int *V = new int[MaxVal];
int *Poz = new int[MaxVal];
Sortare(A, N, T, 0, V, Poz);
Sortare(T, N, A, 1, V, Poz);
delete []T; delete []V; delete []Poz;
}

54

Algoritmi de sortare
Pentru consisten, am putea alege s declarm toate variabilele fr
semn, dar acest lucru nu afecteaz n vreun fel algoritmul.
Menionm c, n practic, Quicksort rmne n continuare
algoritmul mai rapid, Radix sort fiind mai rapid doar dac numrul
elementelor ce trebuie sortate este foarte mare.
Tabelul 2.7.1. Proprietile algoritmului Radix sort
Caz favorabil Caz mediu Caz defav.
Timp de execuie
O(kN)
Memorie suplimentar
O(N)
Stabil
DA

2.8. Concluzii
Am prezentat n acest capitol opt algoritmi de sortare, metode de
implementare a acestora, optimizri i situaiile n care fiecare se potrivete
cel mai bine.
Tabelul 2.8.1. Comparaie ntre toi algoritmii de sortare prezentai
Timp de execuie
Memorie suplimentar
Nume
Caz
Caz
Caz
Caz
Caz
Caz
Stabil
algoritm
fav. mediu defav. fav.
mediu
defav.
Bubble
O(N) O(N2) O(N2)
O(1)
DA
sort
Insertion
O(N) O(N2) O(N2)
O(1)
DA
sort
Quicksort
O(Nlog N)1
O(1) O(log N) O(log N)
NU
Merge
O(Nlog N)
O(N)
DA
sort
Heapsort
O(Nlog N)
O(1)
NU
Introsort
O(Nlog N)
O(log N)
NU
Counting
O(N + MaxVal)
O(MaxVal) 2
DA
sort
Radix
O(kN) 3
O(N)
DA
sort
1

Complexitate bazat pe generarea de numere aleatore. Exist posibilitatea s degenereze


n O(N2), dar de cele mai multe ori aceast posibilitate este neglijabil.
2
Considerat optim deoarece algoritmul se preteaz numai sortrii vectorilor de dimensiuni
foarte mari, dar a cror elemente au valori mici.
3
Considerat optim deoarece pentru numere ntregi pe 32 de bii, k = 2.

55

Capitolul 2
Problema sortrii unor date este aadar o problem studiat de foarte
mult timp i pentru care s-au gsit muli algoritmi, unii eficieni indiferent
de natura datelor care trebuie sortate, alii proiectai special pentru anumite
tipuri de date. Algoritmii de sortare reprezint o introducere perfect n
informatic datorit faptului c sunt uor de neles i pot fi implementai
fr prea mari dificulti. Implementarea acestora nu necesit dect
cunotine elementare ale limbajului de programare n care se lucreaz, n
acest caz C++, fapt care permite studierea i nelegerea acestora i de ctre
persoane care abia au nceput studiul limbajului C++.

56

Tehnici de programare

3. Tehnici de
programare
Acest capitol prezint principalele tehnici tehnici de programare
folosite n rezolvarea problemelor: recursivitatea, backtracking, divide et
impera, greedy i programare dinamic. Aceste tehnici sunt nsoite de
aplicaii practice clasice, cum ar fi problema turnurilor din Hanoi i
generarea permutrilor, aranjamentelor, combinrilor etc.
Acest capitol este foarte important, ntruct orice problem poate fi
rezolvat printr-un algoritm care se ncadreaz ntr-una dintre tehnicile
menionate. Recomandm aadar stpnirea acestora.

57

Capitolul 3

CUPRINS
3.1. Recursivitate ..............................................................................................59
3.2. Backtracking ..............................................................................................68
3.3. Divide et impera ........................................................................................82
3.4. Greedy........................................................................................................89
3.5. Programare dinamic................................................................................96

58

Tehnici de programare

3.1. Recursivitate
S pornim de la definiia de baz: o funcie este recursiv dac n
definiia ei se folosete o referire la ea nsi.
Din aceast definiie putem considera modelul general al unui
algoritm recursiv de forma:
rec(param_formali) { rec(param_formali) }

Pentru ca acest model s aib sens din punct de vedere algoritmic,


avem nevoie de o condiie de oprire dat de modificrile parametrilor
formali:
rec(param_formali)
{
if ( conditie_iesire(param_formali) ) { instructiuni finale }
else rec(param_formali_modificati)
}

Exist o serie de funcii matematice definite prin recursivitate, dintre


care amintim:
Funcia factorial
=

1
1

= 0
> 0

int f(int n)
{
if ( n == 0 ) return 1;
else
return f(n - 1) * n ;
}

Funcia Ackermann (Ackermann-Peter)


+1
( 1, 1)
, =
( 1, , 1 )

59

= 0
> 0 = 0
> 0 > 0

Capitolul 3
int A(int m, int n)
{
if ( m == 0 )
else if ( n == 0 )
else
}

return n + 1;
return A(m - 1, 1);
return A(m - 1, A(m, n - 1));

irul lui Fibonacci


: , =

0
1
1 + ( 2)

= 0
= 1
2

Conform definiiei:
int F(int n)
{
if ( n == 0 ) return 0;
else if ( n == 1)
return 1;
else
return F(n 1) + F(n 2);
}

Putem interpreta definiia i n felul urmtor:


int F(int n)
{
if ( n < 2 ) return n;
else
return F(n 1) + F(n 2);
}
Cel mai mare divizor comun (Algoritmul lui Euclid)
, =

(, % )

int cmmdc(int x, int y)


{
if ( y == 0 ) return x;
else
return E(y, x%y) ;
}

60

= 0
> 0

Tehnici de programare

a) Fractali
O problem important a recursivitii o constituie fractalii. n
aceast seciune vom aminti doar sumar conceptul fractalilor geometrici n
care modelul de baz al funciei recursive va genera figuri geometrice (n
diferite ipostaze i de diferite dimensiuni), iar condiia de oprire va fi dat de
posibilitatea desenrii acestor figuri geometrice (latura > 1 pixel)
S presupunem un exemplu n care figura de baz este un ptrat
(x, y, l). Procedura patrat deseneaz figura geometric numit ptrat cu
centrul n (x, y) i de latur l. (Fig. 3.1.1. a, b, c)

Fig. 3.1.1. a)
Urmtorul subprogram:
fractal (x, y, l)
{
patrat(x, y, l);
fractal((x l) / 2, (y l) / 2, l / 2)
}

Va genera urmtoarea figur:

Fig. 3.1.1. b)
61

Capitolul 3
Genernd eroare datorit lipsei condiiei de oprire.
S implementm condiia de oprire i s construim nc o ramur.
fractal (x,y,l)
{
if ( l>5 ) //se refer la dimensiunea n pixeli
{
patrat(x, y, l)
fractal((x l) / 2, (y l) / 2, l / 2)
fractal((x + l) / 2, (y + l) / 2, l / 2)
}
}

Fig. 3.1.1. c)
n acelai mod se obin i figurile clasice Koch, Sierpinski, Kepler,
Cantor...

b) Turnurile din Hanoi


Nu putem vorbi de recursivitate fr s tratm problema turnurilor
din Hanoi. Aceast problem presupune existena unui set de n discuri de
diferite mrimi (n Fig. 3.1.2. avem n = 4 discuri), aezate n ordine pe o tij
numit surs (discul cu circumferina cea mai mare se gsete cel mai jos).
Exist de asemenea nc dou tije numite intermediar i destinaie.

62

Tehnici de programare
Obiectivul problemei (jocului) const n mutarea celor n discuri de
pe tija surs pe tija destinaie folosind tija intermediar cu urmtoarele trei
restricii:
1. Se poate muta o dat o singur pies de pe o anumit tij (cea
mai de sus). n Fig. 3.1.2. singura pies disponibil este cea
roie.
2. Nu se poate pune un disc de dimensiune mai mare peste un disc
de dimensiune (circumferin) mai mic.
3. Numrul de mutri trebuie s fie minim.

Fig. 3.1.2. Problema turnurilor din Hanoi


Rezolvarea aceastei probleme const n rezolvarea a trei etape
distincte.
Prima etap necesit mutarea a n 1 discuri de pe surs pe
intermediar, ceea ce ne va da acces la discul cel mai mare.

Fig. 3.1.3. Prima etap de rezolvare a problemei


63

Capitolul 3
A doua etap const n mutarea unui disc de pe surs pe destinaie,
lucru ce se poate face foarte uor.

Fig. 3.1.4. A doua etap de rezolvare a problemei


A treia etap const n revenirea la discurile mutate n prima etap
i s mutm aceste n-1 discuri de pe intermediar pe destinaie obinnd
astfel configuraia final (Fig. 3.1.5.)

Fig. 3.1.5. A treia etap de rezolvare a problemei


Dac ne uitm la restriciile iniiale observm c am ndeplinit doar
dou dintre cele trei: nu am pus un disc de dimensiune mai mare pe un disc
de dimensiune mai mic i am obinut un numr minim de pai. Nu am
ndeplinit ns condiia care cere s se mute un singur disc o dat (ar fi
correct dac n 1 ar fi 1 adic n ar fi 2).

64

Tehnici de programare

Fig 3.1.6. Turnurile din Hanoi cu 2 discuri


S revenim ns cnd n 1 > 1 ca n figura 3.1.3. i s rearanjm
tijele, fcnd abstracie de cel mai mare disc (nu intr n calcul dect la
etapa a doua), i obinem problema turnurilor din Hanoi, dar cu n 1 discuri
i alt tij numit intermediar (C) i alt tij numit destinaie (B).

Fig. 3.1.7. Interschimbarea tijelor B i C


Analog pentru etapa a treia n care se schimb sursa i intremediarul.

Fig. 3.1.8. Interschimbarea tijei surs cu tija intermediar


65

Capitolul 3
La acest nivel putem concepe urmtorul model care va constitui i
baza de funcionare a algoritmului recursiv.
Funcia de rezolvare Hanoi(n, A, B, C) se poate descompune n trei
subprobleme n ordinea urmtoare:
1. Hanoi (n 1, A, C, B)
2. Hanoi (1, A, B, C)
3. Hanoi (n 1, B,A,C)
Hanoi(1, A, B, C) este soluia trivial: mut de pe A pe C.
#include <iostream>
using namespace std;
void Hanoi(int nDiscuri, char tSursa, char tInter, char tDest)
{
if( nDiscuri > 0 )
{
Hanoi(nDiscuri 1, tSursa, tDest, tInter);
cout << tSursa << " --> " << tDest << endl;
Hanoi(nDiscuri 1, tInter, tSursa, tDest);
}
}
int main()
{
Hanoi (3,'A','B','C');
return 0;
}

Pentru a nelege mai bine conceptul de recursivitate aplicat la acest


tip de probleme, vom analiza n continuare problema turnurilor din Hanoi cu
aceleai condiii iniiale, dar vom introduce nc o tij intermediar.
Pentru a obine o singur soluie la un numr dat de discuri, vom introduce
restricia de a alege ca i tij intermediar pe care s mutm piesa curent
totdeauna cea mai din stnga liber, n caz c sunt mai multe libere (Fig.
3.1.9.)

66

Tehnici de programare

Fig. 3.1.9. Turnurile din Hanoi cu 4 tije


Se poate observa deja din figura anterioar modulul de funcionare al
algoritmului recursiv: Hanoi(n, A, B, C, D) nseamn:
1. Hanoi (n 2, A, C, D, B)
2. Hanoi (1, A, B, D, C) soluia trivial AC
3. Hanoi (1, A, B, C, D) soluia trivial AD
4. Hanoi (1, C, A, B, D) soluia trivial CD
5. Hanoi (n 2, B, A, C, D)
67

Capitolul 3
Mai trebuie s construim condiia de oprire a algoritmului recursiv.
Acesta va trebui oprit fie cnd n ajunge la valoarea 1 fie cnd ajunge la 2, n
funcie de valoarea iniial a lui n (par sau impar).
#include <iostream>
using namespace std;
void Hanoi4tije(int nDiscuri,
char tSursa, char tInter1, char tInter2, char tDest)
{
if ( nDiscuri == 1 )
cout << tSursa << " --> " << tDest << endl;
else if ( nDiscuri == 2 )
{
cout << tSursa << " --> " << tInter1 << endl;
cout << tSursa << " --> " << tDest << endl;
cout << tInter1 << " --> " << tDest << endl;
}
else
{
Hanoi4tije(nDiscuri - 2, tSursa, tInter2, tDest, tInter1);
cout << tSursa << " --> " << tInter2 << endl;
cout << tSursa << " --> " << tDest << endl;
cout << tInter2 << " --> " << tDest << endl;
Hanoi4tije(nDiscuri - 2, tInter1, tSursa, tInter2, tDest);
}
}
int main()
{
Hanoi4tije(3, 'A', 'B', 'C', 'D');
return 0;
}

3.2. Backtracking
Tehnica backtracking este o tehnic de programare, implementat
de obicei recursiv, care construiete treptat soluia unei probleme, iar n
cazul n care soluia construit se dovedete a fi invalid (sau ne intereseaz
mai multe soluii), revine la un pas precedent pentru a schimba o alegere
fcut. Acest lucru se continu, de obicei, pn cnd au fost explorate toate
posibilitile sau pn cnd am gsit una sau mai multe soluii valide. De
multe ori nu este necesar explorarea tuturor posibilitilor, putnd elimina
68

Tehnici de programare
alegeri care ar conduce la o soluie invalid fr a genera o soluie complet.
Astfel, se reduce foarte mult numrul de operaii efectuate.
Tehnica backtracking are la baz structura de date numit stiv.
Stiva este reprezentat de un vector n care se depune, la fiecare pas,
alegerea fcut la acel pas. Astfel, stiva va reprezenta ntotdeauna soluia
(eventual parial) la care s-a ajuns ntr-un anumit moment.
Forma general a metodei poate fi scris n pseudocod n felul
urmtor: definim o funcie Back(K, N, P, X, Sol), unde K reprezint pasul
curent al algoritmului, N reprezint dimensiunea stivei, adic dimensiunea
maxim a vectorului care poate reprezenta o soluie, P reprezint numrul
maxim de alegeri existente la un anumit pas, X reprezint vectorul ce
conine alegerile posibile pentru fiecare pas, iar Sol reprezint stiva folosit,
adic un vector. Funcia poate fi implementat n felul urmtor:
Dac K > N execut
o Dac soluia reprezentat de vectorul Sol este valid, se
afieaz vectorul Sol i se ncheie algoritmul (n caz c ne
intereseaz o singur soluie).
o Altfel, dac ne intereseaz mai multe soluii sau soluia
curent nu este valid, se continu algoritmul.
Altfel execut
o Pentru fiecare i de la 1 la P, execut
Sol[K] = Xi
Apeleaz recursiv Back(K + 1, N, P, X, Sol)
Dup cum se poate deduce din aceast generalizare a metodei,
timpul de execuie al unui algoritm de tip backtracking este O(NPN ),
deoarece avem P posibiliti pentru fiecare din cei N pai, iar validarea unei
soluie are, de obicei, complexitatea O(N). n practic ns, aceast
complexitate este de multe ori supraestimat, existnd diverse optimizri
euristice pentru problemele care nu admit dect rezolvri de tip
backtracking.
Evident, forma algoritmului se poate schimba de la o problem la
alta. De exemplu, este posibil s nu avem nevoie de vectorul X n cazul n
care alegerile posibile reprezint o mulime care se poate accesa i dac nu
este reinut ntr-un vector, cum ar fi mulimea numerelor naturale.
n cele ce urmeaz vom prezenta detaliat patru probleme a cror
rezolvare nu se poate face dect folosind aceast tehnic de programare:
generarea permutrilor unei mulimi, generarea aranjamentelor, generarea
combinrilor i generarea partiiilor unui numr.
69

Capitolul 3

a) Generarea permutrilor unei mulimi


Ne propunem s generm toate cele N! permutri ale mulimii
X = {1, 2, 3, , N}, pentru N citit din fiierul perm.in. Permutrile se vor
afia n fiierul perm.out, cate una pe o linie, ntr-o ordine oarecare.
Exemplu:
perm.in perm.out
3
123
132
213
231
312
321
Problema se poate rezolva folosind tehnica backtracking. Vom
folosi o stiv Sol, de dimensiune N, a cror elemente vor fi numerele care
reprezint o permutare. Cnd am depus N elemente n stiv, avem o soluie.
Aceast soluie este valid doar dac toate numerele din stiv sunt distincte,
n caz contrar neavnd de-a face cu o permutare. Verificarea ca elementele
s fie distincte se poate realiza n O(N2), ceea ce nu este deloc convenabil,
chiar i pentru valori foarte mici ale lui N.
Putem reduce complexitatea verificrii la O(1), folosind un vector
boolean Fol, tot de dimensiune N, cu semnificaia Fol[i] = false, dac
numrul i nu a fost nc depus n stiv i true n caz contrar. Cnd vrem s
depunem un numr i n stiv, vom verifica mai nti valoarea lui Fol[i]: dac
aceasta este false, vom depune numrul n stiv, vom atribui lui Fol[i]
valoarea true i vom merge mai departe. n caz contrar, dac Fol[i] este
true, numrul i se afl deja n stiv pe o poziie anterioar i nu l mai putem
pune nc o dat. Trebuie avut grij ca la revenirea din recursivitate s setm
valoarea lui Fol[i] pe false nainte de a depune alt numr pe acea poziie a
stivei, deoarece numrul i va putea fi folosit n urmtoarele soluii.
Astfel, verificarea validitii nu mai trebuie efectuat n momentul n
care stiva are N elemente depuse. Cnd ajungem la o stiv cu N elemente,
putem s afim pur i simplu coninutul stivei. Complexitatea algoritmului
este O(NN), dar aceasta este supraestimat, n practic eliminndu-se foarte
multe posibiliti invalide datorit verificrii pe care o efectum atunci cnd
ncercm s depunem un numr n stiv.

70

Tehnici de programare
Privii cum funcioneaz algoritmul pentru N = 3. Iniial, vectorul
Fol se iniializeaz cu 0, iar vectorul Sol poate rmne neiniializat.
La primul pas, K = 1, se depune mai nti n Sol[K] valoarea i = 1,
care se marcheaz apoi ca fiind folosit:
1
Sol

1
K
i
Fol

1
2
3
true false false

Se trece la pasul K = 2. Se ncearc, din nou, depunerea valorii 1 n


stiv, dar Fol[1] este egal cu true, deci se trece la urmtoarea valoare, adic
2: Sol[K] = 2, iar Fol[2] = true:
2
1
Sol

2
1
K
i
Fol

1
2
3
true true false

Se procedeaz similar la pasul K = 3: se ncearc depunerea valorii


1, dar Fol[1] = true, dup care se ncearc depunerea valorii 2, dar
Fol[2] = true. Fol[3] = false, deci se depune valoarea 3 pe poziia 3 a stivei
i Fol[3] ia valoarea true:
3
2
1
Sol

3
2
1
K
i
Fol

1
2
3
true true true

Se trece la pasul K = 4: K > N, deci afim coninuturile stivei (de


jos n sus): 1 2 3 reprezint o permutare valid.
Se revine la K = 3: se scoate ultima valoare din stiv (practic, doar
se ignor), iar Fol[3] devine false:
71

Capitolul 3
3
2
1
Sol

3
2
1
K
i
Fol

1
2
3
true true false

La pasul K = 3 nu se mai poate face nimic, deoarece nu putem folosi


numere mai mari ca N = 3. Se revine aadar la pasul K = 2, Fol[2] devine
false i se depune n Sol[K] urmtoarea valoare nefolosit, adic 3, iar
Fol[3] devine true:
3
2
1
1
K
Sol
i
Fol

1
2
3
true false true

Se continu n acest mod pn cnd nu se mai pot depune valori noi


pe nicio poziie a stivei, lucru ce se va ntmpla dup generarea permutrii 3
2 1.
Se poate observa c algoritmul acesta genereaz permutrile n
ordine lexicografic. Spunem c un ir (Xn) este mai mic lexicografic dect
un ir (Y n) dac i numai dac exist un i (1 < i n) astfel nct:
X1 = Y1, X2 = Y2, ..., Xi 1 = Yi 1, Xi < Yi
#include <fstream>
using namespace std;
const int maxN = 8;
void citire(int &N)
{
ifstream in("perm.in");
in >> N;
in.close();
}

72

Tehnici de programare
void perm(int K, int N, bool Fol[],
int Sol[], ofstream &out)
{
if ( K > N )
{
for ( int i = 1; i <= N; ++i )
out << Sol[i] << ' ';
out << endl;
return;
}

int main()
{
int N, Sol[maxN];
bool Fol[maxN];
citire(N);
for ( int i = 1; i <= N; ++i )
Fol[i] = false;
ofstream out("perm.out");
perm(1, N, Fol, Sol, out);
out.close();

for ( int i = 1; i <= N; ++i )


if ( !Fol[i] )
{
Sol[K] = i;

return 0;
}

Fol[i] = true;
perm(K + 1, N, Fol, Sol, out);
Fol[i] = false;
}
}

Exerciii:
a) Ct de rapid este algoritmul pentru valori mai mari dect 8?
b) Mai putei gsi optimizri pentru acest algoritm? Dar alt
algoritm, care abordeaz diferit problema? (Indiciu: folosii
interschimbri)
c) Modificai algoritmul astfel nct s gseasc numai a P-a
permutare n ordine lexicografic.
d) Modificai algoritmul astfel nct s genereze toate permutrile
unei mulimi citite din fiier, mulime care poate avea elemente
care se repet.

b) Generarea aranjamentelor
Dndu-se dou numere naturale N i P (1 P N), ne propunem s
generm toate aranjamentele de N luate cte P ale mulimii numerelor
!
naturale. Aranjamente de N luate cte P se noteaz , iar =

(numrul aranjamentelor de N luate cte P). Reamintim c reprezint


modalitile de a aranja N obiecte n P poziii, innd cont de ordinea
acestora (de exemplu, aranjamentul 1 3 2 este diferit de aranjamentul 1 2 3).
73

Capitolul 3
N i P se citesc din fiierul aran.in, iar aranjamentele gsite se scriu
n fiierul aran.out, fiecare pe cte o linie.
Exemplu:
aran.in aran.out
32
12
13
21
23
31
32
Rezolvarea problemei este aproape la fel ca rezolvarea problemei
anterioare: generarea permutrilor. Vom folosi aceeai funcie, doar c, de
data aceasta, vom avea o soluie (un aranjament) atunci cnd K > P,
deoarece avem P poziii pe care trebuiesc aranjate obiectele, nu N, ca n
cazul permutrilor. Complexitatea algoritmului va fi O(NP), dar i aceasta
este supraestimat, n practic efectundu-se un numr de operaii mai
apropiat de numrul aranjamentelor.
Privii modul de funcionare al algoritmului pentru exemplul dat: Se
iniializeaz Fol cu false, iar la pasul K = 1, se depune n stiv valoarea 1 i
Fol[1] devine true:
1
1
K
Sol
i
Fol

1
2
3
true false false

Se trece la K = 2 i se ncearc depunerea valorii 1 n stiv, lucru


imposibil deoarece Fol[1] = true. Se depune aadar valoarea 2, iar Fol[2]
devine true:
2
1
Sol

2
1
K
i
Fol

1
2
3
true true false
74

Tehnici de programare
Se trece la pasul K = 3, moment n care condiia K > P devine
adevrat, deci afim coninuturile stivei: 1 2.
Se revine la pasul K = 2, Fol[2] devine false, iar pe poziia 2 a stivei
se depune urmtoarea valoare, adic 3, iar Fol[3] devine true:
3
1
Sol

2
1
K
i
Fol

1
2
3
true false true

Se trece la pasul K = 3, moment n care se afieaz urmtorul


aranjament: 1 3. Se procedeaz n acest mod pn cnd nu mai exist
posibiliti la niciun pas al algoritmului, adic pn cnd au fost afiate toate
aranjamentele existente.
Este uor de observat c i acest algoritm genereaz aranjamentele n
ordine lexicografic, datorit ordinii n care sunt depuse elementele pe
fiecare nivel al stivei.
#include <fstream>
using namespace std;
const int maxN = 12;
const int maxP = 12;
void citire(int &N, int &P)
{
ifstream in("aran.in");
in >> N >> P;
in.close();
}

75

Capitolul 3
void aran(int K, int N, int P, bool Fol[],
int Sol[], ofstream &out)
{
if ( K > P )
{
for ( int i = 1; i <= P; ++i )
out << Sol[i] << ' ';
out << endl;
return;
}
for ( int i = 1; i <= N; ++i )
if ( !Fol[i] )
{
Sol[K] = i;
Fol[i] = true;
aran(K + 1, N, P, Fol, Sol, out);
Fol[i] = false;
}
}

int main()
{
int N, P, Sol[maxP];
bool Fol[maxN];
citire(N, P);
for ( int i = 1; i <= N; ++i )
Fol[i] = false;
ofstream out("aran.out");
aran(1, N, P, Fol, Sol, out);
out.close();
return 0;
}

c) Generarea combinrilor
Dndu-se dou numere naturale N i P (1 P N), dorim s
generm toate combinrile de N luate cte P ale mulimii numerelor
!
naturale. Combinri de N luate cte P se noteaz , iar =

! !

(numrul combinrilor de N luate cte P). Reamintim c, spre deosebire de


aranjamente, reprezint modalitile de a aranja N obiecte n P poziii,
ordinea n care se face aranjarea lor neavnd importan (de exemplu,
aranjarea 1 2 3 este acelai lucru cu aranjarea 2 1 3).
N i P se citesc din fiierul comb.in, iar combinrile generate se
scriu n fiierul comb.out, cte una pe o linie.
Exemplu:
comb.in comb.out
42
12
13
14
23
24
34
76

Tehnici de programare
Modul de rezolvare este similar cu cel al problemelor anterioare.
Mai mult, datorit faptului c ordinea elementelor nu are importan,
algoritmul se simplific mult. S presupunem c suntem la pasul K
(1 K P). Asta nseamn c avem depuse valori n Sol[1], Sol[2], ...,
Sol[K 1]. Considerm Sol[0] = 0. Deoarece ordinea elementelor nu
conteaz, putem depune numere pe poziia actual a stivei ncepnd de la
valoarea precedent, la care se adun unu, adic Sol[K 1] + 1. Astfel nu
mai avem nevoie de vectorul Fol, deoarece numerele din stiv vor fi
ntotdeauna ordonate cresctor i nu vor avea cum s se repete. Mai mult,
acest artificiu ne asigur i c nu vom genera mai multe combinri dect
este nevoie.
Algoritmul este cel mai rapid de pn acum, complexitatea sa fiind
O( ), datorit faptului c nu se va ncerca niciodata depunerea unei valori
invalide n stiv.
Pentru exemplul dat, algoritmul funcioneaz n felul urmtor: mai
nti se iniializeaz Sol[0] cu 0. La pasul K = 1, se depune mai nti
valoarea 1 n stiv:
1
Sol

1
K

Se trece la pasul K = 2. Se depune valorea Sol[K 1] + 1 = 2 n


stiv:
2
2
1
1
K
Sol
Se trece la pasul K = 3 i se afieaz coninuturile stivei, deoarece
K > P.
Se revine la pasul K = 2 i se continu numrtoarea, depunndu-se
n stiv valoarea 3. La urmtorul pas, se va afia din nou stiva. Se continu
n acest mod pn ce vor fi afiate toate combinrile.
i de data aceasta, algoritmul va genera combinrile n ordine
lexicografic.

77

Capitolul 3
#include <fstream>
using namespace std;

int main()
{
int N, P, Sol[maxP];

const int maxN = 12, maxP = 12;


citire(N, P);
void citire(int &N, int &P)
{
ifstream in("comb.in");
in >> N >> P;

Sol[0] = 0;
ofstream out("comb.out");
comb(1, N, P, Sol, out);
out.close();

in.close();
}

return 0;
void comb(int K, int N, int P, int Sol[],
ofstream &out)
{
if ( K > P )
{
for ( int i = 1; i <= P; ++i )
out << Sol[i] << ' ';
out << endl;

return;
}
for ( int i = Sol[K - 1] + 1; i <= N; ++i )
{
Sol[K] = i;
comb(K + 1, N, P, Sol, out);
}
}

Exerciii:
a) Ct de rapid este generarea aranjamentelor i a combinrilor
pentru valori mari, dar apropiate, ale lui N i P?
b) Scriei un program care afieaz toate numerele naturale de trei
cifre care pot forma cu cifrele 1, 3, 4 i 5. Ce algoritm vei
folosi?
c) Modificai ultimii doi algoritmi astfel nct s afieze
aranjamentele, respectiv combinrile, n ordine inverslexicografic.

78

Tehnici de programare
d) Complexitatea algoritmilor prezentai crete de N (pentru
permutri) respectiv P (pentru aranjamente i combinri) ori
datorit faptului c afiarea necesit parcurgerea stivei.
Remediai acest lucru dac este posibil.

d) Generarea partiiilor unui numr


O partiie a unui numr natural N este o modalitate de a-l scrie pe N
ca sum de unul sau mai multe numere naturale nenule. Dou partiii n care
termenii difer doar prin ordinea lor se consider identice.
Ne propunem s generm toate partiiile unui numr N citit din
fiierul part.in. Acestea se vor afia n fiierul part.out, cte o partiie pe
linie, ntr-o ordine oarecare. O partiie este format din unul sau mai multe
numere naturale nenule a cror sum este N.
Exemplu:
part.in part.out
4
1111
112
13
22
4
Explicaie: 1 + 1 + 1 + 1 = 2 + 1 + 1 = 3 + 1 = 4
Problema este similar cu problema generrii combinrilor. Vom
folosi o stiv Sol n care vom depune termenii partiiilor. De data aceasta nu
avem un numr prestabilit de pai, cum a fost cazul la problemele
anterioare, aa c ne trebuie alt modalitate de a afla cnd am ajuns la o
soluie. n momentul n care gsim un termen i, l vom scdea din N i vom
apela funcia recursiv pentru N = N i. Astfel, cnd N ajunge s fie 0, tim
c am gsit o partiie. Mai mult, deoarece ordinea termenilor nu conteaz,
putem ncepe generarea valorilor pentru un anumit pas ncepnd cu valoarea
de la pasul precedent, similar cu modul de generare al combinrilor. Un
termen i este valid dac N i 0.
Pentru exemplul dat, algoritmul va executa urmtorii pai: mai nti
vom iniializa Sol[0] cu 1, deoarece cel mai mic termen posibil ntr-o
partiie este 1.
79

Capitolul 3
La pasul K = 1 se depune mai nti n Sol[1] valoarea Sol[0], adic
1:
1
1
K
Sol
N=4
Dup ce se depune valoarea 1 n stiv, se trece la urmtorul pas, dar
cu N = N 1, adic N = 3.
La pasul K = 2 se depune n stiv valoarea Sol[1], adic 1, i se
apeleaz funcia pentru N = 2. Se continu n acest mod pn cnd se ajunge
la N = 0 i K = 5, dup care se afieaz coninuturile stivei, care va arta n
felul urmtor:
5
1
4
1
3
1
2
1
1
K
Sol
N=0
Se revine la pasul K = 4 i se ncearc depunerea valorii 2 pe aceast
poziie a stivei, lucru imposibil, deoarece la acest pas N = 1 iar 1 2 < 0.
Se revine la pasul K = 3 i se ncearc depunerea valorii 2. La acest
pas N = 2, iar 2 2 = 0, deci se poate depune aceast valoare. Stiva va arta
n felul urmtor la acest pas:
2
3
1
2
1
1
K
Sol
N=2
Se trece napoi la pasul K = 4, cu N = N 2 = 2 2 = 0, deci am mai
gsit o soluie. Se procedeaz n acest mod pn ce am gsit toate soluiile,
adic pn ce pe prima poziie a stivei se depune chiar valoarea N.

80

Tehnici de programare
#include <fstream>

int main()
{
int N, P, Sol[maxN];

using namespace std;


const int maxN = 20;

citire(N);
Sol[0] = 1;

void citire(int &N)


{
ifstream in("part.in");
in >> N;
in.close();
}

ofstream out("part.out");
part(1, N, Sol, out);
out.close();
return 0;
}

void part(int K, int N, int Sol[],


ofstream &out)
{
if ( !N )
{
for ( int i = 1; i < K; ++i )
out << Sol[i] << ' ';
out << endl;
return;
}
for ( int i = Sol[K - 1]; N - i >= 0; ++i )
{
Sol[K] = i;
part(K + 1, N - i, Sol, out);
}
}

Exerciii:
a) Modificai algoritmul astfel nct doar s numere partiiile
existente, nu s le i afieze.
b) Impunei condiia ca numerele folosite ntr-o partiie s fie
distincte.
c) Impunei condiia ca diferena n modul a doi termeni consecutivi
s fie cel puin 2

81

Capitolul 3

e) Concluzii
Am prezentat patru algoritmi reprezentativi pentru tehnica de
programare backtracking. Acetia pun n eviden cel mai bine structura
general a acestei metode i stau la baza majoritii problemelor care se pot
rezolva cu aceast metod.
Trebuie inut cont de faptul c algoritmii de tip backtracking sunt, de
cele mai multe ori, foarte ineficieni, metode precum divide et impera,
greedy, programare dinamic, tehnici aleatoare sau algoritmi genetici
fiind de multe ori de preferat atunci cnd o problem poate fi rezolvat
printr-una din aceste metode, chiar dac implementarea unui algoritm
backtracking este de multe ori mai uoar. Metoda backtracking se
folosete, de obicei, fie cnd o rezolvare eficient folosind alte metode
(polinomiale sau probabiliste) nu este cunoscut, fie cnd dorim s aflm
toate soluiile unei probleme, aa cum a fost cazul problemelor prezentate
anterior.
Aa cum am vzut, putem optimiza de multe ori un algoritm de tip
backtracking, eliminnd astfel multe soluii care s-ar dovedi la un moment
dat a fi invalide. Aceste optimizri depind foarte mult de natura problemei
pe care o rezolvm. De multe ori, un algoritm backtracking optimizat poate
fi cu mult mai eficient n practic dect unul neoptimizat.

3.3. Divide et impera


Tehnica divide et impera (tradus: dezbin i cucerete) este o
tehnic de programare care se bazeaz pe mprirea succesiv a unei
probleme n subprobleme din ce n ce mai mici pn cnd acestea pot fi
rezolvate foarte uor, iar apoi combinate n aa fel nct s obinem soluii la
subprobleme din ce n ce mai complicate, ajungndu-se n final la o soluie
pentru problema iniial. Am vzut deja doi algoritmi de tip divide et
impera: sortarea prin interclasare i sortarea rapid.
Algoritmii divide et impera sunt, de obicei, algoritmi recursivi, dar
uneori transformarea acestora n algoritmi iterativi este uoar i chiar de
preferat.
Dei majoritatea algoritmilor pot fi scrii ca algoritmi divide et
impera, aceasta este o tehnic aplicabil numai anumitor probleme care
chiar necesit o asemenea abordare, deoarece ali algoritmi clasici pot fi mai
rapizi.

82

Tehnici de programare
Un exemplu de problem la care nu este bine s folosim aceast
metod este determinarea minimului dintr-un ir de numere, aa cum vom
vedea.
Structura general a unui algoritm divide et impera este urmtoarea:
Dac problema curent este suficient de uoar pentru a fi
rezolvat, aceasta se rezolv.
Altfel, se mparte problema curent n dou sau mai multe
subprobleme care se rezolv recursiv.
La revenirea din recursivitate, se combin rezultatele tuturor
subproblemelor pentru a rezolva problema curent.
Aceti algoritmi au, de obicei, complexitatea O(Nlog N) datorit
faptului c fiecare problem se mparte de cele mai multe ori n dou
subprobleme de dimensiuni apropiate i pe fiecare nivel al arborelui de
recursivitate astfel obinut se efectueaz O(N) operaii.
Nu este neaprat s se rezolve toate subproblemele recursiv. Exist
algoritmi care pot elimina la fiecare pas una sau mai multe subprobleme,
complexitatea acestora fiind de obicei O(log N). Unul dintre aceti algoritmi
este chiar cutarea binar.

a) Determinarea minimului
Se dau N numere naturale. Se cere determinarea celui mai mic
numr dintre cele N.
Datele de intrare se citesc din fiierul minim.in: pe prima linie
numrul N, iar pe urmtoarea linie N numere naturale. Rezultatul se va afia
n fiierul minim.out.
Exemplu:
minim.in
7
9 7 8 3 4 2 11

minim.out
2

Rezolvarea clasic este evident i nu vom insista asupra ei.


Problema se poate rezolva ns i folosind tehnica divide et impera. Vom
folosi o funcie Minim(A, st, dr) care va returna valoarea minim din
subsecvea [st, dr] a vectorului A, unde A este vectorul care conine

83

Capitolul 3
numerele din fiierul de intrare. Rezultatul care ne intereseaz va fi dat de
apelul Minim(A, 1, N). Funcia poate fi implementat n felul urmtor:
Dac st = dr returneaz A[st]
Altfel, fie m = (st + dr) / 2
Fie min_st = Minim(A, st, m) i min_dr = Minim(A, m+1, dr)
Returneaz minimul dintre min_st i min_dr
Algoritmul este similar cu modul de funcionare al algoritmului de
sortare prin interclasare, aa c nu vom insista prea mult asupra sa. Acesta
are scop pur didactic, n practic algoritmul clasic fiind mai eficient i mai
simplu de scris.
#include <fstream>
using namespace std;
const int maxN = 1001;
void citire(int &N, int A[])
{
ifstream in("minim.in");
in >> N;
for ( int i = 1; i <= N; ++i ) in >> A[i];
in.close();
}
int Minim(int A[], int st, int dr)
{
if ( st == dr )
return A[st];
int m = (st + dr) / 2;
int min_st = Minim(A, st, m);
int min_dr = Minim(A, m + 1, dr);
return min_st<min_dr ? min_st : min_dr;
}
int main()
{
int N, A[maxN];
citire(N, A);
ofstream out("minim.out");
out << Minim(A, 1, N);
out.close();
return 0;
}

84

Tehnici de programare
Exerciiu: desenai arborele de recursivitate al algoritmului pentru
exemplul dat.

b) Cutarea binar
Se dau N numere naturale ordonate cresctor i M ntrebri de
forma numrul Xi se gsete sau nu printre cele N numere? la care trebuie
s se rspund ct mai eficient.
Datele de intrare se citesc din fiierul cbinara.in: pe prima linie
numerele N i M separate prin spaiu, pe urmtoarea linie cele N numere
naturale separate prin spaiu, iar pe urmtoarele M linii cele M ntrebri. Pe
linia i a fiierului de ieire cbinara.out vei afia DA sau NU, n funcie de
rspunsul la ntrebarea respectiv.
Exemplu:
cbinara.in
95
4 6 8 9 12 15 15 16 21
4
5
9
21
15

cbinara.out
DA
NU
DA
DA
DA

O prim idee de rezolvare este s parcurgem ntregul ir de numere


pentru fiecare ntrebare, rezultnd complexitatea O(NM). Folosind
cutarea binar putem reduce timpul de execuie la O(Mlog N).
Pseudocodul pentru cutarea binar poate fi scris n felul urmtor: fie
cbinara(A, st, dr, val) o funcie care returneaz true dac numrul val se
gsete n subsecvena [st, dr] a vectorului A i false n caz contrar. Aceast
funcie poate fi implementat n felul urmtor:
Ct timp st < dr execut
o Fie m = (st + dr) / 2
o Dac val = A[m] returneaz 1
o Altfel, dac val < A[m], execut dr = m
o Altfel, execut st = m + 1
Dac A[st] = val, returneaz true, altfel returneaz false.

85

Capitolul 3
Se poate observa foarte uor c am ales o abordare nerecursiv.
Algoritmul poate fi implementat i recursiv, dar acest lucru nu are niciun
scop practic.
Modul de funcionare al cutrii binare este foarte simplu: la fiecare
pas, se fixeaz mijlocul subsecvenei [st, dr] curente.
Dac valoarea cutat este egal cu elementul din mijlocul secvenei,
se returneaz true. n caz contrar, dac valoarea cutat este mai mic dect
elementul din mijloc, datorit faptului c numerele sunt ordonate cresctor,
este clar c orice element de dup elementul din mijloc va fi i el mai mare
dect valoarea cutat, deci putem elimina toate aceste elemente, adic
putem atribui lui dr valoarea m.
Dac valoarea cutat este mai mare dect elementul din mijloc, se
procedeaz similar: este clar c toate elementele cu indici mai mici dect
mijlocul secvenei sunt mai mici i ele dect valoarea cutat, deci putem
face atribuire st = m + 1.
La sfrit, cnd st == dr, verificm dac A[st] este elementul cutat.
Privii modul de funcionare al algoritmului pentru primele dou
ntrebri din exemplul dat. Am marcat cu rou elementele care sigur nu pot
conine valoarea cutat.
Pentru prima ntrebare, val = 4, st = 1, dr = 9.
Fie m = (st+dr)/2 = 5.
st
m
dr
i 1 2 3 4 5 6 7 8 9
A 4 6 8 9 12 15 15 16 21
A[m] = 12 > val = 4, deci
m, adic putem seta dr = m:
st
m
i 1 2 3 4
A 4 6 8 9

putem elimina toate elementele de dup


dr
5 6 7 8 9
12 15 15 16 21

A[m] = 8 > val = 4, deci dr = m:


st m dr
i 1 2 3 4 5 6 7 8 9
A 4 6 8 9 12 15 15 16 21
A[m] = 6 > val = 4, deci dr = m:
st m dr
i
1
2 3 4 5 6 7 8 9
4
6 8 9 12 15 15 16 21
A
86

Tehnici de programare
A[m] = val, deci algoritmul se oprete. S-au efectuat patru pai.
Pentru a doua ntrebare, val == 5, st = 1, dr = 9. m = (st+dr)/2 = 5.
st
m
dr
i 1 2 3 4 5 6 7 8 9
A 4 6 8 9 12 15 15 16 21
A[m] = 12 > val = 5, deci dr = m:
st
m
dr
i 1 2 3 4 5 6 7 8 9
A 4 6 8 9 12 15 15 16 21
A[m] = 8 > val = 5, deci dr = m:
st m dr
i 1 2 3 4 5 6 7 8 9
A 4 6 8 9 12 15 15 16 21
A[m] = 6 > val = 5, deci dr = m:
st m dr
i
1
2 3 4 5 6 7 8 9
4
6 8 9 12 15 15 16 21
A
A[m] = 4 < val = 5, deci st = m + 1:
st dr
i 1
2
3 4 5 6 7 8 9
6
8 9 12 15 15 16 21
A 4
Deja st = dr, iar A[st] != val, deci valoarea cutat nu se regsete n
ir.
Complexitatea algoritmului este uor de dedus: O(log N), deoarece
la fiecare pas reducem spaiul de cutare la jumtate.
#include <fstream>
using namespace std;
const int maxN = 1001;

87

Capitolul 3
bool cbinara(int A[], int st, int dr, int val)
{
while ( st < dr )
{
int m = (st + dr) / 2;
if ( val == A[m] )
else if ( val < A[m] )
else

return true;
dr = m;
st = m + 1;

}
return A[st] == val;
}

void rezolvare(int &N, int &M, int A[])


{
ifstream in("cbinara.in");
in >> N >> M;
for ( int i = 1; i <= N; ++i )
in >> A[i];
ofstream out("cbinara.out");
for ( int i = 1, x; i <= M; ++i )
{
in >> x;
bool gasit = cbinara(A, 1, N, x);
if ( gasit ) out << "DA\n";
else
out << "NU\n";
}
in.close(); out.close();
}
int main()
{
int N, M, A[maxN];
rezolvare(N, M, A);
return 0;
}
88

Tehnici de programare
Exerciii:
a) Modificai algoritmul astfel nct s returneze cea mai mare
poziie a unui element cutat.
b) Modificai algoritmul astfel nct s lucreze cu un ir sortat
descresctor.
c) Modificai algoritmul de sortare prin inserie astfel nct s
foloseasc algoritmul de cutare binar pentru determinarea
poziiei n care trebuie inserat un element.

a) Concluzii
Algoritmii divide et impera pot fi foarte folositori n gsirea unor
soluii optime la o problem. Dac acest lucru nu este prea dificil,
implementarea acestor algoritmi trebuie fcut nerecursiv, deoarece
apelurile recursive pot ncetini n practic performana algoritmilor.
Aceti algoritmi au aplicabilitate atunci cnd rezolvarea unei
probleme identice, dar de dimensiuni mai mici, poate contribui la rezolvarea
unei probleme mai mari.

3.4. Greedy
Tehnica de programare greedy (n traducere liber: tehnica
lcomiei) se bazeaz pe selectarea succesiv a unor optime locale pentru a
determina ntr-un final optimul global. Tehnica se folosete, de obicei, n
cazul problemelor n care se cere determinarea unui minim sau maxim
respectnd anumite constrngeri dependente de problem.
Datorit faptului c algoritmii greedy iau la fiecare pas decizia cea
mai favorabil existent la acel pas, fr a lua n considerare cum ar putea
afecta aceast decizie ntreg traseul algoritmului, exist posibilitatea ca
aceti algoritmi s nu determine ntotdeauna un optim, ci doar o soluie care
respect constrngerile problemei, dar care nici mcr nu este neaprat
apropiat de optimul global pentru datele date. Din acest motiv, nainte de a
implementa o strategie greedy pentru rezolvarea unei probleme, este
recomandat s se demonstreze matematic corectitudinea strategiei alese.
Dac se poate demonstra c metoda aleas conduce ntotdeauna la
gsirea unui optim global, folosirea unei strategii greedy este de cele mai
multe ori preferabil celorlalte alternative, cum ar fi metodele backtracking
sau divide et impera, deoarece algoritmii greedy sunt, n general, mai
rapizi, avnd o complexitate polinomial, adic O(Nk), unde N reprezint
dimensiunea datelor de intrare, iar k este o constant natural.
89

Capitolul 3
n caz c demonstrarea corectitudinii unei metode nu este posibil,
sau n caz c putem gsi un contraexemplu pentru metoda aleas, este
recomandat s folosim alte metode de rezolvare a problemei.
Forma general a algoritmilor greedy este urmtoarea:
Fie X1, X2, , XN datele de intrare ale problemei i S un optim
care trebuie gsit, iniializat cu o valoare oarecare, sau cu
mulimea vid n caz c se cere gsirea unei mulimi.
Pentru fiecare i de la 1 la N execut
o Dac la pasul i putem lua o decizie care mbuntete
optimul S, vom lua aceast decizie.
n continuare vom prezenta dou probleme abordabile prin metoda
greedy: problema spectacolelor i problema plii unei sume. Cea din urm
va evidenia i cazul n care metoda greedy nu furnizeaz ntotdeauna
rspunsul optim.

a) Problema spectacolelor
Patronul unei sli de spectacole dorete s organizeze, ntr-un
interval de timp oarecare, un numr ct mai mare de spectacole. tiind c
exist N artiti interesai s susin un spectacol i c fiecare artist i poate s
suin spectacolul doar n intervalul de timp [ai, bi], determinai numrul
maxim de spectacole care pot fi organizate astfel nct intervalele de
desfurare a oricror dou spectacole s nu se suprapun.
Datele de intrare se citesc din fiierul spectacole.in: pe prima linie
numrul natural N, iar pe urmtoarele N linii se afl perechi de numere [ai,
bi] avnd semnificaia din enun. n fiierul spectacole.out vei afia
numrul maxim de spectacole care pot fi programate respectnd condiiile
problemei.
Exemplu:
spectacole.in spectacole.out
5
3
14
47
35
89
67
90

Tehnici de programare
Explicaie: se vor organiza spectacolele cu numerele de ordine 1, 4
i 5.
Vom folosi urmtorul algoritm greedy, a crui corectitudine va fi i
demonstrat:
Se sorteaz spectacolele date dup momentul terminrii lor. n
continuare se va lucra pe irul sortat al intervalelor.
Se programeaz primul spectacol.
Pentru fiecare i de la 2 la N execut
o Dac momentul de nceput al spectacolului i este mai
mare dect momentul terminrii ultimului spectacol
programat atunci
Programeaz spectacolul i.
Afieaz numrul spectacolelor programate.
n continuare, vom demonstra faptul c acest algoritm gsete
ntotdeauna numrul maxim de spectacole care pot avea loc.
Fie X1, X2, ..., XK spectacolele programate de ctre algoritmul de
mai sus i Y1 , Y2, ..., YL spectacolele programate de ctre un algoritm
optim. Dac putem demonstra c L = K, atunci algoritmul prezentat este
correct.
Vom presupune prin absurd c L > K.
Presupunem ca Y1 X1. n acest caz, este posibil s-l nlocuim pe
Y1 cu X1 , deoarece X1 este spectacolul care se termin primul, deci nu are
cum s figureze pe alt poziie n cadrul soluiei optime. Dac Y1 = X 1 ,
atunci acest pas nu mai este necesar. Soluia X1, Y2 , ..., YL rmne aadar n
continuare optim.
Fie 1 < P K primul indice pentru care YP XP. Soluia X1, X2 ,
..., XP 1, YP, , YL este optim dintr-un raionament asemntor cu cel de
mai sus.
XP nu face parte din soluia optim. Dac XP ar face parte din soluia
optim, ar trebui s se afle cel puin pe poziia P + 1, ceea ce nseamn c ar
ncepe dup YP, contrazicndu-se astfel modul de funcionare al
algoritmului.
Astfel, la un moment dat se va ajunge la o soluie de forma X1 , X2,
..., XK, ..., YL. Asta ar nsemna c dup XK mai este posibil s selectm
91

Capitolul 3
spectacole, dar conform algoritmului, acest lucru nu este posibil, deci am
ajuns la o contradicie. Rezult ca presupunerea fcut este fals i L = K.
Privii modul de funcionare al algoritmului pentru exemplul dat.
Mai nti se sorteaz spectacolele dup momentul de terminare al acestora,
rezultnd urmtorul ir de intervale:
14
35
47
67
89
Se selecteaz primul spectacol, adic cel cu intervalul [1, 4].
Se trece la al doilea spectacol, dar momentul de nceput al acestuia
(3) nu este mai mare dect momentul terminrii ultimului spectacol selectat
(4), deci nu putem selecta acest spectacol.
Se trece la al treilea spectacol, dar nici acesta nu poate fi selectat din
acelai motiv.
Se trece la al patrulea spectacol, care poate fi selectat, deoarece
6 > 4.
Se trece la ultimul spectacol, care iari poate fi selectat, deoarece
8 > 7.
Astfel s-au selectat trei spectacole, numr care se afieaz.
Complexitatea algoritmului este O(Nlog N) datorit sortrii. Dac datele de
intrare se dau sortate, complexitatea devine O(N). Memoria folosit este
O(N) dac datele de intrare nu se dau sortate i O(1) n caz contrar.
Pentru aceast implementare, vom folosi ca algoritm de sortare
funcia pus la dispoziie de ctre limbajul C++ sort, funcie prezentat pe
scurt i n cadrul capitolului despre algoritmi de sortare.
n cazul acestei probleme vom avea nevoie s sortm un tip de date
definit de ctre utilizator, deoarece vom folosi o structur pentru a reine
momentele de nceput i de sfrit ale fiecrui spectacol. Pentru a putea
sorta structuri folosind funcia sort, trebuie s avem o funcie de comparare
care primete doi parametri (de tipul datelor pe care vrem s le sortm) i
returneaz true dac primul parametru se consider mai mic dect al doilea,
i false n caz contrar. Funcia aceasta se transmite ca i parametru funciei
sort.
Este foarte important ca aceast funcie de comparare s foloseasc
parametri de tip referin (de obicei referin constant, dar acest lucru este
92

Tehnici de programare
mai puin important) atunci cnd se lucreaz cu structuri, mai ales dac
acestea sunt foarte mari, deoarece dac parametrii nu sunt de tip referin,
fiecare apel al funciei va lucra cu o copie a obiectelor transmise ca
argument funciei, iar aceast copiere poate afecta drastic performana unui
program.
#include <fstream>
#include <algorithm>
using namespace std;

void rezolvare(int N, spectacol A[])


{
spectacol precedent = A[1];
int nr = 1;

const int maxN = 1001;

for ( int i = 2; i <= N; ++i )


if ( A[i].inc > precedent.sf )
{
precedent = A[i];
++nr;
}

struct spectacol
{
int inc, sf;
};
bool cmp(const spectacol &x,
const spectacol &y)
{
return x.sf < y.sf;
}
void citire(int &N, spectacol A[])
{
ifstream in("spectacole.in");
in >> N;

ofstream out("spectacole.out");
out << nr << endl;
out.close();
}
int main()
{
int N;
spectacol A[maxN];
citire(N, A);

for ( int i = 1; i <= N; ++i )


in >> A[i].inc >> A[i].sf;

rezolvare(N, A);

sort(A + 1, A + N + 1, cmp);

return 0;
}

in.close();
}

Exerciii:
a) Modificai algoritmul astfel nct s afieze indicii iniiali ai
spectacolelor selectate.
b) Implementai un algoritm de sortare pentru sortarea datelor.
c) Dac fiecare spectacol ar avea asociat un cost i am dori s
organizm un numr maxim de spectacole, dar care s coste ct
93

Capitolul 3
mai puin, ar mai funciona strategia greedy? Dac da, ce ar
trebui modificat?
d) Se d un interval [P, Q] i se cere programarea unui numr
minim de spectacole care s acopere intervalul [P, Q]. Elaborai
un algoritm greedy care rezolv problema.

b) Problema plii unei sume


Presupunem c trebuie s pltim o sum de bani S i c avem la
dispoziie un numr infinit de monede de valoare 25, 10, 5 i 1. Se cere s se
determine numrul minim de monede necesare pentru a plti suma S.
S se citete din fiierul suma.in, iar numrul minim de monede se va
scrie n fiierul suma.out.
Exemplu:
suma.in suma.out
39
6
Explicaie: se folosete o moned de valoare 25, una de valoare 10
i patru de valoare 1.
Problema se poate rezolva optim folosind metoda greedy.
Algoritmul este foarte simplu i intuitiv: pentru fiecare tip de moned,
ncepnd de la cel mai mare la cel mai mic, vom folosi acest tip de moned
de cte ori este posibil.
Pentru exemplul dat, algoritmul funcioneaz n felul urmtor:
39 > 25, deci putem folosi o moned de valoare 25. 39 25 = 14.
14 < 25, deci nu mai putem folosi monede de valoare 25. 14 > 10,
deci putem folosi o moned de valoare 10. 14 10 = 4.
4 < 10, deci nu putem mai folosi monede de valoare 10.
4 < 5, deci nu putem folosi nici monede de valoare 5.
Nu ne mai rmne dect s pltim restul sumei folosind patru
monede de valoare 1. n total, s-au folosit ase monede.
Problema prezentat este de fapt un caz particular al problemei n
care monedele disponibile nu sunt unice, iar cerina este aceeai. De
exemplu, dac am avea la dispoziie monede de valoare 9, 6, 2 i 1 i ar
trebui s pltim suma 12, algoritmul greedy prezentat anterior ar alege o
94

Tehnici de programare
moned de valoare 9, o moned de valoare 2 i o moned de valoare 1, adic
trei monede n total. Soluia optim este ns folosirea a dou monede de
valoare 6. Acesta este un contraexemplu ce demonstreaz c strategia
greedy nu funcioneaz dect pentru anumite tipuri de monede.
Cazul general al problemei se rezolv folosind fie backtracking (dar
acest lucru este foarte ineficient), fie programare dinamic.
#include <fstream>

void rezolvare(int S)
{
int nr = 0;
for ( int i = 0; monede[i]; ++i )
while ( S - monede[i] >= 0 )
{
S -= monede[i];
++nr;
}
ofstream out("suma.out");
out << nr << endl;
out.close();
}
int main()
{
int S;
citire(S);
rezolvare(S);
return 0;
}

using namespace std;


const int monede[] = {25, 10, 5, 1, 0};
void citire(int &S)
{
ifstream in("suma.in");
in >> S;
in.close();
}

c) Concluzii
Algoritmii greedy sunt de obicei mai rapizi dect ali algoritmi, mai
ales dect metodele exhaustive cum este metoda backtracking. Un
dezavantaj al acestora este c programatorul trebuie s se bazeze foarte mult
pe intuiie i pe experien pentru a putea ajunge la un algoritm corect, iar
aparenta corectitudine a unui algoritm poate fi neltoare, aa cum am
artat la ultima problem.
Dac avei de ales ntre un algoritm greedy a crui corectitudine
poate fi demonstrat i un algoritm mai puin eficient, este clar c alegerea
trebuie fcut n favoarea algoritmului greedy. Dac nu se poate demonstra
corectitudinea algoritmului greedy ns, iar importan gsirii unui optim
pentru fiecare caz posibil este foarte mare, atunci este de preferat folosirea
altui algoritm, chiar dac este mai puin eficient.
95

Capitolul 3
O metod uzual de verificare a unui algoritm greedy, cnd nu
putem gsi o demonstraie i nici un contraexemplu, este implementarea
unui algoritm de tip backtracking care rezolv aceeai problem. Generai
aleator date de intrare i rezolvai-le att cu programul backtracking ct i cu
programul greedy. Dac nu apar diferene ntre rezultate pentru mult timp,
atunci probabil c metoda greedy este corect.

3.5. Programare dinamic


Programarea dinamic este o tehnic de rezolvare a problemelor care
se bazeaz pe descompunerea acestora n subprobleme din ce n ce mai mici
(similar tehnicii divide et impera). Tehnica este aplicabil acelor probleme
care prezint subprobleme suprapuse i substructur optim deoarece
aceastea pot fi formulate cu ajutorul unei formule de recuren.
Problemele care prezint substructur optim sunt acele probleme
pentru care se poate ajunge la o soluie optim folosind soluiile optime
gsite pentru subproblemele existente.
Problemele care prezint subprobleme suprapuse sunt acelea
pentru care o abordare recursiv clasic ar rezolva aceeai subproblem de
mai multe ori, cum este cazul irului lui Fibonacci. Folosind programarea
dinamic, soluiile acestor probleme pot fi mbuntite substanial avnd
grij s rezolvm fiecare subproblem o singur dat. Acest lucru poate fi
realizat fie folosind metoda nainte (bottom-up), fie folosind metoda napoi
(top-down) i aplicnd tehnica memoizrii.
Prin metoda nainte se rezolv mai nti subproblemele mici, a cror
rezultate se folosesc dup aceea n rezolvarea subproblemelor care depind
de acestea, pn cnd se ajunge la rezolvarea problemei iniiale. Aceast
metod este implementat, de obicei, iterativ.
Metoda napoi este implementat, de obicei, recursiv, presupunnd
rezolvarea unei subprobleme prin efectuarea unor apeluri recursive care vor
rezolva subproblemele de care avem nevoie la un anumit pas. Aceast
tehnic poate fi mai uor de implementat n unele cazuri i poate fi
optimizat folosind tehnica memoizrii, care va fi prezentat n acest
capitol. Datorit recursivitii, este recomandat folosirea metodei nainte
atunci cnd acest lucru este posibil.
Algoritmii de programare dinamic sunt folosii, de obicei, pentru a
rezolva probleme n care se cere gsirea unui optim sau probleme de
combinatoric.
96

Tehnici de programare
Spre deosebire de tehnica greedy, programarea dinamic pstreaz
toate strile necesare lurii unor decizii care vor conduce sigur la gsirea
unui optim global. Programarea dinamic are aadar o aplicabilitate mai
larg dect tehnicile prezentate pn acum, rmnnd de cele mai multe ori
i o alternativ eficient.
n cele ce urmeaz vom prezenta cteva concepte i probleme
elementare rezolvabile folosind tehnici de programare dinamic: irul
Fibonacci, problema triunghiului, triunghiul lui Pascal i tehnica
memoizrii.

a) irul Fibonacci
irul Fibonacci, sau numerele Fibonacci sunt numerele generate
de funcia:
0
= 0
1
= 1
=
1 + ( 2) 2
Aa cum am vzut la capitolul despre recursivitate, aceast funcie
poate fi implementat recursiv ntr-un mod foarte uor. Practic doar se
traduce definiia matematic a funciei n C++. Timpul de execuie al unei
astfel de implementri este foarte mare, deoarece se vor efectua multe
apeluri recursive care vor rezolva aceeai subproblem. De exemplu, dac
am folosi o asemenea funcie pentru a calcula F(7), ar rezulta urmtorul
arbore de recursivitate (apelurile care s-au mai efectuat deja cel puin o dat
apar n rou):

Fig. 3.5.1. Arborele de recursivitate asociat funciei fibonacci


97

Capitolul 3
Se poate vedea foarte uor c aceast metod efectueaz un numr
mult mai mare de operaii dect este necesar. Pentru a v convinge c
aceast metod este ineficient, folosii implementarea recursiv i apelai
F(100).
Ne propunem s scriem un program eficient care citete din fiierul
fibo.in un numr natural N i afieaz n fiierul fibo.out valoarea F(N).
Exemplu:
fibo.in fibo.out
6
8
Pentru a rezolva eficient problema, vom folosi metoda programrii
dinamice. Fie F un vector a cror elemente au urmtoarea semnificaie:
F[i] = F[i 2] + F[i 1], dac i > 1 i F[0] = 0, F[1] = 1. Putem construi
acest vector n timp O(N), iniializnd primele dou elemente i nsumnd
apoi, pentru fiecare poziie i, valorile de pe poziiile i 1 i i 2. Pentru
N = 6 se va construi urmtorul vector:
i 0 1 2 3 4 5 6
F 0 1 1 2 3 5 8
Rspunsul se va afla n F[N], n cazul acesta n F[6].
Problema prezint substructur optim deoarece, dac avem
calculate corect valorile F[N 2] i F[N 1], putem calcula F[N]. Problema
prezint i subprobleme suprapuse deoarece, aa cum am vzut, o
abordare recursiv va rezolva aceeai subproblem de mai multe ori. Acest
lucru se ntmpl din cauza faptului c ntotdeauna doi termeni consecutivi
ai irului Fibonacci vor avea un termen precedent n comun. De exemplu,
F[6] = F[5] + F[4], iar F[5] = F[4] + F[3].
Se folosete metoda nainte, deoarece valorile funciei se calculeaz
treptat, de la cea mai mic la cea cerut.
#include <fstream>
using namespace std;
const int maxN = 1001;

98

Tehnici de programare
int main()
{
int N, F[maxN];
ifstream in("fibo.in");
in >> N;
in.close();
F[0] = 0, F[1] = 1;
for ( int i = 2; i <= N; ++i )
F[i] = F[i - 2] + F[i - 1];
ofstream out("fibo.out");
out << F[N] << endl;
out.close();
return 0;
}

Memoria folosit de acest program este O(N), dar aceasta se poate


reduce la O(1) fcnd urmtoarea observaie destul de evident: fiecare
termen al irului Fibonacci nu depinde dect de cei doi termeni precedeni.
Astfel, nu mai avem nevoie de vector, ci doar de dou variabile care s
rein ultimii doi termeni, variabile care se vor actualiza corespunztor de
fiecare dat cnd generm un nou termen.
Exerciii:
a) Implementai algoritmul care folosete memorie O(1).
b) Numerele Fibonacci devin mari foarte rapid. Care este cel mai
mare numr Fibonacci care poate fi reinut de tipul de date int?
c) Dac s-ar da mai multe numere pentru care trebuie calculat
funcia F, cel mai mare dintre acestea fiind K, cum s-ar putea
calcula funcia eficient pentru fiecare?

b) Problema triunghiului
Se d un triunghi de numere naturale de latur N pe care se joac
urmtorul joc: se alege numrul din colul de sus al triunghiului. Dup
aceea, la fiecare pas, ne putem deplasa fie cu o poziie n jos, fie cu o poziie
n jos i una ctre dreapta fa de poziia ultimului numr ales. Numrul pe
care se face deplasarea este ales. Acest lucru se continu pn cnd se
ajunge pe ultima linie a triunghiului. Ne intereseaz un traseu pentru care
suma numerelor alese este maxim.
99

Capitolul 3
Datele de intrare se citesc din fiierul triunghi.in: pe prima linie N,
iar pe fiecare linie i a urmtoarelor linii, cte i numere naturale,
reprezentnd o linie a triunghiului. n fiierul triunghi.out se va afia, pe
prima linie, suma maxim gsit. Pe a doua linie, se vor afia n ordine
numerele alese, separate printr-un spaiu.
Exemplu:
triunghi.in triunghi.out
5
50
4 5 2 20 19
4
35
142
7 5 6 20
1 1 7 19 9
Vom folosi o matrice A care va reine numerele date, adic
A[i][j] = numrul de pe linia i i coloana j a triunghiului de numere. Din
poziia A[i][j] ne putem deplasa pe poziiile A[i + 1][j] i A[i + 1][j + 1],
atta timp ct acestea nu depesc limitele matricei. Problema se reduce la a
gsi un drum n matricea A a crui elemente s aib suma maxim, folosind
numai aceste dou tipuri de deplasri.
La prima vedere, problema pare abordabil folosind metoda greedy.
Vom ncepe prin a iniializa S cu primul numr, dup care vom selecta
numrul de valoare maxim dintre numerele A[i + 1][j] i A[i + 1][j + 1] i
ne vom deplasa pe poziia numrului de valoare maxim. Aceast soluie nu
funcioneaz ns ntotdeauna, deoarece nu inem cont de faptul c o alegere
neoptim la pasul curent ne poate duce n final la soluia optim, aa cum
este cazul n exemplul dat.
Problema poate fi rezolvat folosind metoda programrii dinamice.
Vom calcula o matrice B, unde B[i][j] = suma maxim a unui traseu care
se termin cu numrul A[i][j]. Relaiile de recuren sunt uor de dedus:
pentru a ajunge pe A[i][j], trebuie s fim deja fie pe A[i 1][j], fie pe
A[i 1][j 1]. Dac tim cte un traseu optim care se termin cu fiecare
dintre aceste dou numere, putem determina un traseu optim pentru a ajunge
pe A[i][j]: fie adugm numrul A[i][j] la traseul optim care se termin cu
A[i 1][j], fie adugm A[i][j] la traseul optim care se termin cu
A[i 1][j 1].
Acest lucru poate fi scris matematic n felul urmtor:

100

Tehnici de programare
=

[]
max 1 , 1 1

+ []

= = 1

Pentru a nu avea grija indicilor care depesc graniele triunghiului,


vom borda elementele de pe prima coloana i cele imediat deasupra
diagonalei principale cu valoarea minus infinit (n practic, o valoare foarte
mic ce sigur nu va fi luat niciodat n considerare de ctre relaia de
recuren).
Pentru exemplul dat, matricea B arat n felul urmtor:
i\j
0
1
2
3
4
5

inf 4 inf
inf 7
9
inf
inf 8
13
11 inf
inf 15 18
19
31 inf
inf 16 19
26
50
40

Rspunsul problemei este dat de cea mai mare valoare de pe ultima


linie a matricei B, fie aceasta X. Mai rmne problema determinrii
numerelor care alctuiesc acest traseu. Pentru a efectua reconstituirea
soluiei, vom porni de la elementul cu valoarea X al matricii B, care s
zicem c este B[Xi][X j]. Asta nseamn c sigur numrul A[Xi][Xj] aparine
traseului, deci vom reine acest numr ntr-o stiv. tim c pentru calculul
lui B[Xi][X j] am luat n cosiderare maximul valorilor B[Xi 1][Xj] i
B[Xi 1][Xj 1], la care am adugat A[Xi][X j]. Vom verifica dac
B[Xi 1][Xj] = B[Xi][Xj] A[Xi][Xj], iar n caz afirmativ, X va lua
valoarea B[X i 1][Xj], iar dac B[Xi 1][X j 1] = B[Xi][X j] A[Xi][X j]
(una dintre aceste dou condiii se va verifica de fiecare dat), X va lua
valoarea B[X i 1][Xj 1]. Se va introduce apoi n stiv valoarea A[Xi][Xj].
Se procedeaz n acest fel pn cnd se ajunge la primul element selectat.
Pentru exemplul dat, algoritmul funcioneaz n felul urmtor:
Se verific X = 50 valoarea maxim de pe ultima linie a matricei B.
Se reine n stiv A[Xi][Xj], adic A[5][4] = 19.
Se verific B[5 1][4] = B[5][4] A[5][4], deci noul X devine 31.
Se reine n stiv valoarea A[4][4] = 20.
Se verific B[4 1][4 1] = B[4][4] A[4][4], deci noul X devine
11. Se reine n stiv valoarea A[3][3] = 2.

101

Capitolul 3
Se verific B[3 1][3 1] = B[3][3] A[3][3], deci noul X devine
9. Se reine n stiv valoarea A[2][2] = 5.
Se verific B[2 1][2 1] = B[2][2] A[2][2], deci noul X devine
4. Se reine n stiv valoarea A[1][1] = 4.
Am ajuns la elementul de pe linia 1 i coloana 1, care se adaug n
stiv i algoritmul se ncheie. Mai trebuie doar afiat stiva.
n practic, reconstituirea soluiei se poate face i recursiv. Deoarece
numrul apelurilor recursive este mic, aceast abordare este preferabil de
multe ori, datorit simplitii implementrii.
Timpul de execuiei al algoritmului este O(N2), deoarece se parcurg
(+1)
toate numerele date, adic 1 + 2 + 3 + + =
numere. Memoria
2
2
folosit este tot O(N ) deoarece folosim dou matrici ptratice de
dimensiune N.
Rezolvarea folosete metoda nainte. Problema se poate rezolva i
folosind metoda napoi, dar n acest caz timpul de execuie este exponenial,
datorit subproblemelor suprapuse.
#include <fstream>
using namespace std;
const int maxN = 101;
const int inf = -2000000000;
void citire(int &N,
int A[maxN][maxN])
{
ifstream in("triunghi.in");
in >> N;
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= i; ++j )
in >> A[i][j];
in.close();
}

102

Tehnici de programare
void init(int N,
int B[maxN][maxN])
{
for ( int i = 1; i <= N; ++i )
B[i][0] = inf;
for ( int i = 1; i < N; ++i )
B[i][i + 1] = inf;
}

int main()
{
int N, A[maxN][maxN];
int B[maxN][maxN];
citire(N, A); init(N, B);
rez(N, A, B);

void rez(int N,
int A[maxN][maxN],
int B[maxN][maxN])
{
B[1][1] = A[1][1];
for ( int i = 2; i <= N; ++i )
for ( int j = 1; j <= i; ++j )
B[i][j] = max(B[i - 1][j],
B[i - 1][j - 1]) + A[i][j];
}
void reconst(int Xi, int Xj,
int A[maxN][maxN],
int B[maxN][maxN],
ofstream &out)
{
if ( Xi == 1 && Xj == 1 )
{
out << A[1][1] << ' ';
return;
}

ofstream out("triunghi.out");
int Xj = 1;
for ( int i = 2; i <= N; ++i )
if ( B[N][i] > B[N][Xj] )
Xj = i;
out << B[N][Xj] << endl;
reconst(N, Xj, A, B, out);
out.close();
return 0;
}

int t1 = B[Xi - 1][Xj];


int t2 = B[Xi][Xj] - A[Xi][Xj];
if ( t1 == t2 )
reconst(Xi - 1, Xj, A, B, out);
else
reconst(Xi - 1, Xj - 1, A, B, out);
out << A[Xi][Xj] << ' ';
}

Exerciiu: modificai algoritmul astfel nct n loc de matricea B s


folosii doar doi vectori.

103

Capitolul 3

c) Triunghiul lui Pascal


Se dau mai multe perechi de numere (N, K), cu 1 K N 12. Se
!
cere calcularea valorii =
pentru fiecare pereche dat. Putei
! !

considera c rezultatele se ncadreaz ntotdeauna pe tipul de date int.


Perechile se citesc din fiierul pascal.in, cte una pe linie, pn la
sfritul fiierului. Rezultatele se vor afia n fiierul pascal.out, pe linia i
rspunsul pentru perechea i din fiierul de intrare.
Exemplu:
pascal.in
32
73
10 8

pascal.out
3
35
45

O prim idee de rezolvare const n calcularea combinrilor folosind


formula pentru numrul acestora. Acest soluie nu este ns foarte eficient.
Deoarece numerele date sunt cel mult 12, putem calcula triunghiul
lui Pascal pn la linia 12. Al j-lea numr de pe linia i a triunghiului lui

Pascal (numerotarea ncepe de la zero) reprezint . Astfel, avnd calculat


triunghiul, putem afia n O(1) rspunsul pentru fiecare ntrebare.
Reamintim c triunghiul lui Pascal se construiete dup
urmtoarele reguli:
Prima linie (linia 0) conine numrul 1.
Linia i conine i + 1 numere.
Primul i ultimul numr de pe fiecare linie este numrul 1.
Fiecare numr, n afar de primul i ultimul, de pe o linie este
suma celor dou numere de deasupra sa.
De exemplu, primele cinci linii ale triunghiului lui Pascal sunt
urmtoarele:
1
11
121
1331
14641

104

Tehnici de programare
De aici rezult i formula de recuren a combinrilor:

1
= 1
+ 1

Vom folosi aceast formula pentru a calcula triunghiul lui Pascal


pn la linia 12 ntr-o matrice A, dup care, pentru fiecare pereche (N, K)
vom afia valoarea A[N][K]. Triunghiul va fi aliniat la stnga n aceast
matrice, ca la problema anterioar.

Implementare
#include <fstream>

int main()
{
int N, K, A[maxN][maxN];
preprocesare(A);

using namespace std;


const int maxN = 13;
void preprocesare(int A[maxN][maxN])
{
for ( int i = 0; i < maxN - 1; ++i )
A[i][0] = 1, A[i][i + 1] = 0;

ifstream in("pascal.in");
ofstream out("pascal.out");
while ( in >> N >> K )
out << A[N][K] << endl;

A[0][0] = 1;
for ( int i = 1; i < maxN; ++i )
for ( int j = 1; j <= i; ++j )
A[i][j]=A[i - 1][j] + A[i - 1][j - 1];
}

in.close();
out.close();
return 0;
}

Exerciii:
a) Explicai de ce folosirea formulei combinrilor este o metod
mai puin eficient.
b) Scriei o funcie recursiv care folosete recurena combinrilor
pentru a calcula fiecare rspuns. Este aceast abordare eficient?
c) Dac am calcula mai multe linii ale triunghiului lui Pascal, care
este prima linie care conine rezultate greite? De ce se ntmpl
acest lucru?

d) Tehnica memoizrii
Am prezentat pn acum doi algoritmi care folosesc metoda nainte
pentru rezolvarea problemelor. Aa cum am menionat la nceputul
capitolului, metoda napoi poate fi mbuntit folosind tehnica
105

Capitolul 3
memoizrii. Aceast tehnic presupune folosirea unui tabel n care se
memoreaz rezultatele fiecrui apel recursiv. La efectuarea unui apel
recursiv, vom verifica mai nti dac rezultatul acelui apel se afl n tabel:
dac da, atunci returnm pur i simplu acest rezultat, iar dac nu, continum
n modul obinuit, iar la sfrit memorm rezultatul funciei n acest tabel.
Structura general a algoritmilor recursivi care folosesc memoizare
este urmtoarea: fie F(X1 , X2, ..., XN) o funcie recursiv pentru care se
aplic memoizare. Aceast funcie poate fi implementat n felul urmtor:
Dac avem deja calculat rezultatul pentru F(X1, X2, ..., XN), se
returneaz acest rezultat.
Altfel, fie R rezultatul calculat de apelul F(X 1, X2, ..., XN), de
obicei pe baza unor apeluri recursive. Se reine R ca rspunsul
pentru apelul (starea) curent i se returneaz acest rspuns.
De exemplu, putem implementa o funcie recursiv care folosete
memoizare pentru a calcula eficient al N-lea numr fiboacci n felul
urmtor:
#include <fstream>
using namespace std;
const int maxN = 1001;

int main()
{
int N, memo[maxN];
for ( int i = 0; i < maxN; ++i )
memo[i] = -1;

int fibo(int N, int memo[])


{
if ( N == 0 || N == 1 )
return N;
// daca rezultatul e deja calculat
if ( memo[N] != -1 )
return memo[N];
// altfel il calculam si il salvam
memo[N] = fibo(N - 2, memo) +
fibo(N - 1, memo);
return memo[N];
}

ifstream in("fibo.in");
in >> N;
in.close();
ofstream out("fibo.out");
out << fibo(N, memo) << endl;
out.close();
return 0;
}

Acum, aceast funcie are complexitatea O(N), la fel ca varianta ce


folosete metoda nainte. Dei aceast abordare este, de cele mai multe ori,
mai ineficient n practic datorit apelurilor recursive i a unei verificri n
plus, implementarea unei asemenea funcie este mai uoar, de obicei, dect
implementarea unei funcii iterative. Acest lucru se poate ntmpla cnd
avem de-a face cu formule de recuren mai complicate, care nu pot fi
implementate cu ajutorul vectorilor sau a matricilor.
106

Tehnici de programare
Arborele apelurilor recursive pentru apelul F(6), unde funcia F
returneaz al N-lea numr Fibonacci poate fi desenat n felul urmtor.
Apelurile pentru care se returneaz direct rezultatul calculat deja apar n
albastru.

Fig. 3.5.2. Arborele de recursivitate asociat funciei fibonacci memoizate


Se poate observa uor c numrul de apeluri efectuate este cu mult
mai mic dect n implementarea clasic.
Prezentm i o funcie recursiv ce folosete tehnica memoizrii
pentru problema triunghiului. Parametrii funciei, variabilele folosite i
funciile apelate au semnificaia lor de pn acum.
int rezolvare(int x, int y, int A[maxN][maxN], int memo[maxN][maxN])
{
if ( x == 1 && y == 1 )
return A[x][y];
if ( y < 1 || x < y )
return inf;
if ( memo[x][y] != -1 )
return memo[x][y];
memo[x][y] = max(rezolvare(x - 1, y, A, memo),
rezolvare(x - 1, y - 1, A, memo)) + A[x][y];
return memo[x][y];
}

Se poate observa uor c, n acest caz, folosirea acestei tehnice nu


aduce mari beneficii, ba chiar poate prea mai complicat, deoarece trebuie

107

Capitolul 3
s fim ateni la mai multe cazuri particulare. Funcia va trebui apelat pentru
fiecare element al ultimei linii din triunghiul dat.
Exerciii:
a) Cum putem reconstitui soluia la problema triunghiului dac
folosim funcia recursiv anterioar?
b) Dac ne intereseaz drumul minim numai pn la un singur
element de pe ultima linie a triunghiul, care abordare este mai
eficient?
c) Scriei o funcie recursiv ce folosete memoizare pentru calculul
combinrilor.
d) Explicai de ce memoizarea nu mbuntete funcia factorial
sau funcia de rezolvare a problemei turnurilor din Hanoi.
e) Comparai o funcie recursiv memoizat cu o funcie iterativ
echivalent acesteia. Care este mai eficient?

e) Concluzii
Programarea dinamic este o tehnic de programare foarte
folositoare pentru rezolvarea problemelor de numrare sau de gsire a
optimelor. Este o alternativ mai rapid dect metoda backtracking i mai
corect dect metoda greedy.
Dezavantajul principal al acestei metode st n faptul c unele
formule de recuren pot fi neintuitive i dificil de implementat eficient, dar,
de cele mai multe ori, efortul unei implementri corecte merit fcut,
datorit eficienei acestor algoritmi.
Atunci cnd implementarea iterativ a unei formule de recuren ar fi
prea dificil, se poate folosi tehnica memoizrii pentru a pstra eficiena
algoritmului i a simplifica implementarea.
Corectitudinea unui algoritm de programare dinamic poate fi
verificat cu un algoritm backtracking, iar la rndul su, programarea
dinamic poate fi folosit pentru a testa dac o strategie greedy este sau nu
corect.
Programarea dinamic este o tehnic foarte des folosit n
proiectarea algoritmilor, avnd aplicaii n toate ramurile iinelor exacte:
teoria grafurilor, teoria numerelor, biologie computaional, combinatoric
i altele.

108

Algoritmi matematici

4. Algoritmi
matematici
Acest capitol prezint algoritmii care au la baz noiuni elementare
de matematic. Aceti algoritmi sunt folosii, de cele mai multe, ori pentru
rezolvarea unor probleme strict matematice, cum ar fi rezolvarea unor
ecuaii sau sisteme de ecuaii, determinarea numerelor cu anumite
proprieti, rezolvarea unor probleme de geometrie sau calculul unor
formule complexe cu ajutorul calculatorului.
Algoritmii prezentai se axeaz mai mult pe teoria numerelor, acetia
fiind cei mai des ntlnii n domeniul informaticii i totodat cei mai
studiai.

109

Capitolul 4

CUPRINS
4.1. Noiuni despre aritmetica modular .................................................... 111
4.2. Algoritmul lui Euclid ............................................................................... 112
4.3. Algoritmul lui Euclid extins .................................................................... 114
4.4. Numere prime ........................................................................................ 116
4.5. Algoritmul lui Gauss ............................................................................... 130
4.6. Exponenierea logaritmic .................................................................... 136
4.7. Inveri modulari, funcia totenial ...................................................... 143
4.8. Teorema chinez a resturilor ................................................................ 145
4.9. Principiul includerii i al excluderii ........................................................ 150
4.10. Formule i tehnici folositoare ............................................................. 151
4.11. Operaii cu numere mari ..................................................................... 154

110

Algoritmi matematici

4.1. Noiuni despre aritmetica modular


Aritmetica modular este un sistem de aritmetic pentru numerele
ntregi n care numerele revin la o valoare precedent dup ce depesc o
anumit limit. Un exemplu foarte des ntlnit n viaa de zi cu zi este modul
de funcionare al unui ceas. S presupunem c avem un ceas electronic care
afieaz orele n format de 24 de ore. Numerotarea orelor ncepe de la ora
0:00 pn la ora 23:59. Dac ceasul arat ora 22:00, ct va arta peste exact
3 ore? Deoarece cunoatem modul de funcionare al unui ceas, tim c
rspunsul corect este ora 1:00, dar o abordare matematic a problemei ne-ar
putea duce la rspunsul 22 + 3 = 25, lucru evident absurd.
De fapt, orele unui asemenea ceas sunt luate modulo 24 (notat
uneori Z24), sau modulo 12, n cazul formatului de 12 ore. Astfel, putem
aborda i matematic problema, deoarece 22 + 3 = 25, iar 25 = 1 n Z24 .
Acest lucru l vom nota de acum n felul urmtor:
25 1 (mod 24), iar acest lucru se va citi 25 este congruent cu 1 modulo 24.
n cazul general, X Y (mod N) se citete X este congruent cu Y modulo
N.
Matematic, propoziia X Y (mod N) este adevrat dac i numai
dac X i Y au acelai rest la mprirea cu N. De exemplu,
25 1 (mod 24) este adevrat, deoarece 25 : 24 = 1 rest 1 i 1 : 24 = 0 rest
1.
Aadar, prin X modulo (prescurtat de obicei mod) Y nelegem
restul mpririi lui X la Y. De exemplu, 27 mod 24 = 3. n C++ i n alte
limbaje de programare derivate, acest lucru se calculeaz folosind operatorul
%: X % Y.
n continuare vom prezenta cteva formule i proprieti utile n
rezolvarea problemelor de aritmetic modular.

1. Formul uzual de calcul: =

2. =
3. =
4. Dac 0
5. Existena inversului fa de nmulire: nu exist ntotdeauna
1 astfel nct 1 1 .
Acest 1 exist dac i numai dac i sunt coprime.
Dou numere se consider coprime dac nu au niciun factor prim
n comun (cel mai mare divizor comun al lor este 1).
111

Capitolul 4
De exemplu, inversul modular al lui 4 (modulo 9) este 41 = 7,
deoarece 47 1 (mod 9), dar inversul lui 6 (modulo 9) este
inexistent, deoarece 6 i 9 nu sunt coprime.
6. O alt metod de a calcula , care se poate deduce din
formula uzual de calcul, este s-l scdem pe din atta timp
ct este mai mare sau egal cu .
Exerciiu: scriei un program care citete un ir de numere ntregi i
calculeaz suma lor modulo un alt numr citit. n ce caz v poate ajuta
proprietatea 2?
n cele ce urmeaz vom prezenta algoritmi care au la baz aceste
formule i proprieti. Calculul inversului modular este o problem
netrivial pentru care exist mai muli algoritmi, a cror eficien i
aplicabilitate difer.

4.2. Algoritmul lui Euclid


Algoritmul lui Euclid este o metod eficient pentru a determina cel
mai mare divizor comun a dou numere. Cel mai mare divizor comun al
dou numere X i Y este cel mai mare numr Z care divide numerele X i
Y. Algoritmul lui Euclid se bazeaz pe faptul c cel mai mare divizor comun
(c.m.m.d.c.) al numerelor X i Y nu se schimb dac din numrul mai mare
l scdem pe cel mai mic. Acest lucru este uor de demonstrat: s
presupunem c c.m.m.d.c. a numerelor X i Y este d. Atunci, X = dp i
Y = dq, unde p i q sunt numere ntregi corespunztoare ecuaiilor scrise.
Presupunem c X > Y. n acest caz putem scrie:
X Y = dp dq = d(p q), deci d este cel mai mare divizor comun i
pentru Y i X Y. Se repet acest procedeu pn cnd cele dou numere
devin egale, moment n care am gsit c.m.m.d.c. al numerelor iniiale.
De exemplu, modul de calcul al c.m.m.d.c. pentru 12 i 8 poate fi
descris grafic n felul urmtor:

Fig. 4.2.1. Modul de execuie al algoritmului lui Euclid prin scderi


Scznd la fiecare pas numrul mai mic din numrul mai mare,
rmnem n final cu dou numere egale cu 4, iar acest numr reprezint
c.m.m.d.c. pentru 12 i 8.
112

Algoritmi matematici
Dei acest algoritm este uor de implementat i intuitiv, exist cazuri
n care este chiar mai ineficient dect algoritmul naiv, care parcurge
numerele de la min(X, Y) n jos i se oprete cnd gsete un numr care
divide att pe X ct i pe Y. Un astfel de caz este X = 1 000 000 i Y = 1. La
fiecare pas, vom scdea din X valoarea 1, efectund n total un milion de
operaii! Complexitatea n cel mai ru caz este aadar O(max(X, Y)). S
vedem cum putem mbunti algoritmul.
Aa cum am precizat n seciunea anterioar, putem calcula X mod
Y folosind scderi repetate ale lui Y din X. Aceste scderi se efectueaz i
n cazul algoritmului prin scderi repetate, fiind chiar cauza ineficienei
acestuia pe anumite cazuri. Vom elimina aceste scderi folosind operaia
modulo. Noul algoritm devine acum:
Ct timp Y diferit de 0 execut:
o Fie r = X mod Y
o X=Y
o Y=r
Returneaz X
Demonstraia acestui algoritm este foarte similar cu demonstraia
algoritmului iniial, deoarece ideea este aceeai, doar c efectum mai multe
scderi o dat folosind operaia modulo. Numrul de pai efectuai de acest
algoritm nu este niciodat mai mare dect de cinci ori numrul de cifre al
numrului mai mic (acest lucru a fost demonstrat de ctre matematicianul
francez Gabriel Lam n 1844), deci putem considera c este
O(log
(min(X, Y))). Pentru exemplul de mai sus n care X = 1 000 000 i Y = 1,
se efectueaz un singur pas (adic o singur iteraie a ciclului ct timp).
Algoritmul poate fi scris i recursiv n felul urmtor: fie
cmmdc(X, Y) o funcie care returneaz cel mai mare divizor comun al celor
dou numere avute ca parametri. Aceast funcie poate fi implementat
astfel:
Dac Y == 0 returneaz X
Altfel, apeleaz recursiv cmmdc(Y, X mod Y)
Implementarea recursiv are avantajul de a fi foarte compact i uor
de inut minte. Mai mult, aceast implementare este folositoare la extinderea
algoritmului, aa cum vom vedea n seciunea urmtoare.
Prezentm doar funciile relevante. Fiecare funcie returneaz (direct
sau printr-un parametru de tip referin) c.m.m.d.c al numerelor date ca
parametru.
113

Capitolul 4
int euclid_scaderi(int X, int Y)
{
while ( X != Y )
if ( X > Y )
X -= Y;
else
Y -= X;
return X;
}
int euclid_rec(int X, int Y)
{
if ( !Y )
return X;
return euclid_rec(Y, X % Y);
}

int euclid_optim(int X, int Y)


{
while ( Y )
{
int r = X % Y;
X = Y;
Y = r;
}
return X;
}
void euclid_ref(int X, int Y, int &cmmdc)
{
if ( !Y )
cmmdc = X;
else
euclid_ref(Y, X % Y, cmmdc);
}

4.3. Algoritmul lui Euclid extins


Algoritmul lui Euclid extins este folosit pentru a gsi numerele a i
b din ecuaia diofantic: aX + bY = cmmdc(X, Y). Algoritmul este
folositor atunci cnd avem de determinat un invers multiplicativ modulo Y,
deoarece a este inversul multiplicativ al lui X modulo Y. Cu alte cuvinte,
aX 1 (mod Y). Trebuie menionat c inversul multiplicativ al lui X
modulo Y exist dac i numai dac X i Y sunt coprime!
Algoritmul poate fi implementat n felul urmtor: fie
euclid_extins(X, Y) o funcie care returneaz o pereche (a, b) cu
semnificaia precizat anterior. Aceast funcie poate fi implementat n
felul urmtor:
Dac X mod Y == 0, returneaz (0, 1)
Altfel execut:
o Fie (a, b) = euclid_extins(Y, X mod Y)

o Returneaz (b, a b )

Vom demonstra n continuare corectitudinea acestui algoritm:


Fie d c.m.m.d.c. al numerelor X i Y. Vrem s demonstrm c
aX + bY = d. Procedm n felul urmtor:

114

Algoritmi matematici
Dac X mod Y = 0, nseamn c d = Y, iar din valorile returnate
de algoritm n acest caz observm c ecuaia devine
0X + 1Y = d, adic d = Y, ceea ce este corect.
n caz contrar, datorit apelului recursiv efectuat, tim c
aY + b(X mod Y) = d. Acest lucru a fost demonstrat anterior.
o Folosind formula uzual de calcul, putem scrie ecuaia de
mai sus n felul urmtor:

aY + b(X Y ) = d.

o Noua ecuaie poate fi rescris astfel:

aY + bX - bY = d.

o Dm factor comun pe Y:

bX + (a b ) Y = d.

o De aici tragem concluzia ca noua pereche (a, b) este

(b, a b ).

Urmrii modul de funcionare al algoritmului pentru ecuaia


a23 + b51 = 1. Tabelul prezint valorile variabilelor folosite de algoritm
dup fiecare apel al funciei recursive prezentate anterior. Apelul iniial este
cel de pe ultima linie, dar, datorit recursivitii, valorile finale pentru a i b
nu se calculeaz dect dup efectuarea tuturor apelurilor recursive
prezentate. Tabelul se poate citi aadar de jos n sus pentru valorile
variabilelor X i Y, iar apoi de sus n jos pentru valorile variabilelor a i b.
Tabelul 4.3.1. Modul de execuie al algoritmului lui Euclid extins
X Y a b
2 1 0 1
3 2 1 -1
5 3 -1 2
23 5 2 -9
51 23 -9 20
23 51 20 -9
De aici rezult c 2023 + (-9)51 = 1, deci a = 20 i b = -9.
Am menionat c acest algoritm ne ajut s gsim inversul
multiplicativ modulo Y al numrului X. Acest invers este numrul a
determinat de ctre algoritm, iar aX 1 (mod Y). Folosind exemplul
anterior, deoarece 23 i 51 sunt coprime, numrul 23 are un invers
multiplicativ modulo 51, iar acest invers este chiar numrul 20. 2320 = 460,
115

Capitolul 4
iar 460 1 (mod 51). Aa cum vom vedea n seciunile ce urmeaz, acest
lucru are diverse aplicaii i n ali algoritmi.
void euclid_extins(int X, int Y, int &a, int &b)
{
if ( X % Y == 0 )
{
a = 0;
b = 1;
return ;
}
int ta, tb;
euclid_extins(Y, X % Y, ta, tb);
a = tb;
b = ta - tb*(X / Y);
}

Funcia returneaz, prin intermediul parametrilor a i b, numerele ce


trebuie determinate.
Exerciii:
a) Modificai funcia astfel nct s returneze i c.m.m.d.c. al
numerelor X i Y.
b) Modificai funcia astfel nct s returneze soluiile ecuaiei
aX + bY = c, unde c este un numr dat, iar restul variabilelor au
aceeai semnificaie de pn acum. Exist ntotdeauna soluie?
c) Scriei o funcie echivalent, dar care s nu foloseasc apeluri
recursive.
d) Scriei un program care citete dou numere X i Y i determin
inversul multiplicativ al lui X modulo Y dac acesta exist, iar
dac nu afieaz un mesaj corspunztor.

4.4. Numere prime


Un numr prim este un numr natural mai mare ca 1 care nu se
divide dect cu 1 i cu el nsui. De exemplu, primele numere prime sunt 2,
3, 5, 7, 11, . Exist o infinitate de numere prime.
Atenie! numrul 1 nu se consider prim!
116

Algoritmi matematici
Numerele prime au numeroase aplicaii n criptografie i n teoria
numerelor n general. Exist diverse metode de a determina dac un numr
este sau nu prim (aceste verificri poart numele de teste de primalitate) i
de a determina toate numerele prime pn la o anumit limit. Algoritmii de
testare a primalitii se mpart n dou mari categorii: algoritmi
determiniti, care determin cu o probabilitate de 100% dac un numr este
sau nu prim i algoritmi probabiliti, care determin cu o probabilitate mai
mic de 100% dac un numr este sau nu prim. De obicei, algoritmii
probabiliti sunt cu mult mai eficieni, fiind aplicabili numerelor cu sute de
cifre, dar exist posibilitatea ca un numr gsit ca fiind prim de un astfel de
algoritmi s nu fie cu adevrat prim.
O proprietate important a numerelor prime este c orice numr are
un invers multiplicativ modulo orice numr prim. Acest lucru se datoreaz
faptului c orice numr este coprim cu un numr prim.
Alt rezultat important care implic numerele prime este conjectura
lui Goldbach, care afirm c orice numr natural par mai mare ca 2 poate fi
scris ca sum de dou numere prime. De exemplu, 4 = 2 + 2, 6 = 3 + 3,
8 = 3 + 5, 10 = 5 + 5, 12 = 5 + 7...
Exist foarte multe clasificri i rezultate teoretice i practice n
domeniul numerelor prime. De exemplu, la momentul scrierii acestei cri,
cel mai mare numr prim cunoscut are aproape 13 milioane de cifre. Acesta
e un numr prim de tip Mersenne (numerele prime de tip Mersenne sunt
acele numere prime care pot fi scrise ca o putere a lui 2 minus 1) i este egal
cu 243 112 609 1.
O notaie important care va fi folosit pe parcursul acestei seciuni
este funcia , unde (N) reprezint cte numere prime cel mult egale cu N
exist. O aproximaie pentru aceast funcie este urmtoarea:

ln

Nu se cunoate nicio formul exact de calcul a funciei, dar exist


aproximri mai bune.
n continuare, vom prezenta diveri algoritmi de determinare a
primalitii i de generare a numerelor prime. Algoritmii vor fi prezentai de
la cei mai ineficieni la cei mai eficieni. Fiecare algoritm presupune fie o
funcie care returneaz 1 dac un numr N dat ca parametru este prim i 0 n
caz contrar, fie o funcie care determin toate numerele prime pn la N.
117

Capitolul 4

a) Metode clasice
Prima metod la care ne gndim pentru a determina dac un numr
N
N este sau nu prim este s parcurgem toate numerele de la 2 pn la
i s
2
verificm dac numrul N se divide la vreunul dintre aceste numere. Dac
da, atunci N nu este prim, iar dac nu, atunci N este prim. Astfel, o funcie
e_prim1(N) poate fi implementat n felul urmtor:
N
Pentru fiecare i de la 2 la
execut:
2
o Dac N mod i == 0 returneaz false
Returneaz true (dac nu s-a returnat false n ciclul de mai sus,
numrul sigur este prim).
Complexitatea acestei metode este O(N) pe cel mai ru caz. Dac ar
fi s folosim aceast funcie pentru a determina toate numerele prime pn
la N am obine complexitatea O(N2), ceea ce ar fi indezirabil pentru N mai
mare ca ~1 000.
Putem obine un algoritm mai eficient observnd c pentru orice
numr natural N nu are rost s verificm dac este divizibil cu numere mai
mari dect N. Acest lucru este uor de demonstrat: dac X N este un
divizor al lui N, atunci putem opri algoritmul deoarece N nu este prim. n
N
caz contrar, nici
N nu poate fi un divizor al lui lui N. Aadar, este de
X
ajuns s verificm doar numerele pn la radicalul numrului a crui
primalitate ne intereseaz.
ntr-un limbaj de programare se obinuiete s se foloseasc
urmtorul algoritm (e_prim2(N)), deoarece nu necesit folosirea unei
funcii de aflare a radicalului:
Pentru fiecare i de la 2 pn cnd i2 este mai mare ca N execut:
o Dac N mod i == 0 returneaz false
Returneaz true
Complexitatea acestui algoritm este mult mai bun: doar O( ).
Dei din punct de vedere asimptotic aceast limit este greu de mbuntit
pentru un algoritm determinist, mai putem aduce o mbuntire practic
substanial.
Se poate observa c nu are rost s verificm dac un numr este
divizibil cu numere pare mai mari dect 2, deoarece dac este divizibil cu 2
atunci nu este prim (dect dac numrul verificat este chiar 2), iar dac nu
este divizibil cu 2 atunci nu va fi divizibil cu niciun multiplu al lui 2.
118

Algoritmi matematici
Noul algoritm, e_prim3(N), devine acum:
Dac N == 2 returneaz true
Dac N mod 2 == 0 returneaz false
Pentru fiecare i de la 3 pn cnd i2 este mai mare ca N execut
i incrementeaz-l pe i cu 2:
o Dac N mod i == 0 returneaz false
Returneaz true
Acest algoritm este de obicei suficient de rapid pentru a verifica
primalitatea oricrui numr reprezentabil pe tipurile de date existente n
C++ i pentru a genera toate numerele prime pn la o anumit limit (sau
pentru a calcula funcia ).
Funciile prezentate returneaz true dac numrul transmis ca
parametru este prim i false n caz contrar. Funcia pi returneaz cte
numere prime exist mai mici ca parametrul su, folosind toate optimizrile
menionate.
bool e_prim1(int N)
{
for ( int i = 2; i <= N / 2; ++i )
if ( N % i == 0 )
return false;

bool e_prim2(int N)
{
for ( int i = 2; i*i <= N; ++i )
if ( N % i == 0 )
return false;

return true;

return true;

bool e_prim3(int N)
{
if ( N == 2 )
return true;

int pi(int N)
{
int nr = 0;
for ( int i = 2; i <= N; ++i )
if ( e_prim3(i) )
++nr;

if ( N % 2 == 0 )
return false;
for ( int i = 3; i*i <= N; i += 2 )
if ( N % i == 0 )
return false;

return nr;
}

return true;
}

119

Capitolul 4
Primele dou funcii au un scop pur didactic, n practic cea de-a
treia funcie fiind cu mult mai eficient i la fel de uor de implementat.
Funcia pi nu prea se folosete n practic, dect dac N nu este foarte mare.

b) Ciurul lui Eratosthenes


Ciurul lui Eratosthenes este un algoritm care gsete toate
numerele prime mai mici sau egale cu un numr N. Algoritmul i este
atribuit lui Eratosthenes, un matematician al Greciei antice i funcioneaz
n felul urmtor:
Se creeaz o list cu toate numerele naturale de la 2 pn la N.
Fie i un numr care reprezint la fiecare pas un numr prim
determinat de ctre algoritm. Iniial, i = 2.
Ct timp i2 N execut:
o Se elimin toi multiplii lui i mai mici sau egali cu N din
list, ncepnd, eventual, de la i2, deoarece restul
multiplilor au fost deja eliminai.
o i devine urmtorul numr din list.
La sfritul algoritmului, toate numerele rmase n list sunt prime.
De exemplu, dac vrem s aflm toate numerele prime pn la 25 folosind
ciurul lui Eratosthenes vom proceda n felul urmtor (cu rou sunt marcate
numerele care urmeaz s fie terse):
Iniial, i = 2 i eliminm toi multiplii lui i ncepnd de la i2 = 4:
i
2 3 4 5 6 7 8 9 10 11 12 13
14 15 16 17 18 19 20 21 22 23 24 25
Se terg numerele marcate cu rou, i devine urmtorul numr neters
i se repet algoritmul:
i
2 3
5
7
9
11
13
15
17
19
21
23
25
Se terg numerele marcate, iar i devine 5.
i
2 3
5
7
11
17
19
23

120

13
25

Algoritmi matematici
Se terge numrul marcat i i devine 7. Deja i2 = 49 > 25, deci
algoritmul se ncheie. Numerele prime mai mici sau egale cu 25 sunt
urmtoarele (adic cele rmase):
2 3

5
17

7
19

11
23

13

Complexitatea algoritmului este O(Nlog (log N)), mult mai eficient


dect folosind algoritmii clasici prezentai anterior.
Ciurul lui Eratosthenes poate fi folosit i pentru a verifica
primalitatea unor numere foarte mari ntr-un mod eficient, n felul urmtor:
tim c pentru a verifica dac un numr N este prim nu avem nevoie s
verificm dac se divide cu numere mai mari dect N. Mai mult, nu are
rost s verificm dac se divide cu numere neprime, deoarece orice numr
neprim (numerele neprime se mai numesc i numere compuse) este un
produs de numere prime. Aadar, putem genera o singur dat, folosind
ciurul, toate numerele prime pn la radical din valoarea maxim pe care
tim c o pot lua numerele a cror primalitate ne intereseaz, numerele pe
care le vom folosi apoi n verificarea primalitii unui anumit numr: dac
exist un numr prim mai mic dect radicalul numrului verificat la care
numrul verificat se divide, atunci acesta nu este prim, iar n caz contrar
acesta este prim.
Ciurul lui Eratosthenes poate fi aplicat i asupra unui anumit
interval, pentru a determina toate numerele prime din acesta.
n cele ce urmeaz vom prezenta diverse implementri ale ciurului
lui Eratosthenes i ale unor subprograme care folosesc acest algoritm. O
implementare clasic arat n felul urmtor:

121

Capitolul 4
void eratosthenes_clasic(int N)
{
bool *lista = new bool[N + 1]; // lista[i] = true daca numarul i
// este prim si false in caz contrar
// vom folosi si aici optimizarea de a nu lua in considerare
// numerele pare
lista[2] = true;
for ( int i = 3; i <= N; i += 2 )
lista[i] = true;
for ( int i = 3; i*i <= N; i += 2 )
if ( lista[i] ) // daca i nu a fost sters
for ( int j = i*i; j <= N; j += i )
lista[j] = false; // stergem multiplii lui
// afiseaza numerele prime
cout << 2 << ' ';
for ( int i = 3; i <= N; i += 2 )
if ( lista[i] )
cout << i << ' ';
delete []lista;
}

Aceast implementare este de cele mai multe ori suficient de rapid


pentru majoritatea scopurilor practice. Putem ns optimiza memoria
folosit i astfel i timpul de execuie, innd cont de faptul c nu are rost s
folosim tipul de date bool, care ocup de fapt 8 bii, pentru a reine valori de
0 i 1. Astfel, putem folosi de 8 ori mai puin memorie dac folosim
operaii pe bii pentru a accesa biii individuali ai unei variabile de tip
unsigned char.
Vom declara vectorul lista de dimensiune N / 8 + 1 i vom folosi
operaiile pe bii pentru a accesa biii de care avem nevoie la fiecare pas n
felul urmtor:
Pentru a seta al k-lea bit pe valoarea 0 vom folosi funcia:
void del(unsigned char lista[], int nr)

Care va executa urmtoarea operaie:


lista[nr / 8] &= ~(1 << (nr % 8 )).
Aceste instruciuni au efectul urmtor: bitul nr % 8 (de la dreapta la
stnga) al elementului nr / 8 al vectorului lista va primi valoarea 0
122

Algoritmi matematici
(operatorul ~ schimb toi biii unui numr n opusul lor, adica 1 devine 0 i
0 devine 1. Operatorul & se consider cunoscut de la seciunea Radix sort).
Pentru a verifica valoarea unui bit, vom folosi o funcie
bool verif(unsigned char lista[], int nr)

Care va returna valoarea bitului nr al vectorului lista. Funcia va fi


implementat n felul urmtor:
return lista[nr / 8] & (1 << (nr % 8));

Poate prea ciudat la prima vedere numerotarea biilor de la dreapta


la stnga n cadrul unui element al vectorului. Practic, folosind aceste
operaii, al doilea bit este de fapt al aselea. Acest lucru nu afecteaz
corectitudinea algoritmului dac suntem consecveni n folosirea acestei
metode i simplific foarte mult implementarea.
S vedem cum funcioneaz algoritmul pentru N = 12 dac folosim
aceste operaii. Vom declara un vector lista de dimensiune 2 a crui bii i
vom iniializa pe toi cu valoarea 1. Aceast iniializare se poate face
atribuind fiecarui element al vectorului valoarea 2 8 1, deoarece tipul de
date unsigned char este implementat pe 8 bii. Acest lucru se poate face
folosind urmtoarea formul: 2k = 1 << k, unde k este un numr natural.
Formula este uor de dedus observnd modul n care puterile lui doi se scriu
n baza doi (un 1 urmat numai de zerouri). Alternativ, putem folosi
constanta hexazecimal 0xFF pentru a seta cei 8 bii.
Vectorul lista arat n felul urmtor (biii apar n acest tabel
numerotai normal, de la stnga la dreapta):
lista[0]
lista[1]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
Algoritmul ncepe de la numrul 3, care tim sigur c este prim. Se
elimin multiplii lui 3 din list (se seteaz biii corespunztori pe 0)
ncepnd de la 32 = 9. Trebuie s aflm aadar bitul corespunztor lui 9.
nlocuindu-l pe 9 n formula prezentat anterior, obinem urmtoarea
instruciune: lista[9 / 8] &= ~(1 << (9 % 8)), adic lista[1] &= ~(1 << 1).
lista[1] n baza 2 are toi biii setai pe 1. 1 << 1 = 000000102, iar
~000000102 = 111111012.

123

Capitolul 4
11111111 &
11111101

11111101
Deci noul vector lista arat acum n felul urmtor:
lista[0]
lista[1]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1
Se procedeaz n acest fel i cu urmtorii multiplii ai lui 3: 12 i 15,
fiecare avnd un bit care le corespunde conform algoritmului prezentat.
Vectorul lista va arta n final n felul urmtor:
lista[0]
lista[1]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
1 1 1 1 1 1 1 1 0 1 1 0 1 1 0 1
Acesta este i ultimul pas al algoritmului, deoarece 5 2 > 16 i
algoritmul se ncheie. Se poate observa c toate numerele impare a cror bii
nu sunt teri reprezint numere prime. Numerele pare nu vor fi luate n
considerare, aa c nu prezint interes.
inline void del(unsigned char lista[], int nr)
{
lista[nr / 8] &= ~(1 << (nr % 8));
}
inline bool verif(unsigned char lista[], int nr)
{
return lista[nr / 8] & (1 << (nr % 8));
}

124

Algoritmi matematici
void eratosthenes_biti(int N)
{
int dim = N / 8 + 1;
unsigned char *lista = new unsigned char[dim];
for ( int i = 0; i < dim; ++i )
lista[i] = 0xFF;
for ( int i = 3; i*i <= N; i += 2 )
if ( verif(lista, i) ) // daca i e numar prim
for ( int j = i*i; j <= N; j += i )
del(lista, j); // sterg multiplii lui i
// afisez numerele prime
cout << 2 << ' ';
for ( int i = 3; i <= N; i += 2 )
if ( verif(lista, i) )
cout << i << ' ';
delete []lista;
}

Dei aceast implementare folosete de opt ori mai puin memorie


dect precedenta, este posibil s nu observm nicio mbuntire a vitezei.
Acest lucru se datoreaz faptului c efectum operaii mai complicate dect
pn acum. Aceste operaii sunt operaiile de mprire i modulo din cadrul
funciilor del i verif. Putem nlocui aceste operaii folosind operaii pe bii
aplicnd urmtoarele formule, care reies tot din felul n care puterile lui 2
sunt reprezentate n sistemul binar:
1. x / 2k == x >> k;
2. x % 2k == x & (k 1);
Noile funcii sunt aadar:
inline void del(unsigned char lista[], int nr)
{
lista[nr >> 3] &= ~(1 << (nr & 7));
}
inline bool verif(unsigned char lista[], int nr)
{
return lista[nr >> 3] & (1 << (nr & 7));
}

125

Capitolul 4
Algoritmul mai suport o optimizare important: deoarece nu ne
intereseaz numerele pare, putem s le eliminm din list, folosind astfel de
dou ori mai puin memorie. Semnificaia iniial a vectorului lista devine
lista[i] = true dac 2i + 1 este prim i false n caz contrar. Algoritmul
final arat n felul urmtor (restul funciilor rmn neschimbate):
void eratosthenes_final(int N)
{
int dim = N / 8 / 2 + 1;
unsigned char *lista = new unsigned char[dim];
for ( int i = 0; i < dim; ++i )
lista[i] = 0xFF;
for ( int i = 1; ((i*i) << 1) + (i << 1) <= N; ++i )
if ( verif(lista, i) ) // daca 2i + 1 e numar prim
for ( int j = ((i*i) << 1) + (i << 1);
(j << 1) + 1 <= N; j += (i << 1) + 1 )
del(lista, j); // sterg multiplii lui 2i + 1
// afisez numerele prime
cout << 2 << ' ';
for ( int i = 1; (i << 1) + 1 <= N; ++i )
if ( verif(lista, i) )
cout << (i << 1) + 1 << ' ';
delete []lista;
}

Formula ((i*i) << 1) + (i << 1) poate prea ciudat la prima vedere,


dar aceasta are o explicaie foarte simpl. Deoarece lucrm cu i, dar ne
referim la 2i + 1, avem nevoie de a determina i astfel nct (2i + 1)2 s fie
cel mult N. Formula folosit face exact acest lucru i poate fi dedus
1
considernd funcia = 2 + 1, gsindu-i inversul 1 =
i
1

4 2 +4+11

calculnd
2+1
=
= 2 + 2 . Transformnd
2
nmulirile cu 2 n operaii be bii rezult formula folosit.
Urmtorul tabel prezint timpii de execuie a celor trei variante ale
algoritmului prezentate pentru mai multe valori ale lui N. Msurtorile au
fost fcute pe un calculator perfomant, cu afiarea numerelor prime scoas.

126

Algoritmi matematici
Tabelul 4.4.1. Comparaie ntre variantele de implementare
a ciurului lui Eratosthenes
Timpul de execuie n secunde
N eratosthenes_clasic eratosthenes_bii eratosthenes_final
107
0.2
0.16
0.07
108
2.78
2.53
0.91
9
10
33.09
31.72
15.54
Se observ imediat c algoritmul este foarte performant dac se
folosesc toate optimizrile posibile, iar memoria folosit este de 16 ore mai
mic dect N.
Ciurul lui Eratosthenes poate fi implementat i folosind clasa
bitset din Standard Template Library. Prezentm o implementare ce nu
conine i ultimele optimizri, acestea fiind lsate pe seama cititorului n
aceast variant de implementare:
void eratosthenes_bitset(int N)
{
bitset<100000> lista; // clasa bitset nu suporta alocare dinamica
lista.set(); // seteaza toti bitii pe 1 (true)
for ( int i = 3; i*i <= N; i += 2 )
if ( lista[i] )
for ( int j = i*i; j <= N; j += i )
lista[j] = 0;
cout << 2 << ' ';
for ( int i = 3; i <= N; i += 2 )
if ( lista[i] )
cout << i << ' ';
}

Ciurul lui Eratosthenes poate fi aplicat i asupra unui interval


[st, dr], pentru a determina toate numerele prime din acesta. Algoritmul este
asemntor cu ce am prezentat pn acum. Mai nti vom genera o list de
numere prime folosind algoritmul clasic prezentat deja. Numerele prime
generate nu trebuie s fie mai mari dect radicalul captului din dreapta al
intervalului dat. O dat generat aceast list, vom folosi un vector boolean
prim de dimensiune dr st + 2, unde prim[i] = true dac numrul i + st
este prim i false n caz contrar. Pentru fiecare numr prim i generat
anterior, vom marca toi multiplii acestuia care sunt mai mari sau egali cu st
i care sunt diferii de i ca fiind numere neprime. La sfrit, parcurgem
127

Capitolul 4
intervalul i afim numerele prime, innd cont de semnificaia vectorului
prim.
Se pune problema determinrii celui mai mic multiplu al lui i care
este mai mare sau egal cu st. Fie acest numr m. Putem folosi urmtorul
algoritm pentru a-l calcula eficient pe m:
Fie r = st mod i
Atunci m = st + [(i r) mod i]
Implementarea prezentat poate fi mbuntit innd cont de
aspectele menionate pe parcursul acestei seciuni. Codul ar putea fi mprit
i n mai multe funcii, de exemplu o funcie care genereaz numerele prime
pn la radical din dr i o funcie care genereaz numele prime din
intervalul dat prin cei doi parametrii.
n practic, ciurul lui Eratosthenes este un algoritm foarte rapid de
generare a numerelor prime i poate fi folosit i pentru testarea primalitii
unor numere.
Optimizrile care se pot aduce metodei prezentate sunt lsate ca
exerciiu pentru cititor.

128

Algoritmi matematici
void eratosthenes_interval(int st, int dr)
{
// generez numere prime pana la radical din dr
int N = sqrt(dr);
int dim = N / 8 + 1;
unsigned char *lista = new unsigned char[dim];
for ( int i = 0; i < dim; ++i )
lista[i] = 0xFF;
for ( int i = 3; i*i <= N; i += 2 )
if ( verif(lista, i) ) // daca i e numar prim
for ( int j = i*i; j <= N; j += i )
del(lista, j); // sterg multiplii lui i
// generez numerele prime din intervalul [st, dr];
if ( st <= 1 )
st = 2;
unsigned char *prim = new unsigned char[dr - st + 2];
for ( int i = st; i <= dr; ++i )
prim[i - st] = 1;
for ( int i = 3; i*i <= dr; i += 2 )
if ( verif(lista, i) ) // daca i e prim
{
int r = st % i;
int m = st + ((i - r) % i);
if ( m == i ) // am grija sa nu elimin chiar un numar prim
m += i;
for ( int j = m; j <= dr; j += i )
prim[j - st] = 0;
}
delete []lista;
if ( st <= 2 ) // 2 e caz particular la afisare
cout << 2 << ' ';
for ( int i = st; i <= dr; ++i )
if ( prim[i - st] && (i % 2) == 1 )
cout << i << ' ';
delete []prim;
}

129

Capitolul 4

4.5. Algoritmul lui Gauss


Algoritmul lui Gauss pentru rezolvarea sistemelor de ecuaii liniare,
cunoscut i ca metoda eliminrii a lui Gauss, este o generalizare a metodei
de rezolvare a sistemelor prin eliminarea uneia sau mai multor necunoscute,
metod clasic nvat n clasele mici. De exemplu, dac avem sistemul de
dou ecuii liniare:
1 :
2 :

2 + 3 = 4
+ 2 = 3

Cel mai convenabil mod de rezolvare este s nmulim ecuaia E2 cu


2 i s o adunm primei ecuaii. Vom obine o nou ecuie i anume
0 = 2 de unde rezult c = 2. nlocuind acest rezultat n una din
ecuaii, de exemplu n a doua, obinem = 1.
Metoda se complic ns dac avem de rezolvat sisteme mai
complicate, aa c avem nevoie de o metod general care s fie uor de
implementat n C++ i cu ajutorul creia s putem rezolva orice sistem.
Vom considera mai nti un sistem de trei ecuaii cu trei necunoscute pe care
l vom rezolva folosind metoda eliminrii a lui Gauss, dup care vom
prezenta formal algoritmul. Fie urmtorul sistem:
1 :
2 :
3 :

3 + 2 + 2 = 2
+ + 3 = 1
4 + 3 + = 5

Ne propunem s eliminm necunoscuta din toate ecuaiile de sub


1 i necunoscuta din toate ecuaiile de sub 2 . n felul acesta, vom putea
rezolva sistemul de jos n sus.
n cazul nostru, pentru a elimina din 2 i din 3 vom efectua
urmtoarele operaii:
1
2 1 + 2
3
4
3 1 + 3
3
Sistemul va arta n felul urmtor dup efectuarea acestor operaii:

130

Algoritmi matematici
1 :
2 :
3 :

3 + 2 + 2 = 2
1
7
1
+ =
3
3
3
1
5
23
=
3
3
3

Iar pentru a elimina necunoscuta din 3 vom efectua operaia:


3 2 + 3
Sistemul devine:
1 :
2 :
3 :

3 + 2 + 2 = 2
1
7
1
+ =
3
3
3
4 = 8

Deja sistemul este foarte simplu de rezolvat. tim c = 2 din


ultima ecuaie. nlocuindu-l pe n a doua ecuaie l putem afla pe , dup
care l putem afla pe din prima ecuaie. Calculele devin prea complicate
pentru a fi efectuate de mn, deci prezentm doar rezultatele finale:
= 8
= 13
= 2
n cazul general, procedm n felul urmtor. Considerm un sistem
de N ecuaii cu N necunoscute:
1 : 11 1 + 12 2 + + 1 = 1
2 : 21 1 + 22 2 + + 2 = 2
.
.
.
: 1 1 + 2 2 + + =
Unde reprezint o necunoscut iar un coeficient, care este un
numr real. Vom reprezenta sistemul sub form de matrice n felul urmtor:

131

Capitolul 4
11
21

12
22

.
.
.

=
1

1 1
2 2
.
.
.

Primul pas al algoritmului este s transforme matricea ntr-o matrice


triunghiular care are numai zerouri sub diagonala principal. Pentru acest
lucru, vom efectua operaii elementare asupra matricei n aa fel nct
necunoscuta s fie eliminat din toate ecuaiile de dup ecuaia cu
numrul . Pentru a elimina necunoscuta dintr-o ecuaie ,> procedm

n felul urmtor: scdem din ecuaia valoarea
. Procednd n acest

mod pentru toate necunoscutele, vom ajunge n final la o matrice de genul


urmtor:
11 12 13
1 1
0 22 23 2 2
0 0
33 3 3
.
=
.
.
.
.
.
0 0
0

Al doilea pas al algoritmului este rezolvarea efectiv a sistemului,
adic aflarea necunoscutelor. Acest lucru se face tot aplicnd operaii
elementare asupra matricei . Analiznd ultima linie a matricei, observm

c putem afla valoarea ultimei necunoscute: =


. Pentru aflarea

celorlalte necunoscute procedm n felul urmtor: pentru fiecare linie ,
ncepnd de la , parcurgem toate liniile (sau ecuaiile, deoarece o linie a
matricei descrie o ecuaie a sistemului iniial) de deasupra acesteia i
efectum urmtoarele operaii:

1

0

132

Algoritmi matematici
Practic, se nlocuiete la fiecare pas necunoscuta determinat n
restul ecuaiilor. La sfritul algoritmului, coloana termenilor liberi va
conine valorile necunoscutelor, deci sistemul va fi rezolvat. Matricea final
va arta n felul urmtor:
1 0 00
0 1 00
0 0 10
=
.
.
.
0 0 01

1
2
3
.
.
.

n prezentarea algoritmului am presupus c toi coeficienii


sistemului sunt nenuli i c sistemul are ntotdeauna soluie. Dac dorim s
lum n considerare cazul n care sistemul poate s nu aib soluie, putem
verifica dac efectum vreodat o mprire la zero, caz n care sistemul nu
are soluie.
n cadrul implementrii presupunem c datele de intrare se citesc din
fiierul gauss.in. Pe prima linie se afl un numr natural N care reprezint
rangul sistemului. Pe fiecare din urmtoarele N linii se afl cte N + 1
numere naturale: primele N reprezint coeficienii necunoscutelor din
ecuaia descris de linia curent, iar ultimul numr reprezint termenul liber
asociat ecuiei curente. Astfel, vom folosi o matrice cu N linii i N + 1
coloane. La finalul algoritmului, ultima coloana va conine valorile
necunoscutelor, de la 1 la , care se vor scrie n fiierul gauss.out.
inei cont de faptul c implementarea presupune c sistemul are
ntotdeauna soluie i c toi coeficienii sunt nenuli!
Complexitatea algoritmului este O(N3 ), dar n practic algoritmul se
comport mai bine dect ar sugera acest rezultat, fiind aplicabil chiar i pe
sisteme cu rangul ~1 000.

133

Capitolul 4
#include <fstream>
using namespace std;
const int maxn = 101;

int main()
{
int N;
double A[maxn][maxn];

void citire(double A[maxn][maxn], int &N)


{
ifstream in("gauss.in");
in >> N;
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N + 1; ++j )
in >> A[i][j];

citire(A, N);
Gauss(A, N);
ofstream out("gauss.out");
for ( int i = 1; i<=N; ++i )
out << A[i][N+1] << ' ';
out.close();

in.close();
}

return 0;
void Gauss(double A[maxn][maxn], int N)
{
for ( int i = 1; i <= N; ++i )
for ( int j = N + 1; j >= i; --j )
for ( int k = i + 1; k <= N; ++k )
A[k][j] -= (A[k][i] / A[i][i]) * A[i][j];

// aflarea necunoscutelor de la ultimul


// rand spre primul
for ( int i = N; i; --i )
{
A[i][N + 1] /= A[i][i];
A[i][i] = 1;
for ( int j = i - 1; j; --j )
{
A[j][N + 1] -= A[j][i] * A[i][N+1];
A[j][i] = 0;
}
}
}

Dei de multe ori algoritmul prezentat funcioneaz corect, exist


posibilitatea apariiei unor erori de precizie datorate lucrului cu numere
reale. Datorit acestui lucru, unele implementri folosesc urmtoarea
metod de a reduce aceste erori: La fiecare pas, vom interschimba linia
curent cu linia pentru care coeficientul necunoscutei pe care o vom elimina
din urmtoarele ecuaii are valoarea maxim. Aceast operaie nu afecteaz
134

Algoritmi matematici
cu nimic corectitudinea algoritmului, deoarece ordinea ecuaiilor n sistem
este irelevant.
Noua funcie Gauss este urmtoarea:
void gauss(double A[maxn][maxn], int N)
{
for ( int i = 1; i <= N; ++i )
{
// interschimb linia i cu linia care are al i-lea element de
// valoare maxima
int max = i;
for ( int j = i + 1; j <= N; ++j )
if ( A[max][i] < A[j][i] )
max = j;
for ( int j = 1; j <= N + 1; ++j )
swap(A[max][j], A[i][j]);
for ( int j = N + 1; j >= i; --j )
for ( int k = i + 1; k <= N; ++k )
A[k][j] -= (A[k][i] / A[i][i]) * A[i][j];
}
// aflarea necunoscutelor de la ultimul rand spre primul
for ( int i = N; i; --i )
{
A[i][N + 1] /= A[i][i];
A[i][i] = 1;
for ( int j = i - 1; j; --j )
{
A[j][N + 1] -= A[j][i] * A[i][N + 1];
A[j][i] = 0;
}
}
}

Exerciii:
a) Adugai condiii care verific dac un sistem nu are soluie i
afieaz un mesaj corespunztor n acest caz.
b) ncercai s gsii un sistem pentru care cele dou variante
prezentate al algoritmului dau rspunsuri diferite.
135

Capitolul 4

4.6. Exponenierea logaritmic


Prin exponenierea unui numr nelegem ridicarea acestuia la o
anumit putere. De exemplu, numrul a ridicat la puterea b se noteaz ab,
iar procesul se mai numete i exponeniere. a se numete baz, iar b se
numete exponent. n cele ce urmeaz ne propunem s gsim un algoritm
eficient pentru exponenierea unui numr. Vom presupune c att baza (a)
ct i exponentul (b) sunt numere naturale.
Deoarece putem ajunge s lucrm cu numere foarte mari n cazul
unor baze sau exponeni mari, vom calcula ab modulo N.
O prim idee de rezolvare este s declarm o variabil de tip ntreg
iniializat cu 1 care va fi nmulit de b ori cu numrul a i care va pstra la
fiecare pas doar restul mpririi la N. O astfel de funcie poate fi
implementat n felul urmtor:
int exponentiere_clasica(int a, int b, int N)
{
int rez = 1;
for ( int i = 1; i <= b; ++i )
rez = (rez * a) % N;
return rez;
}

Complexitatea acestui algoritm este O(b). Putem obine un algoritm


mai eficient folosind urmtoarea formul de recuren:

= 0

Folosind aceast formul obinem un algoritm de complexitate O(log


b), mult mai rapid dect algoritmul precedent. Funcia poate fi implementat
n modul urmtor:

136

Algoritmi matematici
int exponentiere_log(int a, int b, int N)
{
if ( b == 0 ) return 1;
if ( (b & 1) == 0 ) // daca b e numar par
{
int temp = exponentiere_log(a, b >> 1, N);
return (temp * temp) % N;
}
return ((a % N) * exponentiere_log(a, b - 1, N)) % N;
}

Trebuie acordat o atenie deosebit variabilei N. Pentru ca


algoritmul s funcioneze corect, este necesar ca (N 1)2 s nu depeasc
valoarea maxim care poate fi reinut de tipul de date int.
De exemplu, dac presupunem c tipul de date int poate reine valori
ntregi din intervalul [231, 231 1], iar N este egal cu 100 000, exist
posibilitatea ca n cadrul instruciunii return (temp * temp) % N; variabila
temp s fie egal cu valoarea maxim posibil N 1 (deoarece, datorit
recursivitii, i aceasta se calculeaz modulo N), caz n care se va ncerca
returnarea valorii de tip int 99 9992 = 9 999 800 001, valoare prea mare
pentru acest tip de date i care va fi probabil negativ, sau n orice caz
diferit de valoarea corect.
Pentru a remedia aceast situaie, de cele mai multe ori se folosete
tipul de date pe 64 de bii long long (sau __int64) care poate reine valori
ntregi din intervalul [263, 263 1]. Dei acest tip de date poate fi mai ncet,
algoritmul este suficient de performant pentru a nu fi afectat foarte tare, iar
corectitudinea este cea mai important.
Algoritmul, dei este eficient, nu efectueaz ntotdeauna un numr
minim de operaii (prin operaii nelegem nmuliri) De exemplu, pentru
b = 15, se efectueaz urmtoarele nmuliri:
Tabelul 4.6.1. nmulirile efectuate de ctre algoritmul de exponeniere
logaritmic
Nr. b nmuliri
aa14
1 15
a7a7
2 14
7
aa6
3
6
a3a3
4
3
aa2
5
2
aa
6
137

Capitolul 4
n total ase operaii de nmulire. Am putea folosi ns numai cinci:
Tabelul 4.6.2. O serie optim de nmuliri pentru a
ridica un numr la puterea 15
Nr b nmuliri
a3a12
1 15
a2a6
2 12
6
a2a3
3
3
aa2
4
2
aa
5
Nu se cunoate niciun algoritm care s efectueze un numr minim de
operaii. O problem des studiat este gsirea numrului minim de nmuliri
necesare pentru un anumit exponent, problem care suport diverse
optimizri, dar pentru care nu se cunoate niciun algoritm polinomial.
Exponenierea logaritmic are diverse aplicaii n implementarea
algoritmilor din teoria numerelor, aa cum vom vedea n cele ce urmeaz.

a) Teste probabiliste de primalitate


Am prezentat pn n acest moment dou abordri n determinarea
primalitii unui numr: folosind mpriri repetate la toate numerele care ar
putea fi divizori i folosind ciurul lui Eratosthenes. Din pcate, ambele
abordri devin foarte ncete sau chiar inaplicabile atunci cnd avem de testat
primalitatea unui numr sau a unor numere foarte mari.
Exist algoritmi mai rapizi, dar care nu furnizeaz ntotdeauna un
rspuns corect, adic pot gsi un numr compus ca fiind prim (de obicei
inversa acestei afirmaii nu este adevrat). Aceti algoritmi sunt folosii
adesea n criptografie, unde astfel de erori nu prezint un inconvenient
foarte mare din anumite motive, sau atunci cnd intervalul pe care lucrm
nu conine numere pe care metoda probabilist folosit s furnizeze
rspunsuri greite.
Primul astfel de algoritm este testul de primalitate a lui Fermat.
Acesta folosete urmtoarea teorem a lui Fermat: dac p este un numr
prim i 1 < a < p, atunci:
1 1 ( )

138

Algoritmi matematici
Algoritmul presupune generarea aleatoare a mai multor valori pentru
a i testarea congruenei. Dac aceasta se verific pentru mai multe valori
ale lui a, atunci p este probabil prim (sau pseudoprim). Dac n schimb
gsim un a care nu verific ecuaia, atunci p sigur nu este prim.
De exemplu, dac vrem s verificm primalitatea lui 15, cu
a {4, 11, 12}, vom gsi:
414 1 15 15
1114 1 15 15
1214 9 15 15
Este uor de observat c dac am fi verificat doar 4 i 11, rspunsul
ar fi fost greit.
Complexitatea algoritmului este O(klog p) folosind exponeniere
logaritmic, unde k este numrul de valori pe care le ncercm pentru a.
bool fermat_test(int p, int k)
{
for ( int i = 1; i <= k; ++i )
{
int a = 2 + rand() % (p - 2);
if ( exponentiere_log(a, p - 1, p) != 1 )
return false; // SIGUR nu e prim
}
return true;

// PROBABIL e prim

Algoritmul nu este foarte des folosit n practic, deoarece poate


furniza rspunsuri greite relativ des n comparaie cu ali algoritmi. De
exemplu, exist unele numere compuse p numite numere Carmichael care
pentru orice valoare a astfel nct cmmdc(a, p) = 1 sunt gsite de algoritm
ca fiind prime.
Un algoritm care greete mai rar este testul de primalitate
Miller-Rabin, care este totodat folosit mai des n practic. Acesta are i o
variant determinist, care i pstreaz ntr-o oarecare msur eficiena.
Testul Miller-Rabin determin dac un numr natural impar p este
prim n felul urmtor:

139

Capitolul 4
Se scrie p 1 n forma 2sd, unde s este maxim.
Se repet de k ori (unde k are aceeai semnificaia ca la testul
Fermat; reprezint practic acurateea testului)
o Se alege aleator un a din [2, p 1].
o x = ad mod p
o Dac x == 1 sau x == p 1
Se trece la urmtoarea iteraie a lui k
o Pentru r = 1 pn la s 1 execut
x = x2 mod p
Dac x == 1 returneaz NEPRIM
Dac x == p 1 se trece la urmtoarea iteraie a
lui k
o Returneaz NEPRIM
Returneaz PROBABIL PRIM
Algoritmul se bazeaz pe urmtoarele observaii: fie p > 2 este un
numr prim. Observm c 1 i -1, ridicate la ptrat, vor fi ntotdeauna
congruente cu 1 modulo p. n alte cuvinte, putem scrie:
2 1 1 + 1 0
+1 1 ( )
Deoarece p este un numr prim impar, nseamn c p 1 va fi
ntotdeauna par i poate fi scris ca 2sd, unde s este maxim, iar d este evident
impar. Pentru orice a natural din [2, p 1], una dintre urmtoarele afirmaii
trebuie s fie adevrat:
1. 1

2. 0 < . . 2 1 1 ( )
Demonstraia acestor afirmaii se bazeaz pe torema lui Fermat:
1 1 ( )
Din observaia de mai sus, dac continum s extragem radical din
1 , vom rmne la sfrit fie cu -1 (adic p 1) fie cu 1, modulo p. Dac
obinem -1, atunci a doua egalitate este adevrat. n caz c a doua egalitate
nu a fost niciodat adevrat, nseamn c prima egalitate trebuie s fie
0
adevrat, deoarece avem 2 = 1 ( ).
Testul Miller-Rabin se bazeaz pe opusele celor afirmate mai sus.
Dac putem gsi un a astfel nct
140

Algoritmi matematici

1 (1)
i
1 0 < (2)

Atunci a se numete martor pentru neprimalitatea lui p i, n


consecin, p sigur nu este numr prim. Algoritmul prezentat n pseudocod
este o implementare eficient a acestei metode. Complexitatea sa este
O(klog p), dar, deoarece algoritmul are n primul rnd aplicabilitate pe
numere foarte mari (cu sute sau mii de cifre), trebuie inut cont i de
complexitatea acelor operaii.
Nu este necesar s testm aleator un numr mare de valori pentru a
pentru a fi siguri de corectitudinea rezultatelor. Au fost gsite anumite
praguri X astfel nct pentru orice p < X, s fie de ajuns testarea unor
anumite valori pentru a, n aa fel nct s putem fi siguri de rspunsurile
date de ctre algoritm. Cteva dintre aceste praguri sunt prezentate n tabelul
urmtor:
Tabelul 4.6.3. Valori pentru testarea determinist a
numerelor sub anumite prograguri
X
a
1 373 653
2 i 3
9 080 191
31 i 37
4 759 123 141
2, 7 i 61
2 152 302 898 747
2, 3, 5, 7 i 11
3 474 749 660 383
2, 3, 5, 7, 11 i 13
341 550 071 728 321 2, 3, 5, 7, 11, 13 i 17
Astfel, pentru a testa primalitatea unor numere reprezentabile pe
ntregi pe 32 de bii fr semn, este de ajuns efectuarea a trei iteraii!
n implementarea prezentat, se consider numere reprezentabile pe
32 de bii cu semn. Trebuie precizat c, n forma prezentat, algoritmul
poate da rspunsuri greite pentru numere al cror ptrat depete valoarea
maxim reprezentabil pe 32 de bii cu semn! Pentru a scpa de acest
neajuns se recomand folosirea numerelor pe 64 de bii sau a unei clase de
numere mari.

141

Capitolul 4
bool miller_rabin_test(int p)
{
int a[] = {2, 7, 61, 0}, s = 0, d = p - 1;
while ( d % 2 == 0 )
{
d /= 2;
++s;
}
for ( int k = 0; a[k] && a[k] < p; ++k )
{
int x = exponentiere_log(a[k], d, p);
if ( x == 1 || x == p - 1 )
continue; // urmatoarea iteratie a lui k
bool doi = true; // verificam daca relatia (2) este adevarata.
// Initial presupunem ca da
for ( int r = 1; r < s; ++r )
{
x = (x*x) % p;
if ( x == 1 )
return false;
else if ( x == p - 1 )
{
doi = false;
break;
}
}
if ( doi ) return false; // daca (2) este adevarata,
// atunci p este compus
}
return true; // PROBABIL prim (in practica putem fi SIGURI
// deoarece p este de tip int)
}

142

Algoritmi matematici

4.7. Inveri modulari, funcia totenial


Am vzut anterior c inversul unui numr X modulo Y este acel
numr X-1 pentru care are loc congruena XX-1 1 (mod Y). Acest invers
nu exist dect dac X i Y sunt numere prime ntre ele (coprime sau relativ
prime). De exemplu, dac X = 6 i Y = 13, putem gsi X-1 = 11. Acest
rezultat este corect deoarece 611 = 66, iar 66 1 (mod 13).
Putem determina un invers modular foarte uor n complexitate
O(Y), parcurgnd toate numerele de la 1 la Y 1. n continuare, vom
prezenta o metod uoar i eficient de a determina un invers modular n
complexitate O(log Y).
Prin funcia totenial (sau indicatorul lui Euler) nelegem funcia
(phi), unde (Y) reprezint cte numere naturale mai mici dect Y sunt
coprime cu Y. Funcia totenial se poate calcula n felul urmtor: dac

= 1 1 2 2 , 1 atunci indicatorul lui


Euler poate fi gsit cu ajutorul formulei:

=
=1

De exemplu, dac lum Y = 18, gsim:


1 2
18 = 2 32 = 18 = 6. Cele ase numere coprime cu 18
2 3
i mai mici dect 18 sunt: 1, 5, 7, 11, 13 i 17.
Deoarece pentru calculul funciei toteniale pentru un anumit numr
nu avem nevoie dect de descompunerea acestui numr n factori primi,
aceast funcie se poate calcula n O( Y) folosind un algoritm similar cu
ultimul algoritm clasic de testare a primalitii prezentat. Algoritmul este
urmtorul:
Fie R = Y i Yt = Y
Pentru fiecare i de la 2 pn cnd i2 > Y execut
o Dac Yt mod i = 0 execut
R = (R / i)(i 1)
Ct timp Yt mod i = 0 execut
Yt = Yt / i
Dac Yt > 1 execut
o R = R / Yt
o R = R(Yt 1)
Returneaz R (R reprezint )
143

Capitolul 4
Algoritmul este aplicarea direct a formulei de mai sus. Explicaia
parcurgerii pn la radical din Y este intuitiv: un numr poate avea cel mult
un singur factor prim mai mare dect radicalul su. Gsind toi factorii primi
mai mici dect radicalul i eliminndu-i (mprind numrul la acetia de
cte ori este posibil), vom rmne la sfrit doar cu acest factor prim, n caz
c exist. Acelai algoritm poate fi folosit pentru descompunerea efectiv a
unui numr n factori primi.
Se mai poate aplica optimizarea de a nu lua n considerare multiplii
lui 2, dar acest lucru complic inutil codul pentru scopul gsirii unui
algoritm eficient de determinare a inverilor modulari.
Pentru a determina efectiv un invers modular vom folosi teorema lui
Euler, care afirm c () 1 ( ). De aici rezult
1 1 ( ), ceea ce nseamn c 1 este inversul
modular al lui X. Dac se lucreaz modulo un numr prim, atunci
= 1, deoarece toate numerele mai mici dect un numr prim sunt
coprime cu acesta. n acest caz, inversul multiplicativ este 2 , valoare
care se poate calcula n timp O(log Y) folosind exponeniere logaritmic. n
cazul n care Y nu este numr prim, este necesar calculul indicatorului lui
Euler, ceea ce necesit timp O( Y).
De exemplu, dac dorim s calculm inversul lui 6 modulo 13, vom
proceda de data aceasta n felul urmtor: tim ca 13 = 12 deoarece 13
este numr prim. Inversul modular al lui 6 modulo 13 este aadar
611 11 ( 13). Un calculator ajunge la acest rezultat n mai puin de
12 operaii, cte sunt necesare pentru metoda clasic.
n general, algoritmului lui Euclid extins este mai rapid pentru
gsirea inverilor modulari, dar aceast metod are avantajul de a fi mai
uor de implementat.
Prezentm dou funcii: o funcie phi, care calculeaz indicatorul lui
Euler pentru un numr dat ca parametru i o funcie phi_interval, care
calculeaz valorile indicatorului lui Euler pentru toate numerele mai mici
dect numrul dat ca parametru.

144

Algoritmi matematici
int phi(int Y)
{
int R = Y, Yt = Y;
for ( int i = 2; i*i <= Y; ++i )
if ( Yt % i == 0 )
{
R /= i;
R *= i 1;

void phi_interval(int Y, int A[])


{
for ( int i = 2; i <= Y; ++i )
A[i] = i;
for ( int i = 2; i <= Y; ++i )
if ( A[i] == i )
for ( int j = i; j <= Y; j += i )
{
A[j] /= i;
A[j] *= i - 1;
}

while ( Yt % i == 0 )
Yt /= i;
}
}
if ( Yt > 1 )
{
R /= Yt;
R *= Yt 1;
}
return R;
}

Funcia phi_interval este similar cu ciurul lui Eratosthenes,


deoarece la fiecare pas eliminm numrul curent (care este prim) din toate
numerele care l au pe acesta ca divizor.
Exerciii:
a) Scriei, folosind funcia phi, un program care calculeaz inveri
modulari. Verificai dac aceast metod este mai rapid sau nu
dect folosirea algoritmului lui Euclid extins.
b) Scriei un program care calculeaz cte fracii ireductibile cu
numitorul i numrtorul mai mici dect un numr N exist.

4.8. Teorema chinez a resturilor


Teorema chinez a resturilor este un rezultat important cu privire
la congruene simultane modulo mai multe numere. De exemplu, s
presupunem c avem de gsit un numr X care satisface urmtorul sistem:
2 3
3 5
2 7
145

Capitolul 4
Aceast problem a fost propus de matematicianul chinez Sun Tsu
n secolul IV. Putem rezolva problema n felul urmtor: ne propunem s
gsim trei numere naturale a cror sum s fie o soluie a sistemului. De
exemplu, aceast sum ar putea fi 35 + 63 + 30 = 128. Se poate verfica uor
c X = 128 este o soluie a sistemului. Vom vedea n continuare cum am
ajuns la aceast soluie.
Primul numr, 35, d restul 2 la mprirea cu 3 i este un multiplu al
numerelor 5 i 7, deci nu va afecta celelalte dou congruene. Al doilea
numr, 63, d restul corect la mprirea cu 5 i este un multiplu al
numerelor 3 i 7, deci nu va afecta celelalte dou congruene. Al treilea
numr, 30, d restul corect la mprirea cu 7 i este un multiplu al
numerelor 3 i 5, deci nici acesta nu va afecta restul congruenelor. Aadar,
problema se reduce la a gsi, pentru fiecare congruen, un numr care
satisface acea congruen i care este un multiplu al numerelor modulo care
se cer rezolvate celelalte congruene.
Pentru exemplul dat, gsim prima dat 57 = 35, care verific prima
relaie i nu le afecteaz pe celelalte. Pasul urmtor este s gsim un
multiplu al numerelor 3 i 7 care verific a doua relaie i nu le afecteaz pe
prima i pe ultima. Un astfel de numr este 63. La fel se gsete i numrul
30.
n cazul general, se d urmtorul sistem:
1
2

( 1 )
( 2 )

.
.
.
( )

Conform teoremei chineze a resturilor, acesta are ntotdeauna soluie


dac , sunt coprime pentru orice i i j ntre 1 i k, i j. n caz contrar,
sistemul are soluie dac i numai dac ( , ).
O soluie a sistemului poate fi gsit, aa cum am vzut, adunnd k
numere care satisfac proprietatea discutat mai sus. Totui, gsirea unei
soluii a implicat ghicirea termenilor acestei sume, lucru care nu este
ntotdeauna uor realizabil. n continuare vom prezenta o metod mai
exact.

146

Algoritmi matematici
Fie

= 1 2 .

Fie

= 1 ( ). Soluia sistemului este

1 .

Calculm

( )

=1

S vedem cum funcioneaz acest algoritm pentru exemplul dat:


N = 105
P = {35, 21, 15}
Q = {2, 1, 1}
X = 2 35 2 + 3 21 1 + 2 15 1 105 =
= 140 + 63 + 30 105 = 233 105 = 23.
Se observ c am obinut o soluie diferit fa de cea anterioar. Se
poate observa uor c toate soluiile sistemului sunt de forma
= 23 + 105, deoarece t i 105 nu influeneaz cu nimic relaiile.
n continuare vom demonstra corectitudinea acestei metode. Trebuie
s demonstrm c

( )

=1

Lucru echivalent cu

( )

=1

deoarece | . Din =

rezult 0

0 , . Este de ajuns aadar


demonstrm c ( ), lucru adevrat deoarece
1 ( ), iar 1 1 .

Pentru calcularea inverilor modulari vom folosi almoritmul lui


Euclid extins, deoarece pentru a folosi teorema lui Euler am putea fi nevoii
147

Capitolul 4
s calculm funcia totenial (n caz c numerele nu sunt prime), caz n
care algoritmul devine mai ncet.
Programul prezentat presupune c datele de intrare se citesc din
fiierul TCR.in, fiier ce are urmtorul format: pe prima linie numrul k, iar
pe urmtoarele k linii perechi de numere , avnd semnificaia din
enun. n fiierul de ieire TCR.out se va afia soluia sistemului.
Trebuie precizat c programul prezentat nu ine cont de faptul c un
anumit set de date poate s nu aib soluie sau poate s determine unele
calcule intermediare s depeasc valoarea maxim reprezentabil pe tipul
de date int. Tratarea acestor cazuri este lsat ca exerciiu pentru cititor.
#include <fstream>
using namespace std;
const int maxk = 100;
struct congruenta
{
int a, n;
};
void citire(int &k, congruenta T[])
{
ifstream in("TCR.in");
in >> k;
for ( int i = 1; i <= k; ++i )
in >> T[i].a >> T[i].n;
in.close();
}

148

Algoritmi matematici
void euclid_ext(int X, int Y, int &a, int &b)
{
if ( X % Y == 0 )
{
a = 0;
b = 1;
return ;
}
int ta, tb;
euclid_extins(Y, X % Y, ta, tb);
a = tb;
b = ta - tb*(X / Y);
}
int rezolvare(int k, congruenta T[])
{
int N = 1, P[maxk], Q[maxk];
for ( int i = 1; i <= k; N *= T[i++].n );
for ( int i = 1; i <= k; ++i ) P[i] = N / T[i].n;
int a, b;
for ( int i = 1; i <= k; ++i )
{
euclid_ext(P[i], T[i].n, a, b);
Q[i] = a;
}
int X = 0;
for ( int i = 1; i <= k; ++i )
X += (T[i].a * P[i] * Q[i]) % N;
return X;
}
int main()
{
int k;
congruenta T[maxk];
citire(k, T);
ofstream out("TCR.out"); out << rezolvare(k, T); out.close();
return 0;
}

149

Capitolul 4

4.9. Principiul includerii i al excluderii


Fie intervalul [1, 100]. Cte numere din acest interval sunt divizibile
cu 5 sau cu 6? Putem afla uor c exist
100

100
5

= 20 de numere divizibile cu

5 i
= 16 numere divizibile cu 6. Am putea fi tentai s dm rspunsul
6
20 + 16 = 36 de numere divizibile cu 5 sau cu 6, dar acest rspuns este
greit, deoarece am numrat anumite numere de dou ori. Mai exact, am
numrat toate numerele care sunt multipli att pentru 5 ct i pentru 6, adic
100
multiplii numrului 56 = 30. Exist
= 3 numere divizibile cu 30.
30
Pentru ca rspunsul nostru s fie corect, trebuie s scdem aceast cantitate
din rezultatul obinut anterior, obinnd astfel 20 + 16 3 = 33 de numere
divizibile cu 5 sau cu 6 n intervalul [1, 100].
S lum nc un exemplu: cte numere din acelai interval sunt
divizibile cu 2, 3 sau 7? De data aceasta avem 50 de numere divizibile cu 2,
33 de numere divizibile cu 3 i 14 numere divizibile cu 7, dar este clar c
rspunsul nu este 50 + 33 + 14 = 97, deoarece am numrat multiplii
numerelor 6, 14 i 21 de dou ori. Trebuie s scdem aadar 16 multipli de
6, 7 multipli de 14 i 4 multipli de 21. Am putea fi tentai s considerm
rspunsul final ca fiind 50 + 33 + 14 16 7 4 = 70, dar acest rezultat este
greit, deoarece multiplii numrului 237 = 42 au fost adunai de trei ori (o
dat pentru fiecare dintre numerele 2, 3 i 7) i sczui de trei ori (o dat
pentru fiecare dintre numerele 6, 14 i 21). Aadar am sczut prea mult i
trebuie s adunm din nou numerele divizibile cu 42, care sunt dou.
Rspunsul final este aadar 50 + 33 + 14 16 7 4 + 2 = 72 de numere
divizibile cu 2 sau cu 3 sau cu 7.
S vedem dac putem deduce o regul de calcul pentru cazul n care
trebuie s determinm cte numere sunt divizibile cu cel puin unul dintre
numerele dintr-un ir dat. Dac N(x) este o funcie egal cu numrul
multiplilor lui x din intervalul dat, atunci putem scrie rspunsul la problema
precedent n felul urmtor:
= 2 + 3 + 7 < 2,3 > < 2,7 > < 3,7 > +
+(< 2,3,7 >)
Unde S este numrul care se cere i <x, y> reprezint cel mai mic
multiplu comun al numerelor x i y.
n rezolvarea problemei am folosit principiul includerii i al
excluderii, principiu care ne ajut s determinm cardinalul reuniunii mai
multor mulimi. Dac avem n mulimi notate A1 , A2 , ..., An atunci:
150

Algoritmi matematici

=
=1

1 2 + + (1)1 1 2


=1

11 <2

De obicei, programele care implementeaz principiul includerii i al


excluderii pentru rezolvarea unei probleme au complexitatea cel puin
O(2n), deoarece este necesar generarea tuturor submulimilor unui ir.
Exerciii:
a) Scriei un program care rezolv problema de mai sus pentru
cazul general.
b) Scriei un program care citete n mulimi de numere ntregi dintrun fiier i calculeaz cardinalul reuniunii lor folosind principiul
includerii i al excluderii.
c) Scriei un program care citete n mulimi de numere ntregi dintrun fiier i calculeaz cardinalul reuniunii lor fr a folosi
principiul includerii i al excluderii. Care metod este mai simplu
de implementat? dar mai eficient?

4.10. Formule i tehnici folositoare


De mult ori putem da peste nite probleme pe care s nu tim s le
rezolvm pentru c nu am mai vzut niciodat asemenea cerine i nu
suntem familiari cu un anumit tip de gndire care se cere pentru a ajunge la
o soluie corect i eficient. Alte ori este posibil s nu cunoatem anumite
noiuni sau formule, sau s cunoatem rezolvarea matematic a problemei,
dar s nu tim care este cea mai bun metod de implementare. Aceast
seciune ncearc s nlture aceste neajunsuri, n msura n care acest lucru
este posibil, prezentnd anumite formule matematice i tehnici de
programare des ntlnite i considerate folositoare i eficiente.
1. Calcularea celui mai mare divizor comun (cmmdc) a mai multor
numere:
cmmdc(X, Y, Z) = cmmdc(cmmdc(X, Y), Z).
n cazul general, cmmdc(X1 , X2, ..., Xn) = cmmdc(cmmdc(X1,
X2 , ..., Xn 1), Xn). Cel mai mare divizor comun a dou numere
se calculeaz folosind algoritmul lui Euclid.
151

Capitolul 4
2. Calcularea celui mai mic multiplu comun (cmmmc) folosind
algoritmul lui Euclid:

, =
(, )
3. Pentru calcularea celui mai mic multiplu comun al mai multor
numere, se poate aplica aceeai formul ca la cmmdc, dar uneori
exist posibilitatea folosirii unui algoritm mai simplu i anume:
se nmulete cel mai mic numr din ir cu 0, 1, 2, ..., pn cnd
rezultatul se mparte fr rest la toate celelalte numere.
4. Folosirea operaiilor be bii pentru a optimiza memoria folosit i
timpul de execuie:
a. 2k = 1 << k
b. X2k = X << k
c. X / 2k = X >> k
d. X % 2k = X & (2k 1)
e. Afl dac X este divizivbil cu 2: (X & 1) == 0
f. Afl dac X este putere a lui 2: (X & (X 1)) == 0
g. Seteaz al k-lea bit (de la stnga spre dreapta) al
ntregului X pe 1: X |= 1 << k.
h. Seteaz al k-lea bit al ntregului X pe 0: X &= (1 << k)
1;
5. Calcularea eficient a numerelor Fibonacci: dac irul Fibonacci
este definit astfel:
0
1
=
2 + 1

=0
=1
>1

Atunci:
1 1
1 0

( + 1)
()

()
( 1)

Similar se pot rezolva i alte recurene de genul acesta, rezolvnd


ecuaia:
( + 2)

=
( + 1)

152

( + 1)

()

Algoritmi matematici
6. Pentru ca metoda de mai sus s fie eficient, este necesar s
folosim exponeniere logaritmic. Pentru acest lucru putem lucra
direct cu matrici i cu o funcie de nmulire a matricilor, sau
putem scrie o clas care s gestioneze lucrul cu matrici. Vom
introduce lucrul cu clase n seciunea urmtoare. Deocamdat
prezentm doar o secven de cod ce nmulete dou matrici
ptratice (X i Y) de ordin N date ca parametru ntr-o a treia
matrice T, iniializat cu 0.
for ( int k = 0; k < N; ++k )
for ( int i = 0; i < N; ++i )
for ( int j = 0; j < N; ++j )
T[i][j] += X[i][k] * Y[k][j];
7. Dac avem:

= 1 1 2 2 ,

atunci:

+ 1 , =
=1

8. Folosirea parametrilor de tip referin constant n cazul n care


se transmit variabile mari, cum ar fi de tip string sau alte tipuri
neelementare. De exemplu, n cazul secvenei urmtoare:
void F(T param) { ... }
int main() { T x; F(x); return 0; } // se transmite o copie a
// obiectului x
La apelul funciei F din main, funciei F i se transmite o copie a
variabilei x, iar copierea unui obiect de tip T poate s
ncetineasc programul, mai ales dac avem de a face cu funcii
recursive sau cu un numr mare de astfel de apeluri. Putem
elimina problema copierii folosind parametri de tip referin.
Pentru a elimina riscul modificrii valorii acestora (fiind de tip
referin, s-ar modifica obiectul iniial, lucru care poate fi
nedorit), vom folosi parametri constani:

153

Capitolul 4
void F(const T &param) { ... }
int main() { T x; F(x); return 0; } // se transmite adresa
// obiectului x, nu o copie a sa.
9. Numrul zerourilor terminale ale lui n!:

+ 2 + 3 +
5
5
5

=
10. Dac avem:

= 1 1 2 2 ,

atunci:

=
=1

+1

1
, =
1

4.11. Operaii cu numere mari


Uneori tipurile de date oferite de limbajul de programare C++ nu
sunt suficiente pentru cerinele anumitor probleme. De exemplu, dac ar
trebui s scriem un program care calculeaz 100! nu am putea folosi niciun
tip de date predefinit, deoarece 100! are peste 100 de cifre. n astfel de
cazuri trebuie s implementm propriul nostru tip de date care suport
operaiile necesare (de obicei adunare, scdere, nmulire, ctul impririi i
modulo).
Pentru a putea refolosi implementrile n mai multe probleme, este
folositor s scriem o clas (numit evenntual BigInt) pentru care s
suprancrcm operatorii de care avem nevoie. n acest fel, algoritmii clasici
de rezolvare rmn neschimbai, cu excepia tipurilor de date folosite de
acetia. Acest lucru va rmne ca un exerciiu pentru cititor. Vom prezenta
doar implementarea procedural a algoritmilor de gestiune a numerelor
mari.

154

Algoritmi matematici

a) Reprezentarea unui numr mare


Vom reprezenta un numr mare cu ajutorul unui vector de ntregi.
Primul element al vectorului (cel cu indicele 0) va reprezenta numrul de
cifre din vector, iar restul elementelor vor reprezenta cifrele numrului, dar
n ordine invers. Adic, ultima cifr a numrului va avea indicele 1,
penultima va avea indicele 2 etc. Acest lucru este fcut pentru a putea
efectua mai uor operaiile care au ca efect creterea numrului de cifre.
De exemplu, numrul 290145 este reprezentat prin urmtorul vector:
0 1 2 3 4 5 6
6 5 4 1 0 9 2
Secvena de cod care transform un numr mic (reprezentabil pe
tipul de date int) ntr-un numr mare (reinut n vectorul X) este urmtoarea:
while ( x )
{
X[ ++A[0] ] = x % 10;
x /= 10;
}

b) Adunarea a dou numere mari


Pentru a aduna dou numere mari vom aplica efectiv algoritmul
nvat n clasele primare. Considerm numerele scrise unul sub altul,
aliniate la dreapta (la stnga n cazul reprezentrii noastre). Vom completa
numrul cu mai puine cifre cu zerouri pentru ca numerele s aib acelai
numr de cifre. Numerele fiind reprezentate ca vectori i cifrele fiind n
ordine invers, putem parcurge efectiv vectorii i aduna cifrele de pe aceeai
poziie, innd cont de algoritmul clasic de adunare (cifra curent a primului
numr + cifra curent a celui de-al doilea numr + transportul).
De exemplu, s considerm adunarea numerelor A = 12699 i
B = 94289. Vom considera c dorim s avem rezultatul ntr-un alt vector C.
n caz c dorim ca rezultatul s se memoreze n A, modificrile care
trebuiesc aduse algoritmului sunt minime.
i 0 1 2 3 4 5
A 5 9 9 6 2 1

i 0 1 2 3 4 5
B 5 9 8 2 4 9
155

Capitolul 4
La primul pas i = 1, transport = 0. Efectum
C[i] = A[i] + B[i] + transport, ceea ce nseamn C[1] = 18.
transport devine C[i] / 10, adic 1, iar C[1] devine C[1] % 10,
adic 8.
i 0 1 2 3 4 5 6
C 0 8
transport = 1
La pasul i = 2, efectum aceleai operaii, obinnd
C[2] = A[2] + B[2] + transport, adic C[2] = 18.
transport devine 1, iar C[2] devine 8.
i 0 1 2 3 4 5 6
C 0 8 8
transport = 1
Se procedeaz n acest mod pn ce se ajunge la pasul i = 5, la
sfritul cruia vom avea urmtorul vector:
i
C

0
0

1 2 3 4
8 8 9 6
transport = 1

5
0

Deoarece transport = 1 trebuie s mai efectum un pas (i = 6), i


anume s punem transportul pe ultima poziie a vectorului. Mai mult, mai
este necesar s completm numrul de cifre al rezultatului. Acest numr de
cifre va fi ntotdeauna i, n cazul acesta 6.
i
C

0
6

1
8

2
8

3
9

4
6

5
0

6
1

Rezultatul adunrii este aadar numrul de 6 cifre 106988.


Secvena de cod care efectueaz adunarea este urmtoarea:

156

Algoritmi matematici
void adunare(int A[], int B[], int C[])
{
int i, transport = 0;
for ( i = 1; i <= A[0] || i <= B[0]; ++i )
{
C[i] = A[i] + B[i] + transport;
transport = C[i] / 10;
C[i] %= 10;
}
if ( transport )
C[i] = transport;
C[0] = i;
}

Atenie: am presupus c vectorii A i B sunt iniializai n ntregime


cu 0!

c) Scderea a dou numere mari


Presupunem c vrem s efecutm diferena A B i A B.
Algoritmul este tot cel clasic: vom efectua scderi cifr cu cifr,
mprumutnd o unitate din cifra anterioar dac acest lucru este necesar.
Deoarece A B nu avem probleme scderea celei mai semnificative cifre.
Presupunem c rezultatul scderii se reine n vectorul A.
De exemplu, dac avem de efectuat diferena 131 99 procedm n
felul urmtor:
i
A

0
3

1
1

2
3

3
1

i
B

0
2

1
9

2
9

3
0

Iniial imprumut = 0. Efectum A[1] = A[1] B[1] imprumut,


adic A[1] = -8. Verificm dac A[1] < 0, ceea ce este adevrat, deci
adunm 10 la A[1] i imprumut devine 1. Rezult:
i
A

0
3

1
2

2
3

3
1

Efectum A[2] = A[2] B[2] imprumut, adic A[2] = -7.


A[2] < 0, deci adunm 10 iar imprumut rmne 1:
157

Capitolul 4
i
A

0
3

1
2

2
3

3
1

Efectum A[3] = A[3] B[3] imprumut, adic A[3] = 0. A[3] nu


este mai mic dect 0, aa c imprumut devine 0:
i
A

0
3

1
2

2
3

3
0

Rezultatul final s-ar interpreta n felul urmtor: 131 99 = 032, ceea


ce nu are sens, deoarece un numr nu poate ncepe cu cifra 0. Aadar, ct
timp prima cifr este 0, va trebui s scdem numrul de cifre. Rezultatul
corect este:
i
0 1 2 3
A 2 2 3 0
Scderea a dou numere poate prea puin contraintuitiv datorit
modului n care reinem numerele. Practic nu efectum mprumutul la pasul
curent, ci la pasul curent inem cont de mprumutul efectuat anterior (dac a
existat), scazndu-l la pasul curent, i marcnd faptul c ne-am mprumutat
dac rezultatul scderii devine negativ.
Funcia care efectueaz diferena a dou numere mari este
urmtoarea:
void scadere(int A[], int B[])
{
int imprumut = 0;
for ( int i = 1; i <= A[0]; ++i )
{
A[i] = A[i] - B[i] - imprumut;
if ( A[i] < 0 )
{
A[i] += 10;
imprumut = 1;
}
else
imprumut = 0;
}
while ( A[ A[0] ] == 0 && A[0] > 1 )
--A[0];
}

158

Algoritmi matematici

d) Compararea a dou numere mari


Fie A i B dou numere mari:
Dac A[0] > B[0], atunci A > B
Dac B[0] > A[0], atunci A < B
Dac A[0] == B[0] atunci:
o Dac exist 1 k A[0] astfel nct A[k] > B[k] i
A[k+1] == B[k+1], A[k+2] == B[k+2], ...,
A[ A[0] ] == B[ A[0] ], atunci A > B
o Dac exist 1 k A[0] astfel nct B[k] > A[k] i
A[k+1] == B[k+1], A[k+2] == B[k+2], ...,
A[ A[0] ] = B[ A[0] ], atunci A < B
o Altfel A == B
Practic, dac un numr are mai multe cifre ca cellalt, acel numr
este mai mare. Dac ambele numere au acelai numr de cifre, atunci se
compar numerele cifr cu cifr, ncepnd de la cea mai semnificativ cifr.
Aceaste comparaii fie vor determina care numr este mai mare, fie vor
determina c numerele sunt egale.
Funcia de comparare poate fi implementat astfel:
int comparare(int A[], int B[])
{
if ( A[0] > B[0] ) return 1; // A > B
if ( B[0] > A[0] ) return -1; // A < B
for ( int i = A[0]; i; --i )
if ( A[i] > B[i] )
return 1;
else if ( B[i] > A[i] )
return -1;
return 0; // A == B
}

e) nmulirea unui numr mare cu un numr mic


Putem avea nevoie s nmuim un numr mare cu un numr mic, de
exemplu atunci cnd vrem s calculm puteri mai ale unor numere sau
factorialele unor numere. Acest lucru se face n mod natural: se nmulete
159

Capitolul 4
fiecare cifr a numrului mare cu numrul mic, se adun transportul la
rezultat (care iniial este 0), se pstreaz restul mpririi la 10, iar noul
transport devine rezultatul mprit la 10.
De exemplu, dac vrem s nmulim numrul mare A = 3173 cu
numrul mic B = 13, vom proceda n felul urmtor:
i 0 1 2 3 4
A 4 3 7 1 3
transport = 0
La pasul i = 1 efectum A[1] = A[1] * 13 + transport, adic
A[1] = 39. transport devine A[1] / 10 adic 3, iar A[1] devine A[1] % 10,
adic 9:
i 0 1 2 3 4
A 4 9 7 1 3
transport = 3
Se continu procedeul pn la pasul i = 4, cnd vectorul A va arta
n felul urmtor:
i 0 1 2 3 4
A 4 9 4 2 1
transport = 4
Ct timp mai exist transport, cifrele acestuia trebuie adugate
numrului A. Rezultatul final va fi aadar:
i 0 1 2 3 4 5
A 5 9 4 2 1 4
Adic 3173 13 = 41 249.
O funcie care nmulete un numr mare cu un numr mic poate fi
implementat n felul urmtor:

160

Algoritmi matematici
void inm_mic(int A[], int B)
{
int transport = 0;
for ( int i = 1; i <= A[0]; ++i )
{
A[i] = A[i] * B + transport;
transport = A[i] / 10;
A[i] %= 10;
}
while ( transport )
{
A[ ++A[0] ] = transport % 10;
transport /= 10;
}
}

f) nmulirea a dou numere mari


Algoritmul de nmulire a dou numere mari este puin mai complex
dect algoritmii prezentai pn acuma. O prim idee ar fi s aplicm
algoritmul clasic de nmulire nvat n clasele primare: se scriu numerele
unul sub altul, se nmulete fiecare cifr a celui de-al doilea cu primul
numr iar rezultatele se scriu unul sub altul, fiecare deplasat cu o poziie n
plus spre stnga, dup care se adun rezultatele cifr cu cifr. De exemplu,
pentru a nmuli 1213 cu 413 procedm n felul urmtor:
1213
413
(*)
3639
1213
4852
(+)
500969
Acest algoritm ar putea fi implementat folosind algoritmii de
adunare a dou numere mari i de nmulire a unui numr mare cu un numr
mic, dar aceast abordare ar fi ineficient i greu de implementat, aa c
vom ncerca s obinem ceva mai eficient i totodat mai uor de
implementat.
161

Capitolul 4
Algoritmul propus este urmtorul:
Se nmulete fiecare cifr i a primului numr cu fiecare cifr j a
celui de-al doilea numr, iar rezultatul se adun la cifra i + j 1 a
rezultatului (care este iniial nul)
La sfrit se face corectarea rezultatului (care este evident greit
n aceast form, doarece putem avea o cifr mai mare ca 9) cifr
cu cifr astfel:
o Fie Y = X + transport, unde X este cifra curent
o X va deveni Y % 10
o transport va deveni Y / 10
S vedem ce obinem prin aceast metod pe exemplul anterior:
Rezultatul are fie 4 + 3 = 7 fie 4 + 3 1 = 6 cifre (vom demonstra n
cele ce urmeaz aceast afirmaie), deci vom declara un vector de lungime
7, iniializat cu 0, care va reine rezultatul:
i
A
B
Rez

0
4
3
6

1
3
3
0

2
1
1
0

3
2
4
0

4
1
0
0

5
0
0
0

6
0
0
0

7
0
0
0

Se adun rezultatul nmulirii primei cifre a primului numr (A) cu


fiecare cifr a celui de-al doilea numr n poziia corespunztoare sumei
poziiilor celor dou cifre (aa cum sunt reinute n vectori) minus 1.
Rezultatul este:
i
0 1 2 3 4 5 6 7
Rez 6 9 3 12 0 0 0 0
Se procedeaz la fel cu a doua cifr:
i
0 1 2 3 4 5 6 7
Rez 6 9 6 13 4 0 0 0
A treia cifr:
i
0 1 2 3 4 5 6 7
Rez 6 9 6 19 6 8 0 0

162

Algoritmi matematici
Ultima cifr:
i
0 1 2 3 4 5 6 7
Rez 6 9 6 19 9 9 4 0
Efectum corectarea rezultatului: Rez[1] rmne 9, iar transportul
devine 0. Rez[2] rmne 6, iar transportul rmne 0. Rez[3] devine 9, iar
transportul devine 1:
i
0 1 2 3 4 5 6 7
Rez 6 9 6 9 9 9 4 0
Rez[4] devine (9 + transport) mod 10, adic 0, iar transportul
devine (9 + transport) / 10, adic 1:
i
0 1 2 3 4 5 6 7
Rez 6 9 6 9 0 9 4 0
Rez[5] devine (9 + transport) mod 10, adic 0, iar transportul
devine (9 + transport) / 10, adic 1:
i
0 1 2 3 4 5 6 7
Rez 6 9 6 9 0 0 4 0
Rez[6] devine 5, iar transportul 0:
i
0 1 2 3 4 5 6 7
Rez 6 9 6 9 0 0 5 0
n final mai trebuie s verificm dac transportul este diferit de 0,
caz n care trebuie s cretem numrul de cifre i s mai adugm o cifr
egal cu transportul. Pe acest exemplu nu este ns cazul.
Mai trebuie s demonstm corectitudinea acestei metode. n primul
rnd, produsul a dou numere X i Y, avnd P respectiv Q cifre este egal fie
cu P + Q fie cu P + Q 1. Acest lucru reiese din urmtoarele relaii:
101 < 10
101 < 10
163

Capitolul 4
Dac nmulim cele dou relaii obinem:
10+2 < 10+
De unde rezult afirmaia anterioar.
Avnd demonstrat numrul de cifre, corectitudinea acestei metode
este uor de dedus: la fiecare pas se adun rezultatul nmulirii cifrelor
curente n poziia corespunztoare a vectorului rezultat. Exact acelai lucru
se ntampl i n forma clasic a algoritmului, atta doar c forma clasic
este mai uor de efectuat pentru om (deoarece nu trebuie s inem cont de
poziii i nici de corectarea rezultatului), iar aceast form este mai simplu
de implementat pentru un programator, deoarece nu trebuie reinute i
adunate rezultatele intermediare.
void inm_mare(int A[], int B[], int C[]) // Se presupune C initializat cu 0
{
C[0] = A[0] + B[0] - 1;
for ( int i = 1; i <= A[0]; ++i )
for ( int j = 1; j <= B[0]; ++j )
C[i + j - 1] += A[i] * B[j];
int s = 0, transport = 0;
for ( int i = 1; i <= C[0]; ++i )
{
s = C[i] + transport;
C[i] = s % 10;
transport = s / 10;
}
if ( transport )
C[ ++C[0] ] = transport;
}

g) Ctul mpririi unui numr mare la un numr mic


Se procedeaz n modul clasic, construindu-se rezultatul cifr cu
cifr. De exemplu, dac vrem s mprim numrul mare 62117 la numrul
mic 13, procedm n felul urmtor:

164

Algoritmi matematici
62117 : 13 = 04778
0

62
52

101
91

101
91

107
104

3
Algoritmul se ncheie n momentul n care toate cifrele ale
dempritului au fost coborte. Zerourile din faa rezultatului se ignor.
void impartire(int A[], int B) // rezultatul va fi retinut in A
{
int transport = 0;
for ( int i = A[0]; i > 0; --i )
{
transport = transport * 10 + A[i];
A[i] = transport / B;
transport %= B;
}
while ( !A[ A[0] ] && A[0] > 1 )
--A[0];
}

h) Restul mpririi unui numr mare la un numr mic


Algoritmul de determinare a restului este uor de dedus dac inem
cont de modul n care sunt reprezentate numerele n baza 10. De exemplu,
numrul 3672 se poate scrie n baza 10 n felul urmtor: 3672 = 3103 +
6102 + 7101 + 2100 = (((310 + 6) 10) + 7)10 + 2. Putem aadar s
165

Capitolul 4
parcurgem numrul mare cifr cu cifr i s construim treptat restul innd
cont de proprietile operaiei modulo.
int modulo(int A[], int B)
{
int rest = 0;
for ( int i = A[0]; i > 0; --i )
{
rest = rest * 10 + A[i];
rest %= B;
}
return rest;
}

Operaiile prezentate pn acum reprezint operaiile matematice de


baz.
Exist metode mult mai eficiente dect cele prezentate, dar acestea
depesc scopul acestei lucrri. Dac cititorul consider c are nevoie de o
librrie mai avansat, recomandm librria de numere mari GMP sau
extinderea operaiilor prezentate pn acum.
Reamintim exerciiul de a implementa operaiile prezentate (eventual
i altele) ntr-o clas sau structur. Acest lucru va uura refolosirea codului
de fiecare dat cnd vei avea nevoie de acesta.

166

Algoritmi backtracking

5. Algoritmi
backtracking
Am prezentat pn acum descrierea general a tehnicii de
programare numit backtracking, mpreun cu nite probleme elementare
care se rezolv cu ajutorul acestei tehnici. Problemele prezentate anterior nu
au ns nicio aplicabilitate intrinsec, acestea aparnd de cele mai multe ori
doar ca subprobleme n cadrul altor probleme mai complexe.

167

Capitolul 5

CUPRINS
5.1. Problema labirintului ............................................................................. 169
5.2. Problema sriturii calului ....................................................................... 173
5.3. Generarea submulimilor ...................................................................... 175
5.4. Problema reginelor ................................................................................ 177
5.5. Generarea partiiilor unei mulimi ........................................................ 180

168

Algoritmi backtracking

5.1. Problema labirintului


Se d un labirint reprezentat cu ajutorul unei matrici ptratice A de
ordin N a cror valori pot fi doar 0 i 1. Fiecare element al matricii
reprezint o ncpere a labirintului. Valoarea 0 reprezint faptul c
respectiva camer este deschis, iar valoarea 1 reprezint o camer nchis.
Dintr-o camer oarecare, putem ajunge n camerele care se nvecineaz cu
aceasta la sud, nord, est sau vest (dac acestea sunt deschise desigur). Mai
mult, putem trece prin fiecare camer cel mult o dat!
Se cere determinarea tuturor posibilitilor de a ajunge din ncperea
(1, 1) n ncperea (N, N) respectnd condiiile din enun.
Datele de intrare se citesc din fiierul labirint.in, iar modalitile
gsite se scriu n fiierul labirint.out n felul urmtor: fiecare linie a
fiierului conine, n ordine, cte o pereche i j care descrie o camer a
traseului curent. Cnd se trece la un nou traseu, se las o linie liber.
Exemplu:
labirint.in
2
00
00

labirint.out
11
12
22
11
21
22

Deoarece ni se cere s gsim toate posibilitile de ieire din


labirint, prima metod care ne vine n minte este metoda backtracking.
Vom reprezenta labirintul ntr-o matrice de tip bool i vom folosi o stiv n
care reinem toate elementele matricii pn la pasul curent. La sfrit, adic
atunci cnd am ajuns pe elementul (N, N) afim coninuturile stivei.
Detaliile algoritmului sunt destul de evidente: vom folosi o funcie
care accept ca paramtri pasul la care ne aflm, coordonatele (lin, col) a
camerei n care ne aflm, matricea i stiva folosit. Primul lucru pe care l
facem n aceast funcie este s reinem perechea (lin, col) n stiv. Apoi,
verificm dac ne aflm pe elementul final, caz n care afim coninuturile
stivei i ieim din funcie. n caz contrar, apelm funcia recursiv pentru toi
vecinii valizi. n pseudocod algoritmul este urmtorul:
169

Capitolul 5
fie back(k, lin, col, N, A, st) funcia la care am fcut referire mai sus.
Aceast funcie poate fi implementat astfel:
Reine (lin, col) n st[k]
Dac (lin, col) == (N, N) afieaz coninuturile stivei st
Altfel execut:
o Pentru fiecare vecin valid (n_lin, n_col) execut
A[lin][col] = true
Apeleaz recursiv
back(k + 1, n_lin, n_col, N, A, st)
A[lin][col] = false
n primul rnd trebuie s explicm cteva lucruri care pot prea
ciudate la prima vedere.
Un vecin (n_lin, n_col) se consider valid doar dac
A[n_lin][n_col] este 0 i dac linia i coloana acestuia nu este mai mic
dect 1 sau mai mare dect N.
Pentru a parcurge mai uor vecinii unui element, putem folosi
vectori de direcie. Vectorii de direcie sunt nite vectori dx i dy cu valori
alese n aa fel nct dac adunm dx[0] la lin i dy[0] la col s obinem
primul vecin (n_lin, n_col). Dac adunm dx[1] respectiv dy[1] vom obine
al doilea vecin etc. (ordinea obinerii vecinilor nu are importan atta timp
ct nu se obine acelai vecin de mai multe ori). Deoarece avem 4 vecini,
vectorul va avea patru elemente. Se poate observa uor c:
(n_lin, n_col) {(lin + 1, col); (lin, col + 1); (lin 1, col); (lin, col 1)}
aa c
dx = {1, 0, -1, 0}
dy = {0, 1, 0, -1}.
Deoarece nu avem voie s parcurgem un element de mai multe ori,
trebuie s marcm cumva elementele deja vizitate. Acest lucru l facem prin
nchiderea camerelor prin care am trecut deja, adic prin marcarea lor cu
true. La revenire din recursivitate este clar c acestea trebuie redeschise
deoarece cutm nc un drum, adic trebuie marcate cu 0.
Pentru exemplul dat algoritmul funcioneaz n felul urmtor: prima
dat se adaug (1, 1) n stiv. Pentru fiecare vecin valid al su, adic pentru
(1, 2) i (2, 1), efectum A[1][1] = true i apelm funcia recursiv, prima
dat pentru (1, 2). nainte de apelul recursiv stiva i matricea arat n felul
urmtor:
170

Algoritmi backtracking
st

A
true false
(1, 1)
false false
La apelul recursiv pentru (1, 2), adugm aceast pereche n stiv,
marcm elementul curent ca fiind vizitat i apelm funcia recursiv pentru
singurul vecin valid al lui (1, 2), adic (2, 2). nainte de apel avem
urmtoarea configuraie:
st
A
(1, 2) true true
(1, 1) false false
La apelul recursiv pentru (2, 2) vom ajunge la urmtoarea
configuraie, care ne va da primul traseu:
st
A
(2, 2) true true
(1, 2)
(1, 1) false false
Deoarece (2, 2) reprezint sfritul traseului, acesta nu se mai
seteaz pe true. La revenire din recursivitate se vor redeschide (seta pe
false) celelalte camere i se va gsi cellalt traseu.
Prezentm n continuare implementarea algoritmului de rezolvare a
problemei labirintului.
#include <fstream>
using namespace std;

void citire(int &N,


bool A[maxn][maxn])
{
ifstream in("labirint.in");

const int maxn = 100;


const int dx[] = {1, 0, -1, 0};
const int dy[] = {0, 1, 0, -1};
struct stiva
{
int lin, col;
};

in >> N;
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
in >> A[i][j];
in.close();
}

171

Capitolul 5
bool valid(int lin, int col, int N,
bool A[maxn][maxn])
{
if ( lin > 0 && lin <= N &&
col > 0 && col <= N )
if ( A[lin][col] == false )
return true;

int main()
{
int N;
bool A[maxn][maxn];
stiva st[maxn*maxn];
citire(N, A);
ofstream out("labirint.out");
back(1, 1, 1, N, A, st, out);

return false;
}
void back(int k, int lin, int col, int N,
bool A[maxn][maxn], stiva st[],
ofstream &out)
{
st[k].lin = lin;
st[k].col = col;

out.close();
return 0;
}

if ( lin == N && col == N )


{
for ( int i = 1; i <= k; ++i )
out << st[i].lin << ' ' << st[i].col
<< '\n';
out << '\n';
return;
}
for ( int i = 0; i < 4; ++i )
{
int n_lin = lin + dx[i];
int n_col = col + dy[i];
if ( valid(n_lin, n_col, N, A) )
{
A[lin][col] = true;
back(k+1, n_lin, n_col, N, A, st, out);
A[lin][col] = false;
}
}
}

Exerciiu: modificai programul dat n aa fel nct n cadrul funciei


back, s nu se seteze A[lin][col] pe true sau false n cadrul structurii
repetitive, ci n afara acesteia.
172

Algoritmi backtracking

5.2. Problema sriturii calului


Considerm o tabl de ah (matrice ptratic) de dimensiune N. n
poziia (1, 1) se afl un cal. Ne intereseaz toate posibilitile de a parcurge
toate elementele matricii exact o singur dat respectnd modul de deplasare
al unui cal pe tabla de ah.
Fiierul de intrare cal.in conine doar numrul N. Fiierul de ieire
cal.out va conine numrul traseelor posibile.
Exemplu:
cal.in cal.out
5
304
Rezolvarea problemei este identic din punct de vedere structural cu
rezolvarea problemei anterioare. Difer doar coninutul vectorilor de direcie
i condiia de oprire. Pentru a construi vectorii de direcie vom folosi
urmtoarea figur,

Fig. 5.2.1. Modul de deplasare al unui cal pe o tabl de ah


Aadar avem dx = {-1, -2, -2, -1, 1, 2, 2, 1} i
dy = {-2, -1, 1, 2, 2, 1,-1,-2}
Condiia de oprire este clar: cnd am parcurs N 2 elemente am gsit
un traseu i putem s-l contorizm i s trecem la un alt traseu.
Va trebui s folosim i aici o matrice boolean care reine dac un
element a fost sau nu vizitat la un moment dat.
173

Capitolul 5
Trebuie menionat faptul c, dac ne intereseaz doar un singur
traseu, exist un algoritm liniar n dimensiunea matricii care poate rezolva
problema. Acest algoritm i se datoreaz lui Warnsdorff, iar pseudocodul
su este urmtorul:
Fie C poziia curent a calului. Iniial C = (1, 1)
Se marcheaz poziia C cu 1
Pentru i de la 2 pn la N2 execut
o Deplaseaz calul ntr-o poziie valid (x, y) astfel nct
(x, y) s permit un numr minim de deplasri ulterioare.
o C = (x, y).
o Se marcheaz poziia C cu i.
Numerele din matrice reprezint, n ordine, elementele traseului.
Implementarea algoritmului Warnsdorff este lsat pe seama
cititorului. Prezentm aici doar implementarea cu ajutorul metodei
backtracking. Citirea i afiarea sunt lsate pe seama cititorului.
void back(int k, int lin, int col, int N, bool A[maxn][maxn], stiva st[],
int &nr) // nr va contine rezultatul cerut
{
st[k].lin = lin; st[k].col = col;
if ( k == N*N )
{
++nr;
return;
}
for ( int i = 0; i < 8; ++i )
{
int n_lin = lin + dx[i];
int n_col = col + dy[i];
if ( valid(n_lin, n_col, N, A) )
{
A[lin][col] = true;
back(k + 1, n_lin, n_col, N, A, st, nr);
A[lin][col] = false;
}
}
}

Metoda backtracking aplicat pe matrici se mai numete i


backtracking n plan.
174

Algoritmi backtracking

5.3. Generarea submulimilor


Se d un numr natural N. Ne intereseaz generarea tuturor
submulimilor nevide ale mulimii {1, 2, 3, ..., N 1, N}.
Numrul N se citete din fiierul sub.in, iar submulimile se afieaz
n fiierul sub.out, cte una pe linie. Ordinea nu are importan.
Exemplu:
sub.in sub.out
2
3
2
23
1
13
12
123
Vom folosi o stiv de valori booleane st, unde st[i] = 1 dac numrul
i face parte din submulimea curent i 0 n caz contrar. La fiecare pas k
vom depune n stiv valoarea 0, dup care vom trece la pasul urmtor. La
revenire din recursivitate vom depune n stiv valoarea 1, dup care vom
efectua nc un apel recursiv. Cnd am ajuns la pasul k > N, afim
numerele de ordine a poziiilor pe care se gsete 1 n stiv. Dac exist cel
puin o poziie pe care se gsete 1, trecem la urmtoarea linie la sfrit, n
caz contrar fiind vorba de mulimea vid. n pseudocod algoritmul este
urmtorul: fie back(k, N, st) funcia care rezolv problema:
Dac k > N execut
o Pentru fiecare i de la 1 la N execut
Dac st[i] == 1 afieaz i
o Dac s-a afiat cel puin un numr, treci la linie nou
Altfel execut
o Pentru fiecare i de la 0 la 1 execut
st[k] = i
Apeleaz recursiv back(k + 1, N, st)
Problema se mai poate rezolva i fr a folosi metoda backtracking.
Deoarece numrul submulimilor care ne intereseaz este 2N 1, putem fi
siguri c nu vom avea nevoie de submulimile unei mulimi cu mai mult de

175

Capitolul 5
32 de elemente pentru niciun scop practic (de fapt 32 este chiar o
supraestimare).
Dac analizm algoritmul de mai sus, observm c valorile stivei pot
fi interpretate ca un numr n baza 2. Putem aadar s folosim reprezentarea
numerelor n baza 2 pentru generarea submulimilor astfel: ncepem cu
numrul 1, care n baza doi se reprezint astfel: 000...01 (unde 0 apare de
N 1 ori). Aceast reprezentare semnific faptul c avem o submulime
format fie din numrul N, fie din numrul 1, depinde cum interpretm
ordinea biilor. n continuare vom interpreta biii ca reprezentnd, de la
stnga la dreapta, numerele N, N 1, ..., 3, 2, 1. Pentru a genera urmtoarea
submulime, tot ce trebuie s facem este s adunm 1 pentru a obine
reprezentarea 000...10, reprezentnd submulimea {2}. Mai adunm 1 i
obinem reprezentarea binar 000...11, reprezentnd submulimea {1, 2}. Se
procedeaz n acest mod pn ajungem la reprezentarea 111...11 (N de 1),
care reprezint submulimea final, adic {1, 2, 3, ..., N}.
Observaie: deseori se confund metoda backtracking cu metodele
exhaustive de rezolvare a unei probleme. Doar pentru c ncercm toate
posibilitile nu nseamn c folosim backtracking! Tehnica backtracking
presupune revenirea la un pas anterior pentru a schimba o decizie luat (n
cazul acesta schimbarea unei valori din 0 n 1). Dei algoritmul care
folosete operaii pe bii are aceeai complexitate ca algoritmul
backtracking, cel pe bii nu revine niciodat la un pas precedent pentru a
schimba o alegere fcut, deci nu este un algoritm de tip backtracking!
Prezentm doar funciile relevante, programele complete se
consider uor de realizat, urmnd un tipar care deja ar trebui s fie
cunoscut.

176

Algoritmi backtracking
folosind backtracking

folosind operaii pe bii

void back(int k, int N, bool st[],


ofstream &out)
{
if ( k > N )
{
// am grija sa nu afisez
// multimea vida
bool afisat = 0;
for ( int i = 1; i <= N; ++i )
if ( st[i] )
{
afisat = 1;
out << i << ' ';
}

void submultimi_biti(int N, ofstream &out)


{
// reamintim ca 1 << N este
// egal cu 2 la puterea N
int nr = 1 << N;
for ( int i = 1; i < nr; ++i )
{
// pentru fiecare bit al lui i
for ( int j = 0; j < N; ++j )
if ( i & (1 << j) ) // daca e 1
out << j + 1 << ' '; // afisare
out << '\n';
}

if ( afisat )
out << '\n';

return;
}
for ( int i = 0; i <= 1; ++i )
{
st[k] = i;
back(k + 1, N, st, out);
}
}

5.4. Problema reginelor


Considerm o tabl de ah de dimensiune N. Ne intereseaz toate
posibilitile de a plasa N regine pe tabl astfel nct oricum am alege dou
regine, acestea s nu se atace reciproc. Dou regine se atac reciproc dac se
afl pe aceeai linie, coloan sau diagonal.
Numrul N se citete din fiierul regine.in, iar numrul de
posibiliti se afieaz n fiierul regine.out.
Exemplu:
regine.in regine.out
8
92

177

Capitolul 5
O soluie este:

Fig. 5.4.1. O soluie a problemei reginelor


Problema poate fi abordat folosind backtracking n plan. Aceast
rezolvare este similar cu cele prezentate pn acum, dar mai greu de
implementat i mai puin eficient deoarece trebuie s construim o funcie
de validare mai complex. Vom aborda puin diferit aceast problem i
anume n felul urmtor: vom folosi un vector lin, unde lin[i] = linia pe care
se afl regina de pe coloana i. Aadar, o soluie este caracterizat de o
permutare a primelor N numere naturale. Pentru exemplu de mai sus, soluia
prezentat este caracterizat prin vectorul lin = {6, 4, 7, 1, 8, 2, 5, 3}
Din cauza modului n care am definit vectorul i deoarece lucrm cu
numere distincte n cadrul permutrilor, nu mai este necesar s verificm
dac dou regine se afl pe aceeai linie sau coloan, fiind suficient s
verificm dac dou regine se afl pe aceeai diagonal. Dac avem o regin
n poziia (x, y) i o alt regin n pozia (p, q), atunci cele dou regine se
afl pe aceeai diagonala dac i numai dac |p x| = |q y|. Astfel, pentru
a testa dac introducerea unui nou numr n permutare stric sau nu
validitatea soluiei pariale curente, n momentul n care ncercm depunerea
unui numr i pe poziia k n stiv trebuie s verificm dac exist sau nu o
poziie j < k astfel nct k j = |i st[j]|. Dac da, atunci nu putem depune
acel numr n acea poziie (ar rezulta dou regine care se atac reciproc).
Dac nu exist nicio astfel de poziie, atunci se depune numrul i n stiv
(bineneles, se verific i condiia necesar proprietii de permutare: i s nu
fi fost depus deja n stiv).
Cnd am ajuns la pasul k > N tim c n stiv se afl o soluie valid,
care poate fi contorizat.
178

Algoritmi backtracking
// st ajunge sa aiba dimensiunea 32
bool valid(int k, int i, int st[])
{
for ( int j = 1; j < k; ++j )
if ( k - j == i - st[j] || k - j == st[j] - i )
return false;
return true;
}
// nr va contine rezultatul
void back(int k, int N, int st[], bool fol[], int &nr)
{
if ( k > N )
{
++nr;
return;
}
for ( int i = 1; i <= N; ++i )
if ( !fol[i] )
if ( valid(k, i, st) )
{
st[k] = i;
fol[i] = true;
back(k + 1, N, st, fol, nr);
fol[i] = false;
}
}

Menionm c i aceast problem admite o rezolvare liniar dac ne


intereseaz doar o soluie. Lsm gsirea acesteia pe seama cititorului.
Exerciii:
a) Implementai un program care folosete backtracking n plan
pentru rezolvarea problemei.
b) Comparai timpul de execuie al celor doi algoritmi. ncercai s
gsii optimizri.

179

Capitolul 5

5.5. Generarea partiiilor unei mulimi


Se numete partiie a unei mulimi A o mulime P format din
submulimi distincte ale lui A care ndeplinete condiiile:
1.

2. = , ,
Dndu-se un numr natural N, ne propunem s scriem un program
care genereaz toate partiiile mulimii {1, 2, 3, ..., N}.
Numrul N se citete din fiierul partitii.in, iar partiiile gsite se
afieaz n fiierul partitii.out, fiecare partiie pe o singur linie, cu
mulimile partiiei ntre acolade, separate ntre ele prin spaiu i elementele
unei mulimi separate prin virgul i spaiu, aa cum se vede n exemplul de
mai jos.
Exemplu:
partitii.in partitii.out
3
{1, 2, 3}
{1, 2} {3}
{1, 3} {2}
{1} {2, 3}
{1} {2} {3}
Vom rezolva problema ntr-o manier similar cu problemele
precedente. Vom folosi o stiv st care va codifica o partiie. Fiecare element
i al stivei va reprezenta numrul de ordine al mulimii din care face parte
elementul i n cadrul partiiei curente. De exemplu, codificarea urmtoare:
st = {1, 1, 2} reprezint partiia {1, 2} {3}, codificarea st = {1, 2, 2}
reprezint partiia {1} {2, 3} etc.
O prim idee ar fi s generm pe rnd N posibiliti pentru fiecare
poziie, dar putem gsi uor un contraexemplu la aceast abordare:
codificrile {1, 1, 2} i {2, 2, 1}, la care se va ajunge prin aceast metod,
sunt de fapt identice. Acestea reprezint {1, 2} {3} i {3} {1, 2}, care sunt de
fapt acelai lucru. Avem aadar nevoie fie de o funcie de validare fie de o
metoda de a genera configuraii care produce numai configuraii valide.

180

Algoritmi backtracking
Vom ncerca s generm partiii astfel nct primul element al unei
configuraii valide s fie ntotdeauna 1. Cu alte cuvinte, numrul 1 va face
ntotdeauna parte din prima mulime a unei partiii. Numrul 2 se va afla
ntotdeauna fie n prima mulime a unei partiii, fie n a doua. n cazul
general, numrul i va face parte ntotdeauna dintr-o mulime dintre primele i
mulimi ale unei partiii. Acest lucru este corect, deoarece oricum am genera
partiii putem observa c un element i va face parte din i mulimi distincte.
Tot ce facem este s impunem o anume ordine de generare acestora.
Din cele de mai sus rezult c st[k] va lua valori ntre 1 i
1 + max{st[i] | i < k}. Avem aadar o metod de a genera partiii care va
genera numai partiii valide, deci nu avem nevoie de o funcie de validare.
Menionm c exist o metod eficient de a numra cte partiii
exist. Aceasta va fi prezentat n capitolul dedicat programrii dinamice.
Singura diferen ntre codul ce urmeaz i codul pentru problemele
anterioare este c aici mai introducem un parametru max care ne d
maximul ce ne intereseaz la pasul curent. Astfel, la pasul k tim c max
este maximul elementelor st[1], st[2], ..., st[k 1]. Este clar c atunci cnd
trecem la pasul k + 1, noul maxim va fi maximul dintre max i st[k].
Prezentm doar funcia de generare a partiiilor. Restul programului
este asemntor cu programele prezentate anterior. Observai ct cod s-a
scris numai pentru afiarea n formatul cerut...

181

Capitolul 5
void back(int k, int N, int max, int st[], ofstream &out)
{
if ( k > N )
{
for ( int i = 1; i <= N; ++i )
{
bool primul = true;
for ( int j = 1; j <= N; ++j )
if ( st[j] == i )
{
if ( primul )
{
out << '{' << j; primul = false;
}
else
out << ", " << j;
}
if ( !primul )
out << "} ";
}
out << '\n'; return;
}
for ( int i = 1; i <= max + 1; ++i )
{
st[k] = i;
int n_max = max;
if ( st[k] > max )
n_max = st[k];
back(k + 1, N, n_max, st, out);
}
}

182

Algoritmi generali

6. Algoritmi
generali
Exist algoritmi care nu pot fi ncadrai ntr-o anume categorie fr a
defini nite categorii fie foarte restrictive, fie foarte vagi. Acetia au, de
obicei, aplicabilitate n nite probleme practice foarte specifice. n cele ce
urmeaz vom prezenta civa astfel de algoritmi, care considerm c i
merit totui propria seciune, datorit eleganei acestora i datorit
aplicaiilor teoretice care pot fi gsite pentru acetia.

183

Capitolul 6

CUPRINS
6.1. Algoritmul K.M.P. (Knuth Morris Pratt) .......................................... 185
6.2. Evaluarea expresiilor matematice......................................................... 190

184

Algoritmi generali

6.1. Algoritmul K.M.P. (Knuth Morris Pratt)


Se dau dou iruri de caractere S1 i S2, de dimensiune N respectiv
M. S se determine de cte ori irul S2 apare ca subsecven n irul S1 .
Reamintim c prin subsecvena [st, dr] a unui ir S nelegem secvena de
caractere S[st] S[st+1] S[st+2] ... S[dr-1] S[dr].
Datele de intrare se citesc din fiierul kmp.in. Primul ir pe prima
linie, iar al doilea ir pe cea de-a doua linie. Valoarea cerut se va afia n
fiierul kmp.out.
Exemplu:
kmp.in
kmp.out
abbbbbabaabbbaab 1
abbbaab
O prim idee de rezolvare are complexitatea O(NM) i funcioneaz
destul de intuitiv:
contor = 0
Pentru fiecare i de la 1 la N M + 1 execut
o gsit = true
o Pentru fiecare j de la 1 la M execut
Dac S1 [i + j 1] != S2[j] execut
gsit = false
Se oprete iterarea lui j
o Dac gsit == true execut
contor = contor + 1
Returneaz contor
Practic, pentru fiecare poziie i a irului S1 verificm dac
subsecvena S1 [i, i + M 1] este egal cu irul S2. Dac da, am gsit o
potrivire, adic o aparie a irului S2 ca subsecven n irul S1 , potrivire pe
care o numrm.
Acest algoritm conine o optimizare important n practic: dac
gsim un caracter n S1 care nu se potrivete cu caracterul curent din S2, nu
mai are rost s continum ciclul iterativ interior, deoarece este clar c nu
vom gsi o potrivire ncepnd cu poziia i curent. Se mai pot face i alte
optimizri asemntoare, dar aceastea sunt prea puin intuitive pentru a
putea fi descoperite cu uurin, aa c le vom prezenta detaliat n cadrul
algoritmului care le nglobeaz.
185

Capitolul 6
Metoda clasic prezentat pn acum poate fi vizualizat n felul
urmtor, unde cu rou am marcat caracterele analizate (n ciclul cu j) care
nu se potrivesc, cu albastru cele care nu au fost parcurse n ciclul cu j, iar
cu verde caracterele care au fost analizate i se potrivesc. Cnd se potrivesc
toate caracterele irului S2 avem o soluie:
i
S1[i]
S2[j]
j

1
a
a
1

2
b
b
2

3
b
b
3

4
b
b
4

5
b
a
5

6
b
a
6

7 8 9 10 11 12 13 14 15 16
a b a a b b b a a b
b
7

Urmtorul pas l putem vizualiza ca o deplasare a lui S2 spre dreapta:


i
1 2 3 4
S1[i] a b b b
S2[j]
a b b
j
1 2 3

5
b
b
4

6
b
a
5

7
a
a
6

8 9 10 11 12 13 14 15 16
b a a b b b a a b
b
7

Procedm la fel pn cnd ajungem n final la deplasarea urmtoare:


i
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
S1[i] a b b b b b a b a a b b b a a b
S2[j]
a b b b a a b
j
1 2 3 4 5 6 7
Este uor de observat de ce aceast metod are complexitatea
O(NM) pe cel mai defavorabil caz. Neajunsul acestei metode este c nu ne
folosim de informaiile furnizate de ctre comparaiile efectuate pn la un
anumit pas pentru a deduce ntr-un mod inteligent care sunt acele poziii
(deplasri) care sigur nu vor furniza o potrivire.
Algoritmii eficieni de rezolvare a problemei rein astfel de
informaii i au complexitatea O(N+M). n cele ce urmeaz vom prezenta
doar un singur astfel de algoritm: algoritmul K.M.P., denumit dup cei trei
descoperitori ai acestuia.
Primul pas al algoritmului K.M.P. este calcularea funciei prefix.
Funcia prefix va conine informaii despre modul n care irul cutat se
potrivete cu deplasri ale sale spre dreapta. Aceste informaii pot fi folosite
pentru a evita testarea unor caractere inutile (care tim sigur c nu vor
conduce la o potrivire) n cadrul algoritmului naiv. Astfel, putem spune c
algoritmul K.M.P. reprezint o optimizare a algoritmului naiv. Acesta
186

Algoritmi generali
reprezint ns o optimizare netrivial, aa c l vom considera un algoritm
total distinct. n cadrul algoritmului naiv, primul pas conduce la urmtoarele
operaii:
p
i
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
S1[i] a b b b b b a b a a b b b a a b
S2[j] a b b b a a b
j
1 2 3 4 5 6 7
Dac ne uitm atent la acest tabel, obervm c primul caracter al
ablonului (irul S1) se potrivete cu primul caracter (p) al textului cutat, al
doilea caracter al ablonului se potrivete cu al doilea caracter (p + 1) al
textului cutat .a.m.d. pn la al 5-lea caracter al ablonului, care se
potrivete cu caracterul p + 3 al textului cutat. Cu alte cuvinte, avem
potrivite q = 4 caractere ale textului cutat. tiind acest lucru, putem
determina urmtoarea poziie de la care putem avea o potrivire. Spunem c
p se numete poziia de nceput a unei poteniale potriviri (care poate se
va dovedi ca fiind potrivire sau nu). Se observ uor c, dac deplasm irul
cutat la poziia p = p + 1, primul caracter al textului cutat nu se va potrivi
cu al doilea caracter al ablonului, deoarece tim c acesta trebuie potrivit cu
al doilea caracter al textului cutat. Deplasarea la p = p + 4 n schimb va
conduce la o nou potenial potrivire:
p
i
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
S1[i] a b b b b b a b a a b b b a a b
S2[j]
a b b b a a b
j
1 2 3 4 5 6 7
n general, dac tim c prefixul irului cutat S2[1, q] se potrivete
cu secvena irului ablon S1 [p, p + q 1], trebuie s tim care este cea mai
mic deplasare p > p astfel nct s aib loc egalitatea
S2[1, k] = S1[p, p + k 1], unde p + k = p + q.
Deoarece la ultimul pas nu s-a potrivit niciun caracter, se va efectua
o deplasare cu o singur poziie, dup care se procedeaz similar pn cnd
se ajunge la o potrivire
i
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
S1[i] a b b b b b a b a a b b b a a b
S2[j]
a b b b a a b
j
1 2 3 4 5 6 7
187

Capitolul 6
Pentru a putea calcula deplasrile avem nevoie de calculul funciei
prefix (), unde [i] = lungimea celui mai lung prefix al irului S2 , care
este prefix al secvenei S2[1, i].
Mai mult, vom avea :{1, 2, ..., |S 2 |} {0, 1, ..., |S2 | 1}.
Funcia se calculeaz folosind valorile deja calculate. Evident,
[1] = 0. Vom considera o variabil k = 0. Parcurgem irul cutat de la
stnga la dreapta, ncepnd cu i = 2: dac primul caracter (k + 1) este egal
cu al doilea (i), [2] = 1, iar k se incrementeaz cu 1, deoarece primul
caracter reprezint un sufix de lungime 1 pentru secvena format din
primele 2 caractere. n caz contrar, [2] = 0, din motive evidente.
La al treilea caracter putem deja observa c este de ajuns s
verificm egalitatea dintre S2[k + 1] i S2[i], deoarece toate caracterele pn
la k reprezint un prefix de lungime maxim care este sufix al subsecvenei
formate din primele i 1 caracterele ale textului cutat, deci trebuie doar s
verificm dac putem extinde acest prefix. Aadar, dac aceste dou
caractere sunt egale, se incrementeaz k i [3] ia valoarea lui k.
Dac cele dou caractere nu sunt egale, nu trebuie s ncepem totul
de la zero, ci ne putem folosi de valorile deja calculate ale funciei pentru
a determina un prefix care poate reprezenta, extins cu un caracter, un sufix
de lungime maxim a secvenei curente. Acest lucru l vom face atribuindu-i
lui k valoarea [k] atta timp ct k > 0 i S2[k + 1] != S2[i]. Pentru exemplul
de mai sus, funcia prefix este urmtoarea:
i
1 2 3 4 5 6 7
S2[i] a b b b a a b
[i] 0 0 0 0 1 1 2
Mai trebuie s vedem cum exact ne ajut aceste valori n
determinarea apriiilor textului cutat n textul ablon. Metoda este foarte
similar cu metoda folosit pentru a calcula funcia prefix.
Vom parcurge cu i textul ablon de la stnga la dreapta. Vom
considera k = 0. Dac S1[i] este egal cu S2[k + 1], l incrementm pe k.
Dac cele dou caractere sunt diferite ns, trebuie s vedem care este
urmtoarea poziie la care putem avea o potrivire, atribuindu-i lui k valoarea
[k] atta timp ct k > 0 i S2[k + 1] S1[i]. Dac la un pas k devine egal
cu |S2 |, am gsit o potrivire pe care trebuie s o numrm.

188

Algoritmi generali
#include <fstream>
#include <cstring>
using namespace std;
const int maxn = 100001;

void KMP_potrivire(char S1[], char S2[],


int pi[])
{
KMP_prefix(S2, pi);
ofstream out("kmp.out");
int lgS2 = strlen(S2 + 1);

void KMP_prefix(char S2[], int pi[])


{
pi[1] = 0;
int k = 0;

int nr = 0, k = 0;
for ( int i = 1; S1[i]; ++i )
{
while ( k > 0 &&
S2[k + 1] != S1[i] )
k = pi[k];

for ( int i = 2; S2[i]; ++i )


{
while ( k > 0 &&
S2[k + 1] != S2[i] )
k = pi[k];

if ( S2[k + 1] == S1[i] )
++k;

if ( S2[k + 1] == S2[i] )
++k;

if ( k == lgS2 )
++nr;
}
out << nr;
out.close();

pi[i] = k;
}
}

}
int main()
{
char S1[maxn + 1], S2[maxn + 1];
int pi[maxn];
ifstream in("kmp.in");
in.getline(S1 + 1, maxn - 1);
in.getline(S2 + 1, maxn - 1);
in.close();
KMP_potrivire(S1, S2, pi);
return 0;
}

n cazul irurilor de caractere, lucrul cu vectori indexai de la 0 este


chiar mai uor dect n cazul altor structuri de date, deci recomandm
cititorilor s reimplementeze algoritmul folosind numrtoarea de la 0.
Exerciiu: folosii tipul de date string pentru implementarea
algoritmului, folosind indexarea att de la 1 ct i de la 0.
189

Capitolul 6

6.2. Evaluarea expresiilor matematice


Considerm o expresie matematic format din operatorii celor patru
operaii matematice elementare (adunare, scdere, nmulire i mprire),
paranteze i cifre. Considerm c expresia este valid din punct de vedere
matematic, n sensul c este scris corect i nu exist mpriri la 0.
Prioritile operatorilor sunt cele obinuite. Ne propunem s scriem un
program care s evalueze astfel de expresii.
Fiierul expr.in conine o expresie matematic. n fiierul expr.out
se va afia un numr raional reprezentnd rezultatul (sau o aproximare a
acestuia, dup caz) expresiei date.
Exemplu:
expr.in
expr.out
7*2/3+6-(2+1) 7.66667
Problema se poate rezolva n (cel puin) dou moduri: folosind un
algoritm recursiv sau folosind forma polonez postfixat a expresiei date.
Vom prezenta mai nti algoritmul recursiv. Acesta presupune
existena unei funcii pentru fiecare nivel de prioritate al operatorilor.
Pentru a evidenia mai bine modul de funcionare al algoritmului, s
considerm urmtorul exemplu: 2 + 3 * 2. Aa cum bine tim, aceast
expresie are valoarea 8. Putem argumenta acest rezultat n felul urmtor:
citim expresia de la stnga la dreapta. Reinem valoarea 2. Cnd dm de
semnul +, tim c acesta are cea mai mic prioritate, deci dac numrul de
dup el este urmat de un operator cu prioritate mai mare, trebuie s aplicm
acel operator numrului de dup +, iar rezultatul acelei operaii s l adunm
la 2. Dac numrul de dup operatorul de adunare are prioritate mai mic
sau egal cu adunarea, atunci putem aduna numrul de dup plus la 2 fr
nicio problem. n cazul acesta, avem nmulire dup plus, deci prima dat
vom evalua 3 * 2 = 6 i abia apoi 2 + 6 = 8.
Exemplul de mai sus ne conduce la ideea folosirii recursivitii
indirecte pentru rezolvarea problemei. Astfel, vom avea:
1. O funcie numit plus_min, responsabil de efectuarea
operaiilor asociate operatorilor de prioritate minim, adic de
adunare i scdere.
2. O funcie numit inm_imp, responsabil de efectuarea
operaiilor asociate operatorilor de prioritate imediat superioar,

190

Algoritmi generali
adic de nmulire i mprire. Aceast funcie va fi apelat de
ctre funcia anterioar, din motivul explicat mai sus.
3. Pentru a putea schimba prioritile naturale ale operatorilor,
avem la dispoziie paranteze. Putem considera o subexpresie
ncadrat ntre paranteze ca fiind la rndul ei o expresie, pentru
evaluarea crei vom porni un nou ciclu recursiv prin apelarea
funciei plus_minus. Vom avea aadar o funcie paran, care va
fi apelat de funcia inm_imp i care va verifica dac pe poziia
curent se gsete o parantez deschis: dac da, se sare peste
aceasta, se evalueaz paranteza apelnd funcia plus_min, se
sare peste paranteza nchis i se returneaz rezultatul evalurii
expresiei. Dac n schimb caracterul curent nu este o parantez
deschis, atunci acesta trebuie s fie un operand, adic o cifr,
care se returneaz i se trece la urmtoarea poziie.
Practic, ne putem imagina algoritmul ca o succesiune de ntrebri de
genul: la pasul curent, dac ne aflm pe un operator, putem aplica acest
operator operanzilor asociai lui, sau trebuie s verificm existena unor
paranteze i a operatorilor de prioritate mai mare?
S exemplificm algoritmul pe exemplul dat. Avem expresia
7*2/3+6-(2+1).
Parcurgem expresia de la stnga la dreapta. l reinem pe 7 la primul
pas. La al doilea pas ne aflm pe operatorul *, care se afl pe cel mai nalt
nivel de prioritate. Deoarece dupa * nu exist parantez, aplicm operatorul
operanzilor asociai i obinem 14. Procedm la fel pentru /, obinnd
14 / 3 = 4.66. Procedm la fel i pentru +, obinnd 10.66. Ajungem pe
operatorul , care este urmat de o parantez, pe care va trebui s o evalum
separat. Evaluarea parantezei ne d 3, care se scade din 10.66, obinnd
rezultatul final 7.66.
O prim metod (clasic) de implementare a acestei metode este
urmtoarea:

191

Capitolul 6
#include <fstream>
const int maxn = 1001;
using namespace std;

double plus_min(char expr[], int &k)


{
double ret = inm_imp(expr, k);
while ( expr[k] == '+' ||
expr[k] == '-' )
if ( expr[k++] == '+' )
ret += inm_imp(expr, k);
else
ret -= inm_imp(expr, k);

// avem nevoie de prototipul functiei


// plus_min pentru a putea folosi
// recursivitatea indirecta
double plus_min(char [], int &);
double paran(char expr[], int &k)
{
if ( expr[k] == '(' )
{
++k; // sar peste '('
double ret = plus_min(expr, k);
++k; // sar peste ')'

return ret;
}
int main()
{
char expr[maxn];

return ret;

ifstream in("expr.in");
in >> expr;
in.close();

}
// returnez operandul
return expr[k++] - '0';
}

int k = 0; // pozitia curenta

double inm_imp(char expr[], int &k)


{
double ret = paran(expr, k);

ofstream out("expr.out");
out << plus_min(expr, k);
out.close();

while ( expr[k] == '*' ||


expr[k] == '/' )
if ( expr[k++] == '*' )
ret *= paran(expr, k);
else
ret /= paran(expr, k);

return 0;
}

return ret;
}

Putem implementa aceeai metod scriind mai puin cod i evitnd


recursivitatea indirect. Vom mpri operatorii pe niveluri de prioritate: + i
pe nivelul 0, * i / pe nivelul 1. Parantezele vor fi considerate caz
particular. Astfel, putem folosi o singur funcie recursiv n loc de trei:

192

Algoritmi generali
#include <fstream>

int main()
{
char expr[maxn];

using namespace std;


const int maxn = 1001;
const char oper[2][3] = {"+-", "*/"};
double operatie(double a, double b, char op)
{
switch ( op )
{
case '+': return a + b;
case '-': return a - b;
case '*': return a * b;
case '/': return a / b;
}
}

ifstream in("expr.in");
in >> expr;
in.close();
int k = 0;
ofstream out("expr.out");
out << eval(expr, 0, k);
out.close();
return 0;
}

double eval(char expr[], int nivel, int &k)


{
double ret;
if ( nivel == 2 ) // paranteze sau operand
{
if ( expr[k] == '(' )
{ ++k; ret = eval(expr, 0, k); ++k; }
else
ret = expr[k++] - '0';
return ret;
}
// +, -, * sau /
ret = eval(expr, nivel + 1, k);
while ( expr[k] == oper[nivel][0] ||
expr[k] == oper[nivel][1] )
{
int poz = k++;
ret = operatie(ret,
eval(expr, nivel + 1, k),
expr[poz]);
}
return ret;
}

Aa cum am precizat la nceput, problema se poate rezolva i


nerecursiv, folosind n mod explicit forma polonez postifixat a unei
193

Capitolul 6
expresii (algoritmii recursivi de mai sus folosesc implicit aceast form).
Forma polonez postfixat a unei expresii este o modalitate de a scrie
expresia respectiva n aa fel nct s putem evalua expresia rezultat
printr-o simpl parcurgere a sa de la stnga la dreapta, fr a mai fi nevoii
s inem cont de paranteze i de prioritile operatorilor (care nu mai exist
n cadrul formei poloneze). n cadrul formei poloneze postfixate, un
operator este precedat de operanzii asociai acestuia (care pot fi la rndul lor
subexpresii).
De exemplu, dac avem expresia 2 + 3 * 2, forma sa polonez va fi
3 2 * 2 +, care se va evalua de la stnga la dreapta foarte uor: pentru
fiecare operator ntlnit, aplicm operatorul respectiv celor doi operanzi din
urma sa (ntotdeauna vor fi doi operanzi n urm) i nlocuim operatorul i
operanzii respectivi cu rezultatul operaiei. Pentru exemplul dat, dup
ntlnirea primului operator vom rmne cu 6 2 +, iar dup ntlnirea
ultimului operator vom rmne cu 8, care reprezint rezultatul final. Putem
implementa aceste operaii cu ajutorul unei stive.
Pentru a construi forma polonez a expresiei S, vom considera o
stiv st i un vector fpol n care vom forma rezultatul folosind urmtorul
algoritm, propus de Edsger Dijkstra:
Pentru fiecare i de la 1 pn la |S| execut
o Dac S[i] este un operand, se adaug n vectorul soluie
fpol.
o Dac S[i] este parantez deschis, se adaug n stiva st.
o Dac S[i] este parantez nchis, se scot toate elementele
din vrful stivei i scriu n vectorul fpol, pn la
ntlnirea unei paranteze deschise n st, parantez care se
scoate din stiv, dar nu se scrie n fpol.
o Dac S[i] este operator execut
Ct timp n vrful stivei se afl un operator de
prioritate mai mare sau egal dect S[i], se
scoate acest operator din stv i se trece n fpol.
Se scot din stiv toate elementele rmase i se trec n vectorul
fpol. Acesta va reprezenta forma polonez postfixat a expresiei.
Acest algoritm are avantajul de a fi iterativ i dezavantajul de a fi
mai complicat i mai greu de implementat corect. Varianta nerecursiv se
complic i mai mult dac avem de evaluat expresii mai complicate, care
pot conine i funcii i operatori cu proprieti diferite de ale operatorilor
elementari, cum ar fi factorialul (operator unar) i operatorul de ridicare la
putere (care trebuie evaluat de la dreapta spre stnga: 2^1^2, unde prin a^b
194

Algoritmi generali
2

nelegem ab, este de fapt egal cu 21 = 2, nu cu (21 )2 = 4). Aceste


subtiliti, precum i implementarea suportului pentru funcii, sunt intuitive
i uor de implementat n varianta recursiv, dar mai dificil de implementat
n varianta itertiv.
Prezentm n continuare funciile relevante pentru varianta iterativ.
int prio(char);
void forma_pol(char expr[], char fpol[])
{
int k = 0, p = 0;
char st[maxn];
for ( int i = 0; expr[i]; ++i )
if ( expr[i] >= '0' && expr[i] <= '9' )
fpol[p++] = expr[i];
else if ( expr[i] == '(' )
st[k++] = expr[i];
else if ( expr[i] == ')' )
{
while ( k - 1 >= 0 )
if ( st[k - 1] != '(' )
fpol[p++] = st[--k];
else break;
--k;
}
else // am neaparat un operator
{
while ( k - 1 >= 0 )
if ( prio(st[k - 1]) >=
prio(expr[i]) )
fpol[p++] = st[--k];
else
break;

int prio(char oper)


{
if ( oper == '-' || oper == '+' )
return 0;
else if ( oper == '(' )
return -1;
return 1;
}
double eval(char expr[])
{
char fpol[maxn];
forma_pol(expr, fpol);
// la sfarsit va contine
// rezultatul final
double st[maxn];
int p = 0;
for ( int k = 0; fpol[k]; ++k )
{
if ( fpol[k] >= '0' &&
fpol[k] <= '9' )
st[p++] = fpol[k] - '0';
else
{
st[p - 2] = operatie(st[p - 2],
st[p - 1],
fpol[k]);
--p;
}
}

st[k++] = expr[i];
}
while ( k - 1 >= 0 )
fpol[p++] = st[--k];
fpol[p] = '\0';

return st[0];
}

195

Capitolul 6
Exerciii:
a) Modificai variantele recursive astfel nct s construiasc ntr-un
vector dat ca parametru forma polonez postfixat a expresiei
evaluate.
b) Aceeai cerin pentru forma polonez prefixat. Forma polonez
prefixat i are toi operatorii urmai de operanzii asociai. De
exemplu, 2 + 3 * 2 + * 3 2 2.
c) Folosii o singur stiv att pentru formarea formei poloneze ct
i pentru evaluarea acesteia, n cadrul algoritmului iterativ.
d) Gsii un algoritm iterativ care construiete forma polonez
prefixat a unei expresii.
e) Implementai funciile sin i cos att n variantele recursive ct i
n varianta iterativ prezentat.
f) Considerai existena parantezelor drepte n cadrul expresiei date.
Modificai algoritmii dai astfel nct acestea s fie tratate ca
nsemnnd ridicarea la ptrat a subexpresiei din interior. De
exemplu, [3+2]*2-1 = 49.

196

Introducere n S.T.L.

7. Introducere
n S.T.L.
Biblioteca S.T.L. pune la dispoziia programatorilor C++ mai multe
structuri de date generice, lucru care scutete programatorii de timpul i
efortul implementrii acestor structuri de la zero. n aceeai bibliotec se
regsesc i diferii algoritmi care pot reduce timpul necesar rezolvrii unei
probleme.
Avantajul folosii bibliotecii S.T.L. const, n primul rnd, n
reducerea timpului necesar implementrii unui algoritm. n al doilea rnd,
aceste containere au fost implementate de o echip de profesioniti de-a
lungul unei perioade lungi de timp i testate foarte riguros, deci putem fi
siguri de corectitudinea i eficiena acestora.
n cele ce urmeaz vom prezenta pe scurt principalele containere i
algoritmi din S.T.L. i modul de folosire a acestora n nite situaii concrete.
Multe dintre metodele acestor containere sunt comune, deci
cunoaterea tuturor acestor structuri nu este un lucru greu de realizat.
Recomandm cititorului familiarizarea cu acestea, ntruct vor fi folosit mai
des n capitolele ce urmeaz.
Atenie: toate containerele prezentate de acum ncolo sunt, practic,
variabile. Cnd sunt transmise funciilor trebuie transmise prin referin ca
s poate suferi modificri. Chiar dac nu vrem s sufere modificri, este
preferabil transmiterea prin referin constant, pentru se a evita copierea
lor, lucru care poate scdea drastic performana programelor, mai ales n
cazul funciilor recursive.

197

Capitolul 7

CUPRINS
7.1. Containere secveniale .......................................................................... 199
7.2. Containere adaptoare ............................................................................ 205
7.3. Containere asociative ............................................................................ 210
7.4. Algoritmi S.T.L......................................................................................... 220

198

Introducere n S.T.L.

7.1. Containere secveniale


Containerele secveniale reprezint colecii liniare de elemente de
acelai tip. Acestea permit acces secvenial eficient asupra elementelor (timp
constant), iterarea elementelor ntr-un mod convenabil (timp liniar) i, dup
caz, nserarea i tergerea elementelor n timp constant.

a) Containerul vector
Un vector este foarte similar cu un tablou unidimensional, n sensul
c reprezint o colecie de elemente de acelai tip stocate n locaii
consecutive de memorie. Vectorii rezolv dou mari probleme i surse de
erori pe care o au tablourile obinuite i anume:
- programatorul trebuie s se asigure c are ntotdeauna stocat
undeva dimensiunea fiecrui tablou.
- programatorul trebuie s se asigure c tablourile declarate au
dimensiuni suficient de mari.
Pentru a folosi un vector trebuie inclus fiierul antet <vector> i
folosit spaiul de nume std. Pentru a declara un vector se folosete sintaxa:
vector<T> aniPari;

Unde T este tipul elementelor care vor fi stocate n vector.


Pentru a aduga elemente n vector se folosete metoda push_back.
Exemplul urmtor declar un vector aniPari n care adaug toi anii pari
mai mari dect 2000 i mai mici dect 2014.
vector<int> aniPari;
for ( int i = 2002; i <= 2012; i += 2 )
aniPari.push_back(i);

Pentru a accesa un element oarecare al unui vector se folosete


notaia clasic de la tablouri. Pentru a determina numrul de elemente din
vector se folosete metoda size. Secvena de mai jos afieaz elementele
vectorului aniPari.
for ( int i = 0; i < aniPari.size(); ++i )
cout << aniPari[i] << endl;

199

Capitolul 7
Atenie: n cazul vectorilor, numerotarea ncepe de la 0, i este bine
s nu schimbm forat acest lucru, deoarece pot aprea erori.
O alt modalitate de parcurgere a unui vector este folosind iteratori.
Iteratorii sunt un fel de pointeri care pot simplifica uneori lucrul cu
containerele S.T.L. Exemplul urmtor parcurge acelai vector folosind
iteratori.
// declara un iterator pentru iterarea unui vector de intregi
vector<int>::iterator it;
for ( it = aniPari.begin(); it != aniPari.end(); ++it )
cout << *it << endl; // sintaxa similara cu pointerii

Putem terge elemente de la sfritul vectorului n timp constant


folosind metoda pop_back. Secvena urmtoare terge anul 2012 din
aniPari:
aniPari.pop_back();

Pentru a insera un element la o anumit poziie n vector putem


folosi metoda insert. Aceasta primete ca argumente un iterator, care
reprezint poziia de inserare, i elementul care trebuie inserat. Secvena
urmtoare insereaz anul 1990 nainte de anul 2010, dac acesta exist:
vector<int>::iterator it = aniPari.begin();
while ( it != aniPari.end() && *it != 2010 )
++it;
aniPari.insert(it, 1990);

Inserarea este o operaie liniar, aa c nu trebuie abuzat dac


performana este important.
Vectorii sunt implementaii ca tablouri alocate dinamic. Aceste
tablouri sunt la nceput de o dimensiune mic, iar pe msur ce se insereaz
elemente acestea sunt realocate dac este cazul. Aceste realocri pot fi
costisitoare dac folosim des metoda push_back. Dac tim n prealabil de
cte elemente vom avea nevoie, putem rezerva spaiul necesar folosind
metoda reserve:
aniPari.reserve(92); // rezerva spatiu pentru 92 de ani pari

200

Introducere n S.T.L.
Cnd declarm un vector, putem s-l iniializm din start cu un alt
vector:
vector<int> aniPari;
for ( int i = 2002; i <= 2012; i += 2 )
aniPari.push_back(i);
vector<int> totiAnii(aniPari); // copiaza toti anii pari in vectorul
// tuturor anilor

Putem iniializa un vector i cu un tablou clasic a crui dimensiune


este cunoscut:
int nrPrime[] = {7, 3, 5, 2};
vector<int> numere(nrPrime, nrPrime + 4);

Pentru a compara dac doi vectori sunt egali (au toate elementele de
pe pe poziii identice egale) putem folosi pur i simplu operatorul ==.
Acesta se poate aplica i altor containere.
Asupra vectorilor putem apela funcia sort din <algorithm> pentru a
sorta elementele acestuia:
sort(numere.begin(), numere.end());

Vectorii sunt foarte folositori atunci cnd nu vrem s gestionm


manual alocarea memoriei i numrul de elemente. Datorit gestiunii interne
a memoriei, vectorii sunt uneori mai puin eficieni dect tablourile clasice,
aa c trebuie folosii cu grij.

b) Containerul deque
Un deque (Double-Ended Queue) este similar cu un vector,
diferenele fiind c un deque permite inserarea i tergerea elementelor de la
nceputul acestuia n timp constant, dar cu dezavantajul de a nu avea
elementele n locaii consecutive de memorie.
Pentru a declara un deque este necesar s includem antetul <deque>.
Sintaxa de declarare este exact ca cea pentru vectori:
deque<int> minusPlus;

201

Capitolul 7
Metodele push_back i pop_back sunt folosite pentru a aduga,
respectiv terge, elemente de la sfritul unui deque. Urmtoarea secven
adaug numere la sfritul containerului:
for ( int i = 1; i < 9; ++i )
minusPlus.push_back(i);

Pentru a aduga elemente la nceputul unui deque se folosete


metoda push_front, iar pentru a terge elemente de la nceputul acestuia se
folosete metoda pop_front. Secvena urmtoare terge primul element din
deque (1) i adaug numere negative la nceput:
minusPlus.pop_front();
for ( int i = -9; i < 0; ++i )
minusPlus.push_front(i);

Putem afia coninutul unui deque fie folosind operatorul clasic [ ],


fie cu ajutorul iteratorilor. Prezentm parcurgerea cu ajutorul iteratorilor:
deque<int>::iterator it;
for ( it = minusPlus.begin(); it != minusPlus.end(); ++it )
cout << *it << " ";

Se va afia urmtoarul ir de numere, dup execuia tuturor


secvenelor de cod prezentate:
-1 -2 -3 -4 -5 -6 -7 -8 -9 2 3 4 5 6 7 8
Un deque este mai eficient dect un vector atunci cnd avem mai
multe operaii de inserare, deoarece nu au loc realocri de memorie. Dequeurile au ns o implementare intern mai complex, care poate s le fac mai
ineficiente n unele situaii.
Un deque nu trebuie folosit dect dac avem nevoie s tergem i s
adugm elemente n ambele capete ale unei structuri liniare, situaie care
apare n unii algoritmi.
Deque-urile suport la rndul lor restul operaiilor prezentate la
vectori.

202

Introducere n S.T.L.

c) Containerul list
list este un container care are la baz o list dublu nlnuit. Acest
lucru nseamn c fiecare element are o locaie de memorie imprevizibil i
cte un pointer la elementul precedent i urmtor din list.
O list suport inserarea i tergerea elementelor de oriunde n timp
constant, mutarea elementelor n timp constant i iterarea elementelor n
timp liniar.
Comparativ cu vectorii i deque-urile, listele sunt mai eficiente
atunci cnd efectum multe inserri, mutri i tergeri de elemente din list.
Pentru a declara o list trebuie inclus fiierul antet <list>. Sintaxa de
declarare ar trebui s fie deja uor de intuit:
list<int> nrPrime;

Un dezavantaj important al listelor este c nu suport accesarea


elementelor dup poziie, adic nu au implementat operatorul [ ]. Asta
nseamn c accesarea unui element al listei necesit parcurgerea tuturor
elementelor care l preced, deci este o operaie liniar.
Listele implementeaz metodele push_back, push_front, pop_back
i pop_front, care au aceeai funcionalitate ca i n cazul deque-urilor.
Pentru a accesa primul i ultimul element al unei liste se pot folosi
metodele front, respectiv back. Acestea se execut n timp constant. De
exemplu:
nrPrime.push_back(2);
nrPrime.push_back(5);
cout << nrPrime.front() << " " << nrPrime.back(); // afiseaza 2 5

Inserarea unui element se face cu ajutorul iteratorilor. Operaia de


inserare este constant, cutarea poziiei de inserare este liniar. n cazul
vectorilor, att cutarea poziiei ct i operaia de inserare n sine erau
liniare. Secvena de mai jos insereaz numrul 3 dup numrul 2:
list<int>::iterator it = nrPrime.begin();
while ( it != nrPrime.end() && *it != 5 )
++it;
nrPrime.insert(it, 3);

203

Capitolul 7
Pentru a afia coninutul unei liste este obligatoriu s folosim
iteratori, deoarece listele nu suport accesul aleator la elemente.
Pentru a terge un element se folosete metoda erase, care primete
ca argument un iterator ctre elementul care trebuie ters. Secvena de mai
jos terge primul element al unei liste:
nrPrime.erase(nrPrime.begin());
Putem terge elemente din list i pe baza valorii lor, caz n care nu
trebuie s folosim iteratori. Pentru acest lucru vom folosi funcia remove.
Secvena de mai jos terge numrul 15 dintr-o list de numere prime, dac
acesta exist. Dac nu exist, nu se ntmpl nimic.
nrPrime.remove(15);

Putem terge elemente dintr-o list dac acestea ndeplinesc o


condiie cu ajutorul metodei remove_if. Aceast metod primete ca
parametru o funcie care returneaz bool i accepta ca parametru un obiect
de tipul celor reinute n liste. Funcia va fi apelat pentru toate elementele
listei, iar cele pentru care funcia returneaz true vor fi terse. Secvena de
mai jos terge toate numere prime care au o singur cifr:
bool cifra(const int &nr)
{
return nr < 10;
}
int main()
{
list<int> nrPrime;
nrPrime.push_back(2);
nrPrime.push_back(5);
nrPrime.push_back(666013);
nrPrime.remove_if(cifra); // va ramane doar 666013 in lista
}

Putem terge elementele care se repet folosind metoda unique.


Aceast metod funcioneaz corect doar pe liste care sunt sortate. Din
fiecare grup de elemente egale va rmne doar primul element. Secvena de
cod urmtoare prezint modul de folosire a metodei unique.
204

Introducere n S.T.L.
nrPrime.push_back(5); nrPrime.push_back(3); nrPrime.push_back(3);
nrPrime.push_back(2); nrPrime.push_back(5); nrPrime.push_back(7);
nrPrime.sort();
nrPrime.unique();
for ( list<int>::iterator it = nrPrime.begin(); it != nrPrime.end(); ++it )
cout << *it << " ";

Codul de mai sus afieaz 2 3 5 7.


Dou liste sortate pot fi interclasate ntr-o singur list sortat cu
ajutorul metodei merge. Sintaxa este:
listaUnu.merge(listaDoi); // listaUnu va contine toate elementele sortate

Sau:
listaUnu.merge(listaDoi, comparator); // unde comparator este o functie
// booleana care compara
// elementele date ca parametri

7.2. Containere adaptoare


Containerele adaptoare sunt containere care specializeaz containere
deja existente pentru anumite scopuri. Acestea expun anumite metode care
uureaz gestionarea claselor din fundal pentru aceste scopuri.

a) Containerul stack
Un stack este o stiv, adic o structur de date care opereaz dup
principiul L.I.F.O. ultimul intrat, primul ieit. Stivele sunt folosite des n
viaa de zi cu zi. De exemplu, dup un examen fiecare student pune foile pe
catedr, ntr-o ordine oarecare. Dup ce fiecare student i-a predat foile,
profesorul corecteaz examenul ultimului student care a predat foile, adic
foile de examen din vrful stivei.
n aceste situaii au prioritate obiectele care au fost depuse mai trziu
n stiv.

205

Capitolul 7
Intrare n stiv

Ieire din stiv


Ultimul intrat
...
Primul intrat

Fig. 7.2.1.1. o stiv L.I.F.O.


Pentru a declara o stiv trebuie inclus antetul <stack>. Sintaxa este
urmtoarea:
stack<string> studenti;

Pentru a aduga un element n stiv se folosete metoda push.


Observai c, deoarece stiva permite adugarea de elemente doar n vrful
su, numele metodei nu mai este calificat cu informaii suplimentare, cum
este cazul metodelor de la vectori, deque i liste.
studenti.push("Ionescu");
studenti.push("Popescu");
studenti.push("Georgescu");

Pentru a accesa elementul din vrful stivei se folosete metoda top.


n orice moment se poate accesa doar elementul din vrful stivei. Pentru a
putea fi accesate alte elemente, trebuie eliminat mai nti elementul din vrf.
// Georgescu
cout << "Primul care isi va sti nota este: " << studenti.top();

Pentru a elimina elementul din vrful stivei se folosete metoda pop.


De exemplu:
cout << "Notele se vor da in urmatoarea ordine: " << endl;
while ( studenti.size() > 0 )
{
cout << studenti.top() << endl;
studenti.pop();
}

206

Introducere n S.T.L.
Programul anterior va afia:
Georgescu
Popescu
Ionescu
Pn n acest moment implementam stivele cu ajutorul tablourilor.
Containerul stack ne scutete de necesitatea gestionrii indicilor i a
dimensiunii, reducnd posibilitatea apariiei erorilor. Recomandm
rescrierea programelor prezentate pn acum i care folosesc stive cu
ajutorul containerului stack.

b) Containerul queue
Un queue este o coad, adic o structur de date care opereaz dup
principiul F.I.F.O. primul intrat, primul ieit. O astfel de coad se
ntlnete de exemplu la magazinele aglomerate. Fiecare cumprtor st i
i ateapt rndul la cas dup ce i-a terminat cumprturile. Primul care a
terminat este i primul care va plti i va putea pleca acas.
n aceste situaii au prioritate obiectele care au intrat mai devreme n
coad.
Intrare n coad

Ieire din coad

Ultimul intrat

...

Primul intrat

Fig. 7.2.2.1. o coad F.I.F.O.


Pentru a declara o coad trebuie inclus fiierul antet <queue>.
Sintaxa de declarare este urmtoarea:
queue<string> cumparatori;

O coad permite adugarea elementelor ntr-o parte i tergerea lor


din cealalt parte. Prin convenie, adugrile se fac la nceput i tergerile la
sfrit.
Pentru a aduga un element n coad se folosete metoda push.
cumparatori.push("Vlad");
cumparatori.push("Alex");
cumparatori.push("George");

207

Capitolul 7
Pentru a accesa primul element al cozii (primul intrat n coad) se
folosete metoda front, iar pentru a accesa ultimul element al cozii (ultimul
intrat n coad) se folosete metoda back. De exemplu:
// afiseaza Vlad George
cout << cumparatori.front() << " " << cumparatori.back();

Pentru a scoate un element din coad se folosete metoda pop.


Secvena de mai jos scoate din coad primul element adugat:
cumparatori.pop();
cout << cumparatori.front(); // afiseaza Alex

Nici n cazul cozilor nu avem acces aleator asupra elementelor.


Putem accesa doar primul i ultimul element al cozii.
Pn acum am implementat cozile tot cu ajutorul tablourilor.
Recomandm rescrierea programelor respective folosind containerul queue.

c) Containerul priority_queue
priority_queue este o coad de prioriti care are la baz un heap.
Cozile de prioriti suport interogarea valorii maxime din acestea (maxime
dup o anumit relaie de ordine) n timp constant, inserarea unui element n
timp logaritmic i tergerea valorii maxime tot n timp logaritmic.
Pentru a folosi o coad de prioriti trebuie inclus fiierul antet
<queue>. Sintaxa de declarare este:
priority_queue<int> note;

Operaiile permise sunt exact cele de la stive: push, top i pop.


Implicit este folosit operatorul > pentru prioritizarea elementelor. De
exemplu, codul de mai jos afieaz 100 97 80 30.
note.push(80); note.push(97); note.push(100); note.push(30);
while ( note.size() > 0 )
{
cout << note.top() << " ";
note.pop();
}

208

Introducere n S.T.L.
Putem ns s definim propriul criteriu de prioritizare al elementelor
scriind o clas (sau structur) care suprancarc operatorul (), operator care
primete doi parametri i returneaz true dac primul parametru are o
prioritate mai mic dect al doilea i false n caz contrar. Aceast clas se
folosete n declararea obiectului de tip priority_queue. Se va schimba
puin declararea containerului.
Exemplul de mai jos prioritizeaz elementele dup restul mpririi
la numrul 17. Elementele cu un rest mai mare vor avea prioritate mai mare.
struct cmp
{
bool operator () (const int &x, const int &y) const
{
return x % 17 < y % 17;
}
};
int main()
{
priority_queue<int, vector<int>, cmp> note;
note.push(80); note.push(97); note.push(100); note.push(30);
while ( note.size() > 0 )
{
cout << note.top() << " ";
note.pop();
}
return 0;
}

Se va afia: 100 30 97 80. Numerele cu resturi mai mari la mprirea


cu 17 au prioritate mai mare.
Deoarece declararea unui astfel de obiect este greoaie (tipul
obiectului are un nume foarte mare), se folosete de obicei un typedef dac
tim c vom avea mai multe astfel de declaraii.
Exemplul urmtor poate fi rescris astfel:
...
typedef priority_queue<int, vector<int>, cmp> myQueue;
myQueue note;
...

209

Capitolul 7
Acest container uureaz foarte mult implementarea algoritmului
Heapsort. Recomandm cititorilor s implementeze acest algoritm folosind
containerul priority_queue.

7.3. Containere asociative


Containerele asociative sunt folosite pentru a putea accesa anumite
valori prin intermediul altor valori, care nu sunt limitate la numere naturale.
De exemplu, folosind containere asociative putem afia numrul de telefon
al unei persoane prin numele persoanei respective.
Un element al unui container asociativ este caracterizat printr-o
cheie i valoare. Valoarea este obinut cu ajutorul cheii.

a) Containerele set i multiset


Seturile i multiseturile sunt clase care implementeaz arbori binari
de cutare. Un set admite doar elemente unice, iar un multiset admite i
elemente care se repet.
Seturile permit inserarea, tergerea i gsirea elementelor n timp
logaritmic. Elementele unui set sunt meninute ntotdeauna ordonate dup o
relaie de ordine. Relaia de ordine implicit este cea indus de operatorul <,
dar putem defini propriile relaii.
Pentru a declara seturi i multiseturi trebuie inclus fiierul antet
<set>. Sintaxa de declarare este urmtoarea:
set<int> nrUnice;
multiset<int> nrMultiple;

Pentru a aduga elemente ntr-un set se folosete metoda insert.


Aceasta primete ca parametru valoare pe care vrem s o inserm n set.
n cazul seturilor, metoda insert ntoarce o pereche a crei prim
element este un iterator ctre valoarea nou inserat i a crei al doilea
element este o valoare boolean care specific dac valoarea a existat deja n
set.
n cazul multiseturilor, insert ntoarce doar un iterator ctre valoarea
nou inserat.
Exemplul urmtor prezint un program care insereaz numere
aleatoare ntr-un set i ntr-un multiset:

210

Introducere n S.T.L.
pair<set<int>::iterator, bool> rezultatSet;
multiset<int>::iterator rezultatMultiSet;
for ( int i = 0; i < 5; ++i )
{
rezultatSet = nrUnice.insert(rand() % 17);
rezultatMultiSet = nrMultiple.insert(rand() % 17);
if ( rezultatSet.second == false )
cout << "Numarul " << *rezultatSet.first
<< " exista deja in set." << endl;
else
cout << "Numarul " << *rezultatSet.first
<< " a fost inserat in set." << endl;
cout << "Numarul " << *rezultatMultiSet
<< " a fost inserat in multiset." << endl;
}

Inserrile se pot face i cu ajutorul iteratorilor, ntr-un mod similar


cu celelalte containere.
Pentru a testa dac o valoare exist sau nu ntr-un set se folosete
metoda find. Aceasta primete ca parametru valoarea cutat. Dac aceast
valoare exist n set atunci se returneaz un iterator ctre aceasta. Dac
valoarea nu exist se returneaz un iterator ctre set::end (respectiv
multiset::end), adic un iterator care indic sfritul containerului.
Exemplul de mai jos prezint o secven de cod care caut numere
aleatoare n setul i multisetul declarate anterior:
set<int>::iterator it;
multiset<int>::iterator jt;
for ( int i = 0; i < 5; ++i )
{
if ( (it = nrUnice.find(rand() % 17)) == nrUnice.end() )
cout << "Elementul cautat nu exista in set" << endl;
else
cout << "Elementul " << *it << " exista in set" << endl;
if ( (jt = nrMultiple.find(rand() % 17)) == nrMultiple.end() )
cout << "Elementul cautat nu exista in multiset" << endl;
else
cout << "Elementul " << *jt << " exista in multiset" << endl;
}

211

Capitolul 7
Pentru a terge un element din set se folosete metoda erase. Aceasta
primete ca parametru fie un iterator ctre elementul care trebuie ters, fie
valoarea acestuia. n cazul n care parametrul este valoarea care trebuie
tears, funcia returneaz numrul de elemente care au acea valoare i care
au fost terse (relevant doar n cazul multiseturilor, n cazul seturilor este
ntotdeauna 1).
Exemplul urmtor evideniaz funciile de tergere:
nrUnice.insert(10); nrUnice.insert(12);
nrUnice.insert(9); nrUnice.insert(7);
cout << nrUnice.erase(10) << endl; // afiseaza 1 (s-a sters un 10)
cout << nrUnice.erase(13) << endl; // afiseaza 0 (nu s-a sters nimic)
cout << *nrUnice.begin() << endl; // afiseaza 7, cel mai mic numar
// conform relatiei <
nrUnice.erase(nrUnice.begin()); // se sterge 7, primul element
cout << *nrUnice.begin() << endl; // afiseaza 9
nrMultiple.insert(2010);
nrMultiple.insert(2011);
nrMultiple.insert(2010);
cout << nrMultiple.erase(2010) << endl; // afiseaza 2 (s-au sters
// ambele valori 2010)

Metoda erase are i o variant care primete ca argumente doi


iteratori i terge toate elementele dintre cei doi iteratori.
Metoda count returneaz numrul de elemente care au o valoarea
dat ca parametru. Aceast metod este folositoare mai mult n cazul
multiseturilor, n cazul seturilor returnnd doar 0 sau 1.
nrMultiple.insert(1); nrMultiple.insert(1);
cout << nrMultiple.count(1) << endl; // afiseaza 2

Putem itera un seturile i multiseturile cu ajutorul iteratorilor, cum


fceam i la alte structuri de date. Iterarea se face n timp liniar i n ordine
cresctoare a elementelor, relativ la relaia de ordine folosit. De exemplu:
for ( set<int>::iterator it = nrUnice.begin(); it != nrUnice.end(); ++it )
cout << *it << " ";

Iterarea multiseturilor se face la fel.


212

Introducere n S.T.L.
Alte dou metode importante a seturilor sunt metodele lower_bound
i upper_bound. Ambele se execut n timp logaritmic i primesc o valoare
ca parametru. lower_bound returneaz un iterator ctre cea mai mare
valoare din set mai mic sau egal dect parametrul, iar cea din
upper_bound returneaz cea mai mic valoare din set strict mai mare dect
parametrul. Acestea sunt comune att seturilor ct i multiseturilor.
Exemplul urmtor evideniaz comportamentul acestor dou metode.
for ( int i = 1; i <= 10; ++i ) nrUnice.insert(i * 10);
set<int>::iterator low = nrUnice.lower_bound(20);
set<int>::iterator up = nrUnice.upper_bound(80);
set<int>::iterator saveUp = up;
cout << *low << endl; // afiseaza 30
cout << *up << endl; // afiseaza 90
while ( up != low ) // afiseaza descrescator numerele
// de la 90 la 30 (iterare inversa)
cout << *up-- << " ";
cout << endl;
nrUnice.erase(low, saveUp); // sterge numerele de la 30 la 80
// afiseaza 10 90 100
for ( low = nrUnice.begin(); low != nrUnice.end(); ++low )
cout << *low << " ";

Funcionalitatea este identic pentru multiseturi.


Am afirmat la nceput c putem defini propria relaie de ordine care
s fie folosit n cadrul seturilor. Acest lucru se face aproape la fel ca la
priority_queue.
Exemplul urmtor prezint un set ordonat dup restul mpririi
elementelor sale la 17. Asta nseamn c vor exista maxim 17 elemente n
set, cte un element pentru fiecare rest. Dac vrem s poat exista mai multe
elemente cu acelai rest, trebuie s folosim un multiset.
struct cmp
{
bool operator () (const int &x, const int &y) const
{
return x % 17 < y % 17;
}
};

213

Capitolul 7
set<int, cmp> numeSet; // modul de declarare

Dac vrem s tergem toate elementele dintr-un set, putem folosi


metoda clear, care nu primete niciun parametru. Acest lucru este folositor
atunci cnd vrem s refolosim setul pentru alte lucruri. Metoda clear se
regsete i la restul containerelor.

b) Containerele map i multimap


map i multimap sunt containere asociative care rein elemente
formate dintr-o cheie i o valoare sau valoare mapat. Cheile i valorile
pot fi de tipuri diferite i fiecare cheie identific (n cazul containerului
map, n mod unic) un element. Valoarea mapat este o valoare asociat
cheii.
Un exemplu clasic de folosire este n implementarea unei agende
telefonice: numele unei persoane (tip de date string) identific numrul de
telefon al acelei persoane (tip de date int sau tot string).
Pentru a putea folosi aceste containere trebuie inclus fiierul antet
<map>. Un exemplu de declarare este urmtorul:
map<string, int> agenda;
multimap<string, int> agendaMulti;

Pentru a insera i a accesa un element este suficient s folosim


operatorul [ ]. Fiecare element este accesat prin cheia sa, iar folosind acest
operator putem fie s atribuim o valoare unei chei (care va fi creat dac nu
exist deja, sau suprascris dac exist) fie s accesm valoarea unei chei
deja existente (dac se ncearc accesarea unei chei inexistente se returneaz
o valoare implicit a acelui tip de date). De exemplu:
agenda["John Doe"] = 1352; // asociaza "John Doe" cu 1352
agenda["Popescu Marcel"] = 6399;
cout << agenda["John Doe"] << endl; // afiseaza 1352
cout << agenda["Marcel"] << endl; // afiseaza 0

Datorit modului n care este implementat acest container, operaiile


de inserare i de interogare se execut n timp logaritmic relativ la numrul
de chei. Din acest motiv nu este indicat ca map s fie folosit pe post de
tabel de dispersie dac viteza este critic.
214

Introducere n S.T.L.
n cazul containerului multimap putem avea mai multe valori pentru
aceeai cheie. De exemplu, anumite persoane s-ar putea s aib mai multe
numere de telefon. Din acest motiv, multimap nu are implementat
operatorul [ ] i necesit folosirea metodelor insert i find. Aceste metode
sunt implementate i de ctre containerul map, dar folosite mai rar datorit
existenei operatorului [ ].
Metoda insert primete ca parametru elementul pe care vrem s-l
inserm. Reamintim c un element este o pereche (pair) format din cheie
i valoare. Valoarea returnat de funcie este, n cazul containerului map, o
pereche pair<iterator, bool> unde primul element este un iterator ctre
valoarea inserat i al doilea o valoare boolean care indic dac elementul a
fost inserat sau exista deja n colecie. n cazul containerului multimap, se
returneaz doar un iterator.
Metoda find returneaz un iterator ctre elementul care are cheia
dat ca parametru. Exemplul urmtor evideniaz aceste dou metode.
map<string, int> agenda;
multimap<string, int> agendaMulti;
agenda.insert(pair<string, int>("John Doe", 1352));
agendaMulti.insert(pair<string, int>("John Doe", 1352));
// afiseaza unicul numar de telefon al unei persoane
map<string, int>::iterator it1 = agenda.find("John Doe");
cout << it1->first << " are numarul de telefon "
<< it1->second << endl;
// insereaza mai multe numere de telefon pentru aceeasi persoana
agendaMulti.insert(pair<string, int>("John Doe", 6314));
agendaMulti.insert(pair<string, int>("John Doe", 4272));
// afiseaza toate numerele de telefon ale unei persoane
multimap<string, int>::iterator it2 = agendaMulti.find("John Doe");
cout << it2->first << " are numerele de telefon: ";
// afiseaza 1352 6314 4272
while ( it2 != agendaMulti.end() && it2->first == Ionescu Vlad )
{
cout << it2->second << " ";
++it2;
}

215

Capitolul 7
Pentru a terge elemente dintr-un map sau multimap se folosete
metoda erase. Aceast metod este similar cu cea de la seturi. Exist trei
versiuni: una care primete ca parametru un iterator ctre elementul pe care
vrem s-l tergem i care nu returneaz nimic, una care primete ca
parametru o cheie, terge toate elementele cu acea cheie i returneaz
numrul de elemente terse i una care primete ca parametru doi iteratori i
terge toate valorile dintre cei doi iteratori.
Exemplul urmtor prezint metoda erase.
map<string, int> agenda;
multimap<string, int> agendaMulti;
agenda.insert(pair<string, int>("John Doe", 1352));
agendaMulti.insert(pair<string, int>("John Doe", 1352));
agendaMulti.insert(pair<string, int>("John Doe", 6314));
agendaMulti.insert(pair<string, int>("John Doe", 4272));
// se va sterge un singur element
cout << agenda.erase("John Doe") << endl;
agenda.insert(pair<string, int>("John Doe", 1352));
map<string, int>::iterator it1 = agenda.find("John Doe");
agenda.erase(it1);
it1 = agenda.find("John Doe");
if ( it1 == agenda.end() )
cout << "John Doe nu exista in agenda" << endl; // se va afisa
// se sterg toate numerele
cout << agendaMulti.erase("John Doe") << endl;
agendaMulti.insert(pair<string, int>("John Doe", 1352));
agendaMulti.insert(pair<string, int>("John Doe", 6314));
agendaMulti.insert(pair<string, int>("John Doe", 4272));
multimap<string, int>::iterator it2 = agendaMulti.find("John Doe");
agendaMulti.erase(it2); // sterge doar primul numar: 1352
it2 = agendaMulti.find("John Doe");
// afiseaza 6314 4272
while ( it2 != agendaMulti.end() && it2->first == "John Doe" )
{
cout << it2->second << " ";
++it2;
}

216

Introducere n S.T.L.
Putem numra cte elemente au aceeai cheie (folositor n cazul unui
multimap) folosind metoda count, care primete ca parametru o cheie. De
exemplu:
cout << agendaMulti.count("John Doe") << endl;

Pn acum am iterat elementele cu o anumit cheie ntr-un mod


destul de bizar: am continuat iterarea atta timp ct iteratorul nu a ajuns la
sfritul coleciei i ct timp cheia elementului indicat de iterator este egal
cu cheie elementului care ne intereseaz. Dac am verifica numai s nu
depim sfritul coleciei am risca afiarea unor valori care corespund altor
chei.
O modalitate mai elegant de iterarea a tuturor elementelor care au o
anumit cheie este folosirea metodei equal_range. Aceast metod primete
ca parametru o cheie i returneaz o pereche format din doi iteratori:
primul iterator indic primul element cu cheia dat ca parametru, iar al
doilea iterator indic elementul de dup ultimul element cu cheia dat ca
parametru. Dac nu exist niciun element care s aib cheia dat ca
parametru, ambii iteratori vor indica sfritul coleciei.
Exemplul urmtor prezint un scurt program care afieaz toate
numerele de telefon a unui contact dintr-o agend telefonic.
map<string, int> agenda;
multimap<string, int> agendaMulti;
agendaMulti.insert(pair<string, int>("John Doe", 1352));
agendaMulti.insert(pair<string, int>("John Doe", 6314));
agendaMulti.insert(pair<string, int>("John Doe", 4272));
agendaMulti.insert(pair<string, int>("Popescu Marcel", 3522));
// vom scrie mai putin asa
typedef multimap<string, int>::iterator iterator;
pair<iterator, iterator> it = agendaMulti.equal_range("John Doe");
pair<iterator, iterator> it2;
for ( iterator i = it.first; i != it.second; ++i ) // afiseaza 1352 6314 4272
cout << i->second << " ";

Putem itera ntreg containerul folosind, de exemplu, agenda.begin()


i agenda.end().

217

Capitolul 7
Containerele map i multimap pot servi ca nlocuitori pentru tabele
de dispersie i arbori trie, dar trebuie inut cont de unele lucruri, cum ar fi
timpul de execuie, care este ntotdeauna logaritmic pentru operaiile de
cutare, inserare i tergere. Acest lucru poate reprezenta un dezavantaj n
faa tabelelor de dispersie, sau un avantaj dac dorim s evitm cel mai ru
caz al tabelelor de dispersie, caz n care operaiile de cutare i tergere
devin liniare. De obicei, map i multimap sunt mai puin eficiente dect un
trie implementat manual.

c) Containerul bitset
Containerul bitset ne permite s lucrm cu bii, lucru care poate
reduce semnificativ memoria folosit de un program care nu are nevoie
dect de un tablou a crui elemente poate lua doar dou valori: adevrat (1)
i fals (0). Aadar, fiecare element ocup un singur bit, spre deosebire de
tipurile bool sau char care ocup opt bii.
Pentru a folosi un set de bii trebuie inclus fiierul antet <bitset>.
Sintaxa de declarare este puin diferit fa de sintaxa containerelor
prezentate pn acum, n sensul c ntre parantezele unghiulare nu se mai
trece tipul datelor din container, ci numrul de bii pe care vrem s-l avem la
dispoziie. De exemplu:
bitset<2011> aniBisecti;

Constructorul implicit seteaz toi biii setului pe 0 la declarare.


Pentru a accesa i seta un anumit bit se folosete operatorul [ ].
Secvena de mai jos seteaz pe 1 toi biii corespunztori unui an bisect:
for ( int i = 0; i < aniBisecti.size(); ++i )
if ( (i % 4 == 0 && i % 100 != 0) || i % 400 == 0 )
aniBisecti[i] = 1;

Metodele set i reset permit setarea tuturor biilor pe valoarea 1,


respectiv pe valoarea 0. Acestea nu necesit niciun parametru. Se pot
transmite ns parametri pentru poziie, n caz c nu vrem s afectm
ntreaga colecie, dar este de preferat operatorul de acces n acest caz.
Metoda flip se comport similar: dac nu este dat niciun parametru,
toi biii din colecie sunt scazui pe rnd din 1, adic 1 devine 0 i 0 devine
1. Se poate transmite un parametru pentru poziie.
218

Introducere n S.T.L.
Putem lucru cu valoarea binar reinut ntr-un set de bii folosind
metodele to_ulong i to_string. Acestea transform biii dintr-un set de bii
ntr-o valoare numeric fr semn, respectiv ntr-un string. De exemplu:
bitset<5> test;
test.set(); // test = 11111 in baza 2
cout << test.to_ulong() << endl; // afiseaza 31 (11111 in baza 2)
test.flip(3); // test = 10111 in baza 2
string testString = test.to_string();
cout << testString << endl; // afiseaza 10111

Atenie: dac valoarea dintr-un set de bii este prea mare pentru a fi
reprezentabil pe un ntreg unsigned long, va aprea o eroare!
Alte metode importante sunt count, care numr ci bii au valoarea
1, any, care returneaz true dac exist un bit cu valoarea 1 i false altfel i
metoda none care returneaz true dac toi biii au valoarea 0 i false n caz
contrar.
Constructorul unui set de bii ne permite s iniializm un astfel de
set cu ajutorul unui ntreg sau a unui string. Obinem astfel o metod foarte
simpl i direct de a converti orice numr n baza doi:
bitset<32> numar(2010);
// afiseaza 00000000000000000000011111011010
cout << numar.to_string() << endl;

Containerul bitset permite folosirea tuturor operatorilor pe bii: >>,


<<, |, &, ^, ~. Acetia se comport exact ca n cazul tipurilor de date ntregi.
De exemplu:
bitset<32> numar(2010);
numar >>= 1;
// afiseaza 00000000000000000000001111101101
cout << numar.to_string() << endl;
numar = ~numar;

219

Capitolul 7
// afiseaza 11111111111111111111110000010010
cout << numar.to_string() << endl;
numar ^= 31;
// afiseaza 11111111111111111111110000001101
cout << numar.to_string() << endl;

Avem aadar un container care ne permite s reinem numere foarte


mari n baza doi i s lucrm cu ele ca i cnd ar fi numere obinuite, lucru
care poate reduce foarte mult resursele consumate de un program i timpul
alocat implementrii.

7.4. Algoritmi S.T.L.


Biblioteca S.T.L. pune la dispoziia programatorilor unii algoritmi
care sunt considerai folositori n rezolvarea unui numr mare de probleme.
Vom prezenta n continuare numai civa dintre algoritmii disponibili, pe
care i considerm imediat folositori n rezolvarea de probleme.
Pentru a folosi acete algoritmi trebuie inclus fiierul antet
<algorithm>.

a) Algoritmul for_each
Practic, for_each este o funcie cu ajutorul creia putem aplica o alt
funcie asupra unor anumite elemente (identificate prin doi iteratori) ale unui
container. Funcia aplicat trebuie s accepte un singur parametru de tipul
elementelor din containerul asupra cruia se va aplica. Dac funcia
returneaz ceva, valoarea returnat va fi ignorat.
Exemplul urmtor afieaz dublul elementelor unui vector folosind
aceast metod.
void Afisare(int x) { cout << 2*x << " "; }
int main()
{
vector<int> numere;
numere.push_back(1005); numere.push_back(13);
numere.push_back(9); numere.push_back(4);
// afiseaza 2010 26 18 8
for_each(numere.begin(), numere.end(), Afisare);
return 0;
}

220

Introducere n S.T.L.
Nu este obligatoriu s aplicm funcia asupra unui container S.T.L.
Putem folosi i un tablou clasic:
int numere[4] = {1005, 13, 9, 4};
for_each(numere, numere + 4, Afisare); // afiseaza tot 2010 26 18 8

n general, algoritmii din S.T.L. funcioneaz att asupra tablourilor


clasice ct i a propriilor colecii.

b) Algoritmii find i find_if


Funcia find primete ca argumente doi iteratori (pointeri) i o
valoare cutat. Aceasta returneaz un iterator (sau pointer) ctre elementul
cutat, dac acesta exist. De exemplu:
int numere[4] = {1, 5, 3, 2};
int *p = find(numere, numere + 4, 3);
cout << *p << endl; // afiseaza 3

Funcia find_if este similar, doar c ultimul parametru este o


funcie. Se returneaz un pointer ctre primul element din colecie pentru
care funcia dat returneaz true.
bool Impar(int x)
{
return x % 2 == 1;
}
int main()
{
vector<int> numere;
numere.push_back(10); numere.push_back(20);
numere.push_back(15); numere.push_back(35);
vector<int>::iterator it = find_if(numere.begin(), numere.end(), Impar);
cout << "Primul numar impar din vector este: " << *it << endl;
return 0;
}

221

Capitolul 7

c) Algoritmii count i count_if


Identice n modul de apelare cu find respectiv find_if. Returneaz
numrul de elemente care sunt egale cu o valoare dat respectiv pentru care
o funcie returneaz true.
int numere[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int dim = sizeof(numere) / sizeof(numere[0]);
cout << "Exista " << count_if(numere, numere + dim, Impar) << endl;

d) Algoritmul equal
Compar elementele a dou secvene aparinnd a dou containere
distincte (doi vectori de exemplu) i returneaz true dac cele dou secvene
sunt egale i false n caz contrar.
Funcia accept fie trei parametri fie patru: prim1 un iterator ctre
primul element din prima secven, ultim1 un iterator ctre primul
element care nu va fi inclus n comparaie, prim2, un iterator ctre primul
element din a doua secven i un parametru opional predicat care
reprezint o funcie cu dou argumente conform creia se va testa egalitatea.
Informal, se va compara secvena [prim1, ultim1) cu [prim2, prim2 +
ultim1 prim1).
Exemplul urmtor testeaz dac dou tablouri sunt congruente
modulo 17.
bool EgalMod17(int x, int y) { return x % 17 == y % 17; }
int main()
{
int vec1[4] = {1, 2, 3, 4}, vec2[4] = {18, 19, 20, 21};
cout << equal(vec1, vec1 + 4, vec2, EgalMod17) << endl; // afiseaza 1
return 0;
}

e) Algoritmul unique
Funcia unique terge toate repetiiile unui element dintr-o colecie
sortat. Aceasta primete doi parametri care reprezint graniele n care se
va aplica funcia (iteratori sau pointeri). Se mai poate transmite un
parametru opional: o funcie cu doi parametri care determin dac dou

222

Introducere n S.T.L.
elemente sunt egale, funcie similar cu cea de la funcia equal. Funcia
returneaz un iterator ctre noul sfrit al coleciei.
De exemplu:
int main()
{
vector<int> numere;
for ( int i = 1; i <= 10; ++i )
{
numere.push_back(i - 1);
numere.push_back(i);
}
// numere contine 0 1 1 2 2 3 ... 9 9 10
// numere va contine doar 0 1 2 3 ... 10
vector<int>::iterator it = unique(numere.begin(), numere.end());
// vectorul trebuie redimensionat, altfel
// vor aparea elementele duplicate la sfarsit
numere.resize(it - numere.begin());
for ( it = numere.begin(); it != numere.end(); ++it )
cout << *it << " ";
return 0;
}

f) Algoritmul copy
Funcia copy copiaz o colecie n alta. Primii doi parametri sunt
iteratori care definesc prima secven, iar al treilea parametru este un iterator
ctre poziia n care se va copia primul element. De exemplu:
int numere1[5] = {1, 2, 3, 4, 5};
int numere2[5];
copy(numere1, numere1 + 5, numere2); // copiaza numere1 in numere2
for ( int i = 0; i < 5; ++i ) // afiseaza 1 2 3 4 5
cout << numere2[i] << " ";

223

Capitolul 7

g) Algoritmul reverse
Funcia reverse oglindete o secven dat prin doi iteratori. Aceasta
nu returneaz numic. Exemplul urmtor determin dac un vector este
palindrom:
vector<int> vec, vecInit;
vec.push_back(5); vec.push_back(10); vec.push_back(5);
vecInit = vec;
reverse(vec.begin(), vec.end());
if ( vec == vecInit ) // comparare folosind ==,
// putem folosi si functia equals
cout << "Vectorul dat este palindrom";
else
cout << "Vectorul dat nu este palindrom";

h) Algoritmul rotate
Funcia rotate primete trei parametri: prim, mij, ult i rotete
secvena [prim, ult) n aa fel nct elementul indicat de mij s devin
primul element. Nu se returneaz nimic. De exemplu:
vector<int> numere;
for ( int i = 1; i <= 10; ++i )
numere.push_back(i);
rotate(numere.begin(), numere.begin() + 5, numere.end());
for ( int i = 0; i < 10; ++i ) // afiseaza 6 7 8 9 10 1 2 3 4 5
cout << numere[i] << " ";

i) Algoritmul random_shuffle
Permut aleator elementelor unei colecii. De exemplu:
int nrPrime[5] = {2, 3, 5, 7, 11};
random_shuffle(nrPrime, nrPrime + 5);

224

Introducere n S.T.L.

j) Algoritmii lower_bound, upper_bound i


binary_search
Aceste trei funcii acioneaz doar asupra unor colecii sortate.
Funciile lower_bound i upper_bound sunt variante globale ale
metodelor de la containere. lower_bound returneaz un iterator ctre primul
element mai mare sau egal cu un element dat, iar upper_bound returneaz
un iterator ctre primul element strict mai mare dect un element dat. De
exemplu:
int nrPrime[5] = {2, 3, 5, 7, 11};
cout << *lower_bound(nrPrime, nrPrime + 5, 5) << endl; // afiseaza 5
cout << *upper_bound(nrPrime, nrPrime + 5, 5) << endl; // afiseaza 7

Funcia binary_search folosete cutarea binar pentru a determina


n timp logaritmic dac un element aparine unei colecii sau nu. De
exemplu:
vector<int> nrPrime;
nrPrime.push_back(2); nrPrime.push_back(3);
nrPrime.push_back(5); nrPrime.push_back(7);
if ( binary_search(nrPrime.begin(), nrPrime.end(), 3) )
cout << "3 este numar prim!" << endl;

k) Algoritmii min_element i max_element


Returneaz un pointer ctre cel mai mic, respectiv cel mai mare
element al unei colecii. De exemplu:
vector<int> nrPrime;
nrPrime.push_back(2); nrPrime.push_back(3);
nrPrime.push_back(5); nrPrime.push_back(7);
cout << *min_element(nrPrime.begin(), nrPrime.end()) << endl; // 2
cout << *max_element(nrPrime.begin(), nrPrime.end()) << endl; // 7

Se poate folosi un al treilea parametru: o funcie care determin


minimul a dou elemente.
Funciile min respectiv max fac acelai lucru pentru non-colecii
(ntregi de exemplu). Astfel putem s nu mai scriem propriile funcii de
determinare a minimului sau maximului a dou valori.
225

Capitolul 7

l) Algoritmii next_permutation i prev_permutation


Determin urmtoarea, respectiv anterioara, permutare n ordine
lexicografic pe baza valorilor dintr-un container dat. Implicit, funciile
folosesc operatorul < pentru comparare, dar pot accepta o funcie care s
compare dou elemente. Acestea returneaz true dac exist o permutare
urmtoare i false n caz contrar. Dac funcia returneaz false, mai nti
elementele coleciei se reseteaz, devinind fie prima permutare
lexicografic, fie ultima.
Secvena urmtoare afieaz toate permutrile primelor 5 numere
naturale nenule: mai nti crsctor, iar apoi descresctor lexicografic.
int numere1[5] = {1, 2, 3, 4, 5}, numere2[5] = {5, 4, 3, 2, 1};
do
{
for ( int i = 0; i < 5; ++i )
cout << numere1[i] << " ";
cout << endl;
} while ( next_permutation(numere1, numere1 + 5) );
// numere1 = {1, 2, 3, 4, 5}, deoarece a fost resetat dupa ultimul apel
cout << endl;
do
{
for ( int i = 0; i < 5; ++i )
cout << numere2[i] << " ";
cout << endl;
} while ( prev_permutation(numere2, numere2 + 5) );

226

Algoritmi genetici

8. Algoritmi
genetici
Pentru a nelege algoritmii genetici, n primul rnd trebuie s
nelegem i s cuantificm modelul evoluiei naturale (darwiniste). Modelul
evolutiv presupune existena unui habitat (a unui spaiu de evoluie)
guvernat de legi locale (condiiile de mediu) n care speciile (populaiile
reprezentate de indivizi) se supun urmtorului mecanism:
1. Pe baza seleciei, un numr restrns de indivizi din populaia
iniial vor constitui populaia intermediar de prini (algoritmul
de selecie trebuie s respecte paradigma conform creia un
individ mai bine adaptat s aibe anse mai mari de supravieuire).
2. Din indivizii selectai ca i prini, pe baza operatorilor genetici
(mutaie, ncruciare, ...), se va reconstitui o nou populaie.

Pentru a creea din modelul evolutiv un algoritm genetic (J. Holland,


1970), vom nlocui n primul rnd spaiul de evoluie (habitatul) cu
problema dat, indivizii din populaie cu posibile soluii la problema n
cauz i urmrind mecanismul evolutiv, ne vom atepta ca dup un timp s
gsim cele mai bune (optime) soluii.
Acest va capitol va prezenta pe larg teoria din spatele algoritmilor
genetici, precum i dou probleme rezolvate cu ajutorul acestora.
227

Capitolul 8

CUPRINS
8.1. Descrierea algoritmilor genetici ............................................................ 229
8.2. Problema gsirii unei expresii ............................................................... 236
8.3. Rezolvarea sistemelor de ecuaii .......................................................... 241

228

Algoritmi genetici

8.1. Descrierea algoritmilor genetici


a) Cutarea n spaiul soluiilor
n acest paragraf vom aprofunda modul de construcie a unui
algoritm genetic, setrile i variantele acestuia, modulele folosite n
rezolvarea de probleme i cteva elemente conexe ncadrate de regul n
conceptele de inteligen artificial.
Pentru a putea rezolva o problem prin algoritmi genetici este
necesar transformarea acesteia ntr-o problem de cutare sau optimizare
a soluilor. Ca o analogie cu modelul evoluionist, vom numi n continuare
soluiile, cromozomi, iar spaiul tuturor soluiilor l vom nota cu Crom.
n primul rnd avem nevoie de o funcie sau un criteriu de selecie,
care va arta ct de adaptat este un cromozom soluie, sau dac cromozomul
curent este chiar o soluie acceptabil, asociind acestora, de regul, o valoare
real. Aceast funcie se numete funcie de adecvare (en. fitness function)
i o vom nota cu fadec i avem:
:
n conceptul algoritmilor genetici trebuie acceptat c orice element
din spaiul soluiilor este o posibil soluie, doar c funcia de adecvare
stabilete dac aceast soluie este acceptabil sau nu. S lum de exemplu
ecuaia (cu caracter demonstrativ): 3 6 = 0. Spaiul soluiilor este R i
x = 17 este o soluie, ns, evident, nu este cea mai bun, dar este mai bun
dect x = 46, de exemplu. Continund cutarea n spatiul soluiilor vom gsi
la un moment dat x = 2, aceast soluie fiind cea mai bun.
n general, vom accepta o eroare (), iar
<
va fi una din condiiile de oprire a algoritmului.
Graficul din figura 8.1.1. a fost obinut reprezentnd valoarea
funciei de adecvare pentru fiecare cromozom n parte pentru o problem
oarecare P. Funcia de adecvare este n aa fel construit nct:
1 = 0
229

Capitolul 8

Fig. 8.1.1. Cutarea n spaiul soluiilor


nseamn c c1 este soluia cea mai bun (optimul global), iar pentru
1 < 2 ,
x1 este soluie mai bun (mai acceptabil) dect x2.
n acest caz spunem c minimizm funcia obiectiv.
Exist dou concepte fundamentale de cutare n spaiul soluiilor:
1. explorarea spaiului soluiilor: cutarea complet aleatorie n
spaiul soluiilor atta timp ct nu se gsete o valoare
acceptabil (n cazul nostru, atta timp lum cte o valoare
aleatorie pe axa reprezentat de spaiul soluiilor, pn cnd
aceasta este n unul din intervalele de soluii acceptabile).
2. exploatarea unor poteniale soluii: reprezentat n general de
metodele de coborre (gradient, Newton) care vor minimiza
succesiv graficul funciei de adecvare. Exploatarea unor
poteniale soluii se refer la condiiile iniiale asociate metodei
de coborre. n exemplul nostru, o metod de coborre pornit
din punctul A va ajunge la optimul local, dar pentru datele de
intrare stabilite n punctul B nu va gsi nicio soluie (Fig. 8.1.2.)
Pentru a avea posibilitatea ajungerii la o soluie global, este
necesar stabilirea unui punct de pornire a unei metode de coborre n
intervalul I.
230

Algoritmi genetici

Fig. 8.1.2.
Acest mod se poate realiza cel mai eficient prin procesarea mai
multor posibile soluii simultan, cu diferite puncte de plecare aleatoare.
Conceptul asociat cu mbinarea celor dou metode de cutare n
spaiul soluiilor se numete echilibrul explorare exploatare i tratarea
celor dou concepte simultan reprezint principalul avantaj al algoritmilor
genetici relativ la celelalte metode de optimizare.

b) Algoritmul genetic fundamental


n primul rnd, pentru a construi un model general al unui algoritm
genetic, trebuie s lum n calcul timpul de evoluie (notat n continuare
tEvol). Considerm valoarea iniial tEvol = 0, ce corespunde cu pasul de
initializare a populaiei. Apoi, la fiecare etap de selecie generare, acest
timp de evoluie l incrementm.
1. tEvol = 0
2. Se iniializeaz o populaie iniial de soluii (cromozomi)
a. Se verific dac prin metoda aleatoare de inializare a
populaiei nu s-a obinut o soluie acceptabil, caz n care
se incheie algoritmul.
3. Pe baza funciei de adecvare se selecteaz cele mai optime soluii
(se formeaz populaia de soluii-prini).
4. Pe baza operatorilor genetici se genereaz soluiile-copii din
populaia intermediar.
5. tEvol++
231

Capitolul 8
6. Se verific condiiile de oprire n funcie de tEvol = tEvolMAX
sau dac s-a gsit o soluie acceptabil
a. Dac da algoritmul se ncheie
b. Altfel se revine la pasul 3
Paii 3 i 4 reprezint nucleul algoritmului genetic.

c) Selecia
Exist mai multe tipuri de selecie, toate acestea avnd scopul ca
implementarea capacitii de supravieuire a unei soluii s fie proporional
cu valoarea funciei de adecvare, aici fiind de fapt implementat paradigma
evoluiei darwiniste survival of the fittest. Una dintre cele mai simple
metode de selecie este selecia bazat pe ordonare (ierarhie), n care se
ordoneaz populaia de soluii astfel nct adaptarea lor s fie
descresctoare, dup care se selecteaz primii n indivizi dorii.
Metoda cea mai natural de selecie este metoda de selecie Monte
Carlo (proporional). Aceast metod presupune construirea unei rulete,
fiecare individ din populaie fiind reprezentat sub forma unui sector de cerc
proporional cu o pondere. Pentru a avea sens din punct de vedere evolutiv,
ponderea trebuie s fie cu att mai mare cu ct adecvarea individului soluie
este mai bun. n figura 8.1.3. avem o populaie format din cinci indivizi i
adaptarea cea mai bun o are crom5.

Fig. 8.1.3. Ruleta Monte Carlo


n mod uzual, un algoritm de selecie Monte Carlo are ca date de
intrare o matrice format din simboluri i ponderi asociate. De exemplu s
cuantificm aruncarea unui zar: simbolurile sunt feele notate cu 1, 2, ..., 6,
232

Algoritmi genetici
reprezentate de numrul de puncte, iar ponderile (de apariie a unei fee) ar
trebui s fie identice (1). Astfel avem matricea de intrare:
1 2
1 1

3
1

4
1

5
1

6
1

Cu aceasta construim modelul grafic al rulete: alegem un punct de


referin i nvrtim ruleta. (Fig. 8.1.4.) Simbolul extras este acela a crui
sector de cerc asociat se oprete n dreptul puctului de referin. Se pstreaz
astfel modelul natural al unui zar perfect (fiecare simbol are aceeai
probabilitate de apariie):

Fig. 8.1.4. Ruleta Monte Carlo pentru un zar perfect


Algoritmul de selecie Monte Carlo poate fi exprimat astfel:
Fie = 1
=0 suma ponderilor
se genereaz un numr aleator t ntre 0 i S 1
se parcurg ponderile i atta timp ct t >= 0, se scade din t
ponderea curent.
int AMC (int ponderi[], int n)
{
int s = 0;
for ( int i = 0 ; i < n; i++ ) s += ponderi[i];
int t = rand() % s, symbol_poz = 0;
do
{
t -= ponderi[symbol_poz];
symbol_poz++;
} while (t >= 0);
return symbol_poz - 1;
}

233

Capitolul 8
Exist mai multe tipuri de selecie, pe lng cele dou amintite
anterior. Avantajele, dezavantajele, modul lor de implementare i
particularitile acestora le vom trata ntr-un manual dedicat inteligenei
artificiale.

d) Operatorii genetici
Pentru a continua construcia unui algoritm genetic funcional, avem
nevoie de o modalitate de generare a soluiilor-copii din populaia de
soluii-prini. Aceasta se realizeaz prin operatorii genetici. Exist doi
operatori genetici fundamentali: mutaia (notat n continuare opM),
respectiv ncruciarea (opI). Avem:

i

Se observ c mutaia este un operator unar i acioneaz prin
schimbarea uneia sau a mai multor valori din cromozomul printe: n forma
cea mai simpl fie = (0 , 1 , , 1 ) un cromozom cu valorile
0 , 1 , , 1. Atunci un operator de mutaie ar genera o valoare aleatoare t
ntre 0 i n 1, iar valoarea corespunztoare lui t ar fi schimbat cu o alt
valoare.
, 0 , 1 , , 1 , , +1 , , 1
S considerm cromozomul 1110001010011111, n codificarea
binar. pentru a obine diversitatea populaiei este necesar s inem cont c
vom modifica n 1 bitul ales aleator dac valoarea acestuia este 0, respectiv
n 0 dac aceast valoare este 1.
1110001010011111 4, 1 0, 0 1 11101010011111
Toate variantele operatorilor de mutaie au ca scop diversificarea
populaiei, n efect contrar cu selecia.
Operatorul de ncruciare este un operator binar i are ca scop
schimbarea valorilor existente ntre cromzomii prini. n forma cea mai
simpl (numit ncruciarea cu un punct de tietur) se genereaz un

234

Algoritmi genetici
numr aleator t, acesta reprezentnd punctul n care se rup cei doi
cromozomi i se recombin.

= 0 , 1 , , 1
= 0 , 1 , , 1

0, 1 , , 1 , , +1 , 1

sau
0 , 1 , , 1 , , +1 , 1
Forma cea mai ntlnit a operatorului de ncruciare este aceea n
care se genereaz un ir de numere aleatoare (t i), care vor reprezenta
punctele de tietur:
= 0 , 1 , , 1
0 , 1 , , 1 | < +1
= 0 , 1 , , 1
0 , 1 , , 0 1 , 0 , 0 +1 , , 1 1 , 1 , 1 +1 , , 2 1 , 2 , 2 +1 ,

Un exemplu n codificare binar:

10101 010 10000010 101


11101 011 10111101 010

5,8,16 (1010101110000010 010)

Operatorii genetici de mutaie i ncruciare sunt necesari ntr-un


algoritm genetic pentru a asigura procesul evolutiv. Pe lng aceti operatori
se pot construi i alii, n funcie de cerinele problemei.
Exist i posibilitatea cutrii unor soluii pentru care lungimea
cromozomilor s fie variabil, sau informaia reinut de acetia s fie
supus anumitor restricii, caz n care trebuie adaptai i operatorii genetici
n consecin.
Algoritmii genetici sunt foarte eficieni atunci cnd dorim soluii
apropiate de un optim global ntr-un timp scurt, dar dac dorim optime
globale atunci acetia pot fi mai puin eficieni dect alte abordri.
Prezentm n continuare dou probleme care se pot rezolva n mod
natural cu ajutorul algoritmilor genetici: o problem n care se cere o
expresie a crei rezultat s fie un numr dat i o problem n care se cere
rezolvarea unui sistem de ecuaii. Sperm ca rezolvrile prezentate n cadrul
acestora s v ajute s nelegei att logica din spatele algoritmilor genetici,
ct i modul de implementare al acestora.
235

Capitolul 8

8.2. Problema gsirii unei expresii


Se dau N 1 operatori matematici O1, O2 , ..., ON 1 din mulimea
{+, -, *}, avnd semnificaia lor obinuit i un numr S.
Scriei un program care gsete un ir de N numere naturale
X1, X2, ..., X N din intervalul [1, N], astfel nct expresia format prin
alturarea numerelor gsite cu operatorii dai (adic X1O1X2O2 ... ON-1XN )
s dea, modulo 16 381, rezultatul S.
Datele de intrare se citesc din fiierul expresie.in, iar soluia se scrie
n fiierul expresie.out. Fiierul de intrare conine pe prima linie numerele
N i S, separate printr-un spaiu, iar pe a doua linie N-1 operatori matemtici
din mulimea specificat n enun. n fiierul de ieire se afieaz, separate
printr-un spaiu, pe prima linie, elementele irului X.
Exemplu:
expresie.in expresie.out
4 18
4412
**+
Explicaie: 4 * 4 * 1 + 2 = 18. 18 mod 16 381 = 18. Pot exista i alte
soluii.
O prim idee de rezolvare este s folosim metoda backtracking.
Trebuie s generm toate posibilitile de a completa N poziii cu numere
din intervalul [1, N]. Acest lucru se poate face cu un algoritm similar cu cel
al generrii permutrilor unei mulimi, doar c acuma nu ne intereseaz dac
folosim un element de dou sau mai multe ori. Complexitatea unui astfel de
algoritm este O(NN), deoarece pentru fiecare dintre cele N poziii care
trebuie completate, avem N posibiliti de completare (N resurse i N
poziii).
Complexitatea este foarte mare, iar algoritmul este ineficient i n
practic pentru valori mari ale lui N. Exist diverse optimizri care pot fi
fcute, dar nici acestea nu vor mri cu mult viteza algoritmului.
O alt idee este s generm aleator numere pn cnd gsim o
expresie care d rezultatul S. n practic, nici aceast metod nu
funcioneaz pentru valori mari ale lui N.

236

Algoritmi genetici
Gndii-v ce se ntmpl dac generm un ir de numere a crui
rezultat este foarte aproape de S. Asta nseamn c irul respectiv ar putea fi
o soluie valid, cu mici modificri. n cazul algoritmului anterior ns, acest
ir se va pierde la pasul urmtor, generndu-se alt ir, care va avea mai
multe anse s fie mai ndeprtat de soluie dect mai apropiat ca irul
curent
Ideea din spatele algoritmilor genetici este s reinem mai multe
astfel de iruri (o populaie sau ecosistem) generate aleator, pe care s le
sortm dup o funcie de adecvare (funcie de fitness) care ia valori tot mai
apropiate de 0 pentru iruri (indivizi sau cromozomi) care tind spre o
soluie. Cnd am gsit un ir pentru care funcia de adecvare ia exact
valoarea 0, am gsit o soluie.
Algoritmul nu se oprete ns la sortarea unor iruri generate aleator.
Vom genera un anumit numr de iruri o singur dat, dup care vom aplica
anumii operatori genetici asupra lor. Aceti operatori asigur faptul c
informaia dintr-o generaie nu se va pierde n generaiile urmtoare. O
generaie este o stare a populaiei la un moment dat.
Se pune problema alegerii indivizilor asupra crora vom aplica
operatorii genetici i alegerii indivizilor a cror informaie dorim s o
pstrm i n generaia urmtoare. Evident, dac un individ a fost foarte
aproape de soluie ntr-o generaie, acesta va merita pstrat aa cum e i n
generaia viitoare. Vom menine o list cu elite pentru fiecare generaie,
elite care vor trece nemodificate n generaia urmtoare. Operatorii genetici
se vor aplica asupra elitelor, combinnd calitile acestora n sperana
obinerii unor soluii din ce n ce mai bune.
Operatorii genetici se aplic, fiecare, cu o anumit probabilitate, n
funcie de necesitatea aplicrii lor.
Operatorii cei mai des ntlnii sunt operatorii de recombinare i de
mutaie. Operatorul de recombinare combin informaia reinut de doi
cromozomi A i B ce fac parte din elite ntr-un singur cromozom ce va face
parte din generaia urmtoare. Modul de desfurare al operaiei este similar
cu procedeul biologic: se alege un punct (o gen) oarecare P de pe unul
dintre cei doi cromozomi din elite. Cromozomul C rezultat prin recombinare
va avea primele P gene identice cu primele P gene ale cromozomului A, iar
urmtoarele gene identice cu genele de dup poziia P a cromozomului B
Pentru problema de fa, lucrnd pe exemplul dat, recombinarea s-ar putea
face astfel:

237

Capitolul 8
Cromozom
Informaie
A
4 * 4 * 2 + 3
B
1 * 4 * 1 + 2
C
4 * 4 * 1 + 2
Fig. 8.2.1. Operatorul de recombinare aplicat problemei prezentate
Operatorul de mutaie modific aleator valoarea unei gene alese tot
aleator. n cazul problemei de fa, operatorul de mutaie trebuie s fie
implementat n aa fel nct s nu modifice valoarea unui operator.
Exemplu:
nainte de mutaie
Dup mutaie
4 * 3 * 1 + 2 4 * 4 * 1 + 2
Funcie de adecvare este, n acest caz, foarte simplu de construit.
Aceasta va calcula, pentru fiecare cromozom, diferena n modul dintre
suma S i valoarea expresiei reinute de cromozomul curent.
Astfel, algoritmul de rezolvare este urmtorul:
Iniializeaz aleator maxpop cromozomi / indivizi.
Execut:
o Creaz o nou populaie aplicnd operatorii de
recombinare i de mutaie (fiecare cu probabiliti
prestabilite).
o Sorteaz indivizii cresctor dup funcia de adecvare.
Ct timp valoarea funciei de adecvare pentru primul cromozom
este diferit de 0.
Afieaz operanzii primului cromozom.
Pentru mai multe detalii despre funcia de evaluare a unei expresii,
vedei capitolul Algoritmi generali.
#include <fstream>
#include <algorithm>
#include <cstdlib>
using namespace std;

238

Algoritmi genetici
const int maxN = 1001;
const int maxpop = 400;
const int maxlg = 2*maxN + 1;
const int maxeli = 50;
const int prob_recomb =
(int)((double)0.80 * RAND_MAX);
const int prob_mutatie =
(int)((double)0.95 * RAND_MAX);
const int mod = 16381;

// <EVALUARE>
int paran(int &k, int cr, info
A[])
{
return A[cr].P[k++];
}
int inm(int &k, int cr, info A[])
{
int ret = paran(k, cr, A);

struct info
{
int P[maxlg], fitness;
};

while ( A[cr].P[k] == -3 )
{
++k;

void citire_init(int &N, int &S,


info A[])
{
ifstream in("expresie.in");
in >> N >> S;
char x;
int ops[maxN];
for ( int i = 1; i < N; ++i )
{
in >> x;
if ( x == '-' )
ops[i] = -1;
else if ( x == '+' ) ops[i] = -2;
else
ops[i] = -3;
}
ops[N] = -100;
in.close();
for ( int i = 1; i < maxpop; ++i )
{
for ( int j=1, k=1; j < 2*N;
j += 2, ++k )
{
A[i].P[j] = 1 + rand() % N;
A[i].P[j+1] = ops[k];
}
}
}

ret *= paran(k, cr, A);


ret %= mod;
}
return ret;
}
int eval(int &k, int cr, info A[])
{
int ret = inm(k, cr, A);
while (A[cr].P[k] == -1 ||
A[cr].P[k] == -2)
{
if ( A[cr].P[k++] == -1 )
ret -= inm(k, cr, A);
else
ret += inm(k, cr, A);
ret %= mod;
while ( ret < 0 )
ret += mod;
}
return ret;
}
// </EVALUARE>

239

Capitolul 8
void calc_fitness(int N, int S, info A[])
{
for ( int cr = 1; cr < maxpop; ++cr )
{
int k = 1;
A[cr].fitness = abs(eval(k, cr, A) - S);
}
sort(A+1, A+maxpop);
}
void noua_gen(int N, info A[])
{
for ( int i = maxeli + 1; i < maxpop; ++i )
{
if ( rand() < prob_recomb ) // recombinare
{
int i1, i2;
do
{
i1 = 1 + rand() % maxeli;
i2 = 1 + rand() % maxeli;
} while ( i1 == i2 );
int poz;
do
{
poz = 1 + (rand() % (2*N - 1));
} while ( poz % 2 == 0 );
for ( int j = 1; j < poz; j += 2 )
A[i].P[j] = A[i1].P[j];
for ( int j = poz; j < 2*N; j += 2 )
A[i].P[j] = A[i2].P[j];
}
if ( rand() < prob_mutatie ) // mutatie
{
int poz;
do
{
poz = 1 + (rand() % (2*N - 1));
} while ( poz % 2 == 0 );
A[i].P[poz] = 1 + (rand() % N);
}
}
}

240

bool operator<(const info &x,


const info &y)
{
return x.fitness < y.fitness;
}
void start(int N, int S, info A[])
{
do
{
noua_gen(N, A);
calc_fitness(N, S, A);
} while ( A[1].fitness != 0 );
ofstream out("expresie.out");
for ( int i = 1; i < 2*N;
i += 2 )
{
out << A[1].P[i] << " ";
}
out << endl;
out.close();
}
int main()
{
int N, S;
info *A = new info[maxpop];
srand((unsigned)time(0));
citire_init(N, S, A);
start(N, S, A);
delete[] A;
return 0;
}

Algoritmi genetici
Exerciii:
a) Comparai performana algoritmului cu performana celorlali doi
algoritmi menionai.
b) Cum afecteaz constantele de la nceputul programului timpul de
execuie i memoria folosit?
c) Cum am putea modifica operatorii genetici dac numerele
folosite n expresie ar trebui s fie distincte?

8.3. Rezolvarea sistemelor de ecuaii


Se d un sistem cu M ecuaii i N necunoscute. Considerm ca
necunoscutele se noteaz cu A1, A2, ..., AN, iar o soluie valid este o
permutare a mulimii {1, 2, ..., N} care verific fiecare ecuaie.
Datele de intrare se gsesc n fiierul sistem.in, iar soluia se scrie n
fiierul sistem.out. Fiierul de intrare are urmtoarea structur: pe prima
linie N i M, iar pe urmtoarele M linii cte o ecuaie n care operanzii sunt
desprii de operatori prin cte un spaiu, aa cum se poate vedea n
exemplu.
Se presupune c sistemul are ntotdeauna cel puin o soluie i c, in
cazul unei operaii de mprire, se reine doar partea ntreag a rezultatului.
n ecuaii nu apar paranteze.
Exemplu:
sistem.in
sistem.out
32
312
A1 + A2 - A3 = 2
A1 * A2 / A3 = 1
Explicaie:
3+12=2
3 *1/ 2 =1
Se poate observa c i permutarea (1, 3, 2) ar fi fost valid.
Problema se poate rezolva folosind metoda backtracking. Mai
exact, se folosete algoritmul de generare a tuturor permutrilor unei
mulimi. Folosind algoritmul respectiv, putem verifica, pentru fiecare
permutare P, rezultatul fiecrei expresii date, n care nlocuim fiecare
necunoscut Ai cu numrul Pi (1 i N). Dac am gsit o permutare care

241

Capitolul 8
verific toate ecuaiile date, am gsit o soluie a sistemului i putem opri
cutarea.
Aceast metod are avantajul de a fi relativ uor de implementat i
de a gsi rapid o soluie pentru un sistem oarecare. Alt avantaj este
posibilitatea gsirii tuturor soluiilor unui sistem.
Dezavantajele acestei metode constau n eficien. Complexitatea
asimptotic va fi ntotdeauna O(N!) deoarece trebuie s generm toate
permutrile. Totui, exist optimizri care pot face ca algoritmul s ruleze
foarte rapid n practic. Cteva astfel de optimizri sunt:
Sortarea ecuaiilor dup numrul de necunoscute care apar n
acestea i rezolvarea ecuaiilor cu numr mai mic de variabile
mai nti.
Verificarea ecuaiilor nainte de generarea unei permutri ntregi,
fapt ce ne poate ajuta s respingem o permutare mai devreme.
Diverse optimizri legate de modul de generare al permutrilor.
Aceste optimizri nu garanteaz ns ntotdeauna o mbuntire i
pot fi dificil de implementat.
Problema se poate rezolva mai eficient folosind algoritmi genetici.
Deoarece se cere o singur permutare care s verifice anumite constrngeri
(ecuaiile sistemului), putem ncepe cu un numr prestabilit (populaia) de
permutri generate aleator (indivizi), pe care vom aplica apoi anumii
operatori genetici i pe care le vom sorta dup o funcie de adecvare.
Procedeul se repet pn cnd se ajunge la o soluie.
Corectitudinea i eficiena acestei metode st aadar n alegerea
operatorilor genetici i a funciei de adecvare (fitness).
Propunem urmtoarele dou funcii de adecvare:
1. Prima funcie, F1 , calculeaz, pentru fiecare individ, numrul de
ecuaii ale sistemului pe care permutarea le verific. Evident, am
gsit o soluie atunci cnd exist un individ X pentru care
F1(X) = M.
2. A doua funcie, F2, calculeaz, pentru fiecare individ,


=1

unde f(i) este rezultatul evalurii expresiei i dac nlocuim


fiecare necunoscut cu permutarea reprezentat de individul
curent, iar g(i) este rezultatul pe care trebuie s l aib expresia i,
adic numrul din dreapta egalului expresiei i. Am gsit o soluie
atunci cnd exist un individ X pentru care F2(X) = 0.
242

Algoritmi genetici
Ambele funcii de adecvare se comport similar din punct de vedere
al timpului de execuie. Acelai lucru nu poate fi spus ns i despre
operatorii genetici.
Primul lucru care trebuie observat este c nu putem pstra modelul
clasic al algoritmilor genetici, deoarece nu putem folosi nici operatorul de
recombinare (n caz contrar am genera permutri invalide, cu elemente care
se repet), nici operatorul clasic de mutaie (din acelai motiv).
O prim idee ar fi s folosim un operator de inversare: alegem
aleator dou poziii x1 i x2 , cu 1 x1 < x2 N i inversm secvena cuprins
ntre x1 i x2. Acest lucru ncalc ns ideea principal din spatele
algoritmilor genetici: pstrarea unor trsaturi ale elitelor din generaia
curent pentru a mbunti generaiile urmtoare. Folosind operatorul de
inversare, se pierde informaia din generaia curent.
Propunem urmtorul operator genetic, similar cu operatorul de
recombinare: se alege un individ oarecare din elitele generaiei precedente,
din care se copiaz primele x gene n cromozomul curent. Urmtoarele gene
se completeaz aleator, avnd grij s nu avem dou gene (o gen
reprezint, practic, un element al permutrii) identice. Astfel, generaiile
urmtoare au anse mai mari s fie mai aproape de rezolvarea problemei
dect generaia curent, iar mbuntirea timpului de execuie este evident
pentru un volum mai mare al datelor de intrare.
Putem folosi i operatorul de mutaie, dar i acesta trebuie modificat
pentru necesitile problemei. Mutaia nu va mai avea loc asupra unei
singure gene, ci asupra a dou gene. Vom alege dou gene pe care le vom
interschimba, pstrnd astfel proprietatea de permutare.
Structura de baz a algoritmilor genetici rmne la fel. n consecin,
prezentm doar acele funcii care sufer modificri.
void calc_fitness()
{
for ( int cr = 1; cr < maxpop; ++cr )
{
A[cr].fitness = 0;
for ( int i = 1; i <= M; ++i )
{
int k = 1;
A[cr].fitness += abs(eval(k, cr, A, i) - egal[i]);
}
}
sort(A + 1, A + maxpop);
}

243

Capitolul 8
void noua_gen()
{
int v[maxn];
for ( int i = 1; i <= N; ++i )
v[i] = 0;
for ( int i = maxeli + 1; i < maxpop; ++i )
{
if ( rand() < prob_recomb )
{
int x1 = 1 + rand() % maxeli;
int poz = 1 + rand() % N;
for ( int j = 1; j <= poz; ++j )
{
v[ A[x1].var[j] ] = i;
A[i].var[j] = A[x1].var[j];
}
for ( int j = 1; j <= N; ++j )
if ( v[j] != i )
{
++poz;
A[i].var[poz] = j;
}
}
if ( rand() < prob_mutatie )
{
int x1 = 1 + rand() % N;
int x2 = 1 + rand() % N;
swap(A[i].var[x1], A[i].var[x2]);
}
}
}

Precizm c implementarea aceasta folosete funcia de adecvare


descris anterior ca F2.
Funcia eval() evalueaz expresia numrul i, nlocuind necunoscutele
cu valorile date de cromozomul cr. Aceasta a fost descris n cadrul
capitolului Algoritmi generali i n cadrul problemei precedente.
Exerciiu:
Implementai n ntregime un program care rezolv problema,
folosind, pe rnd, ambii operatori genetici menionai, precum i ambele
funcii de adecvare descrise. Comparai, pe mai multe date de intrare,
performanele acestora.
244

Algoritmi de programare dinamic

9. Algoritmi de
programare
dinamic
Am prezentat ntr-un capitol anterior noiunile de baz ale metodei
programrii dinamice. n acelai capitol am prezentat cteva probleme
elementare rezolvate, urmnd n acest capitol s prezentm mai multe
aplicaii, att clasice ct i mai avansate, ale programrii dinamice. Tot aici
vom face tranziia de la implementrile mai apropiate de limbajul C folosite
pn acum la implementri C++ care profit mai mult de avantajele oferite
de limbajul C++, cum ar fi librria S.T.L.

245

Capitolul 9

CUPRINS
9.1. Problema labirintului algoritmul lui Lee ............................................ 247
9.2. Problema subsecvenei de sum maxim ............................................ 258
9.3. Problema subirului cresctor maximal ............................................... 262
9.4. Problema celui mai lung subir comun ................................................. 269
9.5. Problema nmulirii optime a matricelor .............................................. 273
9.6. Problema rucsacului 1............................................................................ 276
9.7. Problema rucsacului 2............................................................................ 279
9.8. Problema plii unei sume 1.................................................................. 280
9.9. Problema plii unei sume 2.................................................................. 283
9.10. Numrarea partiiilor unui numr ...................................................... 284
9.11. Distana Levenshtein ........................................................................... 286
9.12. Determinarea strategiei optime ntr-un joc ....................................... 289
9.13. Problema R.M.Q. (Range Minimum Query) ....................................... 292
9.14. Numrarea parantezrilor booleane .................................................. 296
9.15. Concluzii ................................................................................................ 300

246

Algoritmi de programare dinamic

9.1. Problema labirintului algoritmul lui Lee


Am prezentat problema labirintului n cadrul seciunii despre
backtracking. Similar, se d o matrice ptratic de dimensiune N, cu valori
de 0 sau de 1, codificnd un labirint. Valoarea 0 reprezint o camer
deschis, iar valoarea zero o camer nchis. Se cere de data aceasta cel mai
scurt drum de la poziia (1, 1) la poziia (N, N), mergnd doar prin camere
deschise i doar la stnga, dreapta, n jos sau n sus. Nu se poate trece de
dou ori prin acelai loc. Lungimea unui drum este dat de numrul de pai
necesari parcurgerii drumului.
Vom citi datele de intrare din fiierul lee.in, iar n fiierul de ieire
lee.out vom afia pe prima linia lungimea drumului minim, iar pe
urmtoarele linii coordonatele care descriu un drum de lungime minim.
Exemplu:
lee.in
4
0111
0100
0000
1110

lee.out
6
11
21
31
32
33
34
44

Rezolvarea prin metoda backtracking de la problema n care se


cereau toate ieirile din labirint se poate aplica i la aceast variant a
problemei. Trebuie doar s generm toate drumurile, iar apoi s-l alegem pe
cel de lungime minim. Aceast rezolvare nu este ns eficient, deoarece
are la baz o cutare exhaustiv.
Problema se poate rezolva eficient n timp O(N2) folosind
algoritmul lui Lee (care este de fapt o parcurgere n lime, pentru cei
familiarizai cu noiuni de teoria grafurilor). Algoritmul poate fi privit ca un
algoritm de programare dinamic. Pentru a evidenia acest lucru, s
presupunem c vrem s aflm lungimea drumului minim de poziia (1, 1) a
matricii pn la poziia (p, q). Deoarece dintr-o poziie (x, y) ne putem
deplasa n poziiile nvecinate cu (x, y) la nord, sud, este sau vest, potem
scrie urmtoarea formul:

247

Capitolul 9
D[p][q] = 1+min(D[p 1][q],D[p + 1][q],D[p][q 1],D[p][q + 1]),
unde D[x][y] reprezint distana minim de la (1, 1) la (x, y). Pentru indici
invalizi sau care reprezint un zid, distana minim va fi infinit.
Exist mai multe metode de implementare a acestui algoritm. Fie A
matricea dat. Prima metod reprezint implementarea relaiei de recuren
exact aa cum este dat, cu ajutorul unei funcii recursive. Vom folosi pentru
aceast metod o matrice D cu semnificaia anterioar i o funcie recursiv
Lee(A, N, x, y, D) care va construi aceast matrice. Vom iniializa D[1][1]
cu 0, iar restul matricei cu infinit, semnificnd faptul c acele valori nu au
fost calculate nc.
Funcia Lee poate fi implementat astfel:
Pentru fiecare vecin (newx, newy) al lui (x, y) execut
o Dac (newx, newy) este o poziie valid, nu reprezint un
zid i D[newx][newy] > D[x][y] + 1 execut
D[newx][newy] = D[x][y] + 1
Apel recursiv Lee(A, N, newx, newy, D)
Dup apelul iniial lee(A, N, 1, 1, D), matricea D va fi calculat
conform definiiei sale, deci D[N][N] va conine distana minim.
Dei aceast metod este cel mai uor de implementat, nu este cea
mai eficient, deoarece funcia lee poate fi apelat de mai multe ori pentru
aceeai poziie. Pentru a evidenia acest lucru vom prezenta modul de
execuie al funciei de mai sus pe exemplul dat. Vom considera c vectorii
de direcie (acest concept a fost definit la seciunea dedicat metodei
backtracking) sunt:
dx[] = {1, 0, -1, 0};
dy[] = {0, 1, 0, -1};
Iniial avem:
A
0111
0100
0000
1110

D
0

Primul vecin al poziiei (1, 1), conform vectorilor de direcie folosii,


este (2, 1). Aceast poziie este valid, nu conine un zid i > 0 + 1. Se
observ c i al doilea pas va conduce la poziia valid (3, 1). Aadar, dup
primii doi pai avem:
248

Algoritmi de programare dinamic


A
0111
0100
0000
1110

D
0
1
2

Primul vecin al poziiei (3, 1), este (4, 1), poziia invalid deoarece
conine un zid. Al doilea vecin este poziia (3, 2), poziie valid. Funcia se
autoapeleaz pentru aceast poziie i obinem:
A
0111
0100
0000
1110

D
0
1
23

Din poziia (3, 2) prima dat se ncearc vecinul (4, 2), care este ns
un zid. Se va merge n continuare la stnga nc doi pai, pn obinem:
A
0111
0100
0000
1110

D
0
1
23 4 5

Din poziia (3, 4) funcia se va apela prima dat pentru poziia (4, 4),
obinndu-se urmtoarea configuraie:
A
0111
0100
0000
1110

D
0
1
2 3 4 5
6

Deoarece niciun vecin al poziiei (4, 4) nu este valid pentru a se


efectua apeluri recursive, se revine din recursivitate la poziia (3, 4), din care
se efectueaz apoi un autoapel pentru vecinul de sus, (2, 4):

249

Capitolul 9
A
0111
0100
0000
1110

D
0
16
2 3 4 5
6

Singurul apel recursiv valid din poziia (2, 4) este pentru poziia (2,
3), obinndu-se:
A
0111
0100
0000
1110

D
0
1 7 6
2 3 4 5
6

Se revine din recursivitate pn la poziia (3, 3), de unde, cnd se


verific vecinul (2, 3) se vor ndeplini toate condiiile necesare efecturii
unui apel recursiv, deoarece 7 > 4 + 1 = 5 i poziia (2, 3) este valid i
conine o camer deschis. Forma final a matricei D va fi:
A
0111
0100
0000
1110

D
0
1 5 6
2 3 4 5
6

Aadar, am actualizat de dou ori valoarea D[2][3]. Vom ncerca s


gsim un algoritm care actualizeaz fiecare valoare o singur dat, dar vom
prezenta mai nti implementarea acestei metode:
#include <fstream>
using namespace std;
const int maxn = 101;
const int inf = 1 << 30;
const int dx[] = {1, 0, -1, 0};
const int dy[] = {0, 1, 0, -1};

250

Algoritmi de programare dinamic


void citire(bool A[maxn][maxn], int &N)
{
ifstream in("lee.in");
in >> N;
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
in >> A[i][j];

void init(int D[maxn][maxn],


int N)
{
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
D[i][j] = inf;
D[1][1] = 0;
}

in.close();
}

int main()
{
int N;
bool A[maxn][maxn];
int D[maxn][maxn];

bool valid(int N, int x, int y)


{
return x >= 1 && y >= 1 &&
x <= N && y <= N;
}

citire(A, N);
init(D, N);
Lee(A, N, 1, 1, D);

void Lee(bool A[maxn][maxn], int N,


int x, int y, int D[maxn][maxn])
{
for ( int i = 0; i < 4; ++i )
{
int newx = x + dx[i];
int newy = y + dy[i];

ofstream out("lee.out");
out << D[N][N] << '\n';
out.close();
return 0;
}

if ( valid(N, newx, newy) )


if ( !A[newx][newy] &&
D[newx][newy] > D[x][y] + 1 )
{
D[newx][newy] = D[x][y] + 1;
Lee(A, N, newx, newy, D);
}
}
}

Precizm c am omis intenionat funcia care determin coordonatele


ce descriu un drum de lungime minim. Aceast funcie va fi prezentat la
sfrit.
Am afirmat la nceputul acestei seciuni c algoritmul lui Lee este de
fapt o parcurgere n lime. Acei cititori care cunosc parcurgerea n lime i
cea n adncime probabil au observat c prima metod este de fapt o
parcurgere n adncime, deoarece se merge n aceeai direcie pn cnd se
251

Capitolul 9
ntlnete un obstacol i abia apoi se revine la un pas anterior sau se schimb
direcia. Parcurgerile grafurilor vor fi prezentate ntr-un alt capitol, aa c nu
vom detalia aici parcurgerea n lime. Ideea de baz este s verificm la
fiecare pas toi vecinii poziiei curente, iar apoi toi vecinii acestora i aa
mai departe, pn cnd se parcurgere ntreaga matrice. Datorit faptului c
vom parcurge matricea uniform, fiecare element va fi analizat o singur
dat.
Ideea de baz rmne aceeai, diferind doar implementarea. Pentru a
putea implementa parcurgerea descris anterior vom folosi o structur de
date numit coad F.I.F.O. Aceast structur de date a fost descris n
capitolul Introduce n S.T.L.
Vom prezenta n continuare noul algoritm n pseudocod. Notaiile
rmn aceleai, iar Q reprezint coada F.I.F.O. folosit, p reprezint poziia
primului element din coada, iar u poziia ultimului element din coad:
p=u=1
Q[p] = (1, 1)
Ct timp p u execut
o (x, y) = Q[p++]
o Pentru fiecare vecin (newx, newy) al lui (x, y) execut
Dac (newx, newy) este o poziie valid, nu
reprezint un zid i D[newx][newy] > D[x][y] + 1
execut
D[newx][newy] = D[x][y] + 1
Q[++u] = (newx, newy)
Datorit modului n care parcurgem matricea, toate drumurile
posibile vor fi parcurse n acelai timp, deci nu va exista posibilitatea
completrii unei pri a matricei D cu valori care vor trebui ulterior
corectate, aa cum a fost cazul n implementarea iniial. Pentru a evidenia
acest lucru vom prezenta modul de execuie al algoritmului pe exemplul dat.
Iniial avem:
A
0111
0100
0000
1110
p, u
Q: (1, 1)

D
0

252

Algoritmi de programare dinamic


Se extrage primul element din coada Q i anume (1, 1). Se
actualizeaz toi vecinii acestuia care se supun condiiilor de mai sus.
Singurul vecin valid este (2, 1), care se actualizeaz, iar poziia (2, 1) se
introduce n coad. Avem configuraia:
A
D
0111 0
0100 1
0000
1110
p, u
Q: (1, 1) (2,1)
La urmtorul pas se extrage Q[p], adic (2, 1). Singurul vecin valid
este (3, 1), care se actualizeaz i se introduce n coad:
A
0111
0100
0000
1110

D
0
1
2

p, u
Q: (1, 1) (2,1) (3,1)
Similar, singurul vecin valid al poziiei Q[p] = (3, 1) este (3, 2), care
se va introduce n coad i se va actualiza. La urmtorul pas se va extrage
(3, 2) din coad i se va introduce singurul vecin valid al acestei poziii,
(3, 3):
A
0111
0100
0000
1110

D
0
1
2 34

p, u
Q: (1, 1) (2,1) (3,1) (3, 2) (3, 3)
Se extrage (3, 3) din Q. Poziia (3, 3) are doi vecini valizi: (3, 4) i
(2, 3), care se actualizeaz i se introduc amndoi n coad:
253

Capitolul 9
A
0111
0100
0000
1110

D
0
15
2 34 5

p
u
Q: (1, 1) (2,1) (3,1) (3, 2) (3, 3) (3, 4) (2, 3)
Se extrage elementul (3, 4), care va actualiza poziiile (4, 4) i (2, 4)
i le va introduce n coad. Se observ c dup acest pas matricea este deja
completat corect.
A
0111
0100
0000
1110

D
0
15 6
2 34 5
6

p
u
(1,
1)
(2,1)
(3,1)
(3,
2)
(3,
3)
(3,
4)
(2,
3)
(4,
4)
(2,
4)
Q:
Dac ne intereseaz doar poziia (N, N), putem returna D[N][N]
imediat ce aceast valoare a fost calculat. Dac ne intereseaz ntreaga
matrice D, algoritmul trebuie continuat pn cnd p devine mai mare dect
u.
Datorit faptului c am introdus fiecare poziie a matricei (care nu
reprezint un zid) n coad exact o singur dat i pentru c am evitat
recursivitatea, timpul de execuie al acestei implementri este cu mult mai
bun dect cel al implementrii recursive.
Pentru a determina coordonatele care alctuiesc traseul vom folosi o
funcie recursiv drum(x, y). Observm c dac D[x][y] == k (k > 0,
k != ) atunci poziia imediat anterioar lui (x, y) n cadrul drumului minim
este acel vecin (p, q) al lui (x, y) pentru care D[p][q] == k 1. Dac
D[x][y] este 0, atunci (x, y) == (1, 1); aceast condiie este chiar condiia de
ieire din recursivitate. Aadar funcia drum(x, y) poate fi scris astfel:
Dac D[x][y] == 0 afieaz (1, 1) i oprete execuia
Caut un singur vecin (p, q) al lui (x, y) pentru care
D[p][q] == D[x][y] 1
Apel recursiv drum(p, q)
Afieaz (x, y)
254

Algoritmi de programare dinamic


n C++ aceast funcie poate fi implementat n felul urmtor.
Funcia funcioneaz att pentru implementarea deja prezentat, ct i pentru
implementrile ce vor urma.
void drum(int D[maxn][maxn], int N, int x, int y, ofstream &out)
{
if ( D[x][y] == 0 )
{
out << 1 << ' ' << 1 << '\n';
return;
}
for ( int i = 0; i < 4; ++i )
{
int newx = x + dx[i];
int newy = y + dy[i];
if ( valid(N, newx, newy) )
if ( D[newx][newy] == D[x][y] - 1 )
{
drum(D, N, newx, newy, out);
break;
}
}
out << x << ' ' << y << '\n';
}

Funcia determin un singur traseu, iar apelul iniial este


drum(D, N, N, N, out) pentru cerina problemei prezentate.
Vom prezenta n continuare dou variante de funcii Lee care
implementeaz ultimul algoritm descris. Avem mai multe posibiliti de a
implementa o coad. Prima i cea mai evident posibilitate este s folosim
un vector cu N2 perechi de numere ntregi (deoarece fiecare poziie poate fi
introdus cel mult o singur dat n coad) i s reinem poziia primului
element al cozii n variabila p i poziia ultimului element n variabila u,
exact aa cum se poate vedea n evidenierea exemplului dat. Pentru aceast
implementare avem nevoie de o structur care grupeaz dou variabile
ntregi:
struct pereche { int x, y; };

Noua funcie lee poate fi implementat n felul urmtor:


255

Capitolul 9
void Lee(bool A[maxn][maxn], int N, int D[maxn][maxn])
{
pereche Q[maxn*maxn];
int p = 1, u = 1;
Q[p].x = 1, Q[p].y = 1;
while ( p <= u )
{
pereche poz = Q[p++]; // extragerea primului element din coada
for ( int i = 0; i < 4; ++i )
{
int newx = poz.x + dx[i];
int newy = poz.y + dy[i];
if ( valid(N, newx, newy) )
if ( !A[newx][newy] && D[newx][newy] > D[poz.x][poz.y]+1 )
{
D[newx][newy] = D[poz.x][poz.y] + 1;
// adaugarea vecinului in coada
++u;
Q[u].x = newx;
Q[u].y = newy;
}
}
}
}

Iar apelul iniial devine Lee(A, N, D).


Putem scrie un cod mai compact i mai natural limbajului C++
folosind utilitile puse la dispoziie de ctre biblioteca S.T.L. i anume
containerele pair i queue, care pun la dispoziie programatorului ceea ce
noi a trebuie s implementm singuri n codul anterior: posibilitatea de a
reine perechi de numere, respectiv o coad F.I.F.O. Avantajul containerului
queue este c acesta nu va folosi niciodata memorie pentru N2 elemente,
deoarece este implementat n aa fel nct elementele scoase din coad s fie
terse i din memorie. n implementarea precedent memoria folosit pentru
coad este ntotdeauna maxim i nici nu tergem efectiv elementele, ci doar
incrementm o limit inferior pentru poziia primului element.
Nu vom prezenta pe larg aici aceste dou containere ntruct au fost
prezentate n cadrul capitolului Introducere n S.T.L. Precizm doar c
pentru folosirea lor trebuie incluse fiierele antet <utility> i <queue>.
Noua implementare este:
256

Algoritmi de programare dinamic


void Lee(bool A[maxn][maxn], int N, int D[maxn][maxn])
{
queue<pair<int, int> > Q;
Q.push(make_pair(1, 1));
while ( !Q.empty() )
{
pair<int, int> poz = Q.front(); // extragerea primului element
Q.pop(); // stergerea efectiva a primului element
for ( int i = 0; i < 4; ++i )
{
int newx = poz.first + dx[i];
int newy = poz.second + dy[i];
if ( valid(N, newx, newy) )
if ( !A[newx][newy] &&
D[newx][newy] > D[poz.first][poz.second] + 1 )
{
D[newx][newy] = D[poz.first][poz.second] + 1;
Q.push(make_pair(newx, newy)); // adaugarea in coada
}
}
}
}

Recomandm cititorilor s se familiarizeze ct mai bine cu biblioteca


S.T.L., mai ales pentru capitolele ce vor urma, deoarece facilitile oferite de
aceasta sunt de multe ori foarte folositoare i conduc la implementri mai
uoare sau mai eficiente. De aceea, de fiecare dat cnd acest lucru este
posibil i preferabil, urmtoarele implementri vor fi prezentate exclusiv
folosind facilitile S.T.L.
Exerciii:
a) Considerm c o persoan pornete din (1, 1) i alta din (N, N).
Cele dou persoane se mic exact n acelai timp. Scriei un
program care determin coordonatele spre care acestea ar trebui
s se ndrepte pentru a se ntlni ct mai rapid.
b) Dai un exemplu pe care soluia recursiv efectueaz cu mult mai
muli pai dect e necesar.
c) Modificai funcia de afiare a drumului astfel nct s afieze
toate drumurile minime existente.
257

Capitolul 9

9.2. Problema subsecvenei de sum maxim


Considerm un numr natural N i un vector A cu N elemente
numere ntregi. O subsecven [st, dr] a vectorului A reprezint secvena de
elemente A[st], A[st + 1], ..., A[dr]. Suma unei subsecvene reprezint
suma tuturor elementelor acelei subsecvene. Se cere determinarea unei
subsecvene de sum maxim.
Datele de intrare se citesc din fiierul subsecv.in, iar suma maxim
se va afia n fiierul subsecv.out.
Exemplu:
subsecv.in
10
-6 1 -3 4 5 -1 3 -8 -9 1

subsecv.out
11

Vom prezenta trei metode de rezolvare, ncepnd de la o metod


trivial i sfrind cu metoda optim de rezolvare, care const ntr-o singur
parcurgere a vectorului.
n implementrile oferite ca model vom prezenta doar o funcie
subsecvi(A, N) care primete ca parametri vectorul A respectiv dimensiunea
acestuia i returneaz suma maxim a unei subsecvene. Considerm citirea
i afiarea ca fiind cunoscute.
Prima metod const n verificarea tuturor subsecvenelor vectorului
de intrare A. Pentru fiecare subsecven [st, dr] vom parcurge elementele
A[st], A[st + 1], ..., A[dr] i vom face suma acestora. Dac aceast sum
este mai mare dect maximul curent (iniializat la nceput cu o valoare foarte
mic: infinit), actualizm maximul curent. Complexitatea acestei metode
este O(N3), deoarece exist O(N2) subsecvene i fiecare dintre acestea
trebuie parcurs pentru a-i afla suma.
Putem implementa aceast metod n felul urmtor:

258

Algoritmi de programare dinamic


int subsecv1(int A[], int N)
{
int max = -inf; // declarat global astfel: const int inf = 1 << 30;
for ( int st = 1; st < N; ++st )
for ( int dr = st; dr <= N; ++dr )
{
int temp = 0;
for ( int i = st; i <= dr; ++i )
temp += A[i];
if ( temp > max )
max = temp;
}
return max;
}

A doua metod de rezolvare are complexitatea O(N2) i este o


simpl optimizare a primei metode. Vom ncerca s eliminm parcurgerea
prin care facem suma subsecvenei [st, dr], sau, altfel spus, vom ncerca s
calculm suma fiecrei subsecvene pe msur ce acestea sunt generate i nu
pentru fiecare n parte printr-o parcurgere. S presupunem c tim care este
suma temp a unei subsecvene [st, dr]. Atunci suma subsecvenei
[st, dr + 1] va fi temp + A[dr + 1]. Vom iniializa aadar temp cu 0 pentru
fiecare st, iar apoi vom aduna, pentru fiecare dr, pe A[dr] la temp i l vom
compara pe temp cu max:
int subsecv2(int A[], int N)
{
int max = -inf;
for ( int st = 1; st < N; ++st )
{
int temp = 0;
for ( int dr = st; dr <= N; ++dr )
{
temp += A[dr];
if ( temp > max )
max = temp;
}
}
return max;
}

259

Capitolul 9
Aceast implementare este deja relativ eficient pentru vectori cu un
numr de elemente de pn la ordinul miilor, spre deosebire de prima
implementare care este aplicabil doar pentru un numr de elemente de
ordinul sutelor. Putem obine ns o rezolvare i mai eficient, care
funcioneaz rapid pe vectori cu sute de mii sau chiar milioane de elemente.
Cea de-a treia metod folosete paradigma programrii dinamice
pentru a obine o rezolvare n O(N). Fie S[i] = suma maxim a unei
subsecvene care se termin cu elementul i. S presupunem c, pentru un
anume 1 k < N, cunoatem valoarea lui S[k]. Ne intereseaz s-l obinem
pe S[k + 1] din S[k]. Observm c avem dou posibiliti:
1. Adugm elementul k + 1 la sfritul subsecvenei de sum
maxim care se termin cu elementul k, obinnd o subsecven
de sum A[k + 1] + S[k].
2. Ignorm subsecvena de sum maxim care se termin cu
elementul k i considerm subsecvena format doar din
elementul k + 1, aceasta avnd suma A[k + 1].
Evident vom alege maximul sumelor aferente celor dou cazuri.
Aadar, obinem urmtoarea formul de recuren:
S[k + 1] = max(A[k + 1] + S[k], A[k + 1]).
Singura iniializare care trebuie fcut este S[1] = A[1]. Rspunsul
problemei este dat de valoarea maxim din S.
n implementarea prezentat am folosit un vector S pentru
implementarea recurenei. Deoarece pentru calculul lui S[k + 1] avem
nevoie doar de S[k], n loc de vectorul S putem folosi doar nite variabile.
Aceast ultim optimizare este lsat ca exerciiu pentru cititor.
S evideniem modul de execuie al algoritmului pe exemplul dat.
Iniial avem:
i
1 2 3 4 5 6 7 8 9 10
A[i] -6 1 -3 4 5 -1 3 -8 -9 1
S[i] -6
Pentru S[2] lum maximul dintre S[1] + A[2] i A[2].
S[1] + A[2] = -5, iar A[2] = 1. Maximul este aadar A[2] = 1:
i
1 2 3 4 5 6 7 8 9 10
A[i] -6 1 -3 4 5 -1 3 -8 -9 1
S[i] -6 1
260

Algoritmi de programare dinamic


Se observ uor c subsecvena de sum maxim care se termin pe
poziia 2 are suma 1 (cealalt posibilitate fiind doar subsecvena [1, 2] care
are suma -5), deci se respect definiia lui S.
La sfrit, vectorul S este urmtorul. Se poate verifica, folosind
eventual implementrile precedente, c acesta este corect calculat.
i
1 2 3 4 5 6 7 8 9 10
A[i] -6 1 -3 4 5 -1 3 -8 -9 1
S[i] -6 1 -2 4 9 8 11 3 -6 1
int subsecv3(int A[], int N)
{
int max = -inf;
int S[maxn];
S[1] = A[1];
for ( int i = 2; i <= N; ++i )
{
S[i] = A[i] + S[i - 1] > A[i] ? A[i] + S[i - 1] : A[i];
if ( S[i] > max )
max = S[i];
}
return max;
}

Exerciii:
a) Modificai implementrile date pentru a afia i poziiile de
nceput i de sfrit a unei subsecvene de sum maxim.
b) Se cere o subsecven de produs maxim, iar numerele sunt reale.
Rezolvai problema att pentru numere strict pozitive ct i
pentru numere nenule (dar care pot fi negative).
c) Se d o matrice i se cere determinarea unui dreptunghi de sum
maxim. Ultimul algoritm prezentat poate fi extins pentru
rezolvarea problemei n O(N3). Cum?

261

Capitolul 9

9.3. Problema subirului cresctor maximal


Considerm un vector A cu N elemente numere ntregi. Un subir a
lui A este o secven de elemente nu neaprat consecutive ale lui A, dar a
cror ordine relativ n A este pstrat. Un subir cresctor a lui A este un
subir a lui A a crui elemente sunt ordonate cresctor. Un subir cresctor
maximal este un subir cresctor la care nu se mai pot aduga elemente fr
a strica proprietatea de subir cresctor. Se cere determinarea celui mai lung
subir cresctor maximal al vectorului A.
Datele de intrare se citesc din fiierul subsir.in. n fiierul
subsir.out se va afia pe prima linie lungimea lg a celui mai lung subir
cresctor maximal gsit, iar pe urmtoarea linie se vor afia valorile (n
numr de lg) care constituie un astfel de subir. Se poate afia orice soluie
dac exist mai multe.
Exemplu:
subsir.in
subsir.out
10
5
6 3 8 9 1 2 10 4 -1 11 6 8 9 10 11
Problema admite o rezolvare prin programare dinamic n timp
O(N2), dar i o rezolvare greedy n timp O(Nlog N). Vom prezenta ambele
rezolvri.
Rezolvarea prin programare dinamic presupune gsirea unei
formule de recuren care fie va furniza direct rspunsul problemei, fie va fi
doar un pas intermediar n rezolvarea problemei. n acest caz, putem gsi o
formul de recuren pentru Lg care va conduce direct la calcularea acestei
valori. Raionamentul este unul similar cu cel de la problema anterioar. Fie
L[i] = lungimea celui mai lung subir cresctor maximal care se termin
pe poziia i. Iniial vom considera L[i] = 1 pentru fiecare 1 i N. Evident,
L[1] va rmne ntotdeauna 1, deoarece singurul subir al unui vector cu un
singur element este nsui acel vector.
S presupunem acum c avem calculate valorile L[1], L[2], ..., L[k]
pentru un k < N. Ne propunem s calculm L[k + 1]. Folosind definiia lui
L, ne propunem aadar s calculm lungimea celui mai lung subir cresctor
maximal care se termin pe poziia k + 1, tiind lungimile celor mai lungi
subiruri cresctoare maximal care se termin pe poziiile 1, 2, ..., k. tiind
aceste valori, este evident c pentru a maximiza lungimea subirului care se
262

Algoritmi de programare dinamic


termin pe poziia k + 1 trebuie adugat A[k + 1] unui subir maximal care
se termin pe o poziie j < k + 1, pentru care L[j] are valoarea maxim i
pentru care A[j] < A[k + 1], deoarece subirul trebuie s fie cresctor.
Aadar obinem recurena:
L[1] = 1
L[i] = 1 + max{L[j] | A[j] < A[i]} sau 1 dac mulimea respectiv e
vid, unde 1 j < i.
Timpul O(N2) rezult din faptul c pentru fiecare i trebuie s
determinm minimul subsecvenei [1, i 1], rezultnd un numr ptratic de
operaii. Valoarea lg este dat de valoarea maxim din vectorul L.
Pentru determinarea valorilor care fac parte din subirul cresctor
maximal vom folosi un vector P unde P[i] = poziia ultimului element
care a intrat n calculul lui L[i] sau 0 dac nu exist. n alte cuvinte, dac
L[i] = 1 + max{L[j] | A[j] < A[i]} = 1 + L[max], 1 j < i, atunci vom avea
P[i] = max.
Vom evidenia n continuare modul de execuie al algoritmului pe
exemplul dat. Iniial avem:
i
A[i]
L[i]
P[i]

1
6
1
0

2
3
1
0

3
8
1
0

4
9
1
0

5
1
1
0

6 7 8 9 10
2 10 4 -1 11
1 1 1 1 1
0 0 0 0 0

La pasul i = 2 cutm poziia max a celui mai mare element din


subsecvena [1, 1] a vectorului L pentru care A[max] < A[2]. Nu se gsete
nicio astfel de poziie, aa c totul rmne neschimbat.
La pasul i = 3 cutm acelai max din subsecvena [1, 2] a
vectorului L pentru care are loc A[max] < A[3]. Putem alege de data
aceasta fie max = 1, fie max = 2, ambele poziii respectnd condiiile
impuse. Vom alege max = 1. Aadar, L[3] devine L[max]+1 = L[1]+1 = 2,
iar P[3] devine max, adic 1. Am marcat cu rou actualizrile:
i
A[i]
L[i]
P[i]

1
6
1
0

2
3
1
0

3
8
2
1

4
9
1
0

5
1
1
0

263

6 7 8 9 10
2 10 4 -1 11
1 1 1 1 1
0 0 0 0 0

Capitolul 9
Se procedeaz n acest fel pn la completarea vectorilor L i P.
Forma lor final este prezentat mai jos. Este uor de verificat corectitudinea
calculrii acestor vectori conform definiiei lor.
i
A[i]
L[i]
P[i]

1
6
1
0

2
3
1
0

3
8
2
1

4
9
3
3

5
1
1
0

6 7 8 9 10
2 10 4 -1 11
2 4 3 1 5
5 4 6 0 7

Am marcat mai sus coloanele care identific o soluie optim. Vom


explica n continuare cum putem folosi vectorul P pentru a obine valorile
soluiei optime. Fie sol poziia celui mai mare element din vectorul L. n
acest caz, sol = 10. Este clar c ultima valoare din subirul cresctor
maximal este atunci A[10]. Deoarece P[i] reprezint ultima valoare care a
intrat n calcului lui L[i] (sau predecesorul lui i), P[10] reprezint poziia
penultimei valori a subirului cresctor maximal gsit. Atunci A[ P[10] ]
reprezint penultima valoare a soluiei. Mergnd n continuare cu acest
raionament, A[ P[ P[10] ] ] va reprezenta antepenultima valoare i aa mai
departe pnd cnd ajungem la o valoare k pentru care P[k] = 0. Cnd acest
lucru se ntmpl, am gsit prima valoare a subirului soluie.
Vom folosi aadar o funcie recursiv care va reconstitui soluia
folosind vectorul P. Acest vector se numete vector de predecesori, iar
ideea folosit n construcia sa poate fi aplicat la orice problem de
programare dinamic la care se cere afiarea unor obiecte care constituie un
optim cerut. Prezentm ntregul program care rezolv problema.
#include <fstream>
using namespace std;
const int maxn = 101;
void citire(int A[], int &N)
{
ifstream in("subsir.in");
in >> N;
for ( int i = 1; i <= N; ++i )
in >> A[i];
in.close();
}

264

Algoritmi de programare dinamic


int cmlscm(int A[], int N, int L[], int P[])
{
for ( int i = 1; i <= N; ++i )
L[i] = 1, P[i] = 0;

int main()
{
int N;
int A[maxn], L[maxn], P[maxn];

int sol = 1;
for ( int i = 2; i <= N; ++i )
{
for ( int j = 1; j < i; ++j )
if ( L[j]+1 > L[i] && A[j] < A[i] )
{
L[i] = L[j] + 1;
P[i] = j;
}
if ( L[i] > L[sol] )
sol = i;

citire(A, N);
ofstream out("subsir.out");
int sol = cmlscm(A, N, L, P);
out << L[sol] << '\n';
reconst(sol, P, A, out);
out.close();
return 0;
}

}
return sol;
}
void reconst(int poz, int P[], int A[],
ofstream &out)
{
if ( P[poz] )
reconst(P[poz], P, A, out);
out << A[poz] << ' ';
}

Aceast rezolvare poate fi optimizat folosind structuri de date


avansate, cum ar fi arbori de intervale sau arbori indexai binar, dar aceste
structuri nu vor fi prezentate n cadrul acestui capitol i nu sunt nici cea mai
bun metod de a rezolva optim aceast problem.
Vom prezenta n continuare o rezolvare optim cu timpul de execuie
O(Nlog N) care nu presupune dect noiuni algoritmice elementare.
Vom considera A ca fiind vectorul citit i vom folosi nc doi vectori
L i P, dar care nu vor avea aceeai semnificaie ca pn acum.
Mai nti iniializm vectorul L cu valoarea infinit. Aplicm apoi
urmtorul algoritm:

265

Capitolul 9
Pentru fiecare i de la 1 la N execut
o Suprascrie A[i] peste cel mai mic element din L, dar care
este strict mai mare dect A[i]. (1)
o Fie k poziia peste care a fost suprascris A[i]. P[i] ia
valoarea k.
Lungimea vectorului L (fcnd abstracie de poziiile marcate cu
infinit) reprezint lungimea celui mai lung subir cresctor
maximal.
Fie lg lungimea vectorului L. Pentru a reconstitui soluia, se
caut n vectorul P poziia ultimei apariii a valorii lg. Fie aceast
poziie klg . Se caut apoi ultima apariie a valorii lg 1, dar care
apare nainte de poziia klg . Aceasta va fi aadar pe o poziie
klg 1 < klg. Se procedeaz similiar pentru valorile lg 2, lg 3,
..., 2, 1. Soluia va fi dat de subirul: A[k1], A[k2], ..., A[klg ].
Putem implementa reconstituirea tot recursiv.
La pasul (1), dac A[i] este mai mare dect toate elementele diferite
de infinit din L, atunci A[i] se va suprascrie peste cea mai din stnga
valoare egal cu infinit. Putem implementa acest pas eficient n timp O(log
N) folosind o cutare binar.
S prezentm modul de execuie al algoritmului pe exemplul dat.
Iniial avem:
i
1
2
3
4
5
6
7
8
9 10
3
8
9
1
2 10 4 -1 11
A[i] 6
L[i] inf inf inf inf inf inf inf inf inf inf
P[i]
La pasul i = 1, se suprascrie A[1] = 6 peste cea mai mic valoare din
L, dar care este strict mai mare dect 6. Singura posibilitate este s
suprascriem elementul A[1] peste primul inf. n P[1] vom reine 1:
i
A[i]
L[i]
P[i]

1 2
3
4
5
6
7
8
9 10
6 3
8
9
1
2 10 4 -1 11
6 inf inf inf inf inf inf inf inf inf
1

La pasul i = 2, A[2] = 3 se va suprascrie peste L[1] = 6, iar P[2]


devine 1:
266

Algoritmi de programare dinamic


i
A[i]
L[i]
P[i]

1 2
3
4
5
6
7
8
9 10
6 3
8
9
1
2 10 4 -1 11
3 inf inf inf inf inf inf inf inf inf
1 1

A[3] se va suprascrie peste L[2], iar P[3] va deveni 2. Se procedeaz


n acest mod pentru fiecare element din A, iar forma final a vectorilor este:
i
1 2
A[i] 6 3
L[i] -1 2
P[i] 1 1

3 4 5 6
7
8
9 10
8 9 1 2 10 4 -1 11
4 10 11 inf inf inf inf inf
3
1
2 3 1 2
4
5

Lungimea lg este aadar 5, deoarece exist 5 elemente diferite de inf


n L. Soluia este dat de A[2], A[3], A[4], A[7] i A[10].
Prezentm n continuare implementarea acestei metode de rezolvare.
#include <fstream>
using namespace std;
const int maxn = 101;
const int inf = 1 << 30;
void citire(int A[], int &N)
{
ifstream in("subsir.in");
in >> N;
for ( int i = 1; i <= N; ++i )
in >> A[i];
in.close();
}

267

Capitolul 9
int cbin(int st, int dr, int val, int L[])
{
while ( st < dr )
{
int m = (st + dr) / 2;
if ( L[m] < val )
st = m + 1;
else
dr = m;
}

int cmlscm(int A[], int N, int L[],


int P[])
{
int lg = 0;
for ( int i = 1; i <= N; ++i )
{
L[i] = inf;
int k = cbin(1, lg + 1, A[i], L);
// creste lungimea celui mai lung
// subsir?
if ( L[k] == inf )
++lg;

return st;
}
void reconst(int N, int A[], int P[],
int val, ofstream &out)
{
for ( int i = N; i; --i )
if ( P[i] == val )
{
reconst(i - 1, A, P, val - 1, out);
out << A[i] << ' ';
break;
}
}

L[k] = A[i];
P[i] = k;
}
return lg;
}
int main()
{
int N, A[maxn], L[maxn], P[maxn];
citire(A, N);
ofstream out("subsir.out");
int sol = cmlscm(A, N, L, P);
out << sol << '\n';
reconst(N, A, P, sol, out);
out.close();
return 0;
}

Dei acest algoritm este mai eficient, spre deosebire de metoda


clasic, nu poate fi adaptat la toate variaiunile problemei.
Exerciiu: scriei un program care determin cel mai scurt subir
cresctor maximal i altul care determin numrul de subiruri cresctoare
maximal.
268

Algoritmi de programare dinamic

9.4. Problema celui mai lung subir comun


Se dau dou iruri de caractere A i B, formate din litere mici ale
alfabetului englez. Se cere gsirea unui ir de caractere C de lungime
maxim care este subir att a lui A ct i a lui B.
irurile A i B se citesc din fiierul sircom.in, fiecare pe cte o linie.
n fiierul sircom.out se va afia pe prima linie lungimea celui mai lung
subir comun, iar pe a doua linie irul gsit.
Exemplu:
sircom.in
sircom.out
gatcbccgaatabbat
10
gcbcataabbaggaacba gcbcatabba

Rezolvare
Pentru a rezolva problema vom ncerca s gsim o formul de
recuren pentru calculul lungimii celui mai lung subir comun. Fie L[i][j] =
lungimea celui mai lung subir comun al secvenelor A[1, i] i B[1, j],
pentru 1 i lungime(A) i 1 j lungime(B). S presupunem c avem
calculate toate valorile matricii L care preced elementul (p + 1, q + 1) (sunt
fie pe aceeai linie i pe o coloan precedent, fie pe o linie precedent). Se
disting dou cazuri:
1. Dac A[p + 1] == B[q + 1], atunci putem aduga caracterul
A[p + 1] celui mai lung subir comun al secvenelor A[1, p] i
B[1, q], obinnd, pentru secvenele A[1, p + 1] i B[1, q + 1] un
subir comun de lungime maxim care este mai lung cu un
caracter. Aadar, L[p + 1][q + 1] = L[p][q] + 1.
2. Dac A[p + 1] != B[q + 1], atunci nu putem extinde niciun
subir de lungime maxim calculat anterior i va trebui s salvm
n L[p + 1][q + 1] lungimea celui mai lung subir de lungime
maxim calculat pn acuma. Aceast valoare este dat de
maximul dintre L[p][q + 1] i L[p + 1][q].
Timpul de execuie al acestei metode este O(NM), unde N este
lungimea primului ir, iar M este lungimea celui de-al doilea ir. Memoria
folosit de algoritm este tot O(NM), deoarece matricea L are N linii i M
coloane. Putem reduce memoria folosit la O(N + M), dar sacrificm astfel
posibilitatea reconstituirii soluiei. Vom descrie ns i aceast metod.
269

Capitolul 9
Pentru a reconstitui soluia vom proceda similar cu metoda de
reconstituire a unui drum a crui lungime minim a fost calculat cu
algoritmul lui Lee. Vom folosi o funcie recursiv reconst(x, y) care va
afia un subir comun de lungime maxim. n primul rnd, condiia de ieire
din recursivitate va fi dac x < 1 sau y < 1. Dac nu, verificm dac
A[x] == B[y], iar dac da, apelm recursiv reconst(x 1, y 1) i afim
A[x]. Dac A[x] != B[y] atunci apelm recursiv fie reconst(x 1, y), n
cazul n care L[x 1][y] > L[x][y 1], fie reconst(x, y 1) altfel.
Pentru a simplifica implementarea am folosit tipul de date string
pentru reinerea irurilor de caractere. Indicii irurilor de caractere ncep de
la 0, aa c trebuie s fim ateni la cum comparm caracterele individuale,
deoarece indicii matricei L ncep de la 1. Singura iniializare care trebuie
fcut este completarea liniei i coloanei 0 a matricei L cu valoarea 0,
pentru a evita cazurile particulare ale recurenei descrise.
Prezentm n continuare implementarea algoritmului de rezolvare.
#include <fstream>
#include <string>
using namespace std;
const int maxn = 101;
void citire(string &A, string &B)
{
ifstream in("sircom.in");
in >> A >> B;
in.close();
}

int cmlsc(string &A, string &B,


int L[maxn][maxn])
{
for ( int i = 0; i <= A.length(); ++i )
L[i][0] = 0;
for ( int i = 0; i <= B.length(); ++i )
L[0][i] = 0;
for ( int i = 1; i <= A.length(); ++i )
for ( int j = 1; j <= B.length(); ++j )
if ( A[i - 1] == B[j - 1] )
L[i][j] = L[i - 1][j - 1] + 1;
else
{
if ( L[i - 1][j] > L[i][j - 1] )
L[i][j] = L[i - 1][j];
else
L[i][j] = L[i][j - 1];
}
return L[A.length()][B.length()];
}

270

Algoritmi de programare dinamic


void reconst(int x, int y, string &A, string &B, int L[maxn][maxn],
ofstream &out)
{
if ( x < 1 || y < 1 )
return;
if ( A[x - 1] == B[y - 1] )
{
reconst(x - 1, y - 1, A, B, L, out);
out << A[x - 1];
}
else
{
if ( L[x - 1][y] > L[x][y - 1] )
reconst(x - 1, y, A, B, L, out);
else
reconst(x, y - 1, A, B, L, out);
}
}
int main()
{
string A, B;
int L[maxn][maxn];
citire(A, B);
ofstream out("sircom.out");
out << cmlsc(A, B, L) << '\n';
reconst(A.length(), B.length(),
A, B,
L, out);
out.close();
return 0;
}

Pentru a reduce memoria folosit la O(N + M) trebuie observat c


pentru calculul unei valori L[i][j] nu avem nevoie dect de valori de pe linia
curent (L[i][j 1]) i de pe linia precedent (L[i 1][j] i L[i 1][j 1]).
Aadar, este de ajuns s folosim doar doi vectori de lungime egal cu
lungimea irului B. Unul dintre vectori, L1 va reprezenta linia precedent
271

Capitolul 9
liniei curente, iar cellalt vector, L2, va reprezenta chiar linia curent. Noua
form a formulei de recuren este:
2 =

1 1 + 1
max
(1 , 2 1 )

= []

Dup fiecare calculare complet a lui L2, nainte de nceperea unei


noi iteraii vectorul L2 va trebui copiat n L1, pentru ca valorile calculate la
pasul curent s poate fi folosite la pasul urmtor. La sfritul algoritmului,
cei doi vectori vor avea acelai coninut, deci rspunsul problemei va fi dat
fie de L1[lungime(B)] fie de L2[lungime(B)].
Prezentm doar modificrile relevante:
int cmlsc(string &A, string &B)
{
int L1[maxn], L2[maxn];
L2[0] = 0;
for ( int i = 0; i <= B.length(); ++i)
L1[i] = 0;
for ( int i = 1; i <= A.length(); ++i )
{
for ( int j = 1; j <= B.length(); ++j )
if ( A[i - 1] == B[j - 1] )
L2[j] = L1[j - 1] + 1;
else
{
if ( L2[j - 1] > L1[j] )
L2[j] = L2[j - 1];
else
L2[j] = L1[j];
}
for ( int j = 1; j <= B.length(); ++j )
L1[j] = L2[j];
}
return L1[B.length()];
}

Aceast implementare este preferabil dac memoria disponibil este


limitat i dac nu se cere reconstituirea unei soluii, acest lucru fiind
imposibil deoarece algoritmul pstreaz doar valorile finale ale recurenei,
nu i pe cele iniiale.
272

Algoritmi de programare dinamic


Exerciii:
a) Afiai ntreaga matrice L pentru a nelege mai bine formula de
recuren.
b) Afiai toate subirurile comune de lungime maxim.
c) n implementarea de mai sus am transmis parametrii A i B prin
referin. Unde era indicat s se foloseasc transmitere prin
referin constant?
d) Scriei o implementare care folosete vectori clasici de caractere
n loc de tipul string.
e) Scriei un program care afieaz acel subir comun de lungime
maxim care este primul din punct de vedere alfabetic.
f) Se poate evita copierea vectorului L2? Dac da, cum?

9.5. Problema nmulirii optime a matricelor


Se dau N matrice, considerate cu elemente numere reale, identificate
printr-un vector de dimensiuni D. Astfel, matricea 1 i N are dimensiunea
(D[i 1], D[i]). Se cere gsirea numrului minim de nmuliri scalare
necesare pentru calcularea produsului celor N matrice.
Fiierul de intrare inmopt.in conine pe prima linie numrul N al
matricelor, iar pe linia urmtoare N + 1 valori ce reprezint vectorul de
dimensiuni. Numrul minim de nmuliri scalare se va afia n fiierul
inmopt.out.
Exemplu:
inmopt.in inmopt.out
3
64
4325
Explicaie: notm cele trei matrice cu A, B i C. Numrul minim de
nmuliri scalare necesare se obine nmulind matricele astfel: (AB)C.
Dac am folosi parantezarea A(BC), am efectua 90 de nmuliri.
Precizm n primul rnd c nmulirea matricelor este asociativ,
deci putem s parantezm nmulirea matricelor n orice mod valid fr a
afecta rezultatul final.
n al doilea rnd, numrul de nmuliri scalare necesare pentru a
nmuli dou matrice de dimensiuni (x, y) i (y, z) este egal cu xyz.

273

Capitolul 9
Vom ncerca s exprimm recursiv problema. Notm matricele date
cu A1, A2, ..., AN. S presupunem c tim un k ntre 1 i N astfel nct
parantezarea (A1A2...Ak)(Ak+1...AN) s fie o parte a soluiei optime.
Atunci, pentru a obine soluia optim, trebuie s tim cum putem paranteza
optim nmulirile A1 A2...Ak i Ak+1...AN.
Pentru a putea exprima matematic acest lucru, fie M[i][j] = numrul
minim de nmuliri scalare necesare nmulirii secvenei de matrice
[i, j]. Dac tim calcula matricea M, atunci rspunsul problemei va fi
M[1][N].
Se disting urmtoarele cazuri:
1. Dac avem o singur matrice, atunci nu trebuie efectuat nicio
nmulire scalar. Acesta este cazul de baz al recurenei, deci
M[i][i] = 0 pentru orice 1 i N.
2. Pentru aflarea numrului minim de nmuliri scalare necesare
pentru nmulirea unei secvene de matrice [i, j], 1 i < j N
este necesar s tim poziia i k < j n care vom mpri
secvena [i, j] n dou secvene parantezate separat [i, k] i
[k + 1, j]. Dimensiunea matricei rezultate din nmulirea
matricelor din secvena [i, k] va fi dat de (D[i 1], D[k]), iar
dimensiunea celei rezultate din nmulirea matricelor din
secvena [k + 1, j] va fi dat de (D[k], D[j]). Avem aadar:
M[i][j] = min(M[i][k] + M[k + 1][j] + D[i 1]D[k]D[j]).
ik<j

Timpul de execuie al algoritmului este O(N3), deoarece pentru


fiecare dintre cele O(N2) secvene efectum o parcurgere n timp O(N)
pentru a gsi k care verific minimul de mai sus. Memoria folosit este
O(N2), deoarece folosim o matrice ptratic de dimensiune N.
#include <fstream>
using namespace std;
const int maxn = 101;
const int inf = 1 << 30;

274

Algoritmi de programare dinamic


void citire(int D[], int &N)
{
ifstream in("inmopt.in");

int main()
{
int N, D[maxn];

in >> N;
for ( int i = 0; i <= N; ++i )
in >> D[i];

citire(D, N);
ofstream out("inmopt.out");
out << rezolvare(D, N);
out.close();

in.close();
}

return 0;
int rezolvare(int D[], int N)
{
int M[maxn][maxn];

for ( int i = 1; i <= N; ++i )


M[i][i] = 0;
for ( int i = N - 1; i; --i )
for ( int j = i + 1; j <= N; ++j )
{
M[i][j] = inf;
for ( int k = i; k < j; ++k )
{
int t = M[i][k]+M[k + 1][j]+
D[i - 1] * D[k] * D[j];
M[i][j] = min(M[i][j], t);
}
}
return M[1][N];
}

Exerciii:
a) De ce i trebuie s porneasc de la N 1 i nu de la 1? Ce se
ntmpl dac i merge de la 1 la N?
b) Concepei o modalitate de a reconstitui soluia. Pentru exemplul
dat, o reconstituire a soluiei ar putea fi (A1*A2)*A3.

275

Capitolul 9

9.6. Problema rucsacului 1


Considerm N obiecte caracterizate prin dou mrimi: greutate (n
kg) i valoare. Considerm un rucscac de capacitate C kg. Ne intereseaz
alegerea unei submulimi de obiecte a cror greutate total s fie cel mult C
i a cror valoare s fie maxim.
Datele de intrare se citesc din fiierul rucsac1.in: pe prima linie
valorile N i C, iar pe urmtoarele N linii cte dou valori Gi Vi
reprezentnd greutatea respectiv valoarea obiectului i. n fiierul de ieire
rucsac1.out se va afia pe prima linie valoarea maxim a obiectelor alese,
iar pe urmtoarea linie se vor afia indicii obiectelor alese, n orice ordine.
Considerm c exist ntotdeauna soluie.
Exemplu:
rucsac1.in rucsac1.out
4 13
22
10 9
32
4 10
5 12
13 20
Putem fi tentai s abordm problema printr-o rezolvare de tip
greedy. Urmtoarea strategie nu este corect: se sorteaz obiectele
descresctor dup valoare i se aleg cele mai valoroase obiecte a cror
greutate nu depete C. n cazul exemplului de mai sus, s-ar putea alege
doar obiectul 4, obinndu-se profitul 20. Rezolvarea corect i eficient a
problemei se face prin metoda programrii dinamice.
Fie F[i] = valoarea maxim care se poate obine dac nu avem
voie s depim greutatea i. Dac putem calcula vectorul F, rspunsul
problemei va fi F[C]. Pentru a gsi o formul de recuren pentru F[i], s
vedem mai nti care sunt cazurile de baz. Este clar c dac nu alegem
niciun obiect, atunci profitul nostru va fi 0, deci vom iniializa F cu 0.
Se presupunem c citim un obiect, adic dou valori Gi Vi. Vom
ncerca s actualizm vectorul F folosind obiectul citit. Pentru acest lucru,
vom itera un j de la C la Gi i vom aplica urmtoarea formul:
F[i] = max(F[i], F[j Gi] + Vi). Formula este corect deoarece, dac

276

Algoritmi de programare dinamic


F[j Gi] + Vi este mai mare dect F[i], nseamn c putem obine o soluie
mai bun adugnd obiectul i obiectelor cu greutatea j Gi, a cror valoare
este F[j Gi].
Este important s iterm variabila j de la C la Gi i nu invers
deoarece, n caz contrar, am putea ajunge n situaia de a folosi un obiect de
mai multe ori: s presupunem c pentru a calcula un F[k] se folosete
valoarea F[k Gi]. Atunci, dac pentru a calcula F[k + Gi] se va folosi
valoarea F[k], obiectul i va fi folosit de dou ori, lucru nepermis. Iterndu-l
pe j de la C la Gi ne asigurm c fiecare obiect va fi folosit o singur dat n
calculul lui F.
Complexitatea algoritmului este O(NC), deoarece parcurgem pentru
fiecare obiect citit vectorul F (de lungime C) pentru a-l actualiza.
Complexitatea este pseudopolinomial, dar n practic de cele mai multe
ori algoritmul este mai eficient dect ar sugera acest rezultat, deoarece nu se
parcurge aproape niciodat ntreg vectorul F. Memoria suplimentar este
O(C).
Pentru a putea reconstitui soluia, vom folosi un vector P unde
P[i] = ultimul element care a intrat n calculul valorii F[i]. Pentru a afla
soluia, vom proceda similar cu celelalte probleme, atta doar c nu mai
avem nevoie de o funcie recursiv, deoarece de data aceasta nu ne
intereseaz ordinea de afiare i c va trebui s pornim de la suma greutile
obiectelor alese de ctre algoritm i nu de la C.
Prezentm n continuare implementarea algoritmului descris.
#include <fstream>
using namespace std;
const int maxn = 101;
const int maxc = 101;
struct obiect { int G, V; };
void citire(obiect A[], int &N, int &C)
{
ifstream in("rucsac1.in");
in >> N >> C;
for ( int i = 1; i <= N; ++i )
in >> A[i].G >> A[i].V;
in.close();
}

277

Capitolul 9
void rezolvare(obiect A[], int N, int C,
int F[], int P[])
{
for ( int i = 0; i <= C; ++i )
F[i] = P[i] = 0;

int main()
{
int N, C;
obiect A[maxn];
citire(A, N, C);

for ( int i = 1; i <= N; ++i )


for ( int j = C; j >= A[i].G; --j )
if ( F[j] < F[j - A[i].G]+A[i].V )
{
F[j] = F[j - A[i].G] + A[i].V;
P[j] = i;
}

int F[maxc], P[maxc];


ofstream out("rucsac1.out");
rezolvare(A, N, C, F, P);
out << F[C] << '\n';
reconst(A, F, P, C, out);

}
out.close();
void reconst(obiect A[], int F[], int P[],
int C, ofstream &out)
{
int max = F[C];
while ( F[C] == max )
--C;
++C;

return 0;
}

while ( P[C] )
{
out << P[C] << ' ';
C -= A[ P[C] ].G;
}
}

Exerciii:
a) Afiai indicii obiectelor cresctor.
b) Afiai vectorii F i P dup fiecare actualizare a lor.
c) Dai exemplu de un set de date de intrare pentru care algoritmul
execut un numr maxim de operaii.

278

Algoritmi de programare dinamic

9.7. Problema rucsacului 2


Aceeai cerina i format al datelor de intrare / ieire ca la problema
anterioar, atta doar c de data aceasta dispunem de un numr infinit de
obiecte din fiecare tip.
rucsac2.in rucsac2.out
4 13
32
10 9
223
4 10
5 12
13 20
Rezolvarea este aproape la fel cu cea de la problema anterioar. Se
modific doar iterarea variabilei j. La problema precedent, j era iterat de la
C la Gi pentru fiecare obiect i tocmai pentru a evita folosirea unui obiect de
mai multe ori. De data aceasta putem folosi un obiect de cte ori dorim, aa
c j va merge de la Gi la C pentru fiecare obiect i.
Noua funcie de rezolvare este:
void rezolvare(obiect A[], int N, int C, int F[], int P[])
{
for ( int i = 0; i <= C; ++i )
F[i] = P[i] = 0;
for ( int i = 1; i <= N; ++i )
for ( int j = A[i].G; j <= C; ++j )
if ( F[j] < F[j - A[i].G] + A[i].V )
{
F[j] = F[j - A[i].G] + A[i].V;
P[j] = i;
}
}

Menionm c prima problem a rucsacului poart numele de


problema 0 / 1 a rucsacului deoarece fiecare obiect poate fi ales cel mult o
dat, iar a doua problem prezentat poart numele de problema ntreag a
rucsacului, deoarece fiecare obiect poate fi ales de un numr ntreg pozitiv
de ori.

279

Capitolul 9
Exerciii:
a) Rezolvai o variant a problemei n care fiecare obiect i poate fi
folosit de cel mult Nri ori.
b) Rezolvai o variant a problemei n care obiectele pot avea
greuti negative.
c) Implementai un algoritm greedy pentru rezolvarea celor dou
probleme. Ct de mare poate ajunge s fie diferena dintre soluia
optim i soluia dat de algoritmul greedy?
d) Implementai un algoritm genetic pentru rezolvarea celor dou
probleme. Ct de aproape de soluia optim este acesta?
Comparai rezultatele algoritmului genetic cu rezultatele
algoritmului greedy.

9.8. Problema plii unei sume 1


Se dau dou numere naturale N i S i un ir de N numere naturale
mai mici ca S. S se determine dac putem alege un subir al celor N
numere astfel nct suma elementelor din subir s fie egal cu S. Fiecare
numr din ir poate fi folosit o singur dat.
Datele de intrare se citesc din fiierul plata1.in, a crui format se
poate deduce din exemplul de mai jos. n fiierul de ieire plata1.out se vor
afia indicii numerelor alese, n orice ordine. Se garanteaz existena unei
soluii.
Exemplu:
plata1.in
plata1.out
7 23
1367
8 3 2 5 7 3 10
Problema este similar cu prima problem a rucsacului. Putem privi
numerele date ca reprezentnd obiecte a cror valoare este 0, fiind deci
caracterizate de o singur mrime: greutatea. Se cere de data aceasta
umplerea complet a rucsacului, a crui capacitate este S. Pentru acest
lucru vom folosi un vector boolean F unde F[i] = true dac putem obine
suma i i false n caz contrar. Iniial, F[0] = true, deoarece suma 0 o
putem obine ntotdeauna, neselectnd niciun numr.
Relaia de recuren este similar cu cea de la problema rucsacului 1.
La fiecare citirea unui numr A[i], se itereaz un j de la S la A[i] i se
execut F[j] |= F[j A[i]]. Operatorul | reprezint sau pe bii. Folosind
280

Algoritmi de programare dinamic


acest operator, ne asigurm c F[j] nu va lua niciodat valoarea false dac
pna acuma a fost true. Pentru a reconstitui soluia, vom folosi un vector P
cu semnificaia obinuit.
Complexitatea algoritmului este O(NS).
Problema admite o rezolvare randomizat care se dovedete a fi
foarte eficient pe majoritatea datelor de intrare i anume:
Fie sel un vector care reine, la fiecare pas, indicii numerelor care
se adun pentru a ncerca s obinem suma S. Fie nesel un
vector care conine indicii numerelor care nu se adun. Fie
stmp = 0. Fie A vectorul care reine numerele date.
Pentru fiecare numr, se decide aleator n care vector va fi plasat
i se actualizeaz, dac este necesar, stmp.
Ct timp stmp != S execut
o Dac stmp > S execut
Mut un element ales aleator din sel n nesel i
actualizeaz stmp.
o Altfel execut
Mut un element ales aleator din nesel n sel i
actualizeaz stmp.
Afieaz vectorul sel.
Complexitatea algoritmului este greu de calculat, deoarece numrul
de operaii depinde n totalitate de nite numere aleatoare. Pe testele
efectuate de autori pe iruri generate aleator, unde N 105, algoritmul
randomizat a rulat de fiecare dat n sub o secund.
Prezentm doar funciile relevante fiecrei metode. n ambele
implementri, A este vectorul dat.

281

Capitolul 9
void dinamica(int A[], int N, int S)
{
bool F[maxn];
int P[maxn];
for ( int i = 0; i <= S; ++i )
F[i] = P[i] = 0; // 0 == false
F[0] = true;
for ( int i = 1; i <= N; ++i )
for ( int j = S; j >= A[i]; --j )
{
F[j] |= F[j - A[i]];

void random(int A[], int N, int S)


{
int sel[maxn], nesel[maxn];
// sel[0], nesel[0] sunt numarul de
// elemente
sel[0] = sel[0] = 0;
int stmp = 0;
srand((unsigned)time(0));
for ( int i = 1; i <= N; ++i )
if ( rand() % 2 )
sel[ ++sel[0] ] = i, stmp += A[i];
else
nesel[ ++nesel[0] ] = i;

if ( F[j - A[i]] && !P[j] )


P[j] = i;

while ( stmp != S )
if ( stmp > S )
{
int poz = 1 + (rand() % sel[0]);
stmp -= A[ sel[poz] ];
nesel[ ++nesel[0] ] = sel[poz];
sel[poz] = sel[ sel[0]-- ];
}
else
{
int poz = 1 + (rand() % nesel[0]);
stmp += A[ nesel[poz] ];
sel[ ++sel[0] ] = nesel[poz];
nesel[poz] = nesel[ nesel[0]-- ];
}
ofstream out("plata1.out");
for ( int i = 1; i <= sel[0]; ++i )
out << sel[i] << ' ';
out.close();

}
ofstream out("plata1.out");
while ( S )
{
out << P[S] << ' ';
S -= A[ P[S] ];
}
out.close();
}

Exerciii:
a) Concepei un algoritm care afieaz soluia cu numr minim de
numere.
b) Cum se pot afia toate soluiile?
c) Ce se ntmpl dac pot exista numere mai mari ca S? Dar dac
exist i numere negative?
d) ncercai s gsii date de intrare pe care algoritmul randomizat
s ruleze mult timp.
282

Algoritmi de programare dinamic

9.9. Problema plii unei sume 2


Aceeai cerin ca la problema anterioar, doar c de data aceasta
putem alege un numr de cte ori dorim pentru a forma suma S.
Exemplu:
plata2.in
plata2.out
7 23
11233
8 3 2 5 7 3 10
Explicaie: 8 + 8 + 3 + 2 + 2 = 23. O alt soluie este 3 + 3 + 3 + 3 +
3 + 8.
Trebuie fcut exact aceeai modificare pe care am fcut-o pentru a
rezolva a doua problem a rucsacului: se schimb ordinea de parcurgere a
sumelor. Astfel, un numr va putea intra de mai multe ori n calculul
formulei de recuren:
for ( int i = 1; i <= N; ++i )
for ( int j = A[i]; j <= S; ++j )
{
F[j] |= F[j - A[i]];
if ( F[j - A[i]] && !P[j] )
P[j] = i;
}

Algoritmul randomizat i pierde din eficien dac l modificm


pentru aceast variant a problemei, deoarece ar trebui s alegem aleator i
de cte ori este folosit un anumit numr.
Variantele randomizate de rezolvare a problemei presupun folosirea
unui algoritm genetic, pentru a evolua de exemplu un vector F unde
F[i] = de cte ori trebuie s folosim numrul A[i] pentru a ne apropia
de suma S. Funcia de fitness va fi dat de diferena n modul dintre suma
codificat de ctre un cromozom i suma cerut S. Lsm implementarea
unui astfel de algoritm ca exerciiu pentru cititor.
Exerciii:
a) Scriei o implementare recursiv pentru ultimele patru probleme
prezentate. Folosii tehnica memoizrii.
283

Capitolul 9
b) Se consider N numere. Scriei un program care adun sau scade
fiecare numr astfel nct s se obin o sum S. Considerai N i
S de ordinul sutelor de mii.
c) n soluiile de la ultimele dou probleme, vectorul F este un
vector boolean. Asta nseamn c putem optimiza memoria
folosit folosind operaii pe bii. Scriei un program care face
acest lucru.
d) Scriei un program care determin n cte moduri se poate obine
suma S.
e) ncercai adaptarea algoritmului randomizat pentru aceast
variant a problemei. Cum se compar acesta cu un algoritm
genetic?

9.10. Numrarea partiiilor unui numr


Se d un numr natural N. S se determine numrul partiiilor lui N.
O partiie a unui numr natural este o modalitate de a scrie numrul N ca
sum de numere naturale nenule.
Fiierele de intrare i de ieire sunt nrpart.in respectiv nrpart.out.
Exemplu:
nrpart.in nrpart.out
7
15
100
190569292
Explicaie:
7=7
1+6=7
1+1+5=7
...
Am rezolvat o problem asemntoare n capitolul despre
backtracking. Acolo se cereau ns i partiiile efective, neavnd alt soluie
dect s le generm pe toate. Deoarece aici se cere numai numrul partiiilor
unui ntreg, vom folosi programarea dinamic pentru a afla acest numr.
Fie nrpart(N, K) o funcie care returneaz numrul partiiilor lui N
n care nu apar termeni mai mici dect K. Putem distinge urmtoarele
situaii:
284

Algoritmi de programare dinamic


1. Numrm doar partiiile pantru care cel mai mic numr folosit
este K, acestea fiind n numr de nrpart(N K, K), deoarece,
dac adugm numrul K fiecrei partiii a numrului N K
(care nu va conine termeni mai mici dect K) atunci obinem
partiii a numrului N.
2. Numrm doar partiiile lui N care conin termeni strict mai mari
dect K. Acestea vor fi n numr de nrpart(N, K + 1), deoarece
o partiie cu termeni de valoare cel puin K care nu conine
termeni de valoare K trebuie s aib toi termenii cel puin
K + 1.
Se poate observa c cele dou situaii n care am mprit problema
sunt disjuncte, deci nu vor numra niciodat aceeai partiie. Mai mult,
reuniunea (suma) lor ne va da numrul de partiii ale numrului N.
Aadar, formula de recuren este urmtoarea:
nrpart(N, K) = 0 pentru K > N, deoarece nu putem avea partiii cu
termeni mai mari dect N.
nrpart(N, K) = 1 pentru K == N deoarece avem o singur partiie cu
termeni egali cu N, dat chiar de numrul N.
nrpart(N, K) = nrpart(N K, K) + nrpart(N, K + 1) din motivele
prezentate mai sus. Rspunsul problemei va fi dat de nrpart(N, 1).
Ai observat probabil c de data aceasta am exprimat recurena
printr-o funcie i nu printr-un vector sau o matrice la fel cum am fcut pn
acum. Vom implementa de data aceasta recurena n mod direct, printr-o
funcie recursiv, dar vom folosi tehnica memoizrii pentru a obine o
soluie eficient. Reamintim c memoizarea const n folosirea unei tabele
de valori (n acest caz o matrice) n care se rein rezultatele calculate de
ctre funcie. La intrarea ntr-un apel recursiv se verific mai nti dac
rezultatul pentru parametrii actuali ai funciei se afl n tabela de valori:
dac da, atunci acest rezultat este returnat direct, iar dac nu rezultatul este
calculat, salvat n tabela de valori i apoi returnat. Astfel, nicio valoare nu va
fi calculat de mai multe ori.
Avantajul unei astfel de abordri este c unele recurene pot fi
implementate scriind mai puin cod.
Prezentm n continuare implementarea acestei rezolvri.

285

Capitolul 9
#include <fstream>
using namespace std;
const int maxn = 101;

int main()
{
int N, memo[maxn][maxn];

int nrpart(int N, int K,


int memo[maxn][maxn])
{
if ( K > N )
if ( N == K ) return 1;

for ( int i = 1; i < maxn; ++i )


for ( int j = 1; j < maxn; ++j )
memo[i][j] = -1;
return 0;
ifstream in("nrpart.in");
in >> N;
in.close();

if ( memo[N][K] != -1 )
return memo[N][K];

ofstream out("nrpart.out");
out << nrpart(N, 1, memo);
out.close();

memo[N][K] = nrpart(N - K, K, memo) +


nrpart(N, K + 1, memo);
return memo[N][K];

return 0;

Pentru cei interesai, numrul de partiii ale unei mulimi de N


elemente este dat de numrul lui Bell, care poate fi calculat printr-o
formul de recuren care folosete combinri. Lsm aceast formul ca
tem de cercetare pentru cititor.
Exerciii:
a) Scriei un program care afieaz numrul de partiii ale lui N
formate doar din numere prime.
b) Care este complexitatea algoritmului de numrare a partiiilor?
c) Scriei un program care folosete o implementare iterativ a
formulei de recuren.

9.11. Distana Levenshtein


Se dau dou iruri de caractere A i B, formate din litere mici ale
alfabetului englez. Asupra irului A putem face urmtoarele trei operaii:
1. Inserm un caracter.
2. tergem un caracter.
3. nlocuim un caracter cu orice alt caracter din alfabetul folosit.
Se cere determinarea numrului minim de operaii necesare
transformrii irului de caractere A n irul de caractere B.
Cele dou iruri de caractere se citesc din fiierul lev.in, iar numrul
minim de operaii se va afia n fiierul lev.out.
286

Algoritmi de programare dinamic


Exemplu:
lev.in lev.out
afara
3
afacere
Explicaie: se insereaz caracterele c i e dup afa i se nlocuiete
ultimul a cu e.
Problema cere determinarea distanei Levenshtein dintre cele dou
iruri de caractere. Aceasta este o distana de editare, adic un metric
folosit pentru msurarea gradului de asemnare a dou iruri.
Algoritmul de rezolvare este similar cu algoritmul de gsire a celui
mai lung subir comun. De fapt, cel mai lung subir comun poate fi privit ca
o distan de editare n care operaiile permise sunt doar inserarea unui
caracter i tergerea unui caracter.
Pentru a rezolva aceast problem, vom construi o matrice D unde
D[i][j] = numrul minim de operaii necesare transformrii secvenei
A[1, i] n secvena B[1, j]. Rspunsul problemei va fi evident
D[lungime(A)][lungime(B)].
Vom trata mai nti cazurile de baz, presupunnd c indicii irurilor
ncep de la 1:
1. Pentru a transforma o secven A[1, i] n secvena nul B[0, 0]
trebuie evident s tergem toate caracterele din secvena A[1, i],
deci D[i][0] = i pentru i de la 0 la lungime(A).
2. Pentru a transforma secvena A[0, 0] ntr-o secven B[1, i]
trebuie s adugm i caractere secvenei A[0, 0], deci D[0][i] = i
pentru i de la 0 la lungime(B).
S presupunem acum c tim valorile D[p][q], D[p + 1][q] i
D[p][q + 1] pentru nite poziii p i q valid alese. Putem atunci calcula
D[p + 1][q + 1] considernd urmtoarele cazuri:
1. A[p + 1] == B[q + 1], caz n care putem face abstracie de
caracterele A[p + 1] i B[q + 1], fiind suficient s transformm
A[1, p] n B[1, q], lucru pe care l putem face cu D[p][q]
operaii.
2. Altfel, fie transformm A[1, p] n B[1, q + 1] dup care tergem
caracterul A[p + 1], fie transformm A[1, p + 1] n B[1, q] dup
care inserm caracterul B[q + 1], fie transformm A[1, p] n
B[1, q] dup care nlocuim A[p + 1] cu B[q + 1].
287

Capitolul 9
Aadar:
D[p + 1][q + 1] = 1 + minim(D[p][q],D[p + 1][q],D[p][q + 1]).
Implementarea prezentat folosete tipul de date string, n care
caracterele sunt numerotate de la 0. Rezolvarea ns nu sufer nicio
modificare major, fiind necesar doar s scdem 1 cnd accesm un
caracter. Prezentm doar funcia relevant, restul programului fiind aproape
identic cu cel prezentat n cadrul problemei celui mai lung subir comun.
int levenshtein(const string &A, const string &B, int D[maxn][maxn])
{
for ( int i = 0; i <= A.length(); ++i )
D[i][0] = i;
for ( int i = 0; i <= B.length(); ++i )
D[0][i] = i;
for ( int i = 1; i <= A.length(); ++i )
for ( int j = 1; j <= B.length(); ++j )
if ( A[i - 1] == B[j - 1] )
D[i][j] = D[i - 1][j - 1];
else
D[i][j] = 1 + min(min(D[i - 1][j], D[i][j - 1]), D[i - 1][j - 1]);
return D[A.length()][B.length()];
}

Se poate face i de aceast dat optimizarea de a pstra doar dou


linii ale matricei D, deoarece pentru a calcula un rnd al matricei nu folosim
dect valori de pe aceeai linie sau de pe linia anterioar. Mai mult, la
aceast problem este puin probabil s avem nevoie de reconstituirea
soluiei.
Am precizat la nceput c distana Levenshtein este o distan de
editare dintre dou iruri. Pentru cei interesai prezentm succint i alte
distane de editare:
Distana Hamming, care se aplic asupra a dou iruri A i B de
lungime egal i este egal cu numrul de poziii i pentru care
A[i] != B[i].
Distana Damerau Levenshtein, care adug operaia de
interschimbare setului de operaii permise de distana
Levenshtein.
Distana Lee, care calculeaz sum din min(|A[i] B[i]|,
|A[i] B[i]|) pentru fiecare caracter i, unde 2 este
dimensiunea alfabetului folosit.
288

Algoritmi de programare dinamic


Exerciii:
a) Complexitatea algoritmului de calcul a distanei Levenshtein este
O(NM), unde N i M reprezint lungimile celor dou iruri.
Putem ns optimiza algoritmul dac tim c putem transforma
irul A n irul B ntr-un numr relativ mic de operaii k. Cum ne
poate ajuta aceast informaie?
b) Considerm existena unor costuri pentru fiecare operaie precum
i pentru caracterele asupra crora se efectueaz operaii. Scriei
un program care rezolv aceast variant a problemei.
c) Scriei un program care afieaz noul ir A pentru fiecare
operaie efectuat.
d) Scriei un program care determin numrul minim de caractere
care trebuie inserate ntr-un ir pentru a-l transforma ntr-un
palindrom.

9.12. Determinarea strategiei optime ntr-un joc


Considerm un ir de 2N numere ntregi i dou persoane A i B
care joac alternativ urmtorul joc: fiecare persoan, ncepnd cu persoana
A, scoate fie primul fie ultimul element din ir, care se adun la numerele
deja alese de acea persoan. Ne intereseaz suma maxim pe care o poate
obine persoana A dac ambele persoane joac optim.
Prima linie a fiierului de intrare joc.in conine pe prima linie
numrul N, iar pe urmtoarea linie 2N numere ntregi reprezentnd irul pe
care se joac. n fiierul joc.out se va afia suma maxim pe care o poate
obine persoana A, n condiiile n care ambii juctori joac optim.
Exemplu:
joc.in
joc.out
3
115
4
5 6 3 2 1
9 100 6 8 4 7
Explicaie: am marcat cu rou numerele alese de persoana A i cu
albastru numerele alese de persoana B. Exponenii reprezint ordinea n
care s-au ales numerele.
n primul rnd precizm c problema nu poate fi rezolvat printr-un
algoritm de tip greedy care alege la fiecare pas cel mai mare numr.
Exemplul de mai sus pune n eviden acest lucru.
289

Capitolul 9
Pentru a rezolva problema vom considera numerele date ca fiind
reinute n vectorul V i vom folosi o matrice S cu semnificaia
S[i][j] = suma maxim care poate fi obinut de primul juctor dac
lum n considerare doar secvena de numere V[i, j]. Vom iniializa
pentru fiecare i de la 1 la 2N pe S[i][i] cu V[i] i pe S[i][i + 1] cu
max(V[i], V[i + 1]). Apoi distingem urmtoarele cazuri pentru a calcula
S[i][j], cu j > i + 1:
1. Alegem numrul V[i]. La pasul urmtor, oponentul va putea
alege ntre V[i + 1] i V[j], aducndu-ne fie n starea S[i + 2][j]
fie n starea S[i + 1][j 1]. Deoarece tim c oponentul joac
optim, acesta ne va aduce cu siguran n starea cea mai
defavorabil, adic din care vom obine o sum ct mai mic.
Aadar, dac alegem numrul V[i], atunci obinem ctigul
V[i] + min(S[i + 2][j], S[i + 1][j 1]) = C1.
2. Alegem numrul V[j]. Dintr-un raionament identic cu cel de mai
sus rezult c obinem ctigul
V[j] + min(S[i][j 2], S[i + 1][j 1]) = C2.
Vom alege maximul celor dou cazuri, deci S[i][j] = max(C1, C 2).
Ordinea de completare a matricei S este similar cu ordinea folosit
n algoritmul de rezolvare a recurenei pentru problema nmulirii optime a
unui ir de matrice. Vom ncepe cu i de la 2N 2 i cu j de la i + 2,
asigurndu-ne n acest mod c nu vom folosi valori ale matricei necalculate
nc.
Iniializarea valorilor S[i][i + 1] este necesar deoarece la fiecare pas
fie vom scdea 2 din j fie vom aduna 2 la i, aceast iniializare avnd rolul
de a evita unele cazuri particulare care pot aprea din cauza acestui lucru.
#include <fstream>
using namespace std;
const int maxn = 101;
void citire(int V[], int &N)
{
ifstream in("joc.in");
in >> N;
N *= 2; // mai simplu decat sa lucram cu 2*N
for ( int i = 1; i <= N; ++i ) in >> V[i];
in.close();
}

290

Algoritmi de programare dinamic


int joc(int V[], int N, int S[maxn][maxn])
{
S[N][N] = V[N];
for ( int i = 1; i < N; ++i )
{
S[i][i] = V[i];
S[i][i + 1] = max(V[i], V[i + 1]);
}
for ( int i = N - 2; i; --i )
for ( int j = i + 2; j <= N; ++j )
{
int C1 = V[i] + min(S[i + 2][j], S[i + 1][j - 1]);
int C2 = V[j] + min(S[i][j - 2], S[i + 1][j - 1]);
S[i][j] = max(C1, C2);
}
return S[1][N];
}
int main()
{
int N, V[maxn], S[maxn][maxn];
citire(V, N);
ofstream out("joc.out");
out << joc(V, N, S);
out.close();
return 0;
}

Exerciii:
a) Modificai algoritmul astfel nct s afieze fiecare numr ales
mpreun cu juctorul care a ales acel numr.
b) Rezolvai problema considernd c se pot alege k numere
consecutive dintr-un capt al irului.
c) Reducei memoria folosit de algoritm la O(N).
d) Modificai implementarea prezentat astfel nct s afieze care
juctor ctig jocul.
e) Rezolvai problema considernd 3 juctori i 3N numere.
291

Capitolul 9

9.13. Problema R.M.Q. (Range Minimum Query)


Se d un ir A de N numere ntregi. Ne intereseaz rspunsul la T
ntrebri de genul: dndu-se x i y care este cel mai mic numr din secvena
A[x, y]?.
Prima linie a fiierului de intrare rmq.in va conine N i T,
urmtoarea linie va conine elementele irului A, iar urmtoarele T lini vor
conine numerele x y cu semnificaia din enun. n fiierul rmq.out se vor
afia T linii, fiecare coninnd rspunsul la intrebarea corespunztoare.
Exemplu:
rmq.in

rmq.out
93
1
819344526 2
13
9
58
33
O soluie n care parcurgem fiecare secven dat i determinm
minimul se dovedete a fi foarte ineficient, avnd complexitatea O(NT) n
cel mai ru caz.
Problema prezentat este cunoscut sub numele de problema Range
Minimum Query (traducere: problema interogrilor de minim pe
intervale). Aceast problem poate fi rezolvat n timp O(Nlog N + T) i
folosind memorie O(Nlog N) calculnd o matrice pe care o vom folosi apoi
pentru a rspunde la fiecare ntrebare n timp O(1).
Fie M[i][j] = cel mai mic numr din subsecvena A[j, j + 2i 1].
Altfel spus, M[i][j] reprezint cel mai mic numr din subsecvena care
ncepe pe poziia j i are lungimea 2i. Vom prezenta mai nti modul de
construcie al acestei matrici iar apoi algoritmul prin care vom rspunde la
ntrebri.
Pentru i = 0 obinem subsecvene de forma A[j, j], aadar avem
M[0][j] = A[j] pentru fiecare element j.
Fie log2[i] = parte ntreag din log 2(i) pentru fiecare i de la 0 la n.
Pentru fiecare i astfel nct 0 < i log2[N] vom calcula vectorul M[i] astfel:
pentru fiecare j 1 astfel nct s aib loc j + 2i 1 N vom efectua operaia
M[i][j] = min(M[i 1][j], M[i 1][j + 2i 1]).
S demonstrm c acest mod de calculare este corect:
292

Algoritmi de programare dinamic


1. n primul rnd trebuie s demonstrm c nu vom folosi valori
necalculate ale lui M i nici indici invalizi. Deoarece i ncepe de
la 1 i valorile pentru i = 0 sunt calculate separat, rezult c
folosim doar valori ale lui M deja calculate. Din modul de
parcurgere al matricei rezult c nu putem avea indici invalizi,
deoarece avem condiia ca j + 2i 1 N.
2. Mai trebuie demonstrat c lund minimul dintre M[i 1][j] i
M[i 1][j + 2i 1] pentru a calcula M[i][j] acoperim exact
subsecvena A[j, j + 2i 1]. Din definiia matricei M tim c
M[i 1][j] este minimul subsecvenei notate A[j, j + 2i 1 1]
respectiv c M[i 1][j + 2i 1] este minimul subsecvenei
A[j + 2i 1, j + 2i 1 + 2i 1 1] adic minimum subsecvenei
A[j + 2i 1, j + 2i 1]. Rezult de aici c alegnd minimul
acestor dou valori, calculm M[i][j] corect.
Pentru a determina rspunsul pentru o subsecven A[x, y], fie
L = log2[y x + 1], adic L este partea ntreag a logaritmului n baza doi
din lungimea subsecvenei. Rspunsul pentru secvena dat este aadar
min(M[L][x], M[L][y 2L + 1]).
Vom demonstra n continuare doar c alegnd minimul dintre aceste
dou valori lum n considerare exact numerele din subsecvena A[x, y].
Vom presupune prin absurd c prin concatenarea subsecvenelor
A[x, x + 2L 1] i A[y 2L + 1, y] fie lum n considerare numere care nu
fac parte din A[x, y] fie nu lum n considerare toate numerele din A[x, y]:
1. Ca s lum n considerare numere care nu fac parte din A[x, y],
fie x + 2L 1 > y, fie y 2L + 1 < x => 2L > y x + 1 sau
y x + 1 < 2L => L > log2[y x + 1] sau L < log2[y x + 1] =>
L > L sau L < L, situaii imposibile.
2. Pentru a nu considera toate numerele din A[x, y] trebuie s aib
loc inegalitatea urmtoare:
x + 2L 1 < y 2L => 2L + 1 < y x + 1 => L + 1 < L, imposibil.
Aadar, se iau n considerare toate numerele din A[x, y] i numai
aceste numere.
Figura urmtoare prezint matricea M pentru exemplul dat.
Numerele din dreptunghiuri reprezint valoarea M[i][j]. Fiecare dreptunghi
este calculat pe baza a dou dreptunghiuri de la nivelul anterior a cror
lungime este jumtate din lungimea dreptunghiului curent. Culorile sunt
folosite pentru a putea identifica mai bine relaiile dintre valori.
293

Capitolul 9
i\j
0

1
8

2
1

3
9

4
3

5
4

6
4

7
5

8
2

9
6

1
1
3
3

4
4
2
2
1
1

3
3
2
2

1
1
Fig. 9.14.1. Modul de execuie al algoritmului R.M.Q.

Se poate observa din acest tabel c oricum am alege o secven


[x, y], vom putea gsi ntotdeauna dou dreptunghiuri care s acopere
complet secvena respectiv. Aadar, tabelul poate servi ca o ilustraie
intuitiv a modului de funcionare al algoritmului i a corectitudinii acestuia.
Matricea M va fi o matrice cu log2[N] linii i N coloane, rezultnd
astfel complexitile menionate la nceput. Ideea folosit n cadrul acestui
algoritm, de a folosi puteri ale lui 2 n cadrul recurenei, se dovedete a fi
folositoare n multe probleme de programare dinamic.
Implementarea prezentat folosete operaii pe bii pentru a calcula
eficient puterile lui 2. Recomandm cititorului s se familiarizeze cu aceste
operaii pentru o mai bun nelegere a codului prezentat i a capitolelor ce
vor urma.
Deoarece M[0][i] = A[i] pentru orice element i, nu este necesar s
folosim efectiv vectorul A, putnd s citim numerele date direct n matricea
M. Acest lucru reduce i memoria folosit i timpul necesar iniializrii.

294

Algoritmi de programare dinamic


#include <fstream>
using namespace std;
const int maxn = 101;
const int maxlog = 7;
void citire(int M[][maxn], int &N, int &T, ifstream &in)
{
in >> N >> T;
for ( int i = 1; i <= N; ++i )
in >> M[0][i];
}
void preproc(int N, int M[][maxn], int log2[])
{
log2[0] = log2[1] = 0;
for ( int i = 2; i <= N; ++i )
log2[i] = log2[i >> 1] + 1;
for ( int i = 1; i <= log2[N]; ++i )
for ( int j = 1; j + (1 << (i - 1)) <= N; ++j )
M[i][j] = min(M[i - 1][j], M[i - 1][j + (1 << (i - 1))]);
}
int main()
{
int N, T, log2[maxn], M[maxlog][maxn];
ifstream in("rmq.in");
citire(M, N, T, in);
preproc(N, M, log2);
ofstream out("rmq.out");
int x, y;
while ( T-- )
{
in >> x >> y;
int L = log2[y - x + 1];
out << min(M[L][x], M[L][y - (1 << L) + 1]) << '\n';
}
in.close();
out.close();
return 0;
}

295

Capitolul 9
Exerciii:
a) Modificai algoritmul astfel nct s afieze poziia numrului
minim n irul dat.
b) Modificai algoritmul astfel nct s afieze cel mai mare element
din ir, precum i poziia acestuia.
c) Extindei algoritmul pentru gsirea celui mai mic sau celui mai
mare element dintr-un dreptunghi al unei matrice.
d) Ce se ntmpl dac interschimbm cele dou dimensiuni ale
matricei? Comparaii timpii de execuie a celor dou variante de
implementare i ncercai s explicai eventualele diferene.

9.14. Numrarea parantezrilor booleane


Fiierul paran.in conine pe prima linie un numr natural N. Pe a
doua linie se afl N valori booleane (0 sau 1), iar a treia linie conine N 1
valori din mulimea {-1, -2, -3}, reprezentnd operatorii and, or, respectiv
xor. Acetia au prioriti egale i se consider dispui ntre operanzii dai.
Se cere numrul de parantezri valide existente astfel nct expresia
rezultat s aib rezultatul true.
Exemplu:
paran.in paran.out
3
1
100
-2 -1
Explicaie: expresia dat este T or F and F. Exist dou parantezri
valide: ((T or F) and F) i (T or (F and F)). Se poate observa c, dintre
acestea, doar a doua are valoarea true.
Spre deosebire de majoritatea problemelor prezentate pn acum,
aceasta este o problem de numrare, nu de determinare a unui optim.
Rezolvarea acestei probleme este similar cu problema numrrii partiiilor
unui numr: va trebui s gsim subprobleme a cror rezultat s l putem
aduna pentru a obine rezultatul unei probleme mai mari.
Este evident c abordrile backtracking ies din discuie, ntruct am
fi astfel limitai la expresii de lungime nu mai mare de ~10.

296

Algoritmi de programare dinamic


S punem la punct modul n care vom reine datele. Vom considera
valorile 1 / 0 date ca fiind nite simboluri i le vom reine ntr-un vector
boolean S. Operatorii dai i vom reine ntr-un vector de numere ntregi op,
cu semnificaie op[i] = operatorul dintre simbolurile i i i + 1. Acesta va
juca un rol foarte important n formula de recuren pe care o vom deduce.
Fie acum T[i][j] = cte posibiliti exist de a paranteza expresia
format din simbolurile [i, j] astfel nct rezultatul acesteia s fie true.
Mai mult, vom calcula n paralel i F[i][j] = cte posibiliti exist de a
paranteza expresia format din simbolurile [i, j] astfel nct rezultatul
acesteia s fie false. Aceste dou matrici se vor calcula similar cu modul de
calcul al matricii de la problema nmulirii optime a matricelor. T i F vor
depinde una de cealalt, dar dependeele nu vor fi circulare, deci le vom
putea calcula pe amndou n paralel.
Am identificat deja elemente descoperite prima dat n cadrul a dou
probleme distincte. Tocmai acest lucru face ca programarea dinamic s fie
o tehnic greu de stpnit: inexistena unui ablon de rezolvare a
problemelor. De multe ori avem nevoie de tehnici pe care nu le-am mai
ntlnit, sau de combinarea mai multor idei de rezolvare a altor probleme.
n primul rnd ne punem problema cazurilor de baz: T[i][i] i
F[i][i] ar trebui s fie evident cum se calculeaz, aa c nu vom insista
asupra acestui aspect.
S presupunem acum c vrem s aflm numrul de parantezri
valide i adevrate (a cror rezultat este true) ale unei subexpresii formate
din simbolurile [i, j]. Mai mult, presupunem c tim care este numrul de
parantezri valide i adevrate (i false) ale subexpresiilor [i, k] i [k + 1, j],
pentru orice 1 i k < j N. Dac tim s calculm T[i][j] i F[i][j] pe
baza acestor informaii, atunci putem aplica acelai procedeu pentru toate
elementele de deasupra diagonalei principale, iar n final rspunsul
problemei va fi dat de T[1][N].
Avem trei cazuri pentru un k fixat:
1. Dac op[k] = -1 (and), atunci adunm la T[i][j] valoarea
T[i][k] * T[k + 1][j], deoarece avem nevoie ca ambele
subexpresii s fie adevrate, deci putem combina orice
parantezare adevrat a acestora.
2. Dac op[k] = -2 (or), atunci este de ajuns ca doar una dintre
subexpresii s fie adevrat. Vom aduna aadar la T[i][j]
valoarea A[i][k] * A[k + 1][j] F[i][k] * F[k + 1][j], unde
A[x][y] = T[x][y] + F[x][y]. Practic, se scad din totalul
parantezrilor cele false, rmnnd cele adevrate.
297

Capitolul 9
3. Dac op[k] = -3 (xor), atunci cele dou subexpresii trebuie s
aib valori diferite (una adevrat, iar cealalt fals). Adunm
aadar la T[i][j] valoarea:
T[i][k] * F[k + 1][j] + F[i][k] * T[k + 1][j]
Valorile matricei F se calculeaz similar. Avem aadar recurenele:
1

=
=

[] [ + 1][]
= 1
+ 1 [] [ + 1][] = 2
+ 1 + [] [ + 1][] = 3

i
1

=
=

+ 1 [] [ + 1][] = 1
+ 1 []
= 2
+ 1 + [] [ + 1][] = 3

Menionm c suma T[1][N] + F[1][N] este al N-lea numr


Catalan. Acestea reprezint, printre altele, numrul de parantezri valide
formate din N paranteze.
Avem aadar un algoritm cu timpul de execuie O(N3), a crui
implementare nu ar trebui s fie o noutate.
#include <fstream>
#include <iostream>
using namespace std;
const int maxn = 101;
int rezolvare(int, bool[], int[]);

int main()
{
// op[i] = operatorul dintre
// simbolul i si i + 1
int N, op[maxn];
bool S[maxn];

void citire(int &N, bool S[], int op[])


{
ifstream in("paran.in");
in >> N;

citire(N, S, op);
ofstream out("paran.out");
out << rezolvare(N, S, op) << endl;
out.close();

for ( int i = 1; i <= N; ++i )


in >> S[i];

return 0;
}

for ( int i = 1; i < N; ++i )


in >> op[i];
}

298

Algoritmi de programare dinamic


int rezolvare(int N, bool S[], int op[])
{
int T[maxn][maxn], F[maxn][maxn];
for ( int i = 1; i <= N; ++i )
{
T[i][i] = S[i] ? 1 : 0;
F[i][i] = S[i] ? 0 : 1;
}
for ( int i = N - 1; i; --i )
for ( int j = i + 1; j <= N; ++j )
{
int t = 0, f = 0;
for ( int k = i; k < j; ++k )
if ( op[k] == -1 ) // and
{
t += T[i][k] * T[k + 1][j];
int total_st = T[i][k] + F[i][k];
int total_dr = T[k + 1][j] + F[k + 1][j];
f += total_st * total_dr - T[i][k] * T[k + 1][j];
}
else if ( op[k] == -2 ) // or
{
int total_st = T[i][k] + F[i][k];
int total_dr = T[k + 1][j] + F[k + 1][j];
t += total_st * total_dr - F[i][k] * F[k + 1][j];
f += F[i][k] * F[k + 1][j];
}
else // xor
{
t += T[i][k] * F[k + 1][j] + F[i][k] * T[k + 1][j];
f += F[i][k] * F[k + 1][j] + T[i][k] * T[k + 1][j];
}
T[i][j] = t;
F[i][j] = f;
}
return T[1][N];
}

299

Capitolul 9

9.15. Concluzii
Am prezentat n acest capitol probleme a cror soluii folosesc
programarea dinamic. Metoda programrii dinamice este o metod foarte
util pentru rezolvarea problemelor de informatic, dar este i metoda cea
mai grea de stpnit, ntruct problemele care se rezolv printr-un algoritm
de programare dinamic pot fi foarte variate, deci este nevoie de experien
pentru a putea gsi anumite recurene..
Propunem aadar spre rezolvare urmtoarele probleme:
1. Dndu-se N i K scriei un program care determin cte numere
de N cifre cu suma cifrelor K exist. Analog pentru produs.
2. Scriei un program care rspunde eficient la mai multe interogri
privind suma unor dreptunghiuri ale unei matrice.
3. Dndu-se un ir de numere naturale A scriei un program care
determin numrul minim de numere din ir a cror sum d
restul R la mprirea la K.
4. Scriei un program care determin cte drumuri exist ntr-un
caroiaj cu obstacole de la poziia (1, 1) la poziia (N, N), drumuri
care pot conine cel mult K pai i care pot parcurge de mai
multe ori orice poziie (drumuri neelementare).
5. Scriei un program care determin cte drumuri elementare exist
ntr-un caroiaj fr obstacole de la poziia (1, 1) la poziia (N, N),
tiind c dintr-o poziie oarecare ne putem deplasa doar n jos sau
la dreapta.
6. Scriei un program care determin numrul irurilor de lungime
N formate cu caracterele a, b, c i d cu proprietatea c a nu poate
fi lng b i c nu poate fi lng d. Problema admite o rezolvare
clasic i una eficient.
7. Scriei un program care determin cel mai lung drum ntr-un graf
orientat aciclic.
8. Scriei un program care afl cel mai mic numr cu K divizori.
9. Scriei un program care citete o matrice binar i determin
dreptunghiul de arie maxim care conine numai valoarea 1.
10. Scriei un program care determin subsecvena de sum maxim
a unui ir circular (dup ultimul element urmeaz primul
element)
11. Scriei un program care determin subsecvena de sum maxim
de lungime cel puin L.

300

Algoritmi de geometrie computaional

10. Algoritmi de
geometrie
computaional
Toate problemele de informatic au la baz probleme matematice,
informatica fiind de fapt o ramur a matematicii aplicate. Pn acum am
prezentat probleme i algoritmi care au o legturi cu domenii precum
algebra, teoria numerelor i logica matematic.
n acest capitol vom prezenta metode de rezolvare a unor probleme
de geometrie cu ajutorul calculatorului. Acest domeniu de studiu se numete
geometrie computaional. Aceast ramur a informaticii are aplicaii
practice importante n programe de grafic (aplicaii CAD, aplicaii de
modelare 2d i 3d etc.), proiectarea circuitelor integrate i altele.
n acest capitol nu vom avea ca obiectiv optimizarea algoritmilor cu
privire la stabilitatea numeric a acestora (limitarea erorilor numerice
generate de ctre algoritm). Vom folosi pur i simplu variabile de tip
double, iar operaiile cu acestea le vom efectua n mod obinuit.
Considerm c acest lucru este suficient pentru nite implementri didactice,
accentul fiind pus pe validitatea teoretic a algoritmilor i pe uurina de
nelegere a acestora.
Scopul acestui capitol nu este de a prezenta pe larg algoritmii de
geometrie computaional, deoarece acest lucru ar necesita un spaiu mult
prea larg i nu face obiectul acestei lucrri. Capitolul acesta are ca scop doar
introducerea unor noiuni i algoritmi de baz, cu ajutorul crora cititorul s
poat cuta i nelege lucruru mai avansate.

301

Capitolul 10

CUPRINS
10.1. Convenii de implementare ................................................................. 303
10.2. Reprezentarea punctului i a dreptei ................................................. 304
10.3. Panta i ecuaia unei drepte................................................................ 305
10.4. Intersecia a dou drepte .................................................................... 306
10.5. Intersecia a dou segmente ............................................................... 308
10.6. Calculul ariei unui poligon ................................................................... 311
10.7. Determinarea nfurtorii convexe (convex hull) ............................ 313

302

Algoritmi de geometrie computaional

10.1. Convenii de implementare


O problem important i o surs nsemnat de erori n informatic o
constituie lucrul cu numere raionale i iraionale. Deoarece numerele
iraionale sunt formate dintr-un numr infinit de cifre, este imposibil ca
acestea s fie reprezentate cu exactitate ntr-un program. Datorit acestui
fapt se folosesc aproximri, adic numere raionale ale cror valoare este
relativ apropiat de numrul iraional aproximat. De exemplu, o aproximare
a lui pi este 3.14159, dar este important de tiut c aceasta nu reprezint
valoarea exact a lui pi, ci doar un numr raional format din primele ase
cifre ale sale. Aceeai probleme sa pune i n cazul numerelor raionale
atunci cnd acestea au prea multe cifre pentru a putea fi stocate n ntregime
ntr-un tip de date elementar.
Mai mult, este posibil ca efectund multe operaii cu numere
raionale, rezultatul s nu fie cel ateptat, chiar dac acesta poate fi
reprezentat pe tipurile double sau float. Nu vom intra n detaliile de
implementare a acestor tipuri de date, fiind suficient contientizarea celor
afirmate anterior pentru a evita, pe ct posibil, lucrul cu numere n virgul
mobil, sau pentru a ine cont de eventualele erori numerice n stabilirea
corectitudinii unui algoritm.
De exemplu, considerai urmtoarea secven de cod:
float t = 0.0;
for ( int i = 0; i < 20; ++i ) t += 0.1;
t *= 10000000;
cout << (int)t;

Matematic, valoarea afiat pe ecran ar trebui s fie 20 000 000,


valoare reprezentabil pe patru octei (dimensiunea unui float). Valoarea
afiat este ns 20 000 002. Acest lucru se datoreaz faptului c unele
numere nu pot fi reprezentate exact ntr-un float, ci doar aproximate. Aceste
aproximri genereaz erori iniial nesemnificative, dar care, prin operaii
aritmetice repetate, pot ajunge s denatureze rezultatul unui algoritm.
Dac nlocuim float cu double n secvena de mai sus, valoarea
afiat va fi corect. Acest lucru se datoreaz faptului c double este
reprezentat pe opt octei i poate reprezenta exact mai multe valori dect un
float, deci erorilor generate sunt mai mici. Pot aprea ns aceleai erori i
pentru un double, dac lucrm cu numere mai mari sau efectum mai multe
operaii. Erorile numerice sunt aadar o problem greu de rezolvat, care
uneori nici nu se poate rezolva n totalitate, scopul fiind pstrarea erorilor de
calcul la un anumit nivel i nicidecum eliminarea total a acestora.
303

Capitolul 10

10.2. Reprezentarea punctului i a dreptei


Pentru a putea rezolva probleme de geometrie trebuie mai nti s
introducem un model de reprezentare a formelor geometrice elementare:
punctul i dreapta. Deoarece vom lucra exclusiv n spaiul bidimensional, o
asemenea reprezentare nu este greu de gsit. Fiecare punct este determinat
de o pereche (x, y), unde x (abscisa) i y (ordonata) sunt numere raionale.
Aadar, putem folosi urmtoarea structur pentru a memora un
punct:
struct Punct
{
double x, y;
Punct(double abscisa, double ordonata) : x(abscisa), y(ordonata) {}
Punct() {}
};
...
Punct P(1, 2);
cout << P.x << << P.y; // afiseaza 1 2

Pentru a reprezenta o dreapt avem mai multe posibiliti, n funcie


de ce problem vrem s rezolvm. O metod des ntlnit este reprezentarea
printr-un triplet de numere raionale (a, b, c) format din coeficienii din
ecuaia dreptei ax + by + c = 0. De exemplu, dreapta d din figura
urmtoare are ecuaia x y = 0.

Fig. 10.2.1. Reprezentarea grafic a unei drepte


Ceea ce nseamn c oricare punct (x, y) pentru care x y = 0
aparine dreptei d.
O dreapt poate fi reprezentat ntr-un program prin urmtoarea
structur:
304

Algoritmi de geometrie computaional


struct Dreapta
{
double a, b, c;
Dreapta(double p, double q, double r) : a(p), b(q), c(r) {}
Dreapta() {}
};
...
Dreapta d(1, -1, 0); // reprezinta dreapta din figura anterioar

Avnd structuri pentru reprezentarea punctelor i a dreptelor putem


ncepe s vorbim despre algoritmi care lucreaz ce acestea. Este important
nelegerea acestor structuri i a corespondeei acestora cu realitatea
matematic, ntruct acestea vor sta la baza tuturor algoritmilor din acest
capitol.

10.3. Panta i ecuaia unei drepte


Pentru a putea lucra cu drepte este important s tim s calculm
pante i ecuaii, deoarece aceste dou atribute se regsesc n foarte muli
algoritmi de geometrie computaional.
Reamintim c panta unei drepte d este un numr raional egal cu
tangenta unghiului pozitiv dintre axa Ox i dreapta d.

Fig. 10.3.1. Vizualizarea pantei unei drepte


Aadar, panta dreptei d, notat cu md, este egal cu tg(t). Deoarece
tangenta unui unghi ntr-un triunghi dreptunghic este egal cu cateta opus

unghiului supra cateta alturat unghiului, deducem c = . Pentru

dreapta din figura de mai sus, =

= 0.5.

n general, dac tim c dou puncte (x1 , y1) i (x2, y2) aparin dreptei
d, atunci panta dreptei va fi

305

Capitolul 10
=

2 1
.
2 1

tiind panta unei drepte d, putem s i aflm i ecuaia cu ajutorul


formulei:
d: y y0 = m(x x0)
Aadar, putem s calculm ecuaia unei drepte tiind un punct (x0,
y0) care aparine acesteia i panta sa. Dac tim dou puncte care aparin
dreptei putem din nou s aflm ecuaia dreptei calculndu-i mai nti panta,
iar apoi ecuaia cu ajutorul formulei de mai sus.
Putem s aflm panta unei drepte creia i tim doar ecuaia n felul
urmtor: ecuaia o avem sub forma d: ax + by + c = 0. Rescriem ecuaia n
a
c
felul urmtor: d: y = x . Observm c ecuaia unei drepte poate fi
b
b
calculat cu ajutorul pantei i a unui punct care aparine dreptei n felul
urmtor: d: y = mx mx0 + y0. Comparnd cele dou forme ale ecuaiei

rezult c panta unei drepte d: ax + by + c = 0 este egal cu .

Dou proprieti importante ale pantei unei drepte sunt date de


condiiile de paralelism i perpendicularitate a dou drepte:
Dou drepte sunt paralele dac i numai dac pantele lor sunt
egale. Acest lucru este evident, deoarece dou drepte paralele vor
avea acelai unghi fa de Ox, iar dou drepte cu acelai unghi
fa de axa Ox sunt evident paralele.
Dou drepte sunt perpendiculare dac i numai dac produsul
pantelor acestora este -1.
Un caz particular l reprezint dreptele verticale i orizontale. Panta
unei drepte verticale este nedefinit, iar panta unei drepte orizontale este
zero.

10.4. Intersecia a dou drepte


Pn acum am prezentat mai mult noiuni teoretice. n aceast
seciune ne propunem s scriem o funcie care s determine dac dou
drepte, date prin ecuaiile lor, se intersecteaz sau nu. n caz afirmativ, ne
propunem s aflm punctul de intersecie al acestora.
Din punct de vedere geometric, dou drepte se intersecteaz dac au
cel puin un punct n comun. Practic ns, dac dou drepte au mai mult de
306

Algoritmi de geometrie computaional


un punct n comun atunci ele pot fi considerate ca fiind aceeai dreapta
(ecuaiile lor vor coincide), aa c nu vom considera i acest caz, pentru a
pstra programul ct mai scurt. Mai mult, vom considera c dreptele date nu
sunt nici orizontale nici verticale, aceste tipuri de drepte introducnd din nou
cazuri particulare.
Aadar, ne propunem s determinm dac dou drepte se
intersecteaz. Deoarece o dreapt are lungime infinit, oricare dou drepte
care nu sunt paralele se intersecteaz, aa c este suficient s verificm dac
pantele sunt sau nu egale pentru a determina dac dreptele date se
intersecteaz sau nu.
Pentru a determina punctul de intersecie trebuie s rezolvm
urmtorul sistem:
1 : 1 + 1 + 1 = 0
2 : 2 + 2 + 2 = 0
Sistemul se poate rezolva prin metoda substituiei. l scriem pe x n
funcie de y n prima ecuaie, dup care l nlocuim n a doua. Se procedeaz
la fel i cu y i se ajunge n final la urmtorul rezultat:
2 1 1 2
=
2 1 1 2
=

2 1 1 2
1 2 2 1

Deoarece nu vom aplica aceste formule dect dac tim sigur c


dreptele se intersecteaz, adic pantele lor nu sunt egale, nu exist riscul ca
unul dintre numitori s fie 0.
Prezentm n continuare implementarea unei funcii care determin
dac dou drepte se intersecteaz. n caz afirmativ, punctul de intersecie
este ntors prin intermediul unui parametru referin.

307

Capitolul 10
bool Intersect(const Dreapta &d1, const Dreapta &d2,
Punct &intersectPct)
{
double m1 = -d1.a / d1.b, m2 = -d2.a / d2.b;
if ( m1 == m2 ) return false;
double x = (d2.b*d1.c - d1.b*d2.c) / (d2.a*d1.b - d1.a*d2.b);
double y = (d2.a*d1.c - d1.a*d2.c) / (d1.a*d2.b - d2.a*d1.b);
intersectPct = Punct(x, y);
return true;
}

10.5. Intersecia a dou segmente


Este simplu s rspundem la ntrebarea se intersecteaz aceste dou
drepte date?, deoarece oricare dou drepte neparalele se intersecteaz sigur,
ntruct lungimea unei drepte este infinit. n cazul segmentelor ns
problema este mai grea, deoarece segmentele au lungimi finite. De exemplu,
figura de mai jos prezint dou perechi de segmente: prima pereche const
n dou segmente neparalele care nu se intersecteaz, iar a doua pereche
prezint dou segmente care se intersecteaz.

Fig. 10.5.1. Perechi de segmente neintersectante, respectiv intersectante


Pentru a rezolva aceast problem vom introduce noiunea de ordine
a trei puncte. Vom spune c trei puncte A, B, C se afl n ordine
trigonometric (se mai folosete i n ordine negativ) dac mAB < mAC i
n ordine invers trigonometric (sau ordine pozitiv) n caz contrar. Mai
mult, dac A, B, C se afl n ordine trigonometric, vom spune c punctul
C se afl n partea stng a dreptei AB, iar dac A, B, C se afl n ordine
invers trigonometric, vom spune c punctul C se afl n partea dreapt a
dreptei AB.
308

Algoritmi de geometrie computaional


Avnd aceste definiii, condiia de intersecie a dou segmente nu
este greu de observat: folosind notaiile din figura de mai sus, putem afirma
c dou segmente se intersecteaz dac i numai dac A i B nu se afl n
aceeai parte a dreptei CD, iar C i D nu se afl n aceeai parte a dreptei
AB. n continuare vom exprima formal aceste condiii.
Fie o funcie Orientare(A, B, C) care returneaz -1 dac cele trei
puncte date ca parametri sunt dispuse n ordine trigonometric, 0 dac
acestea sunt coliniare (observaie: n implementarea prezentat nu vom
trata acest caz) i 1 dac acestea sunt dispuse n ordine invers
trigonometric. Pentru a putea implementa aceast funcie vom folosi
formula pantei n inecuaia din definiie pentru a ajunge la o formul de
calcul care nu presupune compararea unor numere n virgul mobil:
. . . .
<

. . . .
. . . . . . . . < 0
Folosind aceast formul se reduc erorile de calcul i este mai uor
de scris funcia ajuttoare Orientare(A, B, C):
Dac (B.y A.y)*(C.x A.x) (C.y A.y)*(B.x A.x) < 0
execut
o returneaz -1
Altfel dac expresia este egal cu 0 execut
o returneaz 0
Altfel execut
o returneaz 1
Aceast funcie va juca un rol important n simplificarea i
optimizarea multor algoritmi de geometrie computaional.
Acum, pentru ca un segment [AB] s intersecteze un segment [CD],
sunt necesare urmtoarele condiii:
Orientare(A, B, C) diferit de Orientare(A, B, D). Aceast
condiie ne asigur c punctele C i D nu sunt de aceeai parte a
dreptei AB.
Orientare(C, D, A) diferit de Orientare(C, D, B). Aceast
condiie ne asigur c punctele A i B nu sunt de aceeai parte a
dreptei CD.
Dac aceste dou condiie sunt ndeplinite, atunci tim sigur c cele
dou segmente se intersecteaz. Ne-am putea pune acum problema
determinrii punctului de intersecie dintre acestea. Dac tim c dou
309

Capitolul 10
segmente se intersecteaz, atunci punctul n care acestea se intersecteaz va
coincide cu punctul n care dreptele asociate acestora se intersecteaz, aa c
putem pur i simplu s aplicm algoritmul de determinare a punctului de
intersecie din cazul dreptelor, algoritm prezentat anterior.
Implementarea algoritmului prezentat nu este dificil. n primul rnd
vom folosi urmtoarea structur pentru a memora un segment:
struct Segment
{
Punct A, B;
Segment(Punct P1, Punct P2) : A(P1), B(P2) {}
Segment() {}
};

Avnd aceast structur implementarea algoritmului de intersecie


este foarte intuitiv:
int Orientare(const Punct &A, const Punct &B, const Punct &C)
{
double temp = (B.y A.y)*(C.x A.x) (C.y A.y)*(B.x A.x);
if ( temp < 0 )
return -1;
else if ( temp == 0 )
return 0;
else
return 1;
}
bool IntSegmente(const Segment &s1, const Segment &s2,
Punct &intersectPct)
{
return Orientare(s1.A, s1.B, s2.A) != Orientare(s1.A, s1.B, s2.B) &&
Orientare(s2.A, s2.B, s1.A) != Orientare(s2.A, s2.B, s1.B);
// exercitiu: modificati functia astfel incat sa returneze prin intermediul
// parametrului intersectPct coordonatele punctului de intersectie,
// daca acesta exista
}

310

Algoritmi de geometrie computaional

10.6. Calculul ariei unui poligon


Pentru poligoane precum triunghiul, ptratul, pentagonul etc. se
cunosc multe metode cu ajutorul crora putem calcula ariile acestora. O
problem important const n calculul ariei unui poligon oarecare.
Poligoanele se mpart n dou categorii: convexe, pentru care orice
linie tras ntre dou puncte rmne n interiorul poligonului (sau, altfel
spus, msura fiecrui unghi interior este mai mic de 180 de grade) i
concave, care nu sunt convexe.

Fig. 10.6.1 Un poligon convex i unul concav


Vom rezolva n continuare urmtoarea problem: dndu-se N puncte
X1, X2, ..., XN care reprezint vrfurile unui poligon oarecare (dar a crui
muchii nu se intersecteaz dect n extremiti), dispuse n ordine
trigonometric sau invers trigonometric, s se calculeze aria poligonului
respectiv.
Putem rezolva aceast problem n timp O(N) cu ajutorul
determinanilor. Aria unui poligon P este:
=

1
2

1
1

+ 2
2
2

+ +
3

1
1

Modul de calcul poate fi vizualizat n felul urmtor:

X1
Y1

X2

X3

...

XN

X1

Y2
+

Y3
+

...
+

YN
+

Y1
+

Fig. 10.6.2. Vizualizarea formulei de arie a unui poligon

311

Capitolul 10
Formula ne va da o arie cu semn. Semnul ariei unui poligon convex
va fi pozitiv dac punctele sunt dispuse n sens trigonometric i negativ dac
sunt dispuse n sens invers trigonometric. Dac nu ne intereseaz semnul
atunci putem pur i simplu s calculm modulul acestei funcii.
Funcie Arie(P, N), unde P este un vector de puncte care reprezint
un poligon, iar N numrul punctelor din vector va returna aria poligonului
definit de punctele din P. Aceast funcie poate fi scris n pseudocod astfel:
total = 0
pentru fiecare i de la 1 la N execut
o total += P[i].x*P[i % N + 1].y P[i].y*P[i % N + 1].x
returneaz total / 2
Am folosit expresia i % N + 1 pentru a obine valoarea i + 1 pentru
orice i < N i valoarea 1 atunci cand i == N. Astfel evitm tratarea ultimului
determinant ca i un caz particular. Folosirea operatorului modulo poate s
ncetineasc ns un algoritm care apeleaz des aceast funcie, aa c n
unele cazuri este preferabil adunarea ultimului determinant la sfrit.
Aceast funcie va funciona corect pentru orice poligon (convex sau
concav), mai puin pentru poligoanele care au laturi ce se taie reciproc, cum
ar fi de exemplu urmtorul poligon:

Fig. 10.6.3. Un poligon cu laturi ce se intersecteaz


Implementarea n C++ a algoritmului nu este foarte dificil. Vom
folosi un tablou unidimensional P care va reine punctele i o variabil
ntreag N care ne va da numrul punctelor. Codul este urmtorul:
double Arie(Punct P[], int N)
{
double total = 0;
for ( int i = 1; i <= N; ++i )
total += P[i].x * P[i % N + 1].y - P[i].y * P[i % N + 1].x;
return total / 2.0;
}

312

Algoritmi de geometrie computaional


O problem nrudit este urmtoarea: dndu-se un poligon a crui
vrfuri au coordonate numere ntregi, s se determine numrul de puncte cu
coordonate numere ntregi care se afl n interiorul poligonului, respectiv
numrul de puncte cu coordonate numere ntregi care se afl pe laturile
poligonului.
Pentru a rezolva aceast problem vom folosi teorema lui Pick, care
afirm c aria unui astfel de poligon P este:
| | = +

1
2

unde i reprezint numrul de puncte din interiorul poligonului i l


numrul de puncte de pe grania poligonului.
Pentru a putea rezolva problema dat trebuie s calculm i i l.
Formula nu ne ajut foarte mult atta timp ct avem dou necunoscute i o
singur ecuaie, dar exist o metod simpl de a calcula valoarea l. Numrul
de puncte laticiale (puncte de coordonate ntregi) de pe un segment [AB]
este:
[] = . . , . . + 1
Avnd aceast formul putem calcula punctele de pe fiecare latur a
poligonului n timp ce calculm aria, dup care i poate fi aflat uor:
=

+1
2

10.7. Determinarea nfurtorii convexe (convex hull)


Dndu-se N > 2 puncte n plan, notate P1, P2, ..., PN , s se determine
poligonul convex de arie minim care are n interiorul su sau pe muchiile
sale toate cele N puncte. Se consider c oricare trei puncte sunt necoliniare.
S se afieze punctele care reprezint vrfurile poligonului
determinat, ntr-o ordine oarecare.
Problema cere determinarea nfurtorii convexe a unui set de
puncte. Figura de mai jos prezint nfurtoarea convex a 13 puncte:

313

Capitolul 10

Fig. 10.7.1. nfurtoarea convex a unui set de puncte oarecare


Vom prezenta n continuare trei algoritmi de rezolvare: un algoritm
naiv cu timpul de execuie O(N3), unul cu timpul de execuie O(Nh), unde
h este numrul de vrfuri ale nfurtorii convexe i un algoritm de
complexitate O(Nlog N), care este eficient pentru orice set de puncte dat.
Algoritmul naiv se bazeaz pe observaia c un segment [Px Py]
reprezint o latur a nfurtorii convexe dac i numai dac toate punctele
Pk, k x, k y se afl de aceeai parte a dreptei PxPy.
Dac segmentul [PxPy] reprezint o latur a nfurtorii convexe,
atunci evident c punctele Px i Py reprezint vrfuri ale acesteia. Algoritmul
naiv poate fi exprimat n pseudocod astfel:
pentru fiecare x de la 1 la N execut
o pentru fiecare y de la x + 1 la N execut
semn = 0
pentru fiecare k de la 1 la N execut
dac k != x i k != y execut
o semn+=Orientare(P[x],P[y],P[k])
dac |semn| == N 2 execut
afieaz y
o dac y == x + 1 execut: afieaz x
punctele afiate reprezint vrfurile nfurtorii convexe a
setului de puncte dat.
Dezavantajele acestui algoritm sunt n primul rnd numrul mare de
operaii efectuate i n al doilea rnd faptul c vrfurile nfurtorii convexe
sunt afiate ntr-o ordine imprevizibil, lucru care nu poate fi remediat uor.
n practic acest algoritm nu se folosete, fiind preferai algoritmi
mai eficieni, de genul celor prezentai n continuare.
Algoritmul de complexitate O(Nh), unde h este numrul de vrfuri
al nfurtorii convexe se numete algoritmul lui Jarvis i este o
optimizare a algoritmului naiv prezentat anterior.
314

Algoritmi de geometrie computaional


Vom ncepe prin a selecta un punct care tim sigur c reprezint un
vrf al nfurtorii convexe. Un astfel de punct este punctul cel mai din
stnga (cu abscisa minim), iar n caz de egalitate cel mai de sus (cu
ordonata maxim).
Pentru a nelege mai bine algoritmul lui Jarvis, vom face o analogie
cu problema determinrii celui mai mic numr dintr-o secven. Cel mai mic
numr dintr-o secven este acel numr pentru care nu exist niciun alt
numr mai mic dect el. Un algoritm naiv de determinare a acestui numr
este urmtorul:
pentru fiecare numr x din secven execut
o minim = adevrat
o pentru fiecare numr y != x din secven execut
dac y < x execut
minim = fals
o dac minim == adevrat execut
raporteaz x ca fiind cel mai mic numr din
secven
Acest algoritm este evident aberant i nu servete absolut niciun scop
practic. Acesta seamn ns cu algoritmul naiv prezentat anterior pentru
determinarea nfurtorii convexe, care caut toate muchiile care au toate
punctele din setul dat ntr-o singur parte. Algoritmul naiv poate fi optimizat
printr-un raionament similar cu cel care st la baza algoritmului eficient de
determinare a minimului dintr-o secven:
minim = secv[1]
pentru fiecare i de la 2 la N execut
o dac secv[i] < minim execut
minim = secv[i]
raporteaz minim ca fiind cel mai mic numr din secven
Dei algoritmul este foarte simplu i printre primii care se nva la
informatic, logica formal care st la baza acestuia este deseori
necunoscut. Aceast logic este urmtoarea: se consider minim ca fiind,
la fiecare pas i, cel mai mic element dintre primele i ale secvenei. Iniial
minim este secv[1] (primul element al secvenei secv). Acest lucru este
corect deoarece minimul oricrei secvene de un singur element este chiar
acel element. La fiecare pas i > 1, verificm dac secv[i] > minim. Dac da,
atunci este necesar s atribuim lui minim valoarea secv[i] pentru a menine
invariantul c minim este la fiecare pas i cel mai mic element dintre primele
i. Dac nu, atunci nu trebuie s facem nimic deoarece invariantul se
menine. La sfrit, datorit invariantului ales i a respectrii acestuia pe tot
315

Capitolul 10
parcursul algoritmului, minim va conine cel mai mic element din secvena
secv.
O analiz intuitiv a algoritmului este simpl: de fiecare dat cnd
dm de un element mai mic dect cel presupus a fi minimul global, revizuim
presupunerea fcut, considernd acest nou element ca fiind minimul global.
Dup parcurgerea tuturor elementelor, vom avea evident adevratul minim
global.
Algoritmul lui Jarvis are un raionament aproape identic. Fie P1
punctul ales care face sigur parte din nfurtoarea convex. Vom
presupune c segmentul [P1PnewPct] este o muchie a nfurtorii convexe.
Iniial vom considera newPct = 2. Parcurgem acum toate celelalte puncte
date. Dac exist un punct Pq, astfel nct Orientare(P1, Pq, PnewPct) > 0
(sau mai mic ca zero, nu are importan atta timp ct suntem consisteni n
alegere) atunci segmentul [P1Pq] are mai multe anse s aib restul
punctelor ntr-o singur parte dect segmentul [P1PnewPct]. Acest lucru se va
clarifica imediat. Vom seta newPct = q i vom continua algoritmul, cutnd
(fr a reporni cutarea!) un alt q astfel nct Orientare(P 1, Pq, PnewPct) > 0
(atenie, newPct este acuma egal cu vechiul q).
La finalul acestui pas, PnewPct va face sigur parte din nfurtoarea
convex. Mai mult, segmentul [P1PnewPct] va avea toate punctele date ntr-o
singur parte.
Se reia algoritmul de la urmtorul punct de pe nfurtoarea
convex, adic PnewPct. Acesta va juca acum rolul lui P1. Se continu pn
cnd se ajunge din nou la punctul de nceput. Deoarece se execut O(N) pai
pentru fiecare punct de pe nfurtoarea convex deducem c timpul de
execuie a ntregului algoritm este O(Nh).
Funcia Jarvis(P) care determin vrfurile nfurtorii convexe a
setului de puncte P poate fi implementat astfel:
adaug n CH cel mai din stnga punct din P, iar n caz de
egalitate cel mai de sus.
pentru fiecare punct startPct din CH execut
o nextPct = un punct din P diferit de startPct
o pentru fiecare punct q din P execut
dac Orientare(startPct, q, nextPct) > 0 execut
nextPct = q
o dac nextPct != CH[1] execut
adaug nextPct n CH
CH reprezint punctele de pe nfurtoarea convex a setului P.

316

Algoritmi de geometrie computaional


Figura de mai jos prezint modul de selectare a urmtorului punct de
pe nfurtoare. Liniile albastre unesc startPct cu nextPct, iar liniile verzi
unesc startPct cu q. Se poate observa uor corectitudinea algoritmului din
figur.

Fig. 10.7.2. Modul de execuie al algoritmului lui Jarvis


Se poate observa cum la fiecare pas albastrul ia locul verdelui de la
pasul anterior, pn cnd toate punctele ajung s fie ntr-o singur parte a
dreptei selectate n final. Algoritmul continu n acest fel pn cnd se
completeaz ntreg poligonul.
Prezentm n continuare un program complet C++ care citete N
puncte i afieaz cele h vrfuri ale nfurtorii convexe a setului de puncte
dat folosind algoritmul lui Jarvis. Acest algoritm este folositor atunci cnd
numrul de puncte de pe nfurtoare este mic (pentru puncte generate
aleator acest lucru este adevrat).
#include <iostream>
#include <vector>
using namespace std;

317

Capitolul 10
struct Punct
{
double x, y;
Punct(double abscisa, double ordonata) : x(abscisa), y(ordonata) {}
Punct() {}
// se vor compara puncte, deci trebuie sa
// supraincarcam operatorii de egalitate
bool operator ==(const Punct &other)
{
return x == other.x && y == other.y;
}
bool operator !=(const Punct &other)
{
return x != other.x || y != other.y;
}
};
int Orientare(const Punct &A, const Punct &B, const Punct &C)
{
double temp = (B.y - A.y)*(C.x - A.x) - (C.y - A.y)*(B.x - A.x);
if ( temp < 0 )
return -1;
else if ( temp == 0 )
return 0;
else
return 1;
}
void Citire(vector<Punct> &P, int &N)
{
cin >> N;
double x, y;
for ( int i = 1; i <= N; ++i )
{
cin >> x >> y;
P.push_back(Punct(x, y));
}
}

318

Algoritmi de geometrie computaional


vector<Punct> Jarvis(const vector<Punct> &P)
{
int start = 0; // vectorii incep de la 0
for ( int i = 1; i < P.size(); ++i )
if ( P[i].x<P[start].x || (P[i].x == P[start].x && P[i].y>P[start].y) )
start = i;
vector<Punct> CH;
CH.push_back(P[start]);
for ( int i = 0; i < CH.size(); ++i )
{
Punct nextPct = (CH[i] == P[0]) ? P[1] : P[0];
for ( int j = 0; j < P.size(); ++j )
if ( Orientare(CH[i], P[j], nextPct) > 0 )
nextPct = P[j];
if ( nextPct != CH[0] )
CH.push_back(nextPct);
}
return CH;
}
int main()
{
int N;
vector<Punct> P;
Citire(P, N);
vector<Punct> CH = Jarvis(P);
cout << endl;
for ( int i = 0 ; i < CH.size(); ++i )
cout << CH[i].x << " " << CH[i].y << endl;
return 0;
}

Exerciii:
a) n ce ordine se afieaz punctele de pe nfurtoare?
b) Cum se poate modifica algoritmul astfel nct punctele de pe
nfurtoare s fie afiate n alt ordine?
c) Dai exemplu de un set de puncte pe care algoritmul efectueaz
un numr nefavorabil de pai.
319

Capitolul 10
Algoritmul de complexitate O(Nlog N) este algoritmul lui
Graham (sau scanarea Graham). Acesta este un algoritm foarte eficient pe
orice set de puncte, presupunnd doar o sortare i o parcurgere liniar a
punctelor.
Funcia Graham(P) care determin punctele ce reprezint vrfuri ale
nfurtorii convexe a setului de puncte P poate fi scris n pseudocod n
felul urmtor:
Interschimb P[1] cu un punct care sigur este vrf al
nfurtorii.
Sorteaz P[2, N] cresctor dup panta dreptei format de fiecare
dintre aceste puncte cu punctul P[1].
P[0] = P[N]
nrH = 2, va reprezenta numrul de vrfuri ale nfurtorii.
Pentru fiecare i de la 3 la N execut
o Ct timp nrH > 1 i
Orientare(P[nrH 1], P[nrH], P[i]) < 0 execut
nrH = nrH 1
o nrH = nrH + 1
o interschimb P[nrH] cu P[i]
P[1, nrH] reprezint mulimea punctelor care sunt vrfurii ale
nfurtorii convexe a setului de puncte P.
Se poate observa c algoritmul folosete o stiv n care construiete
soluia (practic se folosete vectorul dat). La fiecare pas se verific dac
ultimele dou puncte din stiv, mpreun cu cel de-al i-lea punct, sunt sau nu
valide. Invariantul pstrat de algoritm este ca toate punctele s aib semnul
pozitiv, aa c vor fi scoase din stiv punctele care nu ndeplinesc aceast
condiie. La sfritul algoritmului, n stiv vor rmne doar punctele valide.
Scanarea Graham este practic o optimizare a algoritmului lui Jarvis,
optimizare facilitat de sortarea punctelor dup panta dreptei pe care o
formeaz cu punctul care sigur face parte din nfurtoare.
Deoarece fiecare punct intr n stiv o singur dat i iese din stiv
cel mult o dat, deducem c asupra fiecrui punct se efectueaz O(N)
operaii de cost constant. Complexitatea algoritmului este deci O(Nlog N)
datorit sortrii.
Algoritmul ncepe cu cel mai din stnga punct i construiete
muchiile n sensul acelor de ceasornic. Muchiile albastre din figura
urmtoare reprezint muchii alese la un moment dat de ctre algoritm, dar
care au fost determinate apoi ca fiind greit alese, fornd algoritmul s
320

Algoritmi de geometrie computaional


revin asupra deciziei fcute. Am putea spune aadar c scanarea Graham
este un algoritm de tip backtracking, deoarece revine asupra deciziilor
fcute atunci cnd este cazul.

Fig. 10.7.3. Modul de execuie al scanrii Graham


Prezentm n continuare implementarea algoritmului. Aceasta este
puin mai dificil datorit faptului c avem un criteriu de sortare mai
complex.
struct Sorter // o structura care nu poate fi initializata,
// folosita pentru a sorta in functie de un element al vectorului
{
private:
static Punct st;
static bool cmp(const Punct &A, const Punct &B)
{
return (A.y - st.y) * (B.x - st.x) < (B.y - st.y) * (A.x - st.x);
}
Sorter() {}
public:
static void Sort(vector<Punct> &P, int start)
{
st = P[start];
Punct t = P[0];
P[0] = P[start];
P[start] = t;
sort(P.begin() + 1, P.end(), Sorter::cmp);
}
};
Punct Sorter::st = Punct();

321

Capitolul 10
vector<Punct> Graham(vector<Punct> P)
{
int start = 0; // vectorii incep de la 0
for ( int i = 1; i < P.size(); ++i )
if ( P[i].x<P[start].x || (P[i].x == P[start].x && P[i].y>P[start].y) )
start = i;
Sorter::Sort(P, start);
P.insert(P.begin(), P[P.size() - 1]);
int nrH = 2;
for ( int i = 3; i < P.size(); ++i )
{
while ( nrH > 1 && Orientare(P[nrH - 1], P[nrH], P[i]) > 0 )
--nrH;
++nrH;
swap(P[nrH], P[i]);
}
return vector<Punct>(P.begin(), P.begin() + nrH);
}

322

Liste nlnuite

11. Liste
nlnuite
Acest capitol prezint noiunile elementare despre liste nlnuite
(simplu nlnuite, dublu nlnuite i circulare). Vor fi prezentate noiuni
teoretice i detalii de implementare.
Listele nlnuite au anumite avantaje i dezavantaje relativ la
tablouri (vectori). Principalul dezavantaj este c nu suport accesul aleator:
pentru a accesa al k-lea element al unei liste, este necesar s parcurgem
toate elementele anterioare. Principalul avantaj este c listele suport
inserri mai eficiente, mai ales la sfritul i nceputul acestora (acestea se
pot face n timp constant.
Aadar, alegerea dintre liste i tablouri trebuie fcut n funcie de
natura problemei pe care vrem s o rezolvm. Vom prezenta n acest capitol
i alte avantaje i dezavantaje.
Dei listele nlnuite exist deja implementate n cadrul librriei
S.T.L. (containerul list), considerm c este important pentru orice
programator s cunosc modul de implementare manual al acestora, ntruct
implementarea manual ofer mai mult control asupra acestora. Acest
control va fi necesar n capitolele urmtoare, care au de a face cu grafuri i
cu structuri avansate de date, structuri care uneori se pot implementa mai
simplu i mai eficient printr-o implementare manual a listelor nlnuite
care stau la baza acestora.
Recomandm aadar parcurgerea i nelegerea acestui capitol
nainte de a trece mai departe la capitolele urmtoare.

323

Capitolul 11

CUPRINS
11.1. Noiuni introductive ............................................................................. 325
11.2. Tipul abstract de date list simplu nlnuit..................................... 327
11.3. Aplicaii ale listelor nlnuite ............................................................. 339
11.4. Tipul abstract de date list dublu nlnuit ...................................... 343
11.5. Dancing Links ........................................................................................ 354

324

Liste nlnuite

11.1. Noiuni introductive


Din punct de vedere al structurilor de date nlnuite vom introduce
urmtoarea clasificare: vectori statici, vectori dinamici i liste. Vectorul
static este caracterizat prin numrul fix de elemente i operaiile de adugare
sau tergere sunt de fapt inexistente:
const int N = 100; // dimensiunea vectorului
int v[N]; // declararea vectorului

Iar parcurgerea celor N elemente se face astfel:


for ( int i = 0; i < N; ++i ) { ... }

Dac se simuleaz tergerea unui element prin parcurgerea apoi pn


la N 1 sau se adaug un element, de fapt vectorul are tot 100 de elemente
indiferent ce valoare are N.
n cazul vectorului dinamic, vom declara mai nti un pointer:
int *V;

Iar prin procesul de alocare dinamic vom aloca efectiv memoria:


V = (int *) malloc(N * sizeof(int)); // varianta C
V = new int[N]; // varianta c++

Se aloc astfel N elemente de tip ntreg, deci dimensiunea vectorului


este N. Aceast metod ofer un plus de eficien la nivel de memorie,
deoarece putem aloca exact atta memorie ct avem nevoie. n cazul n care
se dorete adugarea sau tergerea unui element prin realocarea memoriei,
codul devine puin mai complex.
Un avantaj major al folosirii vectorilor, att statici ct i dinamici,
este acela c, prin definiie, vectorul este indexat, fapt ce permite
vizualizarea sau modificarea elementului i prin poziionarea pe elementul
respectiv prin operatorul [ ]: V[i]. Acest acces direct prin indexare este
pierdut la urmtorul tip de date nlnuit: lista. La nivel teoretic, prin
pierderea indexului, n cazul listei, fiecrui element ce conine informaie
(element numit acum nod) i se mai asociaz o variabil de tip pointer care
va reine adresa unui nod.
Astfel nodul devine o structur:
325

Capitolul 11
struct nod
{
T informatie;
nod *link_;
};

Care prin valoarea variabilei link_ se leag (pointeaz) de


urmtoarea structur de tip nod, creendu-se astfel o structur de date
nlnuit la care se face referire prin adresa primului nod, respectiv se
ncheie atunci cnd elementul curent pointeaz spre elementul NULL, de
unde i reprezentarea uzual:

Fig. 11.1.1. Reprezentarea uzual a unei liste nlnuite


Acest tip de date permite o utilizare mult mai eficient a memoriei la
nivel de modificri structurale (adugare, stergere) a informaiei, dar (n
cazul definiiei clasice) pierde avantajul indexrii, deci pentru a vizualiza
sau modifica elementul i dintr-o list este necesar parcurgerea de la primul
element pn la elementul i din adres n adres.
Mecanismele de utilizare a tipurilor de date nlnuite (n acest
model de definiie) relativ la operaiile de baz (adugare Add, tergere
Del, vizualizare View i modificare Mod) se pot sintetiza n figura
urmtoare:

Fig. 11.1.2. Operaiile de baz a tipurilor de date nlnuite


326

Liste nlnuite
Cu alte cuvinte, dac este necesar folosirea n program a unui tip de
date nlnuit, fr necesitatea modificrilor structurale de adugare sau
tergere, se recomand utilizarea vectorilor statici. Dac se cer modificri
structurale minime i se pune accentul pe modificri i vizualizri ale
elementelor (satistici, ordonri...), atunci se recomand utilizarea vectorilor
dinamici (vector din S.T.L. este implementat ca un vector dinamic). n
schimb, dac se cere folosirea unui tip de date nlnuit bazat pe modificri
structurale, atunci este necesar i util folosirea unei liste.

11.2. Tipul abstract de date list simplu nlnuit


n continuare vom construi, pe baza unei structuri nod ce conine o
informaie virtual T info; i respectiv pointerul spre structura nod, funciile
aferente ale unui model general al unei liste numit i T.A.D. lista (Tipul
Abstract de Date) din care vom deriva mai trziu o serie de obiecte familiare
(stiva, coada, lista simplu nlnuit, lista circular) acestor structuri de date.
n prima parte a construirii T.A.D. list este necesar stabilirea
funciilor de baz ce trebuie explicitate, n cazul acesta adugarea unui nod
n list (add), tergerea unui nod (del), modificarea informaiei unui nod
(mod) i vizualizarea informaiei unui nod (view). n cazul adugrii i
tergerii, construcia i implementarea funciilor se poate face prin pri,
mecanismele n cazul adugrii sau tergerii la nceput (beg), la mijloc
(mid), sau la sfrit (end) fiind uor diferite ntre ele.

a) add_beg (adugarea unui nod la nceputul listei)


S presupunem construcia listei pe urmtoarea structur:
struct nod
{
int info;
nod * link_;
}

Pentru fiecare procedur care necesit adugare vom construi un nou


element cu numele New.
Pentru a analiza mecanismul de adugare la nceput s urmrim
figura urmtoare:

327

Capitolul 11

Fig. 11.2.1. Adugarea unui nou element la nceputul listei


Vom construi o funcie care primete lista veche i returneaz lista
nou:
nod *add_beg(nod *Old) { ... }

n prima parte va trebui s avem un nod nou:


nod *New = new nod;

Citim informaia acestuia:


cin >> New->info;

i aa cum se vede n figura anterioar, pointerul elementului New


pointeaz spre lista veche, obinnd astfel o adugare la nceput a nodului
New.
New->link_ = Old;

Deoarece lista este de fapt adresa primului element, mai trebuie


precizat faptul c functia add_beg trebuie s returneze adresa lui New
(aceasta fiind acum primul element):
return New;

Acelai mecanism se folosete i dac add_beg se construiete sub


form de procedur: valorile modificate se vor salva prin transmiterea prin
referin a listei Old:
void add_beg(nod *&Old)
{
nod * New = new nod;
cin >> New->info;
New->link_ = Old;
Old = New;
}

328

Liste nlnuite

b) add_end (adugarea unui nod la sfritul listei)


n cazul adugrii unui element la sfritul listei este necesar s
gsim adresa ultimului element din lista veche (Temp) ca s putem scrie:
Temp->link_ = New; // adresa elementului nou

i
New->link_ = NULL;

Fig. 11.2.2. Adugarea unui nod la sfritul unei liste nlnuite


Pentru a gsi adresa ultimului element n cazul unei liste este
necesar parcurgerea nodurilor listei pas cu pas, printr-o metod repetitiv,
pn cnd se ndeplinete condiia ce caracterizeaz ultimul nod:
Temp->link_ == NULL.

Fig. 11.2.3. Gsirea adresei ultimului element dintr-o list nlnuit


n cazul nostru, datorit capacitii de utilizare a instruciunii for n
C++, modalitatea de a ajunge la ultimul element se poate scrie ntr-o singur
instruciune:
for ( nod *Temp = Old; Temp->link_ != NULL; Temp=Temp>link_);

Avnd aceste date putem scrie codul funciei add_end:

329

Capitolul 11
nod *add_end(nod *Old)
{
// construcia nodului de adugat
nod *New = new nod;
New->link_ = NULL;
cin >> New->info;
// gsirea adresei ultimului nod
nod *Temp;
for ( Temp = Old; Temp->link_ != NULL; Temp = Temp->link_ );
// legtura
Temp->link_ = New;
// valoarea de returnat
return Old;
}

Precondiii: este necesar s amintim c aceast funcie nu este


complet deoarece ntr-un anume caz aceasta va genera erori:
nod *LISTA = NULL;
LISTA = add_end(LISTA);

nod *LISTA = NULL;


LISTA = add_beg(LISTA);
LISTA = add_end(LISTA);

eroare!

fr erori!

i anume n cazul n care LISTA == NULL;


Temp va porni direct de la NULL, iar expresia Temp->link_
(NULL->link_) nu mai are sens. Acest lucru se poate prentmpina prin
introducerea primului element prin add_beg (...), sau completarea funciei
add_end dup cum urmeaz:
nod *add_end(nod *Old)
{
if ( Old == NULL )
Old = add_beg(Old); // add_beg(Old) in cazul procedurii
else
{
//construcia nodului de adugat
nod *New = new nod;
New->link_ = NULL;
cin >> New->info;

330

Liste nlnuite
//gsirea adresei ultimului nod
nod *Temp;
for ( Temp = Old; Temp->link_ != NULL; Temp = Temp->link_ );
//legtura
Temp->link_ = New;
//valoarea de returnat
return Old;
}
}

c) add_mid (adugarea unui nod n interiorul listei)


Un proces mai complex este acela prin care se adug un element n
interiorul unei liste. Pentru adugarea n interior este necesar n primul rnd
o cheie care ne arat care element unde trebuie introdus. n funcie de
anumite probleme cheia poate fi o poziie (s se introduc dup poziia k un
element...), caz n care cheia face referire la structura listei, sau o valoare
(s se construiasc o list cu informaia numere ntregi ordonat n timpul
construciei), caz n care cheia face referire la coninutul listei.
Pentru a introduce un nou nod de valoare oarecare astfel nct lista s
rmn sortat, se parcurge lista pn cnd valoarea nodului care trebuie
adugat este mai mic sau egal cu urmtorul element (evident, pot exista i
alte criterii de inserare, acesta este doar un exemplu), adic:
New->info <= Temp->link_->info

n cazul figurii urmtoare variabila nod *Temp este variabila care


parcurge lista n funcie de cheie (aici nodul nou trebuie introdus ntre
nodurile 2 i 3).
Se creeaz nodul nou:
nod *New = new nod;
cin >> New->info;
Legtura acestuia trebuie s fie ctre nodul 3, la adresa cruia
ajungem prin Temp->link_.
New->link_ = Temp->link_;

(1)

Refacerea legturii iniiale dintre nodurile 2 i 3 astfel nct nodul 2


s pointeze la nodul nou:
Temp->link_ = New;

(2)

331

Capitolul 11

Fig. 11.2.4. Inserarea unui nod n interiorul unei liste nlnuite


Este foarte important ordinea (1) i (2), deoarece n caz contrar prin
Temp->link_ = New;

(1)

Spre adresa nodului 3 nu mai pointeaz nimic i


New->link_ = Temp->link_;

(2)

Creeaz structura din figura de mai jos, n care se pierd toate datele
de la nodul 3 ncolo.

Fig. 11.2.5. O greeal des ntlnit n implementarea inserrii


Observaie: de multe ori cutarea dup cheie poate s nu dea
rezultate, caz n care este necesar i condiia de oprire pentru cnd
pointerul Temp ajunge la ultimul element.
Vom construi funcia add_mid n cazul problemei n care se cere
crearea unei liste nlnuite ordonate cu informaie de tip ntreg completnd
i precondiiile necesare rulrii corecte (vezi add_end).
Presupunem c de la tastatur se citesc N numere ntregi. Se cere
crearea unei liste nlnuite care s conin numerele n ordine cresctoare.
Coninutul liste se va afia pe ecran.
Prezentm doar funcia add_mid. Programul principal presupune
declararea unei liste LISTA, iniializat cu NULL, citirea lui N, iar apoi o
structur repetitiv de genul:
for ( int i = 1 i <= N; ++i ) LISTA = add_mid(LISTA);

332

Liste nlnuite
Pentru funcionarea corect a funciei n orice caz, avem nevoie de
tratarea unor cazuri particulare: n primul rnd, dac lista nu are niciun nod,
se adaug pur i simplu nodul citit n list. n al doilea rnd, observm c
parcurgerea menionat mai sus nu funcioneaz corect dac primul nod al
listei are deja o valoare mai mare dect a nodului care trebuie adugat,
deoarece verificarea ncepe abia de la al doilea nod. De aceea, vom pune o
condiie ca dac primul nod are o valoare mai mare dect a noului nod, noul
nod va fi adugat la nceputul listei. Restul condiiilor sunt clare:
nod *add_mid(nod *Old)
{
if ( Old == NULL ) Old = add_beg(Old); // adaugarea primului nod
else
{
// nodul care trebuie adaugat
nod *New = new nod;
cin >> New->info;
// daca noua valoarea este mai mica sau egala cu cea a primului
// element, atunci aceasta se adauga la inceput
if ( New->info <= Old->info )
{
New->link_ = Old; Old = New;
}
else
{
// caut unde trebuie adaugat noul nod
nod *Temp;
for ( Temp = Old; Temp->link_ != NULL; Temp = Temp->link_ )
if ( New->info <= Temp->link_->info )
break; // am gasit pozitia pe care trebuie adaugat nodul
if ( Temp->link_ == NULL ) // adaugare la sfarsit
{
New->link_ = NULL; Temp->link_ = New;
}
else // nodul se adauga undeva in interior
{
New->link_ = Temp->link_; Temp->link_ = New;
}
}
}
return Old;
}

333

Capitolul 11

d) del_beg (tergerea unui nod de la nceputul listei)


tergerea unui nod de la nceputul listei se poate realiza simplu prin
schimbarea adresei de nceput la adresa urmtoare, astfel:

Fig. 11.2.6. tergerea primului nod al unei liste nlnuite


nod *del_beg(nod *Old)
{
return Old->link_;
}

void del_beg(nod *&Old) // procedura


{
Old = Old->link_;
}

Codul anterior, dei funcional n anumite cazuri, necesit dou


mbuntiri: tratarea listei n cazul n care nu exist Old->link_ i eliberarea
memoriei folosite de nodul peste care s-a srit. Pentru prima parte avem:
nod *del_beg(nod * Old)
{
if ( Old != NULL )
return Old->link_;
else
return Old;
}

Pentru eliberarea memoriei se construiete un pointer ToDel care va


pointa la valoarea care trebuie tears, dup care memoria alocat acestuia
va fi tears prin instruciunea delete:

Fig. 11.2.7. tergerea efectiv (din memorie) a unui nod


334

Liste nlnuite
nod *del_beg(nod *Old)
{
if ( Old != NULL )
{
nod *ToDel = Old;
Old = Old->link_;
delete ToDel;
}
return Old;
}

e) del_end (tergerea unui nod de la sfritul listei)


n cazul acestei tergeri trebuie s ne poziionm cu o variabil de tip
pointer Temp pe penultima poziie din list, iar apoi prin instruciunea
Temp->link_ = NULL vom sri peste ultimul element.
Dac dorim s completm codul cu eliberarea memoriei nodului
ters, avem nevoie de un pointer ToDel, care va pointa la ultimul element,
dat de ToDel = Temp->link_, pointer care va fi apoi ters.

Fig. 11.2.8. tergerea ultimului nod al unei liste nlnuite


Vom ine cont i de precondiiile date de existena penultimului
element: Temp->link_->link_ != NULL;
Dac lista are un singur element sau este NULL, atunci returnm
NULL;

335

Capitolul 11
nod *del_end(nod *Old)
{
if ( Old == NULL )
return NULL;
else
{
if ( Old->link_ == NULL )
{
nod *ToDel = Old;
Old = NULL;
delete ToDel;
}
else
{
nod *Temp;
for ( Temp = Old; Temp->link_->link_; Temp = Temp->link_ );
nod *ToDel = Temp->link_;
Temp->link_ = NULL;
delete ToDel;
}
}
return Old;
}

f) del_mid (tergerea unui element din interiorul listei)


La fel ca i n cazul inserrii unui element n interiorul listei i aici
trebuie s existe o cheie care arat care element trebuie ters. Pentru
exemplificare, s presupunem c avem o list a crei noduri conin ca
informaie un numr ntreg i trebuie s construim cheia (key un numr
ntreg) astfel nct s tergem nodul a crui informaie este egal cu valoarea
dat prin cheie:
nod *del_mid (nod *Old, int key) {...}

n prima variant (neinnd cont de precondiii i restricii) trebuie s


parcurgem lista cu o variabil de tip pointer Temp astfel nct s ne
poziionm pe elementul imediat anterior celui a crui valoare este egal cu
variabila key:
for ( nod *Temp = Old; Temp->link_ ; Temp = Temp->link_) { ... }

336

Liste nlnuite
Vom folosi condiia Temp->link_->info == key pentru a verifica
dac urmtorul element este cel care trebuie ters. Dac da, acest element se
va terge n felul urmtor: construim o variabil pointer ToDel care va lua
adresa elementului ce trebuie ters:
nod *ToDel = Temp->link_;
(1)
Trecem peste nodul pointat de Temp link_ prin
Temp->link_ = Temp->link_->link_;
(2)
i eliberm memoria lui ToDel prin
(3)

delete ToDel;

Fig. 11.2.9. tergerea unui nod din interiorul unei liste nlnuite
n acest caz avem tratat condiia Temp->link_->info == key, dar
dac primul element este chiar cel care trebuie ters? ]n implementarea
iniial nu avem tratat acest caz. Codul prezentat mai jos trateaz ns i
cazul acesta:
nod *del_mid(nod *Old, int key)
{
if ( Old->info == key )
Old = del_beg(Old);
else
{
for ( nod *Temp = Old; Temp->link_; Temp = Temp->link_ )
if ( Temp->link_->info == key )
{
nod *ToDel = Temp->link_;
Temp->link_ = Temp->link_->link_;
delete ToDel; break;
}
}
return Old;
}

337

Capitolul 11

g) Parcurgerea unei liste


n acest caz este necesar folosirea unui pointer Temp care va
parcurge toate elementele listei (ct timp Temp != NULL) i la fiecare pas se
vizualizeaz (afieaz sau prelucreaz n vreun fel) Temp->info;
void view(nod *Old)
{
for ( nod *Temp = Old; Temp != NULL; Temp = Temp->link_ )
cout << Temp->info << ' ';
cout << endl;
}

O problem important (un dezavantaj major) a acestui tip de list se


leag de parcurgerea n ordine invers, caz n care trebuie s parcurgem
toat lista pentru a ajunge la ultimul element, apoi la penultimul, .a.m.d.
Acest dezavantaj major va introduce un nou tip de date nlnuit: lista dublu
nlnuit, despre care vom vorbi n mai trziu tot n cadrul acestui capitol,
dar mai nti s rezolvm problema parcurgerii inverse.
Putem parcurge lista n mod normal, iar valorile elementelor s le
reinem ntr-un vector pe msur ce acestea sunt parcurse. Parcurgerea
invers a listei este dat de afiarea n ordine invers a elementelor acestui
vector. Pentru a nu se utiliza un vector, putem implementa recursiv o funcie
de afiare, care afieaz elementele la revinirea din recursivitate, avnd
acelai efect, dar scriind mai puin cod:
void view_rev(nod *Old)
{
if ( Old == NULL )
{
cout << endl;
return;
}
view_rev(Old->link_);
cout << Old->info << ' ';
}

Exerciiu: cnd se va trece la linie nou n cadrul programului de


mai sus? Scriei o funcie care face trecerea la linie nou dup afiarea
tuturor elementelor.
338

Liste nlnuite

11.3. Aplicaii ale listelor nlnuite


Pe acest model de definire a T.A.D. list se pot construi mai multe
obiecte. Majoritatea acestora pot fi implementate i ca vectori, dar uneori
implementarea cu ajutorul listelor nlnuite este mai eficient, deoarece
acestea ne permit tergerea din memorie a elementelor de care nu mai avem
nevoie.
Vom prezenta n continuare cteva astfel de obiecte. Acestea se
regsesc i n librria S.T.L.

a) Stiva
Stiva este un tip particular de list n care adugarea i tergerea
nodurilor se realizeaz ntr-un singur capt (numit uzual vrf). n cazul
definiiei noastre, tipul de date stiv permite fie (add_beg i del_beg), fie
(add_end i del_end), crend astfel un mecanism F.I.L.O. (First In Last
Out) / L.I.F.O. (Last In First Out, folosit mai des). n multe dintre
problemele care necesit un tip de date de tip stiv este necesar
introducerea unei variabile ce reprezint dimensiunea maxim a stivei,
mpreun cu o subrutin cu ajutorul creia s se poat verifica dac o stiv
este sau nu plin (sau goal).

Fig. 11.3.1. Modul de funcionare al unei stive

b) Coada
Pentru acest tip particular de list, adugarea se realizeaz la un
capt n schimb ce tergerea unui nod se realizeaz la captul opus. n cazul
definiiei noastre, tipul coad va permite sau (add_beg i del_end) sau
(add_end i del_beg), crendu-se un mecanism F.I.F.O. (First In First Out,
folosit mai des) / L.I.L.O. (Last In Last Out). La fel ca i n cazul stivei, de
multe ori este necesar introducerea unei variabile ce reprezint lungimea
339

Capitolul 11
maxim a cozii i a unei funcii cu ajutorul creia s putem verifica dac
structura este plin sau nu.

Fig. 11.3.2. Modul de funcionare al unei cozi

c) Lista simplu nlnuit


Trebuie s amintim aici c, n urma definirii tipurilor de date
nlnuite stiv i coad prin folosirea anumitor module de adugare sau de
tergere dintr-o list, nerestricionarea utilizrii acestor module determin
tipul de date numit list simplu nlnuit.

d) Lista circular simplu nlnuit


Se deriveaz din list, prin adugarea condiiei prin care ultimul nod
nu va pointa la NULL, ci va pointa la nceputul listei. Aceast condiie
restricioneaz adugarea i tergerea unor elemente la funciile add_mid i
del_mid, deoarece lista nu mai are nceput i sfrit.

Fig. 11.3.3. O list circular simplu nlnuit


Nu putem vorbi de lista circular simplu nlnuit fr s tratm
problema cavalerilor mesei rotunde:
Din fiierul cavaleri.in se citete un numr natural N reprezentnd
numrul de cavaleri aezai la masa rotund. Regele Arthur vrea s aleag
un cavaler pe care s-l trimit ntr-o misiune foarte important. Pentru
340

Liste nlnuite
aceasta, el va ncepe o numrtoare ncepnd de la cavalerul cu numrul de
ordine 1. Iniial, el numr pn la 1, oprindu-se pe cavalerul imediat
urmtor, adic 2, care este eliminat (sigur nu va fi trimis n misiune i se
ridic de la mas). Dup aceea, el numr pn la 2, oprindu-se pe al doilea
cavaler dup cel eliminat anterior, acesta fiind cel cu numrul de ordine 4,
care este i el eliminat. Dup aceea numr pn la 3, oprindu-se pe al
treilea cavaler dup ultimul eliminat. Regele se oprete atunci cnd mai
rmne un singur cavaler neeliminat, cavaler care va fi trimis n misiune.
Fiierul de ieire cavaleri.out va conine, n ordinea eliminrii lor,
numerele de ordine ale cavalerilor eliminai.
Exemplu:
cavaleri.in cavaleri.out
6
24135
Problema poate fi rezolvat folosind o list circular n felul
urmtor: vom ine o variabil nr care va reprezenta numrul de cavaleri
eliminai deja. Cnd nr = N 1, algoritmul se ncheie. Eliminarea efectiv
este simplu de realizat: ne vom deplasa la fiecare pas de attea ori de ct
este necesar pentru a gsi urmtorul cavaler care trebuie eliminat i vom
terge nodul asociat acestuia din list.
Trebuie avut grij ca ultimul nod al listei s fie legat de primul,
pentru a putea efectua deplasrile n mod natural.
#include <fstream>
using namespace std;
struct nod
{
int info;
nod *link_;
};

nod *add_end(nod *Old, int val)


{
if ( Old == NULL )
Old = add_beg(Old, val);
else
{
nod *New = new nod;
New->link_ = NULL;
New->info = val;

nod *add_beg(nod *Old, int val)


{
nod *New = new nod;
New->info = val;
New->link_ = Old;

nod *Temp;
for ( Temp = Old;
Temp->link_ != NULL;
Temp = Temp->link_ );
Temp->link_ = New;
}
return Old;

return New;
}

341

Capitolul 11
void rezolvare(nod *LISTA, int N)
{
int nr = 0;
ofstream out("cavaleri.out");
for ( int i = 1; i <= N; ++i )
LISTA = add_end(LISTA, i);
nod *Temp;
for ( Temp = LISTA; Temp->link_ != NULL; Temp = Temp->link_ );
Temp->link_ = LISTA; // lista devine circulara
while ( nr < N - 1 )
{
for ( int i = 0; i < nr; ++i )
LISTA = LISTA->link_;
// LISTA->link_ este cavalerul care
// trebuie eliminat, adica sters
nod *Tmp = LISTA->link_;
out << Tmp->info << ' ';
LISTA->link_ = LISTA->link_->link_;
delete Tmp;
++nr;
}
out.close();
}
int main()
{
int N;
ifstream in("cavaleri.in");
in >> N;
in.close();
nod *LISTA = NULL; // necesar pentru a evita anumite erori
rezolvare(LISTA, N);
return 0;
}

342

Liste nlnuite

11.4. Tipul abstract de date list dublu nlnuit


Pe parcursul acestui subcapitol vom construi i explicita tipul
abstract de date list dublu nlnuit (T.A.D. list D..). n primul rnd,
prin list dublu nlnuit nelegem un tip de date a crui elemente sunt
structuri numite noduri ce conin informaie i doi pointeri la tipul nod ce
vor pointa unul spre adresa nodului urmtor (list simplu nlnuit), iar
cellalt spre adresa nodului anterior, eliminnd astfel dezavantajul
parcurgerii inverse ntlnit la listele simplu nlnuite.

Fig. 11.4.1. O list dublu nlnuit, reprezentat n dou moduri


struct nod
{
T info;
nod *link_;
nod *_link; // pointer catre nodul anterior
};

Fig. 11.4.2. Pointerii existeni n cadrul unei liste dublu nlnuite


Diferena dintre cele dou modele prezentate n figura 11.4.1. const
n modul de folosire a listei dublu nlnuite.

343

Capitolul 11
n primul caz (clasic), se face referire la lista dublu nlnuit
printr-un singur pointer la primul nod:
nod *LISTA;
Iar apoi, pentru a ajunge la adresa ultimului element de pe direcia
link_, se va executa un ciclu repetitiv:
for ( nod *Temp = LISTA; Temp->link_; Temp = Temp->link_);

Dup care se poate ncepe parcurgerea invers:


for (nod *bTemp=Temp; bTemp; bTemp=bTemp->_link)
{...}

Acest lucru se poate vizualiza astfel:

Fig. 11.4.3. Parcurgerea n ambele sensuri a unei liste dublu nlnuite


n al doilea caz, lista dublu nlnuit este reprezentat de o alt
structur care are doi pointeri la tipul de date nod:
struct Lista
{
nod *adrp;
nod *adru;
};

Acetia au rolul de a reine att adresa primului element ct i adresa


ultimului element, parcurgerea invers fiind de fapt ca o parcurgere normal
pornind de la variabila adru:
for ( nod *backTemp = Obiect.adru;
backTemp != NULL; backTemp = backTemp->_link )
{...}

n continuare vom prezenta funciile de adugare, tergere i


vizualizare pentru listele dublu nlnuite.
344

Liste nlnuite

a) add_beg (adugarea unui nod la nceputul listei)


Presupunem informaia de tip int.
struct nod
{
int info;
nod *link_;
nod *_link;
};

Adugarea, fiind o modificare structural, presupune un nou nod


(New) care se va lega de lista Old.
Din figura urmtoare rezult c vom avea nevoie de
New->link_ = Old;
Old->_link = New;

(1)
(2)

Fig. 11.4.4. Adugarea unui nod la nceputul unei liste dublu nlnuite
S nu uitm c adresa primului element se schimb, deci va trebui s
returnm noua adres sau s transmitem primul nod al listei prin referin.
nod *add_beg(nod *Old)
{
nod *New = new nod;
cin >> New->info;
New->_link = NULL;
New->link_ = Old;
Old->_link = New;
Old = New;
return Old;
}

345

Capitolul 11
Mai trebuie s amintim cazul n care Old este NULL, astfel
neexistnd Old->_link; fapt ce poate genera erori. Modul tratare al acestui
caz este foarte simplu: se nlocuiete n funcia anterioar linia
Old->_link = New;

cu:
if ( Old != NULL ) Old->_link = New;

Ceea ce este suficient.

b) add_end (adugarea unui nod la sfritul listei)


Pentru acest tip de adugare vom avea nevoie de un pointer Temp,
pe care l vom pointa spre ultimul element, pe direcia link_, astfel nct s
putem scrie:
Temp->link_ = New;
New->_link = Temp;

(1)
(2)

Fig. 11.4.5. Adugarea unui nod la sfritul unei liste dublu nlnuite
nod *add_end(nod *Old)
{
nod *New = new nod;
cin >> New->info;
New->link_ = NULL;
nod *Temp;
for ( Temp = Old; Temp->link_ != NULL; Temp = Temp->link_ );
Temp->link_ = New;
New->_link = Temp;
return Old;
}

346

Liste nlnuite
Mai trebuie verificat condiia de existen a lui Temp->link_: dac
lista Old este NULL, atunci adugarea la sfrit se schimb n add_beg:
if ( Old == NULL )
Old = add_beg(Old);
else { codul anterior }

Nu se schimb nimic la adresa primului element (return Old). Se


schimb adresa ultimului element, aceasta fiind adresa noului nod (New);

c) add_mid (adugarea unui nod n interiorul listei)


n cazul acestui tip de adugare vom proceda la fel ca la adugarea
n interiorul unei liste simplu nlnuite: avem nevoie de un parametru care
ne spune unde trebuie introdus noul nod (o poziie sau o valoare naintea
creia se va efectua adugarea). Este necesar ca pointerul Temp s ajung
naintea elementului cutat.
Va trebui s avem grij la restabilirea proprietii de list (atribuirea
pointerilor). Acest lucru se poate realiza comform figurii urmtoare:

Fig. 11.4.6. Adugarea unui nod n interiorul unei liste dublu nlnuite
Implementarea, respectiv condiiile de existen a acestui mod de
adugare, se pot urmri n aplicaia asociat acestui subcapitol.

347

Capitolul 11

d) del_ beg (tergerea unui nod de la nceputul listei)


Vom construi pointerul ToDel pentru a salva valoarea pointerului
Old, n vederea tergerii acestuia.
ToDel = Old;

(1)

Vom trece peste primul nod


Old = Old -> link_

(2)

i respectiv vom rupe legtura pointerului _link cu nodul anterior


Old -> _link = NULL

(3)

Fig. 11.4.7. tergerea primului nod al unei liste dublu nlnuite


Urmnd instruciunea de eliberare a memoriei alocat pointerului
ToDel: delete ToDel;

e) del_ end (tergerea unui nod de la sfritul listei)


n prima faz stabilim pointerii suplimentari de care avem nevoie:
Temp, pentru a pointa la penultimul nod
(1)
for ( Temp = Old; Temp->link_->link_; Temp = Temp->link_ );

respectiv
ToDel, pentru a elibera memoria ultimului nod

(2)

Fig. 11.4.8. Poziionarea pointerilor pentru tergerea ultimului nod


348

Liste nlnuite
Dup care:

Fig. 11.4.9. tergerea efectiv a ultimului nod


Temp -> link_ = NULL;
delete ToDel ;

(3)
(4)

f) del_ mid (tergerea unui nod din interiorul listei)


Asemntoare cu tergerea de la sfritul listei prezentat anterior, i
aici n prima parte trebuie s stabilim pointerii Temp i ToDel:
Temp, unde Temp->link_ pointeaz la nodul pe care vrem s-l
tergem din list.
(1)
respectiv
ToDel, pentru a elibera memoria nodului ters
ToDel = Temp -> link_;

(2)

Fig. 11.4.10. Poziionarea pointerilor pentru tergerea nodului 3


Urmeaz saltul ponterului Temp->link_ peste nodul care se dorete
ters, astfel:
Temp -> link_ = Temp-> link_ -> link_;

349

(3)

Capitolul 11

Fig. 11.4.11. Stabilirea legturii de la nodul 2 ctre nodul 4


Avem pn n acest moment stabilit legtura de la nodul 2 ctre
nodul 4. Mai trebuie s stabilim i legtura de la nodul 4 ctre nodul 2,
deoarece avem de a face cu o list dublu nlnuit:
Temp -> link_ ->_link = Temp

(4)

Fig. 11.4.12. Stabilirea legturii inverse, de la nodul 4 ctre nodul 2


Tot ce mai avem de fcut n final este delete ToDel;
Un model complet al tergerii n lista dubl nlnuit se poate
urmri n implementarea urmtoarei probleme:
De la tastura se citesc N i M N, dup care se citesc N numere
ntregi, iar apoi nc M numere ntregi. S se construiasc o list dublu
nlnuit format din cele N numere citite, astfel nct aceasta s fie
ordonat cresctor. S se afieze lista, iar apoi s se tearg din ea toate cele
M numere (dac acestea exist). S se afieze lista dup fiecare tergere.
Pentru rezolvarea acestei probleme prezentm un program complet,
program care incorporeaz toate funciile prezentate n acest subcapitol,
inclusiv cazurile particulare ale acestora. Am folosit comentarii pentru a
clarifica anumite lucruri.
Recomandm cititorilor s reimplementeze de la zero rezolvarea
acestei probleme, pentru o mai bun nelegere a listelor.
350

Liste nlnuite
#include <iostream>
using namespace std;
struct nod
{
int info;
nod *link_;
nod *_link;
};
void add_beg(nod *&Old, int info)
{
nod *New = new nod;
New->info = info;
New->_link = NULL;
New->link_ = Old;
if ( Old != NULL )
Old->_link = New;
Old = New;
}
void add_end(nod *&Old, int info)
{
// nu mai este necesar sa ne deplasam
// pe ultimul nod, deoarece functia
// va fi intotdeauna apelata cu ultimul nod
// ca parametru.
nod *New = new nod;
New->info = info;
New->link_ = NULL;
Old->link_ = New;
New->_link = Old;
Old = New;
}

351

Capitolul 11
void add_mid(nod *&Old, int info)
{
if ( Old == NULL ) // adaugarea primului nod
add_beg(Old, info);
else
{
// daca valoarea este mai mica sau egala,
// cu a primului nod, se adauga la inceput
if ( info <= Old->info )
add_beg(Old, info);
else
{
// caut unde trebuie adaugat noul nod
nod *Temp = Old;
for ( ; Temp->link_ != NULL; Temp = Temp->link_ )
if ( info <= Temp->link_->info )
break; // am gasit pozitia pe care trebuie adaugat
if ( Temp->link_ == NULL ) // adaugare la sfarsit
add_end(Temp, info);
else // adaugare undeva in interior
{
nod *New = new nod;
New->info = info;
New->link_ = Temp->link_;
New->_link = Temp;
Temp->link_->_link = New;
Temp->link_ = New;
}
}
}
}
void del_beg(nod *&Old)
{
nod *ToDel = Old;
Old = Old->link_;
if ( Old != NULL ) // altfel nu exista Old->_link
Old->_link = NULL;
delete ToDel;
}

352

Liste nlnuite
void del_mid(nod *&Old, int info)
{
if ( Old->info == info )
del_beg(Old);
else
{
nod *Temp = Old;
for ( ; Temp->link_ != NULL; Temp = Temp->link_ )
if ( Temp->link_->info == info )
break;
if ( Temp->link_ != NULL )
{
nod *ToDel = Temp->link_;
Temp->link_ = Temp->link_->link_;
if ( Temp->link_ != NULL )
Temp->link_->_link = Temp;
delete ToDel;
}
}
}
void view(nod *L)
{
for ( ; L; L = L->link_ )
cout << L->info << ' ';
cout << endl;
}

int main()
{
// atentie la initializarea cu NULL!
nod *LISTA = NULL;
int N, M, x;
cin >> N >> M;
for ( int i = 0; i < N; ++i )
{
cin >> x;
add_mid(LISTA, x);
}
view(LISTA);
for ( int i = 0; i < M; ++i )
{
cin >> x;
del_mid(LISTA, x);
view(LISTA);
}
return 0;
}

353

Capitolul 11

11.5. Dancing Links


O proprietate important a listelor circulare dublu nlnuite este
aceea c, dac ne permitem s nu eliberm memoria alocat unui nod ters,
acesta poate fi adugat napoi n list ntr-un mod foarte uor, efectund un
numr constant de operaii cu pointeri. Aceast metod a fost popularizat
de Donald Knuth 1 i poart numele de Dancing Links (Legturi Dansante)
sau DLX.
S presupunem o list circular dublu nlnuit cu cel puin un nod
i un pointer X ctre un nod din aceast list. Atunci, secvena de operaii:
X->_link->link_ = X->link_;
X->link_->_link = X->_link;

Va avea ca efect scoaterea nodului X din list, iar secvena:


X->_link->link_ = X;
X->link_->_link = X;

Va avea ca efect reintroducerea nodului X n list!


Aceast tehnic este folositoare n cadrul algoritmilor backtracking,
pentru a putea evita folosirea unor vectori de genul V[i] = true dac i este
n stiv i false n caz contrar. Folosind DLX, putem pur i simplu s
scoatem nodul i dintr-o list circular n timp constant, s apelm recursiv
funcia, iar la revenire din recursivitate s reintroducem nodul i n list. n
acest fel, la fiecare pas al recursivitii se va parcurge o list cu (cel puin)
un element mai mic, mbuntindu-se timpul de execuie.
Mai mult, se simplific astfel codul. Problema anterioar, de
exemplu, ar putea fi rezolvat mai uor prin acest tip de tergere. Singurul
dezavantaj este c nodurile nu vor fi eliberate efectiv din memorie.
Recomandm cititorilor implementarea unor algoritmi backtracking
folosind aceast tehnic. Algoritmii care se preteaz acestei abordri sunt
cei de generare a permutrilor, a aranjamentelor etc.

Profesor de informatic la Universitatea Stanford.

354

Teoria grafurilor

12. Teoria
grafurilor
Teoria grafurilor este un domeniu al matematicii care se ocup cu
studiul structurilor matematice numite grafuri. Un graf este o reprezentare
abstract a relaiilor existente ntre elementele unui set suport. Elementele
din setul suport se numesc noduri, iar relaiile existente ntre acestea se
numesc muchii sau arce (termen folosit uneori n cazul grafurilor orientate).
n cele ce urmeaz ne propunem s prezentm n detaliu principalele
structuri de date folosite n teoria grafurilor i algoritmii cei mai des folosii
pentru rezolvarea problemelor cu grafuri. Datorit faptului c avem de a
face cu un domeniu foarte vast, care este i ntr-o continu dezvoltare, nu
putem dect s tindem spre a fi exhaustivi, deci materialul ce urmeaz
trebuie luat ca o trecere detaliat prin acest domeniu i nicidecum ca o
prezentare complet a acestuia.
Problemele de grafuri i algoritmii aplicabili acestora sunt foarte
numeroi. Temele abordate n acest capitol sunt ns suficiente pentru ca
cititorul s poat, o dat cu nelegerea acestora, aborda de unul singur
aproape orice problem din acest domeniu.
Vom prezenta, acolo unde este cazul, mai muli algoritmi de
rezolvare a unei probleme, sau mai multe posibiliti de implementare a unui
anumit algoritm. Considerm c lucrrile care se limiteaz la prezentarea
unei singure metode de rezolvare a unei probleme fac un mare deserviciu
cititorilor, deoarece metoda optim de rezolvare depinde aproape
ntotdeauna de mai muli factori, iar alegerea soluiei folosite trebuie s in
cont de avantajele i dezavantajele unei metode relativ la situaia n care ne
aflm.
Toate funciile i structurile de date folosite n acest capitol au fost
prezentate n cadrul seciunii Introducere n S.T.L., seciune pe care
cititorul ar trebui s o parcurg nainte de a ncepe aceste capitol.
355

Capitolul 12

CUPRINS
12.1. Noiuni teoretice .................................................................................. 357
12.2. Reprezentarea grafurilor n memorie ................................................. 360
12.3. Probleme introductive ......................................................................... 364
12.4. Parcurgerea n adncime ..................................................................... 369
12.5. Parcurgerea n lime........................................................................... 380
12.6. Componente tare conexe .................................................................... 388
12.7. Determinarea nodurilor critice ........................................................... 391
12.8. Drum i ciclu eulerian .......................................................................... 394
12.9. Drum i ciclu hamiltonian .................................................................... 399
12.10. Drumuri de cost minim n grafuri ponderate ................................... 404
12.11. Reele de transport ............................................................................ 423
12.12. Arbore parial de cost minim ............................................................ 438
12.13. Concluzii .............................................................................................. 445

356

Teoria grafurilor

12.1. Noiuni teoretice


n cele ce urmeaz vom prezenta termeni, definiii, teoreme i
formule importante pentru nelegerea temelor care vor urma a fi abordate.
Nu este necesar nsuirea ntregului volum de informaii prezentat n
aceast seciune nainte de a merge mai departe; cititorul poate oricnd s
revin aici cnd ntlnete ceva necunoscut i care nu este explicat n locul
ntlnit.
n aceast seciune vor fi doar enunate anumite definiii i formule.
Eventualele demonstraii i exemple vor fi prezentate n momentul aplicrii
acestora.
1. Un graf G este o pereche (V, E), unde V reprezint mulimea
nodurilor grafului (en. Vertices) i E reprezint mulimea
muchiilor (en. Edges) grafului. Matematic, E = {(i, j) | i, j V}.
De cele mai multe ori are loc i i diferit de j.
2. Un graf se numete orientat dac mulimea E este ordonat i
neorientat n caz contrar. O muchie a unui graf orientat se mai
numete i arc i are se deseneaz ca o sgeat dinspre nodul
surs spre nodul destinaie. Grafurile orientate se mai numesc i
digrafuri.
3. Un graf se numete ponderat dac fiecare muchie are asociat un
cost sau o lungime.
4. O muchie (i, j) se numete incident la nodurile i i j. Similar,
dou muchii ce au un nod n comun se numesc incidente.
5. Dac exist muchia (i, j) atunci nodurile i i j se numesc
adiacente i i se numete vecin al lui j i invers. n cazul
grafurilor neorientate, i i j se mai numesc extremiti ale
muchiei (i, j), iar n cazul grafurilor orientate i se numete surs,
iar j se numete destinaie.
6. Gradul unui nod i este egal cu numrul muchiilor incidente la i.
7. n cazul grafurilor orientate, gradul interior al nodului i este
egal cu numrul de muchii care l au ca destinaie pe i, iar gradul
exterior al lui i este egal cu numrul de muchii care l au ca
surs pe i.
8. Un nod cu gradul 0 se numete nod izolat.
9. O muchie de la un nod la el nsui se numete bucl.
10. Ordinul unui graf este dat de |V|.
11. Dimensiunea unui graf este dat de |E|.

357

Capitolul 12
12. Un graf complet este un graf fr bucle care are muchie ntre
oricare dou noduri. Numrul de muchii al unui graf neorientat
complet este egal cu:
( 1)
2
Numrul de muchii al unui graf orientat complet este egal cu:
( 1)
.
Numrul de grafuri neorientate cu N noduri este dat de formula:
(1)
2

13. Un graf se numete planar dac poate fi desenat n aa fel nct


muchiile sale s nu se intersecteze.
14. Un graf se numete nul dac are 0 noduri.
15. Un graf se numete infinit dac are un numr infinit de noduri.
16. Un subgraf G = (V, E) al unui graf G = (V, E) este un graf
obinut din G prin eliminarea unor noduri i a muchiilor
incidente acestora. Aadar, V V i E E.
17. Un graf parial G = (V, E) al unui graf G = (V, E) este un graf
obinut din G prin eliminarea unor muchii. Aadar, E E.
18. Un drum (sau drum elementar) este o secven de noduri
distincte N1, N2, ..., Nk pentru care exist muchie n graf ntre
oricare dou noduri consecutive.
19. Lungimea unui drum este egal cu numrul de muchii existente
n drum. Costul unui drum este egal cu suma costurilor asociate
fiecrei muchii din drum.
20. Un graf G = (V, E) se numete bipartit dac nodurile sale pot fi
partiionate n dou mulimi X i Y astfel nct V = X Y, X
Y = i oricare muchie a lui G are o extremitate n X i cealalt
extremitate n Y.
21. Se numete ciclu (sau ciclu elementar) un drum n care ultimul
nod coincide cu primul.
22. Un graf care nu conine cicluri se numete aciclic.
23. Un graf neorientat G se numete conex dac oricum am alege
dou noduri i i j ale sale, exist un drum de la i la j.
358

Teoria grafurilor
24. Se numete component conex a grafului neorientat G un
subgraf conex de ordin maxim a lui G.
25. Un graf orientat G se numete tare conex dac oricum am alege
dou noduri i i j ale sale, exist drumt de la i la j i de la j la i.
26. Se numete component tare conex a grafului orientat G un
subgraf tare conex de ordin maxim a lui G.
27. n cazul grafurilor orientate, transpusa unui graf G este graful GT
construit astfel: pentru fiecare arc (i, j) din G, n GT vom avea
doar arcul (j, i). Practic, graful transpus este format prin
inversarea sensului fiecrui arc al grafului original.
28. Suma gradelor nodurilor unui graf neorientat este egal cu 2|E|.
29. Un arbore este un graf neorientat n care exist un singur drum
ntre oricare dou noduri. Un arbore cu N noduri are N 1
muchii. Uneori, arborii pot fi i grafuri orientate.
30. O pdure este un graf a crui componente conexe sunt arbori.
31. Se numete nivelul k al unui arbore o mulime format din toate
nodurile aflate la distana k fa de rdcin.
32. nlimea unui arbore este dat de numrul de niveluri existente
n arbore.
33. Se numete predecesor (sau tat) al unui nod i dintr-un arbore
acel nod j care este adiacent cu i i pe un nivel cu 1 mai mic
dect al lui i. Nodul i se numete fiu al lui j.
34. Se numete strmo al unui nod i dintr-un arbore acel nod j care
se afl pe un nivel mai mic dect cel al lui i. Nodul i se va numi
descendent al nodului j.
35. Se numete rdcin a unui arbore nodul care nu are predecesor.
36. Se numete nod terminal (sau frunz) acel nod al unui arbore
care nu are fii.
37. Spunem c un graf se numete rar dac numrul de muchii este
relativ mic (graful este mai aproape de un arbore dect de un graf
complet) i dens dac numrul de muchii este relativ mare
(graful se apropie de un graf complet).

359

Capitolul 12

12.2. Reprezentarea grafurilor n memorie


Pentru a putea lucra cu grafuri, trebuie s tim cum putem reine un
graf ntr-un program C++. Exist mai multe metode de a face acest lucru,
fiecare avnd anumite avantaje i dezavantaje. Alegerea celei mai bune
reprezentri pentru o anumit problem este responsabilitatea
programatorului.
n cazul general, exist dou metode de reprezentare a grafurilor:
Folosind o matrice de adiacen. O matrice de adiacen G este
o matrice ptratic boolean n care A[i][j] = 1 dac nodurile i
i j sunt adiacente i 0 n caz contrar (sau true / false).
Folosind liste de adiacen (sau liste de vecini). Listele de
adiacen se pot implementa ca un vector G de liste nlnuite
unde G[i] reprezint o list cu toate nodurile adiacente nodului i.
De exemplu, pentru graful urmtor:

Fig. 12.2.1. Un graf neorientat oarecare


Avem urmtoarele reprezentri:

1
2
3
4
5
6

1
0
1
0
1
0
0

Matrice de adiacen
2
3
4
0
1
1
0
0
0
0
0
0
0
0
0
0
0
1
0
1
1

5
0
1
0
0
0
1

6
0
1
1
0
1
0

Liste de adiacen
G[1] = {2, 4}
G[2] = {1, 5}
G[3] = {6}
G[4] = {1}
G[5] = {2, 6}
G[6] = {5, 2, 3}

Dup cum se poate vedea din acest exemplu, matricele de adiacen


au dezavantajul de consuma mult mai mult memorie dect este necesar
pentru a reprezenta graful: fiecare valoare de 0 reprezint o risip de
memorie, deoarece ne intereseaz doar muchiile existente, nu i cele
360

Teoria grafurilor
inexistente. Listele de adiacen nu au acest dezavantaj, deoarece fiecare
list conine doar acele noduri adiacente nodului asociat listei respective.
Pentru a reprezenta un graf cu N noduri i M muchii, matricea de adiacen
va folosi O(N2) memorie, pe cnd listele de adiacen vor folosi doar
O(N + M) memorie. Pentru grafuri rare sunt mai eficiente listele de
adiacen, iar pentru grafuri dese matricile de adiacen.
Un dezavantaj al listelor fa de matricea de adiacen este timpul
necesar determinrii adiacenei a dou noduri. Pentru a verifica dac dou
noduri i i j sunt adiacente folosind matricea de adiacen, este suficient s
verificm valoarea elementului A[i][j]. Pentru a verifica acelai lucru
folosind liste, trebuie s parcurgem ntreaga list asociat nodului i sau
nodului j, lucru care, n cel mai ru caz, se efectueaz n timp O(N).
Pe grafuri rare, majoritatea algoritmilor care lucreaz cu grafuri se
execut mult mai rapid dac grafurile sunt reinute ca liste de adiacen. Pe
grafuri dense, este mai convenabil folosirea matricei de adiacen, care este
i mai eficient.
Exist i alte metode de a reprezenta grafurile. De exemplu, putem
folosi vectorii de tai n cazul arborilor. Dac vrem s reprezentm un
arbore, l putem reprezent reinnd pentru fiecare nod al su care este tatl
acestuia, folosind un vector T, unde T[i] = j se citete j este tatl lui i.
T[i] = 0 dac i este rdcina arborelui. De exemplu, urmtorul arbore:

Fig. 12.2.2. Un arbore neorientat oarecare


Se poate reprezenta cu ajutorul urmtorului vector de tai:
i
1 2 3 4 5
T[i] 0 1 1 2 2
Evident, arborii se pot reprezenta i cu ajutorul matricelor i listelor
de adiacen, dar reprezentarea prin vector de tai are anumite avantaje i
aplicaii care vor fi discutate mai trziu.
361

Capitolul 12
O alt metod de reprezentare a grafurilor este folosirea listelor de
muchii. O list de muchii este un vector simplu care reine muchiile
grafului. Aceast poate fi implementat construind o structur care reine
doi ntregi care definesc o muchi sau folosind containerul S.T.L. pair. i
aceast structur de date are unele avantaje exploatate de unii algoritmi.
Aceste metode pot fi extinse i la grafuri ponderate: matricea de
adiacen devine matrice de ntregi care n loc de 1 i 0 reine costul muchiei
respective, respectiv o valoare care semnific inexistena muchiei; listele de
adiacen mai rein o valoare mpreun cu fiecare nod, care reprezint costul
de la nodul asociat listei curente la nodul aflat la poziia respectiv etc.
n continuare vom prezenta nite modele de implementare a
metodelor discutate de reprezentare a grafurilor. Implementrile propuse fac
uz de liste nlnuite i de containerul S.T.L. vector. Este recomandat ca
cititorul s fie familiarizat cu listele nlnuite i cu noiunile de baz S.T.L.
n restul capitolului, implementarea listelor de adiacen se va face
folosind exclusiv containerul vector, fiind cel puin la fel de eficient, mai
uor de folosit i mai flexibil.

a) Declaraii
Pentru a folosi liste nlnuite avem nevoie de o structur care ne
permite folosirea acestora. Pentru a folosi vectori, tot ce trebuie s facem
este s includem fiierul antet <vector> i s declarm un vector S.T.L. de
vectori clasici.
Declaraie liste nlnuite
struct graf
{
int nod;
graf *link_;
};
graf *G[maxn]; // vector clasic de liste inalntuite

Declaraie S.T.L. vector


#include <vector>
using namespace std;
// G[i] este vector S.T.L.
vector<int> G[maxn];

b) Iniializri
n cazul listelor nlnuite, este necesar s iniializm fiecare list cu
valoarea NULL nainte de aplicarea altor operaii. Acest lucru este necesar
pentru a ti unde se termin o list.
graf *G[maxn];
for ( int i = 1; i < maxn; ++i ) G[i] = NULL;

362

Teoria grafurilor

c) Adugarea unei muchii


n cazul listelor nlnuite, adugarea unei muchii se face n felul
urmtor: dac dorim s adugm muchia (x, y), l vom aduga pe y la
nceputul listei asociate lui x. Ordinea nodurilor dintr-o list nu are
importan, iar alegerea de a aduga la nceput nodul este pentru a evita
parcurgerea ntregii liste, lucru necesar dac vrem s adugm nodul la
sfrit.
n cazul vectorilor, adugarea se face la sfrit folosind funcia
push_back(). Adugarea la sfrit n cadrul unui vector se realizeaz tot n
timp O(1) (complexitate amortizat, deoarece pot aprea realocri de
memorie pentru redimensionarea vectorului. n practic de cele mai multe
ori acest lucru este neglijabil, dar uneori poate fi preferabil containerul list).
Folosind liste nlnuite

Folosind S.T.L. vector

void ad_lista(graf *G[], int x, int y) void ad_vector(vector<int> G[],


{
int x, int y)
graf *t = new graf;
{
t->nod = y; t->link_ = G[x];
// sintaxa identica pentru S.T.L. list
G[x] = t;
G[x].push_back(y);
}
}

n cazul vectorilor nu vom folosi o funcie separat pentru adugare.

d) Parcurgerea unei liste de adiacen


Dac vrem s afim listele de adiacen, trebuie avut grij n cazul
listelor s nu le i stricm, adic s nu mutm pointerul link_. Pentru acest
lucru vom introduce o variabil auxiliar cu ajutorul creia vom parcurge
listele. n exemplu, N reprezint numrul de noduri ale grafului.
Folosind liste nlnuite

Folosind S.T.L. vector

for ( int i = 1; i <= N; ++i )


{
cout << i << ": ";
for ( graf *tmp = G[i]; tmp;
tmp = tmp->link_ )
cout << tmp->nod << ' ';
cout << endl;
}

for ( int i = 1; i <= N; ++i )


{
cout << i << ": ";
for ( int j = 0;
j < G[i].size(); ++j )
cout << G[i][j] << ' ';
cout << endl;
}

363

Capitolul 12

12.3. Probleme introductive


Pentru a familiariza cititorul cu noiunile de pn acum, prezentm
cteva probleme elementare gata rezolvate, folosind att reprezentarea prin
matricea de adiacen ct i reprezentarea prin liste de adiacen
implementate ca vectori. Pentru problemele ce urmeaz, datele de intrare se
citesc din fiierul graf.in i se afieaz n fiierul graf.out.

a) Determinarea gradelor tuturor nodurilor


Se d matricea de adiacen a unui graf neorientat cu N noduri. S se
determine gradul fiecrui nod. Pe prima linie a fiierului de intrare se
gsete numrul N, iar pe urmtoarele linii matricea de adiacen a grafului.
Linia i (1 i N) a fiierului de ieire va conine gradul fiecrui nod. Pentru
implementarea cu vectori, considerm c se d N, numrul de muchii M i
lista acestora!
Rezolvarea problemei este imediat n cazul ambelor modaliti de
reprezentare. Gradul unui nod este egal cu numrul vecinilor acelui nod.
Folosind matricea de adiacen, gradul unui nod i este egal cu suma
elementelor de pe linia (sau coloana) i. n cazul listelor de adicen, gradul
unui nod este egal cu numrul de elemente din lista asociat acelui nod. De
exemplu:

Fig. 12.3.1. Deducerea gradelor nodurilor din matricea de adiacen,


respectiv din listele de adiacen ale unui graf.

364

Teoria grafurilor
Cu matrice de adiacen

Cu vectori

#include <fstream>
using namespace std;
const int maxn = 101;

#include <fstream>
#include <vector>
using namespace std;
const int maxn = 101;
int main()
int main()
{
{
int N;
int N, M, x, y;
bool G[maxn][maxn];
vector<int> G[maxn];
ifstream in("graf.in");
ifstream in("graf.in");
ofstream out("graf.out");
in >> N >> M;
for ( int i = 1; i <= M; ++i )
in >> N;
{
for ( int i = 1; i <= N; ++i )
in >> x >> y;
{
G[x].push_back(y);
int grad = 0;
G[y].push_back(x);
for ( int j = 1;
}
j <= N;
grad += G[i][j++] )
ofstream out("graf.out");
in >> G[i][j];
for ( int i = 1; i <= N; ++i )
out << grad << '\n';
out << G[i].size() << '\n';
}
in.close(); out.close();
in.close(); out.close();
return 0;
return 0;
}
}

b) Problema identificrii tipului unui graf


Se d un graf orientat cu N noduri i M muchii, de data asta prin lista
de muchii n cazul ambelor implementri. S se determine dac graful poate
fi considerat neorientat. Afiai 1 dac da i 0 dac nu.
Un graf poate fi considerat neorientat dac matricea sa de adiacen
este simetric fa de diagonala principal.
Dac reinem graful prin liste de adiacen, pentru fiecare nod j din
lista nodului i, trebuie s verificm dac i nodul i se gsete n lista nodului
j.

365

Capitolul 12
Cu matrice de adiacen
#include <fstream>

Cu vectori
#include <fstream>
#include <vector>

using namespace std;


using namespace std;
const int maxn = 101;
const int maxn = 101;
int main()
{
int N, M, x, y;
bool G[maxn][maxn];
ifstream in("graf.in");
in >> N >> M;

bool cauta(const vector<int> &L,


int nod)
{
for ( int i = 0; i < L.size(); ++i )
if ( L[i] == nod )
return 1;

for ( int i = 1; i <= N; ++i )


return 0;
for ( int j = 1; j <= N; ++j ) }
{
in >> x >> y;
int main()
G[x][y] = 1;
{
}
int N, M, x, y;
in.close();
vector<int> G[maxn];
int neor = 1;
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
if ( G[i][j] && !G[j][i] )
neor = 0;

ifstream in("graf.in");
in >> N >> M;
for ( int i = 1; i <= M; ++i )
{
in >> x >> y;
G[x].push_back(y);
}
in.close();

ofstream out("graf.out");
out << neor;
out.close();

bool neor = 1;
for ( int i = 1; i <= N; ++i )
for ( int j = 0; j < G[i].size(); ++j )
if ( !cauta(G[ G[i][j] ], i) )
neor = 0;
ofstream out("graf.out");
out << neor;
out.close();

return 0;
}

return 0;
}

366

Teoria grafurilor

c) Problema identificrii frunzelor unui arbore


Se d un arbore reprezentat prin vectorul de tai. S se determine
nodurile care sunt frunze. Prima linie a fiierului de intrare conine numrul
de noduri N, iar a doua linie conine elementele vectorului de tai.
Pentru a rezolva aceast problem vom porni de la definiia
vectorului de tai: valoarea fiecrui element i al vectorului de tai reprezint
tatl nodului i. Mai mult, tim c o frunz este un nod care nu are fii. Altfel
spus, o frunz este un nod care nu este tatl niciunui nod. Aadar, problema
se reduce la a determina care numere naturale de la 1 la N ce nu apar n
vectorul de tai.
De exemplu, pentru arborele:

Fig. 12.3.2. Un arbore neorientat oarecare


Avem vectorul de tai T = {0, 1, 1, 2, 3, 3}. Singurele numere
naturale de la 1 la N = 6 care nu apar n acest vector sunt 4, 5 i 6. Acestea
reprezint nodurile terminale ale arborelui.
Putem aborda problema n dou moduri. Fie parcurgem toate
numerele de la 1 la N i vedem care nu se afl n vector, obinnd
complexitatea O(N2), fie folosim un vector boolean de caracterizare V, unde
V[i] = true dac numrul i se afl n vectorul de tai i false n caz contrar.
Indicii elementelor care au valoarea 0 reprezint nodurile terminale.
Complexitatea acestei metode este O(N), att ca timp ct i ca memorie.
Prezentm doar implementarea celei de-a doua metode:

367

Capitolul 12
#include <fstream>
using namespace std;
const int maxn = 101;
int main()
{
int N, T[maxn];
bool V[maxn];
ifstream in("graf.in"); ofstream out("graf.out");
in >> N;
for ( int i = 1; i <= N; ++i )
{
in >> T[i];
V[i] = false;
}
for ( int i = 1; i <= N; ++i )
V[ T[i] ] = true;
for ( int i = 1; i <= N; ++i )
if ( !V[i] )
out << i << ' ';
in.close();
out.close();
return 0;
}

d) Alte probleme
Pentru o mai bun familiarizare cu structurile de date folosite n
lucrul cu grafuri i cu noiunile de baz a grafurilor, propunem urmtoarele
probleme. Cititorul este ncurajat s exploreze, pe ct posibil, mai mult de o
singur metod de rezolvare.
a) Se d un graf oarecare. S se determine dac acesta conine
bucle.
b) Se d un graf neorientat. S se determine dac acesta este
complet. Algoritmul se schimb sau nu pentru grafuri orientate?
c) Scriei un program care construiete o matrice de adiacen din
liste de adiacen i invers.
d) Scriei un program care construiete un vector de tai dintr-o
matrice de adiacen, iar apoi din liste de adiacen.
e) Scriei un program care implementeaz o matrice de adiacen
folosind operaii pe bii.
368

Teoria grafurilor
f) Se d un graf neorientat i o secven de noduri. S se determine
dac secvena reprezint un drum elementar, un drum
neelementar, un ciclu elementar, un ciclu neelementar sau
niciuna dintre aceste variante.
g) Care este numrul maxim de componente conexe a unui graf cu
2010 noduri i 100 de muchii?
h) Scriei un program care implementeaz listele de adiacen
folosind vectori clasici i alocare dinamic.
i) Scriei un program care genereaz graful transpus al unui graf
orientat dat prin list de muchii.
j) Rezolvai problemele propuse folosind containerul S.T.L. list.
k) Scriei un program care determin numrul de arbori existeni
ntr-o pdure reprezentat prin vector de tai.

12.4. Parcurgerea n adncime


Parcurgerea grafurilor este o tem foarte important n teoria
grafurilor, aceste parcurgeri stnd la baza celor mai muli algoritmi care
lucreaz cu grafuri. Parcurgerea n adncime (en. depth-first search DFS)
este o parcurgere a grafurilor care are la baz mecanismul backtracking.
Aceast parcurgere presupune explorarea complet a unui drum i revenirea
la un pas anterior pentru a alege o alt direcie atunci cnd drumul curent nu
mai poate fi extins.
Desenul de mai jos prezint ordinea n care sunt vizitate nodurile
unui arbore n cadrul parcurgerii DFS, dac nodurile din stnga sunt alese
naintea celor din dreapta:

Fig. 12.4.1. Ordinea de procesare a nodurilor n cadrul


parcurgerii n adncime
369

Capitolul 12
Algoritmul n pseudocod folosete o funcie DFS(G, nod, V), unde
G reprezint graful reprezentat prin liste de adiacen, nod reprezint nodul
curent al parcurgerii, iar V reprezint un vector boolean n care V[i] = true
dac nodul i a fost deja parcurs i false n caz contrar. Funcia poate fi
implementat astfel:
Dac V[nod] == true se iese din funcie
V[nod] = true
Prelucreaz nodul nod
Pentru fiecare vecin i al nodului nod, apeleaz recursiv
DFS(G, i, V)
n cazul arborilor reprezentai ca grafuri orientate nu este necesar
vectorul V, deoarece nu exist posibilitatea s vizitm un nod deja vizitat,
pentru c nu avem muchie de la un nod la tatl su. n cazul grafurilor
orientate avem ns nevoie de un vector care s ne spun dac am vizitat
deja un anumit nod.
Parcurgerea n adncime este un algoritm fundamental n teoria
grafurilor, ntruct st la baza altor algoritmi i structuri de date mai
avansate. Este un algoritm uor de implementat care poate fi folosit pentru a
rezolva elegant o multitudine de probleme. De exemplu, folosind
parcurgerea n adncime putem determina distana dintre dou noduri,
nlimea arborelui, nivelul la care se afl un nod, dac exist sau nu cicluri,
ce cicluri exist i altele.
Timpul de execuie al parcurgerii n adncime pe un graf cu N
noduri i M muchii este O(N + M) n cazul reprezentrii prin liste de
adiacen i O(N2) n cazul reprezentrii prin matrice de adiacen.
n continuare vom prezenta o implementare recursiv a parcurgerii
n adncime, cteva particularizri a acestei parcurgeri, dou probleme
rezolvate i o implementare iterativ.
Programul urmtor citete din fiierul dfs.in un graf dat prin lista de
muchii i afieaz n fiierul dfs.out nodurile grafului n ordinea n care au
fost parcurse de ctre algoritm, pornind de la nodul 1.

370

Teoria grafurilor
Exemplu:
dfs.in dfs.out
45
1324
13
23
31
34
14
Exist mai multe rspunsuri corecte, n funcie de ordinea nodurilor
n listele de adiacen.
#include <fstream>
#include <vector>
using namespace std;
const int maxn = 101;

int main()
{
int N, M;
bool V[maxn];
vector<int> G[maxn];

void citire(vector<int> G[], int &N, int &M)


{
ifstream in("dfs.in");
in >> N >> M;
int x, y;
for ( int i = 1; i <= M; ++i )
{
in >> x >> y;
G[x].push_back(y);
G[y].push_back(x);
}
in.close();
}
void DFS(vector<int> G[], int nod, bool V[],
ofstream &out)
{
if ( V[nod] )
return;
V[nod] = true;
out << nod << ' ';
for ( int i = 0; i < G[nod].size(); ++i )
DFS(G, G[nod][i], V, out);
}

371

citire(G, N, M);
for ( int i = 1; i <= N; ++i )
V[i] = false;
ofstream out("dfs.out");
DFS(G, 1, V, out);
out.close();
return 0;
}

Capitolul 12
n cazul arborilor exist dou tipuri de parcurgeri n adncime:
1. Parcurgerea n preordine, care este dat de algoritmul discutat i
prezentat anterior. Aceast parcurgere este cea mai natural,
prelucrnd nodurile dup principiul: prelucreaz nodul curent
nainte de a prelucra vreun subarbore.
2. Parcurgerea n postordine este dat de prelucrarea nodului
curent dup ce toate apelurile recursive s-au ncheiat. Se aplic
aadar principiul prelucreaz nodul curent dup prelucrarea
tuturor subarborilor. Dac se d un arbore i se cere parcurgerea
sa n postordine, se poate folosi funcia urmtoare:
void DFS(vector<int> G[], int nod, bool V[], ofstream &out)
{
if ( V[nod] )
return;
V[nod] = true;
for ( int i = 0; i < G[nod].size(); ++i )
DFS(G, G[nod][i], V, out);
out << nod << ' ';
}

n cazul arborilor binari (arbori n care fiecare nod are cel mult doi
fii), mai exist i parcurgerea n ordine. Aceasta presupune prelucrearea
nodului curent dup apelul recursiv pentru subarborele stng i nainte de
apelul recursiv pentru subarborele drept. O astfel de implementare este
lsat ca exerciiu pentru cititor.
Aceste trei parcurgeri au aplicaii n algoritmica arborilor de expresii
i n determinarea componentelor tare conexe a unui graf.
Cele dou desene de mai jos reprezint ordinea prelucrrii nodurilor
n cadrul parcurgerii unui arbore binar n postordine (stnga), respectiv n
ordine (dreapta).

372

Teoria grafurilor

Fig. 12.4.2. Parcurgerea n postordine i n ordine

a) Determinarea proprietii de graf aciclic


Se d un graf neorientat conex prin lista de muchii. S se determine
dac graful conine cicluri. n caz afirmativ se va afia 1, iar n caz negativ
0. Fiierele asociate problemei sunt dfs.in i dfs.out.
O prim idee de rezolvare ar fi s folosim parcurgerea n adncime
aa cum a fost aceasta prezentat anterior, adugnd condiia c dac ne
aflm pe un nod deja marcat ca vizitat, am gsit un ciclu. Aceast soluie ar
fi ns eronat, deoarece, graful fiind neorientat, am ajunge s considerm
orice muchie (x, y) ca fiind un ciclu de lungime doi. Un ciclu trebuie s
conin ns cel puin trei muchii.
Pentru a evita situaia n care o muchie neorientat este considerat
ciclu, vom introduce nc un parametru pred funciei DFS care va fi un
vector de predecesori ai nodurilor deja vizitate, adic nodurile din care s-au
efectuat apelurile recursive pentru nodurile parcurse. Vom avea grij s nu
efectum apeluri recursive pentru un nod care este predecesorul nodului
curent. De exemplu, dac ne aflm n nodul x i predecesorul lui x este y, nu
vom putea merge direct din x n y, dar vom putea prin alte noduri
intermediare, de exemplu din x n z i din z n y. Astfel, cnd vrem s
efectum un apel recursiv, verificm mai nti dac nodul pentru care vrem
s apelmrecursiv nu este predecesorul nodului curent. Dac nu este,
verificm dac acest nod are deja un predecesor: dac da, am gsit un ciclu,
iar dac nu salvm predecesorul i efectum apelul recursiv.
Desenele de mai jos reprezint un posibil mod de funcionare al
algoritmului pe un graf oarecare. Am marcat cu rou nodurile vizitate n
cadrul parcurgerii i cu albastru predecesorii nodurilor.

373

Capitolul 12

Fig. 12.4.3. Modul de execuie al algoritmul de determinare a ciclurilor


#include <fstream>
#include <vector>
using namespace std;
const int maxn = 101;
void citire(vector<int> G[], int &N, int &M)
{
ifstream in("dfs.in");
in >> N >> M;
int x, y;
for ( int i = 1; i <= M; ++i )
{
in >> x >> y;
G[x].push_back(y);
G[y].push_back(x);
}
in.close();
}

374

Teoria grafurilor
bool DFS(vector<int> G[], int nod, int pred[])
{
for ( int i = 0; i < G[nod].size(); ++i )
if ( pred[ G[nod][i] ] != nod )
{
if ( pred[ G[nod][i] ] != -1 )
return true;
else
{
pred[ G[nod][i] ] = nod;
return DFS(G, G[nod][i], pred);
}
}
return false;
}
int main()
{
int N, M;
int pred[maxn];
vector<int> G[maxn];
citire(G, N, M);
for ( int i = 1; i <= N; ++i )
pred[i] = -1;
ofstream out("dfs.out");
out << DFS(G, 1, pred);
out.close();
return 0;
}

Exerciii:
a) Modificai algoritmul n aa fel nct s i afieze un ciclu n
cazul existenei unuia.
b) Cum s-ar putea rezolva problema pentru grafuri orientate?
c) Scriei un program care afieaz toate ciclurile existente ntr-un
graf.

375

Capitolul 12

b) Determinarea diametrului unui arbore


Se d un arbore neorientat cu N noduri prin lista de muchii. S se
determine distana maxim dintre dou noduri. Distana maxim dintre dou
noduri se mai numete i diametrul arborelui.
Exemplu:
dfs.in dfs.out
5
3
12
23
24
45
Explicaie: distana maxim este dat de drumurile de la 1 la 5 i de
la 3 la 5.
Este evident c unul dintre cele dou noduri aflate la distan
maxim va fi o frunz. tim c o frunz se afl la distan maxim fa de
rdcin, aa c vom aplica urmtorul algoritm:
Se determin distanele de la rdcin (vom considera rdcina
ca fiind nodul cu numrul 1) la toate celelalte noduri ale
arborelui. Acest lucru se poate face adugnd un parametru dist
funciei DFS, care iniial este 0 i care crete cu 1 la fiecare apel
recursiv. Vom folosi un vector de distane D care va reine aceste
distane.
Se determin acel nod X pentru care D[X] este maxim.
Se determin distanele de la X la toate celelalte noduri ale
arborelui. Distana maxim determinat la acest pas reprezint
soluia problemei.
Pentru exemplul de mai sus, avem distana maxim de la rdcin la
celelalte noduri dat de distana de la nodul 1 la nodul 5. Rspunsul
problemei va fi dat de distana maxim de la nodul 5 la celelalte noduri.
Acest maxim are loc pentru distana 3 dintre dintre nodul 5 i nodul 3 sau 1.
n figura urmtoare, distanele calculate apar n albastru:

376

Teoria grafurilor

Fig 12.4.4. Modul de execuie al algoritmul de determinare a


diametrului unui arbore
Poate prea inutil i complicat aceast abordare. Muli
programatori se gndesc c pot pur i simplu s determine distana de la
rdcin la toate nodurile, iar apoi s fac suma celor mai mari dou
distane. Exemplul dat este un contraexemplu la aceast abordare. Alt
abordare eronat este considerarea rspunsului ca fiind distana maxim de
la rdcin la celelalte noduri. Gsirea unui contraexemplu pentru aceast
idee este lsat cititorului.
Prezentm n continuare implementarea algoritmului.
#include <fstream>
#include <vector>
using namespace std;
const int maxn = 101;
void citire(vector<int> G[], int &N)
{
ifstream in("dfs.in");
in >> N;
int x, y;
for ( int i = 1; i < N; ++i )
{
in >> x >> y;
G[x].push_back(y);
G[y].push_back(x);
}
in.close();
}

377

Capitolul 12
void init(int D[], int N)
{
for ( int i = 1; i <= N; ++i )
D[i] = -1;
}

int main()
{
int N;
int D[maxn];
vector<int> G[maxn];

void DFS(vector<int> G[], int nod, int dist,


int D[])
{
// putem folosi D ca sa vedem daca
// un nod a mai fost sau nu vizitat
if ( D[nod] != -1 )
return;
D[nod] = dist;

citire(G, N);
init(D, N);
DFS(G, 1, 0, D);
int X = 1;
for ( int i = 2; i <= N; ++i )
if ( D[i] > D[X] )
X = i;

for ( int i = 0; i < G[nod].size(); ++i )


DFS(G, G[nod][i], dist + 1, D);

init(D, N);
DFS(G, X, 0, D);

int max = D[1];


for ( int i = 2; i <= N; ++i )
if ( D[i] > max )
max = D[i];
ofstream out("dfs.out");
out << max;
out.close();
return 0;
}

Exerciii:
a) Rezolvai aceeai problem pe un graf ponderat (fiecare muchie
are asociat un anumit cost).
b) Modificai programul n aa fel nct s afieze i nodurile
drumului.

c) Implementare iterativ
Pot exista cazuri n care, din considerente de timp de execuie sau de
limitri ale stivei, nu ne permitem s folosim parcurgerea n adncime
implementat recursiv. Putem ns implementa iterativ aceast parcurgere
simulnd stiva folosind un vector clasic. Implementarea este dificil, mai
378

Teoria grafurilor
ales dac folosim vectori pentru reinerea listelor de adiacen sau liste
nlnuite pe care nu dorim s le distrugem n timpul prelucrrii. Funcia
prezentat este doar orientativ; cititorul este sftuit s o studieze i s o
mbunteasc. n practic, astfel de implementri sunt foarte rar ntlnite
sau necesare.
void DFSi(vector<int> G[], int nod, bool V[], ofstream &out)
{
// poz[i] = pozitia de la care trebuie continuata
// iterarea nodurilor din lista de adiacenta a lui i
// st = stiva in care se vor retine nodurile
int poz[maxn], st[maxn];
// un mod mai simplu de a seta toate valorile pe 0
memset(poz, 0, maxn*sizeof(int));
int k = 1; // pozitia in stiva
st[k] = nod;
cout << nod << ' ';
while ( k ) // cat timp stiva nu e goala
{
int nod = st[k]; // extrage un nod din stiva
V[nod] = true;
// parcurge fiii nodului extras
bool depus = false;
for (; poz[nod] < G[nod].size(); ++poz[nod] )
if ( !V[ G[nod][ poz[nod] ] ] )
{
depus = true;
cout << G[nod][ poz[nod] ] << ' ';
st[++k] = G[nod][ poz[nod] ];
break;
}
if ( !depus )
--k;
}
}

Exerciii:
a) Folosii parcurgerea n adncime pentru a determina
componentele conexe ale unui graf.
b) Implementai varianta iterativ a parcurgerii n adncime
folosind liste nlnuite.
379

Capitolul 12
c) Implementai varianta iterativ folosind liste nlnuite
implementate manual. Codul scris se reduce cu mult. Explicai
de ce.
d) Implementai parcurgerea DF pe un graf reprezentat prin matrice
de adiacen.
e) Scriei variante iterative ale parcurgerii n adncime care parcurg
un arbore binar n preordine, ordine i postordine.
f) Scriei un program care determin, pentru mai multe perechi de
noduri, dac acestea se afl n aceeai component conex.
g) Aceeai cerin ca la f), dar pentru aceeai component tare
conex.

12.5. Parcurgerea n lime


Parcurgerea n lime (en. breadth-first search BFS) este o
parcurgere a grafurilor care st la baza algoritmilor de determinare a
distanelor minime n grafuri. Putem extinde noiunea de nivel de la arbori
la grafuri oarecare. Astfel, vom defini nivelul k al unui graf ca fiind
mulimea tuturor nodurilor aflate la distana k fa de un nod fixat (sau nod
surs), care va fi considerat de acum n colo nodul 1. Parcurgerea n lime
presupune prelucrarea nodului 1, dup aia prelucrearea tuturor vecinilor
acestuia, dup aia prelucrarea vecinilor acestora .a.m.d. pn cnd se
parcurge tot graful sau se gsete un nod anume. Altfel spus, se parcurg
toate nodurilor de pe un anumit nivel nainte de a trece la urmtorul nivel.
Desenul de mai jos prezint o posibil ordine de procesare a
nodurilor unui graf neorientat oarecare n cadrul parcurgerii BFS:

Fig. 12.5.1. Ordinea de prelucrare a nodurilor n cadrul


parcurgerii n lime

380

Teoria grafurilor
Acest desen ascunde o proprietate important a parcurgerii n lime:
parcurgerea n lime determin distana minim de la nodul surs la toate
celelalte noduri accesibile ale grafului. Trebuie menionat totui c acest
lucru are loc numai atunci cnd toate muchiile au acelai cost (adic graful
nu este ponderat).
Timpul de execuie al parcurgerii n lime pentru un graf cu N
noduri i M muchii este O(N + M) n cazul n care graful este reinut prin
liste de adiacen i O(N2) n cazul n care graful este reinut prin matricea
de adiacen.
Cititorii care au parcurs seciunea dedicat programrii dinamice
sunt deja familiari cu acest algoritm: acesta nu este altceva dect o
generalizare a ceea ce atunci am numit algoritmul lui Lee. Algoritmul lui
Lee este folosit pentru a determina distane minime ntr-o matrice n care
avem doar elemente accesibile i elemente neaccesibile, matrice n care ne
putem deplasa dintr-o celul n toate celulele nvecinate la stnga, dreapta,
sus, jos i eventual pe diagonale. Din punct de vedere conceptual, putem
transforma uor matricea ntr-un graf neorientat n care un nod este
caracterizat prin linia i coloana pe care s-a situat n matrice. Aadar,
algoritmul lui Lee este de fapt o parcurgere n lime.
Deoarece am prezentat deja algoritmul, nu vom insista foarte mult
asupra modului de funcionare i asupra detaliilor de implementare. Singura
diferen este c aici vom folosi containerul S.T.L. queue pentru a
implementa coada FIFO (First In First Out). Recomandm citirea seciunii
Introducere n S.T.L. cititorilor nefamiliarizai cu acest container.
Vom prezenta dou aplicaii ale parcurgerii n lime:
a) Determinarea proprietii de graf bipartit.
b) Sortarea topologic
n primul rnd, prezentm o funcie care doar afieaz nodurile unui
graf, ntr-o ordine dat de parcurgerea sa n lime, n fiierul bfs.out.
Programul ntreg are aceeai structur cu programele deja prezentate.

381

Capitolul 12
void BFS(vector<int> G[], int N)
{
queue<int> C;
bool V[maxn];
ofstream out("bfs.out");
memset(V, 0, maxn*sizeof(bool)); V[1] = true;
C.push(1);
while ( !C.empty() )
{
int nod = C.front();
out << nod << ' ';
for ( int i = 0; i < G[nod].size(); ++i )
if ( !V[ G[nod][i] ] )
{
C.push( G[nod][i] );
V[ G[nod][i] ] = true;
}
C.pop();
}
out.close();
}

n general, parcurgerea n lime este preferabil parcurgerii n


adncime, datorit faptului c nu folosim apeluri recursive. Mai mult, dac
ne intereseaz gsirea unui nod, parcurgerea BFS poate fi, de cele mai multe
ori, oprit mai devreme dect parcurgerea n adncime, deoarece explorarea
grafului se face mai uniform, iar nodurile aflate la o distan mai mic de
nodul surs vor fi parcurse mai repede, ceea ce nu este ntotdeauna adevrat
pentru parcurgerea n adncime.
Dezavantajul acestei parcurgeri const n faptul c trebuie s scriem
un pic mai mult cod, dar acest lucru nu este un impediment major i n
faptul c memoria folosit poate fi mai mare pe anumite grafuri dac
parcurgerea dureaz mult timp.
n general, alegerea uneia dintre cele dou tipuri fundamentale de
parcurgere a grafurilor trebuie fcut n funcie de problema pe care dorim
s o rezolvm i de resursele disponibile.

382

Teoria grafurilor

a) Determinarea proprietii de graf bipartit


Un graf este bipartit dac putem partiiona nodurile acestuia n dou
mulimi disjuncte X i Y n aa fel nct orice muchie a grafului s aib una
dintre extremiti n X i cealalt n Y. De exemplu, graful de mai jos este
bipartit:

Fig. 12.5.2. Un graf bipartit oarecare


O proprietate important a grafurilor bipartite este c orice graf
bipartit este 2-colorabil. Un graf este k-colorabil dac putem colora toate
nodurile sale cu k culori distincte n aa fel nct extremitile fiecrei
muchii s fie colorate diferit. n cazul grafurilor bipartite este evident c
putem colora nodurile grafului cu dou culori distincte n aa fel nct
extremitile oricrei muchii s fie colorate diferit. Fiecare culoare va
determina cte o partiie a grafului.
Pentru a determina dac un graf este sau nu bipartit putem s
ncercm s colorm graful cu dou culori distincte: dac reuim, atunci
graful este bipartit, iar dac nu atunci graful nu este bipartit. Vom folosi un
vector cul, unde cul[i] = culoarea nodului i (0 pentru un nod necolorat).
Vom colora primul nod (1) cu culoarea 1, vecinii si cu culoarea 2, vecinii
acestora iari cu culoarea 1 etc. Dac la un anumit pas ar trebui s
schimbm culoarea unui nod pentru a putea continua, atunci graful nu este
bipartit. Dac n schimb am colorat toate nodurile i nu am dat peste acest
caz, atunci graful este bipartit.
Pentru implementarea acestui algoritm vom folosi parcurgerea n
lime. Vom seta primul nod pe culoarea 1, iar apoi vom aplica parcurgerea
n lime pornind din primul nod. Pentru fiecare nod nod extras din coad,
vom ncerca s atribuim vecinilor si culoarea 3 cul[nod] (astfel, n caz c
avem cul[nod] == 1, vecinii vor primi culoarea 2 i invers).
Desenele de mai jos reprezint execuia algoritmului pe trei grafuri:
primul este cel de mai sus, al doilea este un graf bipartit pentru care se
ncearc colorarea unui nod de dou ori, dar cu aceeai culoare, deci acesta
este tot bipartit, iar al treilea este un graf care nu e bipartit, nodul colorate n
383

Capitolul 12
verde reprezentnd nodul pe care algoritmul a ncercat s-l coloreze cu dou
culori distincte. Fiecare nod are asociat un numr care reprezint o posibil
ordine de parcurgere.

Fig. 12.5.3. Determinarea proprietii de graf bipartit


pe mai multe grafuri
Prezentm doar funcia care returneaz true dac graful G este
bipartit i false n caz contrar.

384

Teoria grafurilor
bool bipartit(vector<int> G[], int N)
{
queue<int> C;
int cul[maxn];
cul[1] = 1;
for ( int i = 2; i <= N; ++i )
cul[i] = 0;
C.push(1);
while ( !C.empty() )
{
int nod = C.front();
for ( int i = 0; i < G[nod].size(); ++i )
if ( !cul[ G[nod][i] ] ) // nu este colorat, il coloram
// in culoarea opusa nodului *nod*
{
cul[ G[nod][i] ] = 3 - cul[nod];
C.push( G[nod][i] );
}
else if ( cul[ G[nod][i] ] == cul[nod] ) // trebuie schimbata
// culoarea
return false;
C.pop();
}
return true;
}

Exerciii:
a) Implementai algoritmul prezentat folosind parcurgerea n
adncime.
b) Considerm funciile f definite pe mulimea numerelor naturale
cu valori tot n mulimea numerelor naturale. Dai exemple de
funcii pentru care graful format din muchiile (x, f(x)) este
bipartit i de funcii pentru care acelai graf nu este bipartit, cnd
x parcurge pe rnd numerele naturale.

385

Capitolul 12

b) Sortarea topologic
Considerm o mulime de activiti {A1, A2, ..., AN} i o mulime de
relaii (Ap, Aq) care semnific faptul c activitatea Ap trebuie desfurat
neaprat naintea activitii Aq. Se cere gsirea unei ordini de desfurare a
activitilor.
Rezolvarea problemei presupune modelarea acesteia ca o problem
de grafuri. Astfel, fiecare activitatea va reprezenta un nod i fiecare relaie
(Ap, Aq) va reprezenta o muchie orientat de la Ap la Aq. Graful rezultat va
fi evident un graf orientat aciclic (n caz contrar, am avea dou activiti
care depind reciproc una de cealalt, deci nu ar exista soluie). Rezolvarea
problemei se reduce la gsirea unei ordini a nodurilor n care fiecare nod i
apare dup apariia tuturor nodurilor care au muchie nspre i.
Figura de mai jos este un graf care reprezint un posibil set de date
de intrare, mpreun cu o posibil sortare topologic a sa:

Fig. 12.5.4. Un graf orientat aciclic oarecare i


o sortare topologic a sa
Pentru rezolvarea acestei probleme vom folosi parcurgerea n lime
n felul urmtor: iniial vom calcula gradul interior al fiecrui nod, dup care
vom aduga n coad toate nodurile care au gradul interior 0 (adic acele
noduri n care nu intr nicio muchie). De fiecare dat cnd extragem un nod
din coad, vom scdea cu 1 gradele interioare ale vecinilor acestora i vom
aduga n coad doar acele noduri ale cror grade interioare devin 0. Astfel
ne asigurm c un nod nu va fi prelucrat dect dac toate nodurile ce au
muchii nspre el au fost la rndul lor prelucrate. Mai mult, putem detecta n
acest fel i dac avem sau nu soluie, adic dac graful pe care lucrm este
sau nu ciclic. Dac graful este ciclic, vom ajunge la un moment dat cu un
grad interior negativ.
386

Teoria grafurilor
Sortarea topologic are aplicaii n probleme de planificare a
activitilor. De exemplu, algoritmul poate fi folositor pentru a stabili
prioriti i a stabili sarcini n cadrul unui proiect complex.
Funcia urmtoare citete un graf orientat din fiierul bfs.in i
afieaz o sortare topologic a sa n fiierul bfs.out.
void topo(vector<int> G[], int N, ofstream &out)
{
int gr[maxn]; // gr[i] = gradul interior al lui i
memset(gr, 0, maxn*sizeof(int));
for ( int i = 1; i <= N; ++i )
for ( int j = 0; j < G[i].size(); ++j )
++gr[ G[i][j] ];
queue<int> C;
for ( int i = 1; i <= N; ++i )
if ( gr[i] == 0 )
C.push(i);
while ( !C.empty() )
{
int nod = C.front();
out << nod << ' ';
for ( int i = 0; i < G[nod].size(); ++i )
{
--gr[ G[nod][i] ];
if ( gr[ G[nod][i] ] == 0 )
C.push( G[nod][i] );
}
C.pop();
}
}

387

Capitolul 12

12.6. Componente tare conexe


O component conex a unui graf neorientat este un subgraf
maximal conex al acestuia. De exemplu, graful neorientat din figura de mai
jos are trei componente conexe:

Fig. 12.6.1. Un graf neorientat cu trei componente conexe


Pentru grafurile neorientate, determinarea componentelor conexe
este foarte simpl: pentru fiecare nod i, dac i nu a fost nc etichetat, se
parcurge graful n adncime sau n lime pornind de la i i se eticheteaz
toate nodurile, inclusiv nodul surs, cu un numr k, care iniial este 1. Se
incrementeaz k i se trece la urmtorul nod. La sfrit, nodurile marcate cu
1 vor face parte din prima component conex, cele marcate cu 2 din a doua,
..., cele marcate cu k din a k-a component conex.
Pentru grafurile orientate aceast metod nu are cum s funcioneze,
deoarece, n cazul grafurilor orientate, dac avem drum de la un nod x la un
nod y, nu este obligatoriu s avem un drum i de la y la x. De exemplu,
graful orientat de mai jos are 2 componente tare conexe:

Fig. 12.6.2. Un graf orientat cu dou componente tare conexe


Observaie: Un graf orientat se numete tare conex dac pentru
oricare dou noduri x i y exist drum att de la x la y ct i de la y la x.
388

Teoria grafurilor
Dac exist drum doar de la x la y sau doar de la y la x, graful se numete
conex sau slab conex.
n rezolvarea problemei vom folosi algoritmul lui Kosaraju, care
pornete de la urmtoarea idee pentru a determina componentele tare conexe
ale lui G:
Se construiete graful transpus GT .
Ct timp exist noduri n G execut
o Alege un nod nod din G
o Eticheteaz toate nodurile din G accesibile din nod
(inclusiv nod) cu +.
o Eticheteaz toate nodurile din GT accesibile din nod
(inclusiv nod) cu .
o Nodurile etichetate att cu + ct i cu reprezint o
component tare conex. Acestea se elimin din cele
dou grafuri.
Aceast metod poart numele de algoritmul plus-minus.
Complexitatea acestuia, n cazuri favorabile este O(N + M) pe un graf cu N
noduri i M muchii. Exist ns grafuri pe care algoritmul are un timp de
execuie de O(N2). Exerciiu: gsii un astfel de caz.
Algoritmul lui Kosaraju este o optimizare a algoritmului
plus-minus, optimizare care face algoritmul s aib timpul de execuie de
O(N + M) pe toate cazurile. Aceast optimizare const n reinerea
nodurilor grafului G n ordinea dat de parcurgerea n postordine a grafului.
Se prelucreaz apoi nodurile din GT n ordinea invers n care acestea apar
n parcurgerea n postordine a lui G. Se eticheteaz nodurile accesibile
dintr-un nod fixat cu k, care iniial este 0 i care se incrementeaz dup
fiecare etichetare. Nodurile marcate cu 1 vor reprezenta prima component
tare conex, cele cu 2 a doua, ..., cele cu k a k-a component tare conex.
Programul urmtor afieaz direct componentele tare conexe ale
unui graf citit, fr a efectua propru-zis etichetarea menionat.

389

Capitolul 12
#include <fstream>
#include <vector>

void DFS_GT(vector<int> GT[],


int nod,
bool V[],
ofstream &out)
{
if ( !V[nod] )
return;
V[nod] = false;

using namespace std;


const int maxn = 101;
void citire(vector<int> G[],
vector<int> GT[],
int &N, int &M)
{
ifstream in("ctc.in");
in >> N >> M;

out << nod << ' ';


for ( int i=0; i<GT[nod].size(); ++i )
DFS_GT(GT, GT[nod][i], V, out);

int x, y;
for ( int i = 1; i <= M; ++i )
{
in >> x >> y;
G[x].push_back(y);
GT[y].push_back(x);
}

}
int main()
{
int N, M;
vector<int> G[maxn], GT[maxn];
bool V[maxn];

in.close();

citire(G, GT, N, M);


for ( int i = 1; i <= N; ++i )
V[i] = false;

}
void DFS_G(vector<int> G[], int nod,
bool V[],
vector<int> &postord)
{
if ( V[nod] )
return;
V[nod] = true;

vector<int> postord;
for ( int i = 1; i <= N; ++i )
DFS_G(G, i, V, postord);
ofstream out("ctc.out");
for ( int i = 1; i <= N; ++i )
if ( V[i] )
{
DFS_GT(GT, i, V, out);
out << '\n';
}

for ( int i=0; i < G[nod].size(); ++i )


DFS_G(G, G[nod][i], V, postord);
postord.push_back(nod);
}

out.close();
return 0;
}

390

Teoria grafurilor

12.7. Determinarea nodurilor critice


Un graf neorientat cu N noduri i M muchii se numete biconex
dac acesta nu conine puncte de articulaie (numite i noduri critice). Un
nod se numete nod critic dac tergerea sa din graf, mpreun cu toate
muchiile incidente acestuia, cauzeaz apariia unei noi componente conexe.
ntr-un graf conex, un nod este critic dac tergerea sa face ca graful s nu
mai fie conex.
O component biconex a unui graf este un subgraf biconex
maximal al grafului.
Ne propunem s scriem un program care determin nodurile critice
ale unui graf conex. Aceast problem are aplicaii n telecomunicaii. O
reea de telefonie sau internet este bine s nu aib niciun nod critic, deoarece
defectarea acestuia poate duce la cderea comunicaiilor pentru muli
utilizatori.
Pentru a rezolva aceast problem, vom introduce mai nti cteva
noiuni teoretice.
Definiia 1: Se numete arbore parial al grafului G un graf parial
al lui G care este arbore. De exemplu, graful parial marcat cu rou din
urmtorul desen este arbore parial:

Fig. 12.7.1. Un arbore parial al unui graf oarecare


Definiia 2: Se numete arbore DFS un arbore parial obinut prin
parcurgerea grafului n adncime. Muchiile care duc spre un nod nevizitat
nc fac parte din arbore, iar muchiile care duc spre un nod deja vizitat (de
exemplu muchia (3, 1)) se numesc muchii de ntoarcere i nu fac parte din
arbore. Arborele parial de mai sus este un arbore DFS, care arat n felul
urmtor (muchiile de ntoarcere apar cu albastru):

391

Capitolul 12

Fig 12.7.2. Un exemplu de arbore DFS


Avnd construit arborele DFS, putem face urmtoarea observaie: un
nod i este critic dac i numai dac exist cel puin un fiu al su de la care
nu se poate ajunge la un strmo al nodului i (lund n considerare i
muchiile de ntoarcere) fr a trece prin i. n exemplul anterior, astfel de
noduri sunt 2 (de la 4 nu se poate ajunge la 1 fr a trece prin 2), 5 (de la 6
nu se poate ajunge la 2 i 1 fr a trece prin 5) i 8.
n cazul rdcinii arborelui DFS, aceasta este nod critic dac i
numai dac are cel puin doi fii.
Putem determina aadar nodurile critice ale unui graf cu o simpl
parcurgere n adncime, deci n timp O(N + M).
Pentru a putea face acest lucru, vom calcula dou seturi de valori
pentru fiecare nod n timpul parcurgerii n adncime:
1. D, unde D[nod] = distana de la rdcina arborelui DFS la nodul
nod, fr a lua n considerare muchiile de ntoarcere
2. minim, cu minim[nod] = minimul dintre minim[nod] i D[i],
cnd din nod vrem s ne deplasm pe nodul deja vizitat i
folosind o muchie de ntoarcere i minimul dintre minim[nod] i
minim[i] cnd din nod vrem s ne deplasm n nodul nevizitat i.
minim[nod] se iniializeaz cu D[nod].
Semnificaia primului set de valori este evident. Cel de-al doilea set
de valori ne ajut s verificm pentru un nod nod dac tergerea lui
pstreaz subarborele su conectat de restul grafului (adic dac nod este
sau nu punct de articulaie). Dac tergerea lui nod pstreaz graful conex,
atunci minim[i] trebuie s fie strict mai mic dect D[nod], unde i este un fiu
al lui nod (cu alte cuvinte, exist o muchie de ntoarcere de la unul dintre
descendenii lui nod la unul dintre strmoii si). Altfel, dac minim[i] este
mai mare sau egal cu D[nod], nod este nod critic.

392

Teoria grafurilor
Figura urmtoare prezint arborele DFS anterior completat cu
valorile D (rou) i minim (albastru).

Fig 12.7.3. Modul de execuie al algoritmului de determinare a


componentelor biconexe
Implementarea este intuitiv dac algoritmul a fost neles. Trebuie
tratat special nodul 1, care este critic doar dac are cel puin doi fii n
arborele DFS (fcnd abstracie de muchiile de ntoarcere!).
Prezentm doar funciile relevante. Singura precondiie necesar este
iniializarea vectorului D cu -1 nainte de apelarea funciei.

393

Capitolul 12
void DFS(vector<int> G[], int D[], int minim[], int nod, int tata, int ad,
ofstream &out)
{
D[nod] = minim[nod] = ad;
int nrf = 0; // nr de fii ai lui *nod* in arborele DFS
bool critic = 0;
for ( int i = 0; i < G[nod].size(); ++i )
if ( G[nod][i] != tata ) // am grija sa nu merg inapoi de unde am venit
if ( D[ G[nod][i] ] == -1 )
{
++nrf;
DFS(G, D, minim, G[nod][i], nod, ad + 1, out);
minim[nod] = min(minim[nod], minim[ G[nod][i] ]);
if ( minim[ G[nod][i] ] >= D[nod] )
critic = 1;
}
else
minim[nod] = min(minim[nod], D[ G[nod][i] ]);
// nodul 1 e critic doar daca are cel putin 2 fii
if ( (nod == 1 && nrf > 1) || (nod != 1 && critic) )
out << nod << ' ';
}

Exerciii:
a) Problema se poate rezolva i mai intuitiv, dar mai puin eficient.
Care ar fi un algoritm naiv de rezolvare?
b) Modificai programul prezentat astfel nct s afieze muchiile
critice, adic acele muchii a cror nlturare ar deconecta graful.
c) Modificai programul prezentat astfel nct s afieze
componentele biconexe ale grafului.

12.8. Drum i ciclu eulerian


Un drum eulerian este un drum care parcurge toate muchiile unui
graf exact o singur dat. Similar, un ciclu eulerian este un ciclu care
parcurge toate muchiile grafului exact o singur dat. Problema a fost
abordat pentru prima dat de ctre matematicianul Leonhard Euler, care a
rezolvat problema podurilor din Knigsberg. Aceast problem cere
parcurgerea tuturor podurilor din urmtoarea figur o singur dat
(exerciiu: este acest lucru posibil?)
394

Teoria grafurilor

Fig. 12.8.1. Problema podurilor din Knigsberg


Pentru a rezolva problema determinrii unui drum sau ciclu eulerian,
trebuie s enunm cteva condiii de existen a acestora. n primul rnd, un
graf admite un drum sau ciclu eulerian dac acest graf este eulerian (n caz
c graful admite doar un drum eulerian i nu un ciclu, graful se numete
semi-eulerian). Pentru un graf conex G avem urmtoarele proprieti:
1. Dac G este neorientat i toate nodurile sale au graf par, atunci
G este eulerian.
2. Dac G este neorientat i toate nodurile sale, mai puin dou,
au grad par, atunci G este semi-eulerian.
3. Dac G poate fi descompus n reuniuni de cicluri disjuncte
relativ la muchii, atunci G este eulerian.
4. Dac G este orientat i toate nodurile sale au gradul interior egal
cu gradul exterior, atunci G este eulerian.
n cele ce urmeaz vom presupune c se d un graf neorientat care
tim sigur c este ori eulerian ori semi-eulerian. Pentru a determina un drum
sau un ciclu eulerian n acest graf ne vom folosi de proprietatea 3 i de
parcurgerea n adncime a grafului (din nodul 1; vom presupune i c dac
graful are un drum eulerian, acesta ncepe din nodul 1). n cazul n care
graful G poate fi descompus n reuniuni de cicluri disjuncte relativ la
muchii, G este eulerian. Evident, dac G este eulerian atunci l vom putea
descompune n cicluri disjuncte relativ la muchii. Aceast descompunere,
fcut convenabil, ne va furniza fie un ciclu eulerian fie un drum eulerian.
Algoritmul va folosi o funcie euler(G, nod, st) care va construi n vectorul
st parcurgerea eulerian a grafului. Aceast funcie va fi implementat
astfel:

395

Capitolul 12
Pentru fiecare muchie (nod, i) din G execut
o Elimin muchia (nod, i)
o Apeleaz recursiv euler(G, i, st)
Adaug-l pe nod la sfritul lui st.
La finalul algoritmului, vectorul st, citit invers, ne va da un drum sau
un ciclu eulerian al lui G. Din punct de vedere al implementrii
algoritmului, tergerea nodului i din lista de adiacen a lui nod se va face
atribuind poziiei lui i valoarea -1. O valoare de -1 va indica faptul c acea
poziie nu reprezint un nod existent n lista de adiacen curent.
Atenie! Deoarece implementarea prezentat lucreaz cu grafuri
neorientate, pentru a terge o muchie (nod, i), trebuie ters att i din lista lui
nod ct i nod din lista lui i!
Desenele urmtoare prezint modul de execuie al algoritmului
(ordinea efecturii apelurilor recursive) pe un graf neorientat care admite
doar un drum eulerian. Cu rou sunt marcate nodurile i muchiile care fac
parte dintr-un ciclu nedescoperit nc, iar cu albastru nodurile i muchiile
care fac parte dintr-un ciclu deja descoperit.

396

Teoria grafurilor

Fig 12.8.2. Modul de execuie al algoritmului de determinare


a unui drum eulerian
Se poate observa din desen c un nod poate fi parcurs de mai multe
ori, i c se fac attea apeluri recursive cte muchii exist. Complexitatea
397

Capitolul 12
acestui algoritm este aadar O(M) pentru un graf cu M muchii. n final,
st = {8, 7, 4, 6, 5, 4, 3, 2, 4, 1}. Citit invers, obinem urmtorul drum
eulerian: 1 4 2 3 4 5 6 4 7 8.
#include <fstream>
#include <vector>
using namespace std;
const int maxn = 101;

int main()
{
int N, M;
vector<int> G[maxn], st;

void citire(vector<int> G[], int &N, int &M)


{
ifstream in("euler.in");
in >> N >> M;
int x, y;
for ( int i = 1; i <= M; ++i )
{
in >> x >> y;
G[x].push_back(y);
G[y].push_back(x);
}
in.close();
}
void euler(vector<int> G[], int nod,
vector<int> &st)
{
for ( int i = 0; i < G[nod].size(); ++i )
{
// trebuie sters G[nod][i] din lista lui
// nod si nod din lista lui G[nod][i]
int temp = G[nod][i];
G[nod].erase(G[nod].begin() + i);
for ( int j = 0; j < G[temp].size(); ++j )
if ( G[temp][j] == nod )
{
G[temp].erase(G[temp].begin() + j);
break;
}
euler(G, temp, st);
}
st.push_back(nod);
}

398

citire(G, N, M);
euler(G, 1, st);
ofstream out("euler.out");
for ( int i = st.size() - 1;
i >= 0;
--i )
out << st[i] << ' ';
out.close();
return 0;
}

Teoria grafurilor

12.9. Drum i ciclu hamiltonian


Un drum hamiltonian este un drum care viziteaz toate nodurile
unui graf exact o singur dat. Un ciclu hamiltonian este un ciclu care
viziteaz toate nodurile unui graf o singur dat, mai puin nodul surs, care
este identic cu nodul destinaie (deci este vizitat de dou ori). Vom spune c
un graf este hamiltonian dac acesta admite un ciclu hamiltonian. Vom
spune c un graf este semi-hamiltonian dac acesta admite doar un drum
hamiltonian. n cele ce urmeaz, vom lucra doar cu grafuri neorientate i cu
drumuri hamiltoniene. Extinderea noiunilor i algoritmilor prezentai la
cicluri hamiltoniene i la grafuri orientate este lsat ca un exerciiu pentru
cititor.
Un exemplu de graf semi-hamiltonian este urmtorul:

Fig 12.9.1. Un graf semi-hamiltonian


Acest graf admite drumurile hamiltoniene 4 1 2 3 5 i
4 1 2 5 3 avnd ca surs nodul 4. n general, gsirea unui drum
hamiltonian este o problem care nu are rezolvri deterministe eficiente. O
soluie evident este generarea tuturor permutrilor P a mulimii
{1, 2, ..., N} i verificarea existenei muchiei (Pi, Pi+1) pentru 1 i < N.
Exist cteva rezultate importante care ne pot ajuta s determinm
mult mai rapid dac un graf admite sau nu un drum hamiltonian. Este clar c
dac un graf este hamiltonian, acesta e i semi-hamiltonian, deoarece n
cazul existenei unui ciclu, putem s tergem o muchie din ciclu i s
obinem astfel un drum. Aadar, urmtoarele afirmaii se aplic i grafurilor
semi-hamiltoniene, care ne intereseaz:
1. n primul rnd, toate grafurile complete sunt hamiltoniene.
2. Teorema lui Dirac (1952): un graf cu N 3 noduri este

hamiltonian dac fiecare nod al su are cel puin gradul


.
2
3. Teorema lui Ore (1960): un graf cu N 3 noduri este
hamiltonian dac pentru orice pereche de noduri neadiacente,
suma gradelor acestora este cel puin N.
399

Capitolul 12
4. Teorema Bondy-Chvtal (1972): un graf G cu N noduri este
hamiltonian dac i numai dac cl(G) este hamiltonian. Prin
cl(G) nelegem nchiderea lui G, adic graful obinut prin
adugarea de muchii ntre oricare dou noduri neadiacente a lui
G a cror sum a gradelor este cel puin N.
Folosind a patra teorem, putem determina eficient existena unui
ciclu hamiltonian, dar gsirea efectiv a unui astfel de ciclu rmne o
problem dificil.
n cele ce urmeaz vom aborda o problema mai interesant din punct
de vedere algoritmic i anume problema comis voiajorului. Practic, n
aceast problem avem ca date de intrare un graf neorientat conex i
ponderat, adic fiecare muchie are ataat un cost (sau o distan). Se cere
un drum (uneori un ciclu) hamiltonian de cost minim. Nici aceast problem
nu are soluii deterministe eficiente, dar vom prezenta dou abordri
probabiliste care sunt mult mai rapide dect abordrile determineste i care
furnizeaz rspunsuri foarte apropiate de optimul global.
De exemplu, dac atribuim costuri muchiilor grafului din exemplul
anterior, avem urmtorul graf ponderat:

Fig 12.9.2. Un graf semi-hamiltonian ponderat


Cele dou drumuri hamiltoniene au costurile 5 + 2 + 13 + 10 = 30 i
5 + 2 + 8 + 10 = 25. Ne propunem s scriem un program care determin un
drum hamiltonian de cost minim ntr-un graf ponderat neorientat cu N
noduri i M muchii, avnd ca surs un nod x i ca destinaie un nod
oarecare.
O prim idee de rezolvare const n parcurgerea tuturor drumurilor
de la x cu ajutorul unei parcurgeri n adncime. S analizm ns
complexitatea acestei metode pe un graf complet. Exist N 1 muchii care
unesc alte noduri cu nodul x, aadar se vor efectua pe rnd N 1 apeluri
recursive. Pentru fiecare dintre aceste apeluri se vor efectua alte N 2
400

Teoria grafurilor
apeluri .a.m.d. Complexitatea algoritmului n cel mai ru caz este aadar
O(N!).
Deoarece complexitatea algoritmului este O(N!) nu ne vom permite
s lucrm cu grafuri cu mai mult de ~10 noduri. Aadar, pentru o
implementare mai simpl vom folosi matrici de adiacen pentru
reprezentarea grafului. Considernd c avem costuri diferite de 0, elementul
de pe linia i i coloana j a matricii de adiacen va fi egal cu c dac costul
muchiei (i, j) este c i cu 0 n caz contrar.
Rezolvarea problemei este foarte asemntoare cu o parcurgere n
adncime. Va trebui doar s adugm civa parametri suplimentari funciei
de parcurgere. Fie hamilton(G, nod, nr, c, cmin, V, st, sol) o funcie care
construiete n sol un drum hamiltonian de cost minim cmin. Variabila nod
reprezint nodul curent, nr reprezint numrul de noduri deja parcurse, st
reprezint drumul curent iar c reprezint costul drumului curent. Aceast
funcie poate fi implementat n felul urmtor:
Dac c > cmin sau V[nod] == true se iese din funcie
Adaug i n st
Dac nr == N i c < cmin execut
o Actualizeaz sol i cmin
o Ieire din funcie
V[nod] = true
Pentru fiecare vecin i a lui nod execut
o Apel recursiv
hamilton(G, i, nr + 1, c + G[nod][i], cmin, V, st, sol)
V[nod] = false
Rezolvarea folosete metoda backtracking pentru parcurgerea tuturor
drumurilor. Datorit optimizrii facute n prima linie a programului, aceea
de a nu continua pe traseul curent dac costul acestuia este deja mai mare
dect minimul gsit pn acum, este posibil ca pe grafuri generate aleator
algoritmul s funcioneze mai bine dect ar indica notaia asimptotic.
Totui, n practic, aceast metod este ineficient pentru un numr mare de
noduri (mii, zeci de mii) i vom ncerca s gsim soluii mai eficiente.
Prezentm totui o implementare a acestei metode.
Datele de intrare se citesc din fiierul hamilton.in, care conine pe
prima linie numerele N M x, iar pe urmtoarele M linii o list de muchii
ponderate care descriu un graf semi-hamiltonian. n fiierul de ieire
hamilton.out se va afia costul minim al unui drum hamiltonian i un drum
care are acest cost.
401

Capitolul 12
#include <fstream>
#include <vector>
using namespace std;
const int maxn = 101;

int main()
{
int G[maxn][maxn], N, M, x;
bool V[maxn];
citire(G, N, M, x);

void citire(int G[maxn][maxn],


int &N, int &M, int &x)
{
ifstream in("hamilton.in");
in >> N >> M >> x;
int p, q, c;
for ( int i = 1; i <= M; ++i )
{
in >> p >> q >> c;
G[p][q] = G[q][p] = c;
}
in.close();
}

for ( int i = 1; i <= N; ++i )


V[i] = false;
int st[maxn], sol[maxn];
int cmin = 1 << 30;
hamilton(G, N, x, 1, 0,
cmin, V, st, sol);

void hamilton(int G[maxn][maxn], int N,


int nod, int nr, int c, int &cmin,
bool V[], int st[], int sol[])
{
if ( c > cmin || V[nod] == true )
return;
st[nr] = nod;
if ( nr == N && c < cmin )
{
cmin = c;
for ( int i = 1; i <= N; ++i )
sol[i] = st[i];
return;
}
V[nod] = true;
for ( int i = 1; i <= N; ++i )
if ( G[nod][i] )
hamilton(G, N, i, nr + 1,
c + G[nod][i], cmin,
V, st, sol);
V[nod] = false;
}

402

ofstream out("hamilton.out");
out << cmin << '\n';
for ( int i = 1; i <= N; ++i )
out << sol[i] << ' ';
out.close();
return 0;
}

Teoria grafurilor
Algoritmii folosii pentru a rezolva instane ale problemei cu un
numr foarte mare de noduri sunt algoritmi probabiliti. n cele ce urmeaz
vom prezenta un algoritm genetic care rezolv problema i un algoritm
aleator mai eficient i care se comport totodat foarte bine n practic.
Pentru algoritmul genetic, avem nevoie de:
1. O metod de codificare a unui drum hamiltonian. Este clar c
vom reine pur i simplu un vector cu N noduri care va
reprezenta un astfel de drum.
2. Unul sau mai muli operatori genetici care se aplic
cromozomilor n cadrul trecerii de la o generaie la alta.
3. O funcie de adecvare.
Operatorii genetici care pot fi folosii sunt:
Operatorul de mutaie: se aleg dou poziii i i j dintr-un
cromozom i se interschimb nodurile reinute n acele poziii.
Operatorul de inversiune: se aleg dou poziii i i j dintr-un
cromozom i se nlocuiete secvena [i, j] a acestui cromozom cu
inversul acesteia.
Operaia de recombinare trebuie tratat puin diferit, deoarece avem
de-a face cu permutri i nu putem s concatenm pur i simplu dou
secvene disjuncte a doi cromozomi diferii fr a strica validitatea
cromozomului rezultat. Vom aplica aadar urmtorul algoritm pentru
recombinare:
Fie C1 i C2 cei doi cromozomi din care vrem s obinem un
cromozom pentru generaia viitoare.
Se copiaz primele k gene (elemente) din C1 n cromozomul
rezultat, unde k este un numr aleator. Din C2 se copiaz toate
genele care nu se afl deja n cromozomul rezultat.
Funcia de adecvare va fi evident costul traseului codificat de ctre
un cromozom.
Algoritmul aleator este mai uor de implementat i presupune
mbuntirea unui drum ales aleator cu ajutorul efecturii unor schimbri
aleatoare.
Mai exact:
Se genereaz aleator un drum valid sol (o permutare a primelor
N numere naturale care ncepe cu x)
Se execut de k ori (cu ct k este mai mare, cu att este mai mare
probabilitatea s gsim un optim global)
403

Capitolul 12
o Efectueaz dou interschimbri aleatore n sol.
o Dac noul vector sol codific o soluie de cost mai mic,
se pstreaz interschimbrile, altfel se anuleaz.
Deoarece lucrm cu permutri i nu avem garania faptului c graful
citit este graf complet, vom aduga muchii de cost infinit (un numr foarte
mare) grafului pn ce acesta devine complet. Probabilitatea ca muchiile de
cost infinit adugate s fac parte dintr-o soluie furnizat de oricare
algoritm este foarte mic.
Implementrile acestor algoritmi sunt similare cu altele prezentate
deja, aa c le lsm ca exerciiu pentru cititor.

12.10. Drumuri de cost minim n grafuri ponderate


Am discutat pn acum despre drumuri minime n grafuri
neponderate i despre drumuri de cost minim n cadrul problemei comis
voiajorului. ntr-un graf neponderat putem gsi un drum minim dintre dou
noduri foarte uor aplicnd o parcurgere n adncime din nodul surs.
Problema se complic ns atunci cnd vrem s gsim un drum minim ntre
dou noduri ntr-un graf ponderat. Rezolvrile eficiente sunt netriviale i
trebuie inut cont de anumite neajunsuri a unor algoritmi.
Aplicaiile algoritmilor de determinare a drumurilor de cost minim
sunt foarte vaste, att n alte probleme teoretice ct i direct n practic. De
exemplu, n scrierea unui program pentru un sistem de navigaie este de
dorit s putem calcula drumuri minime foarte rapid.
Vom lucra n cele ce urmeaz cu grafuri neorientate cu N noduri i
M muchii, date prin liste de muchii. Dac nu se specific altfel, programele
prezentate gsesc un drum minim de la nodul 1 la nodul N.
Trebuie precizat c prin drum ne referim la un drum elementar, care
trece cel mult o singur dat prin fiecare nod.
n cadrul acestei seciuni vor fi prezentai urmtorii algoritmi de
determinare a drumurilor minime:
a) Algoritmul Roy-Floyd
b) Algoritmul lui Dijkstra
c) Algoritmul Bellman-Ford
d) Algoritmul lui Dial

404

Teoria grafurilor

a) Algoritmul Roy Floyd


Algoritmul Roy-Floyd (sau Floyd-Warshall) este un algoritm
folosit pentru a gsi distana minim dintre toate perechile de noduri (i, j).
Este un algoritm de programare dinamic. Acesta se aplic asupra matricii
ponderilor prin care se reine graful. Pentru un graf cu N reinut n matricea
G algoritmul este urmtorul:
Pentru fiecare k de la 1 la N execut
o Pentru fiecare i de la 1 la N execut
Pentru fiecare j de la 1 la N execut
G[i][j] = min(G[i][j], G[i][k] + G[k][j])
La sfritul algoritmului, semnificaia lui G va fi G[i][j] = costul
unui drum de cost minim de la i la j. Practic, se iniializeaz drumurile
minime dintre fiecare dou noduri cu costurile muchiei dintre acestea (sau
cu infinit n caz c nu exist muchie) i se ncearc mbuntirea tuturor
drumurilor dintre un nod i i un nod j trecnd printr-un nod intermediar k
(adic de la i la k la j). n caz c drumul care trece prin nodul intermediar k
are un cost mai mic, se reine acest cost. Procedeul poart i numele de
relaxare.
Mai jos este prezentat un graf ponderat mpreun cu matricea
ponderilor nainte de aplicarea algoritmului i dup (dup aplicarea
algoritmului obinem matricea drumurilor de cost minim).

Fig. 12.10.1. Un graf ponderat oarecare


Matricea ponderilor Matricea drumurilor de cost minim
1 2 3 4 5
1
2
3
4
5
0
2
6
1
4
1 0 3 0 1 0
1
2
0
4
1
2
2 3 0 4 1 2
2
6
4
0
5
5
3 0 4 0 0 5
3
1
1
5
0
3
4 1 1 0 0 6
4
4
2
5
3
0
5 0 2 5 6 0
5
405

Capitolul 12
Unde 0 semnific inexistena unei muchii ntre cele dou noduri. n
cadrul implementarii, valorile de 0 care nu se afl pe diagonala principal
vor trebui nlocuite cu infinit.
Complexitatea algoritmului Roy-Floyd este O(N3 ), ceea ce nu l face
aplicabil pe grafuri cu mai mult de cteva sute de noduri. O proprietate
important a acestui algoritm este faptul c poate detecta dac exist cicluri
de cost negativ ntr-un graf. Un ciclu de cost negativ este un drum de la i la
i a crui cost scade cu fiecare parcurgere. De exemplu, graful urmtor
conine un astfel de ciclu:

Fig. 12.10.2. Un ciclu de cost negativ ntr-un graf ponderat


Problema gsirii unui ciclu de cost negativ este echivalent cu
gsirea unui drum de cost mai mic dect 0 de la un nod i la acelai nod i.
Deoarece algoritmul ncearc mbuntirea drumului curent dintre oricare
dou noduri i i j, atunci cnd j = i, dac exist un nod k astfel nct
G[i][k] + G[k][j] < G[i][j] = G[i][i] = 0, atunci exist un ciclu de cost
negativ n graf. Aadar, dac la sfritul algoritmului exist o valoare
negativ pe diagonala principal, graful dat are un ciclu de cost negativ.
Programul urmtor citete un graf dat prin matricea ponderilor i
afieaz matricea drumurilor de cost minim:

406

Teoria grafurilor

#include <fstream>
using namespace std;
const int maxn = 101;
const int inf = 1 << 29;
void citire(int G[maxn][maxn],
int &N)
{
ifstream in("rf.in");
in >> N;
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
{
in >> G[i][j];

void roy_floyd(int G[maxn][maxn], int N)


{
for ( int k = 1; k <= N; ++k )
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
G[i][j]=min(G[i][j],G[i][k]+G[k][j]);
}
int main()
{
int G[maxn][maxn], N;
citire(G, N);
roy_floyd(G, N);
ofstream out("rf.out");
for ( int i = 1; i <= N; ++i )
{
for ( int j = 1; j <= N; ++j )
out << G[i][j] << ' ';
out << '\n';
}
out.close();
return 0;

if ( i != j && !G[i][j] )
G[i][j] = inf;
}
in.close();
}
}

Putem fi interesai de nodurile care alctuiesc un drum de cost


minim dintre dou noduri x i y. Pentru a putea reconstitui un drum dintre
dou noduri, vom porni de la urmtoarele observaii:
1. Deoarece algoritmul caut la fiecare pas un nod intermediar k
pentru a mbunti distana dintre dou noduri i i j rezult c,
dup execuia algoritmului, exist un k astfel nct
G[x][k] + G[k][y] == G[x][y].
2. tiind care este acel k pentru care se verific egalitatea de mai
sus, este clar c drumul de cost minim de la x la y este format din
drumul de la x la k concatenat cu drumul de la k la y. Aadar,
vom folosi o funcie recursiv pentru reconstituirea drumului.
3. Dac acel k care verific egalitatea de mai sus exist doar pentru
k = x sau k = y, este clar c x i y reprezint o muchie a grafului
iniial, deci putem afia pur i simplu acea muchie.
O funcie care afieaz pe ecran drumul minim dintre dou noduri
poate fi implementat n felul urmtor:
407

Capitolul 12
void reconst(int G[maxn][maxn], int N, int x, int y)
{
for ( int k = 1; k <= N; ++k )
if ( G[x][k] + G[k][y] == G[x][y] && k != x && k != y )
{
reconst(G, N, x, k);
reconst(G, N, k, y);
return; // ne intereseaza un singur drum
}
// daca s-a ajuns aici, (x, y) e muchie in graful initial
cout << x << ' ' << y << '\n';
}

Exerciii:
a) Ce se ntmpl dac folosim const int inf = 1 << 30; ?
b) Modificai algoritmul astfel nct s determine dac un graf este
conex (sau tare conex).
c) Memorai, pentru fiecare pereche (i, j) din cadrul algoritmului de
calculare a costurilor minime, nodul k intermediar ales. Scriei o
funcie de reconstituire care folosete aceste informaii pentru a
gsi mai eficient drumurile.
d) Modificai algoritmul de reconstituire astfel nct s afieze
nodurile unui drum n loc de muchiile unui drum.

b) Algoritmul lui Dijkstra


Am prezentat anterior un algoritm care determin n timp O(N3)
distanele minime dintre toate perechile de noduri. n majoritatea
problemelor ns nu ne intereseaz distanele minime dintre toate nodurile,
ci distanele minime de la un singur nod la toate celelalte sau la unul singur.
Aceast problem se mai numete i problema drumurilor minime de
surs unic.
Un algoritm clasic de rezolvare a problemei este algoritmul lui
Dijkstra1, un algoritm greedy care este simplu de neles, de implementat i
care suport o optimizare foarte important. Presupunnd c vrem s
calculm distanele minime de la nodul 1 la toate celelalte noduri ale unui
graf G, algoritmul presupune alegerea la fiecare pas a unui nod min care nu
a mai fost ales pn atunci i pn la care distana minim calculat deja este
minim. Se actualizeaz vecinii i ai lui min i se continu algoritmul pn
1

Matematician olandez, numele su se citete Dai stra

408

Teoria grafurilor
cnd au fost alese toate nodurile. Actualizarea se face verificnd dac
distana pn la min plus costul muchiei de la min la i este mai mic dect
distana pn la i.
Figurile urmtoare prezint modul de execuie al algoritmului pe un
graf oarecare. Cu rou apar distanele minime calculate de ctre algoritm de
la nodul 1 la toate celelalte noduri mpreun cu nodurile i muchiile deja
parcurse, iar cu verde nodul min i vecinii si.

Fig. 12.10.3. Modul de execuie al algoritmului lui Dijkstra


409

Capitolul 12
n implementarea clasic folosim un vector D cu semnificaia
D[i] = distana minim de la nodul 1 la nodul i, un vector V cu semnificaia
V[i] = true dac nodul i a fost deja extras ca minim i false n caz contrar i
un vector P cu semnificaia P[i] = ultimul nod care a mbuntit distana
pn la i. Vectorul P va fi folosit pentru a reconstitui soluia. Programul
urmtor citete un graf dat prin lista de muchii i afieaz un drum de cost
minim de la nodul 1 la nodul N.
#include <fstream>
#include <vector>
#include <utility>
using namespace std;
const int maxn = 101;
const int inf = 1 << 29;

void Dijkstra(vector<PER> G[], int N,


int D[], int P[])
{
bool V[maxn];
for ( int i = 0; i <= N; ++i )
D[i] = inf, P[i] = 0, V[i] = false;
D[1] = 0;

typedef pair<int, int> PER;

for ( int i = 1; i <= N; ++i )


{
int min = 0;

void citire(vector<PER> G[],


int &N, int &M)
{
ifstream in("dijkstra.in");
in >> N >> M;

// aflu nodul min cu D[min] minim


for ( int j = 1; j <= N; ++j )
if ( !V[j] && D[j] < D[min] )
min = j;
V[min] = true;

int x, y, c;
for ( int i = 1; i <= M; ++i )
{
in >> x >> y >> c;
G[x].push_back(make_pair(y,c));
G[y].push_back(make_pair(x,c));
}

// incerc sa relaxez vecinii lui min


vector<PER>::iterator j;
for ( j = G[min].begin();
j != G[min].end(); ++j )
if (D[min]+j->second<D[j->first])
{
D[j->first]=D[min]+j->second;
P[ j->first ] = min;
}

in.close();
}
void drum(int N, int P[maxn],
ofstream &out)
{
if ( !N )
return;

}
}

drum(P[N], P, out);
out << N << ' ';
}

410

Teoria grafurilor
int main()
{
int N, M, D[maxn], P[maxn];
vector<PER> G[maxn];
citire(G, N, M);
Dijkstra(G, N, D, P);
ofstream out("dijkstra.out");
out << D[N] << '\n'; // costul minim
drum(N, P, out); // drumul in sine
out.close();
return 0;
}

n cadrul implementrii am folosit containerul pair din antetul


<utility> n implementarea listelor de adiacen. Acum, o list de adiacen
asociat unui nod x reine toate nodurile adiacente cu x mpreuna cu
costurile muchiilor care le unesc de x. n aa fel obinem o implementare
simpl care folosete numai uneltele puse la dispoziie de ctre limbajul de
programare.
Complexitatea algoritmului este O(N2). Pe grafuri dense (M =
2
O(N )), algoritmul este foarte eficient n forma n care este. Pentru grafuri
rare n schimb (M = O(N)), putem obine un algoritm mult mai performant,
de complexitate O(Mlog N).
Algoritmul de complexitate O(Mlog N) este doar o optimizare a
variantei clasice. Se poate observa c n cadrul implementrii precedente
avem de aflat un minim, iar pentru aflarea acestui minim parcurgem toate
cele N noduri, obinnd n felul acesta timpul de execuie O(N2 ).
Optimizarea pe care o vom face este exact optimizarea care st la baza
algoritmului heapsort i care a fost prezentat n capitolul despre algoritmi
de sortare: vom folosi un heap pentru aflarea minimului. Nu vom insista
asupra acestei implementri deoarece majoritatea funciilor necesare au fost
deja prezentate i vom prezenta oricum o variant mai uor de implementat
a acestei idei. Prezentm schiat implementarea manual a heap-urilor
pentru cititorii interesai:
Se folosete un vector H care reprezint heap-ul i un vector poz
unde poz[i] = poziia nodului i n heap sau -1 n caz c nodul i nu
se afl n heap.
Se introduce nodul 1 n heap.
411

Capitolul 12
Heap-ul va fi un min-heap ordonat dup distanele din vectorul
D.
Ct timp heap-ul este nevid execut
o Se salveaz n min valoarea din rdcina heap-ului (adic
H[1], care reprezint nodul pn la care distana de la
nodul surs este minim i care nu a mai fost selectat
pn acuma) i se terge rdcina. Rdcina se terge
interschimbnd H[1] cu H[k], sczndu-l pe k cu 1 i
aplicnd procedura Downheap lui H[1], unde k
reprezint numrul de elemente din heap.
o Se ncearc relaxarea tuturor vecinilor lui min, ca i n
cadrul implementrii clasice. Dac un vecin i i
mbuntete distana minim D[i], avem dou cazuri:
Dac i se afl deja n heap (poz[i] != -1), este
posibil ca i s trebuiasc s urce n heap. Pentru a
urca un element n heap, vom folosi o funcie
numit Upheap care interschimb un nod x
transmis ca parametru (practic, se transmite
poziia lui x n heap) cu tatl su (tatl
elementului H[x] este H[x / 2]) atta timp ct are
loc inegalitatea:
D[ H[x] ] < D[ H[ x / 2] ].
Atenie la implementare! va trebui actualizat i
vectorul poz dup ce se efectueaz o
interschimbare!
Dac i nu se afl n heap (poz[i] == -1), atunci
trebuie inserat. Se incrementeaz k, se adaug i n
H[k], poz[i] devine k i se apeleaz procedura
Upheap cu poziia nodului nou inserat.
Noul algoritm este mult mai eficient atunci cnd nu avem foarte
multe muchii, dar este dificil de implementat. Din fericire, biblioteca S.T.L.
ne vine n ajutor cu tipul priority_queue, care este de fapt un heap. Acest
container a fost prezentat pe scurt deja. Singurul lucru care ngreuneaz
puin implementarea este faptul c avem nevoie de o modalitate de a ordona
coada de prioriti dup un criteriu dat de noi, deoarece n aceasta vom
reine etichetele nodurilor, iar ordonarea vrem s se fac dup distanele
minime pn la acele noduri. Mai mult, tim c priority_queue se comport
ca un max-heap, iar noi avem nevoie de un min-heap.
Pentru a rezolva aceste probleme, vom insera n coad perechi de
forma (D[nod], nod), folosind utilitarul pair. tim c acest container are
definite relaii de ordine n funcie de prima component (avem nevoie de
412

Teoria grafurilor
greater<pair> pentru priority_queue). Aadar, putem declara un
priority_queue care reine etichetele nodurilor ordonate cresctor dup
distanele pn la nodurile reinute! Implementarea funciei Dijkstra este
foarte simpl:
void Dijkstra(vector<PER> G[], int N, int D[], int P[])
{
for ( int i = 1; i <= N; ++i )
D[i] = inf, P[i] = 0;
D[1] = 0;
priority_queue<PER, vector<PER>, greater<PER> > Q;
Q.push( make_pair(D[1], 1) );
while ( !Q.empty() )
{
PER tmp = Q.top();
Q.pop();
int min = tmp.second;
if ( tmp.first != D[min] )
continue;
vector<PER>::iterator i;
for ( i = G[min].begin(); i != G[min].end(); ++i )
if ( D[min] + i->second < D[ i->first ] )
{
D[ i->first ] = D[min] + i->second;
P[ i->first ] = min;
Q.push( make_pair(D[ i->first ], i->first) );
}
}
}

Trebuie menionat c implementarea manual a heap-urilor este


puin mai eficient dect cea cu priority_queue, deoarece n cadrul celei
din urm se poate insera un nod de mai multe ori n coad, mai exact de
attea ori de cte ori distana pn la acel nod se actualizeaz. Acest lucru
este necesar deoarece inserm perechi de forma (D[nod], nod) pe care nu le
putem actualiza dup inserare, ci trebuie s introducem o nou pereche n
caz c un D[nod] i schimb valoarea. Acest lucru se evit n cadrul
implementrii manuale a heap-urile deoarece reinem poziia nodurilor n
heap, putnd astfel actualiza heap-ul dup modificarea unei distane. Faptul
c un nod poate fi inserat de mai multe ori nu are ns un impact negativ
413

Capitolul 12
semnificativ asupra performanei algoritmului, deoarece verificm dac
perechea extras la pasul curent are distana care trebuie. De multe ori se
prefer aadar aceast implementare.
Dou probleme importante n determinarea drumurilor de cost
minim sunt date de existena muchiilor de cost negativ i a ciclurilor de cost
negativ. Dei acestea nu apar n majoritatea problemelor, exist probleme
modelabile cu grafuri n care apar muchii de cost negativ sau chiar cicluri de
cost negativ. n continuare vom analiza comportamentul algoritmului lui
Dijkstra pe grafuri cu muchii sau cicluri de cost negativ.
Vom considera urmtorul graf, orientat de data aceasta:

Fig. 12.10.4. Un graf orientat cu arce de cost negativ


Dac modificm cele dou implementri s lucreze cu grafuri
orientate, implementarea clasic gsete costul minim ca fiind 7 i drumul
1 2 3 4, iar implementarea cu priority_queue gsete costul minim 6
i drumul 1 2 3 4. Este clar aadar c algoritmul lui Dijkstra, n
varianta clasic, nu funcioneaz corect pe grafuri cu muchii de cost negativ.
S analizm comportamentul implementrii clasice a algoritmului.
Prima dat este ales nodul etichetat cu 1, deoarece D[1] este 0. D[2] devine
D[1] + 4 = 4, iar D[3] devine D[1] + 2 = 2. Urmtorul nod ales este nodul 3,
deoarece D[3] == 2 este minim (V[1] este acum true, deci nu mai poate fi
ales). D[4] devine D[3] + 5 = 7. Urmtorul nod neales cu distana minim
este 2. Deoarece se verific inegalitatea D[2] + (-3) < D[3], adic 1 < 2,
D[3] ia valoarea 1. Urmtorul pas este alegerea nodului 4, care nu
actualizeaz nicio distan. Aadar, distana pn la nodul 4 rmne 7, dei
distana minim corect este 6. Chiar dac n acest caz drumul raportat este
corect, costul acestuia este greit. Acest lucru se datoreaz faptului c
distana pn la nodul 3 a fost actualizat dup ce acest nod a fost selectat.
Deoarece un nod este selectat o singur dat, actualizrile nodului trei nu
mai au ansa de a se propaga la nodurile la care nodul 3 are muchii, adic la
nodul 4 n acest caz.
Putem modifica algoritmul lui Dijkstra astfel nct s funcioneze pe
grafuri cu arce de cost negativ n felul urmtor: dac distana pn la un nod
414

Teoria grafurilor
i se mbuntete la un anumit pas, atunci vom permite ca acel nod s fie
selectat ca minim chiar dac a mai fost deja selectat (adic vom seta
V[i] = false).
n cadrul implementrii clasice, este necesar n primul rnd
modificarea secvenei urmtoare:
for ( j = G[min].begin(); j != G[min].end(); ++j )
if ( D[min] + j->second < D[ j->first ] )
{
D[ j->first ] = D[min] + j->second;
P[ j->first ] = min;
V[ j->first ] = false;
}

i modificarea primului for astfel nct s se execute pn cnd V


conine numai 1. Aceast modificare este lsat ca un exerciiu pentru
cititor.
n cadrul implementrii cu priority_queue nu este necesar nicio
modificare, deoarece un nod se adaug oricum n heap o dat cu actualizarea
distanei acestuia, iar algoritmul nu se termin dect atunci cnd toate
nodurile au fost scoase din heap.
n cazul grafurilor care conin un ciclu de cost negativ (chiar i n
cazul grafurilor orientate care conin arce de cost negativ), algoritmul lui
Dijkstra nu va funciona cum trebuie n formele prezentate, intrnd ntr-un
ciclu infinit.
Cum nu are sens s vorbim de distane minime n cazul grafurilor cu
cicluri de cost negativ, nu se pune problema calculrii distanelor minime n
astfel de grafuri, ci se pune problema raportrii faptului c exist un ciclu de
cost negativ. Tehnicile prezentate n cadrul urmtorului algoritm se pot
aplica i la algoritmul lui Dijkstra.

c) Algoritmul Bellman Ford


Algoritmul Bellman Ford este un algoritm de programare dinamic
folosit pentru a rezolva problema drumurilor minime de surs unic. Spre
deosebire de algoritmul lui Dijkstra, Bellman Ford funcioneaz n mod
natural i atunci cnd exist arce de cost negativ n graful pe care se aplic
algoritmul. Mai mult, n cazul existenei unui ciclu de cost negativ,
Bellman Ford raporteaz existena unui astfel de ciclu.
n cadrul algoritmului lui Dijkstra alegem la fiecare pas nodul pn
la care distana minim calculat deja este cea mai mic i ncercm s
415

Capitolul 12
relaxm distanele minime pn la fiecare vecin al nodului selectat.
Algoritmul Bellman Ford nu se bazeaz pe selectarea unui minim, ci pe
relaxarea tuturor distanelor ntr-o ordine oarecare de N 1 ori (unde N
este numrul de noduri ale grafului). Aadar, se parcurg toate muchiile (x, y)
de N 1 ori i se verific dac putem mbunti distana pn la y folosind
muchia (x, y), adic dac D[x] + C[x][y] < D[y], unde D[i] reprezint
distana minim pn la nodul i, iar C[i][j] reprezint costul arcului sau
muchiei (i, j).
Parcurgerea tuturor muchiilor de N 1 ori permite distanelor
minime s se propage n tot graful, deoarece, n absena ciclurilor de cost
negativ, un drum de cost minim poate vizita un nod cel mult o singur dat.
Spre deosebire de algoritmul lui Dijkstra, un algoritm de tip greedy care
profit de anumite particulariti ale problemei (n acest caz lipsa arcelor de
cost negativ), algoritmul Bellman Ford funcioneaz i pe cazul general.
Figura urmtoare prezint modul de execuie al algoritmului pe un
graf orientat. Pe fiecare desen sunt trecute cu verde iteraia curent
(1 i < N), iar cu rou muchia curent i nodurile incidente acesteia,
mpreun cu distanele minime pn la fiecare nod. Deoarece ordinea
parcurgerii muchiilor este aleatoare, pot exista mai multe soluii.

Fig. 12.10.5. Modul de execuie al


algoritmului Bellman - Ford
416

Teoria grafurilor
Exist mai multe metode de a implementa algoritmul. Metoda
clasic presupune reinerea grafului prin liste de muchii. S presupune c
graful este reinut n lista de muchii E i c tripletul (E[i].x, E[i].y, E[i].c)
este format din nodul surs al muchiei i, nodul destinaie al muchiei i,
respectiv costul muchiei i. Atunci algoritmul poate fi implementat n felul
urmtor:
Pentru fiecare i de la 1 la N 1 execut
o Pentru fiecare j de la 1 la M (numrul de muchii) execut
Dac D[ E[j].x ] + E[j].c < D[ E[j].y ] execut
D[ E[j].y ] = D[ E[j].x ] + E[j].c
P[ E[j].y ] = E[j].x
Dac exist un j, 1 j M, pentru care condiia de mai sus se
verific, atunci exist un ciclu de cost negativ n graf.
La finalul execuiei, vectorul D va conine distanele minime de la
nodul surs la toate celelalte noduri, iar P va fi vectorul de predecesori.
Trebuie fcute aceleai iniializri ca i pentru algoritmul lui Dijkstra.
Complexitatea acestui algoritm este O(NM).
Exist o implementare care, n practic, se dovedete a fi mult mai
eficient dect implementarea corespunztoare pseudocodului de mai sus.
Aceast implementare este foarte similar cu o parcurgere n lime, singura
deosebire fiind c un nod poate fi introdus n coad de N 1 ori. Aadar,
complexitatea teoretic rmne O(NM), dar algoritmul se comport mai
bine n practic. Vom prezenta modul de funcionare al algoritmului pe
exemplul anterior, presupunnd c vom folosi coada Q. Prima dat se
insereaz n coad nodul surs. Am marcat cu albastru nodul extras din
coad la pasul curent i cu rou nodurile introduse n coad la pasul curent
i valorile actualizate. La fiecare pas, se ncearc mbuntirea distanei
minime pn la vecinii nodului extras trecnd prin nodul extras. Un nod nu
va fi introdus n coad dac se afl deja acolo, deoarece, de data aceasta,
natura algoritmului nu necesit acest lucru.
Pasul 1
i 1 2
3
4
Q 1
D 0 inf inf inf
P 0

417

Capitolul 12

i
Q
D
P
i
Q
D
P

Pasul 2
1 2 3 4
1 2 3
0 4 2 inf
0 1 1
Pasul 4
1 2 3
1 2 3
0 4 1
0 1 2

i
Q
D
P

4
4
5
3

i
Q
D
P

Pasul 3
1 2 3 4
1 2 3
0 4 1 inf
0 1 2
Pasul 5
1 2 3
1 2 3
0 4 1
0 1 2

4
4
5
3

Algoritmul se ncheie deoarece nu mai exist elemente n coad


(practic, elementele extrase din coad se vor terge deoarece vom folosi
containerul S.T.L. queue).
Observm c, n cadrul implementrii clasice a algoritmului, pe
exemplul acesta se efectueaz 16 operaii, iar n cazul general se vor efectua
ntotdeauna exact NM operaii. Folosind o coad ns, pe exemplul acesta
am efectuat doar 4 extrageri din coad (timp constant O(1)) i 4 actualizri,
dintre care 3 au fost nsoite de inserri ale unor noduri n coad. Aadar,
putem spune c am efectuat doar 11 operaii (dei am putea s considerm
inserrile ca fiind actualizri).
Pentru a putea verifica existena ciclurilor de cost negativ, este
necesar s reinem pentru fiecare nod de cte ori a fost extras din coad.
Dac am extras un nod de N ori, atunci exist un ciclu de cost negativ.
Folosind aceast implementare, performana algoritmului de drumuri
minime Bellman Ford este, n practic, similar cu cea a algoritmului lui
Dijkstra implementat cu heap-uri. Spre deosebire de algoritmul lui Dijkstra
ns, Bellman Ford este un algoritm care funcioneaz fr modificri i pe
grafuri cu arce de cost negativ, iar implementarea este convenabil, fiind
asemntoare cu implementarea unei parcurgeri n lime.
Mai putem face o optimizare care se dovedete a fi foarte util n
cazul unor anumite grafuri. Aceast optimizare este cunoscut sub numele
de euristica parent checking i are de a face cu urmtoarea situaie: s
presupunem c am extras din coad un nod X i c nodul P[X], care a
actualizat ultima dat distana pn la X, se afl undeva n coad. Atunci nu
are rost s actualizm distanele pn la vecinii lui X, deoarece este clar c
distana pn la X se va mai actualiza prin P[X]. Implementarea acestei
euristici este lsat ca exerciiu pentru cititor.
418

Teoria grafurilor
n literatura de specialitate, algoritmul prezentat apare i sub
denumirile de algoritmul Bellman Ford Moore (mai ales n cadrul
implementrii ce folosete o coad) i algoritmul Bellman Kalaba.
Deoarece ideea care st la baza fiecrei implementri este aceeai, am ales
folosirea denumirii algoritmului clasic pentru fiecare implementare.
n implementarea urmtoare am presupus un format al fiierelor
identic cu cel de la algoritmul lui Dijkstra, cerinele fiind identice i ele.
Prezentm aadar doar funcia de rezolvare efectiv a problemei.
void BellmanFord(vector<PER> G[], int N, int D[], int P[])
{
// V[i] = true daca i se afla in coada si false altfel
bool V[maxn];
for ( int i = 0; i <= N; ++i )
D[i] = inf, P[i] = 0, V[i] = false;
D[1] = 0;
V[1] = true;
queue<int> Q; Q.push(1);
while ( !Q.empty() )
{
int nod = Q.front();
V[nod] = false;
vector<PER>::iterator i;
for ( i = G[nod].begin(); i != G[nod].end(); ++i )
if ( D[nod] + i->second < D[ i->first ] )
{
D[ i->first ] = D[nod] + i->second;
P[ i->first ] = nod;
if ( !V[ i->first ] )
{
Q.push( i->first );
V[ i->first ] = true;
}
}
Q.pop();
}
}

419

Capitolul 12

d) Algoritmul lui Dial


Algoritmul lui Dial este de fapt algoritmul lui Dijkstra implementat
ntr-o manier similar cu algoritmul de sortare prin numrare. De data
aceasta nu o s selectm nici minime i nici nu o s relaxm pe rnd toate
muchiile, ci vom selecta la fiecare pas cst toate nodurile pn la care
distana minim de la nodul surs este cst i vom ncerca s actualizm
distanele pn la vecinii acestora.
De exemplu, s considerm urmtorul graf:

Fig. 12.10.6. Un graf orientat oarecare


Vom reine un vector de Nmaxc cozi (uneori se folosete denumirea
de glei) notat Q cu semnificaia Q[cst] = o list cu nodurile pn la care
distana de la surs este cst, unde maxc reprezint muchia de cost maxim a
grafului (n cazul nostru, N = 4 i maxc = 5). Prima dat inserm n Q[0]
nodul 1. Apoi aplicm urmtorul algoritm:
Pentru fiecare cst de la 0 pn la N maxc 1 execut
o Pentru fiecare element i din Q[cst] execut
Dac D[i] == cst execut (necesar deoarece i
poate s fi fost scos deja dintr-o coad asociat
unui cost mai mic)
Pentru fiecare vecin j al lui i execut
o Dac D[i] + C[i][j] < D[j] execut
... actualizrile clasice...
Se adaug j n Q[ D[j] ]
La fel ca pn acum, D, P i C reprezint vectorul distanelor
minime, vectorul predecesorilor, respectiv matricea costurilor (n
implementare vom folosi evident liste de adiacen).
Tabelul urmtor reprezint modul de execuie al algoritmului pe
graful anterior. Cu albastru apare elementul curent i iar cu rou apar vecinii
acestuia care se adaug ntr-o coad. Fiecare coloan reprezint variabila

420

Teoria grafurilor
cst, iar fiecare linie reprezint nodurile care se afl n fiecare gleat la pasul
respectiv. n practic, se trece i peste gleile goale.
Tabelul 12.10.7. Modul de execuie al algoritmului lui Dial
0 1 2 3 4 5 6 7 8 9 10
19
1
2
3
1
2 3 3
...
1
2 3 3
4
1
2 3 3
4
1
2 3 3
4
Complexitatea algoritmului este O(Nmaxc + M) ca timp i
O(Nmaxc) ca memorie auxiliar. n practic ns algoritmul ruleaz foarte
rapid, deoarece multe din cele Nmaxc cozi vor fi goale.
Memoria auxiliar folosit de algoritm poate fi mbuntit
observnd c nu este necesar s reinem Nmaxc cozi, fiind suficiente
maxc + 1. Mai mult, vom reine un contor cnt care va reprezenta numrul
de elemente din toate cele maxc + 1 cozi i vom opri algoritmul atunci cnd
cnt devine 0. n cadrul acestui algoritm, nu vom mai insera un nod j n
coada Q[ D[j] ] ci n coada Q[ D[j] % (maxc + 1) ]. Pe graful anterior,
modul de execuie al algoritmului este urmtorul:
Tabelul 12.10.8. Modul de execuie al algoritmului lui Dial optimizat
0 1 2 3 4 5
1
2
3
1
2 3 3
1
4 2 3 3
1
4 2 3 3
Se observ c nodul 4, care are D[4] = 8, este inserat n coada Q[2],
deoarece 8 % 6 = 2.
Astfel, memoria auxiliar folosit de ctre algoritm este O(maxc).
Algoritmul se bazeaz pe observaia c, folosind aceast metod, dac la
pasul curent am analizat un nod din coada Q[k], atunci la sfritul pasului
curent cozile Q[k + 1], ..., Q[Cmax], Q[0], ..., Q[k 1] vor conine noduri
cu distane din ce n ce mai mari, deci se pstreaz parcurgerea nodurilor n
ordinea cresctoare a distanelor minime.

421

Capitolul 12
Faptul c vom opri algoritmul atunci cnd toate cozile sunt goale va
face algoritmul mult mai rapid n practic, datorit faptului c nu vor exista
aproape niciodat un numr mare de distane minime distincte.
Implementarea prezentat conine toate optimizrile discutate. Acest
algoritm este indicat a fi folosit n cazurile n care costurile muchiilor sunt
mici sau distanele minime se repet des. Datorit faptului c folosim
indexarea dup distane, este clar c algoritmul lui Dial nu va funciona
corect n cazul existenei distanelor negative. n cazul general, rmne
aadar preferabil algoritmul Bellman Ford sau algoritmul lui Dijkstra
implementat cu heap-uri.
Menionm c n cadrul implementrii prezentate am presupus c
lungimea maxim a unui arc este 1000. Mai mult, am folosit un vector de
1024 de cozi pentru ca operaia modulo s se poat efectua mai eficient cu
ajutorul operaiilor pe bii. Implementarea conine doar funcia relevant.
const int maxc = 1023;
void Dial(vector<PER> G[], int N, int D[], int P[])
{
for ( int i = 0; i <= N; ++i ) D[i] = inf, P[i] = 0;
D[1] = 0;
queue<int> Q[maxc + 1];
Q[0].push(1);
int cnt = 1;
for ( int cst = 0; cnt; ++cst )
for ( ; !Q[cst & maxc].empty(); Q[cst & maxc].pop() )
{
int j = Q[cst & maxc].front();
--cnt;
if ( D[j] != cst )
continue;
vector<PER>::iterator i;
for ( i = G[j].begin(); i != G[j].end(); ++i )
if ( D[j] + i->second < D[ i->first ] )
{
D[ i->first ] = D[j] + i->second;
P[ i->first ] = j;
Q[ D[ i->first ] & maxc ].push( i->first ); // modulo (maxc+1)
++cnt;
}
}
}

422

Teoria grafurilor

12.11. Reele de transport


Un alt capitol important n teoria grafurilor l reprezint reelele de
transport. Acestea pot fi folosite pentru a modela o multitudine de situaii
care apar n diferite domenii, cum ar fi transporturi, telecomunicaii,
reelistic etc. Vom considera c o reea de transport este un graf orientat cu
N noduri i M arce n care fiecare muchie are asociat o anumit capacitate.
Apare aici noiunea de flux, care reprezint o abstractizare pentru cantitatea
de date (n practic, fluxul poate reprezenta de fapt nite obiecte, sau alte
concepte palpabile) care circul de la un nod la altul.
Problemele pe care le vom prezenta vor presupune gsirea fluxului
maxim dintre dou noduri ale unei reele. Nodul surs, considerat de acum
nodul 1, reprezint nodul de la care ncepem trimiterea fluxului, iar nodul
destinaie, considerat de acum nodul N, reprezint nodul la care trebuie s
ajung tot fluxul trimis din nodul 1. Trebuie respectate dou reguli:
1. Fluxul care trece printr-o muchie poate fi cel mult egal cu
capacitatea muchiei
2. Fluxul care intr ntr-un nod trebuie s fie egal cu fluxul care iese
din acel nod, mai puin n cazul nodurilor 1 i N. Altfel spus,
trebuie s se respecte legea lui Kirchoff.
Gsirea fluxului maxim nseamn gsirea cantitii maxime de flux
care poate ajunge la nodul N.
Graful urmtor reprezint o reea de transport. Am marcat cu
albastru capacitatea arcelor i cu rou fluxul trimis pe fiecare arc. Fluxul
maxim n acest graf este 4.

Fig. 12.11.1. O reea de transport oarecare

423

Capitolul 12
n cele ce urmeaz vom prezenta algoritmi de determinare a fluxului
maxim n grafuri ct i probleme care au la baz aceti algoritmi. Mai exact,
vom aborda urmtoarele teme:
a) Algoritmul de flux maxim Edmonds Karp
b) Fluxul maxim de cost minim
c) Cuplajul maximal n graf bipartit

a) Algoritmul de flux maxim Edmonds Karp


Algoritmul Edmonds Karp este folosit pentru determinarea
fluxului maxim ntr-o reea de transport n complexitatea O(NM 2). Acesta
const n gsirea unor drumuri de ameliorare n graful (reeaua de
transport) dat i trimiterea unei cantiti maxime de flux pe aceste drumuri.
Cnd nu se mai poate gsi niciun drum de ameliorare, algoritmul garanteaz
c fluxul trimis deja este maxim posibil.
Pentru a obine o implementare mai uoar, vom folosi matrici
pentru reinerea capacitilor i fluxului existent la un moment dat n reeaua
de transport. Aadar, matricea C va fi o matrice similar cu matricea
ponderilor, doar c va reine de data aceasta capacitatea fiecrui arc.
Matricea F va reprezenta cantitatea de flux trimis la un moment dat pe
fiecare muchie a grafului. Evident, F[x][y] va trebui s fie ntotdeauna cel
mult egal cu C[x][y]. Datorit faptului c algoritmul lucreaz i cu arcele
inversate ale grafului dat, vom reine graful orientat dat ca un graf neorientat
cu ajutorul listelor de adicen.
Gsirea drumurilor de ameliorare se face cu ajutorul mai multor
parcurgeri n lime de la nodul 1 la nodul N. Exist un drum de ameliorare
dac i numai dac se poate ajunge de la nodul 1 la nodul N parcurgnd doar
arce nesaturate, adic arce (x, y) pentru care are loc inegalitatea
F[x][y] < C[x][y]. S presupunem c am gsit un astfel de drum i c tim
pentru fiecare nod x din drum c P[x] este predecesorul su. Urmtorul pas
este s trimitem o cantitate maxim de flux pe acest drum. Cantitatea
maxim de flux care se poate trimite pe un drum de ameliorare este limitat
evident de capacitatea minim a muchiilor de pe acest drum. Aadar, trebuie
s aflm minimul valorii min = C[ P[x] ][x] F[ P[x] ][x] pentru x de la N
la 1 i s trimitem min flux pe toate muchiile drumului de ameliorare curent.
Acest lucru nu este ns suficient.
Am precizat anterior c algoritmul folosete arcele inversate ale
grafului dat. Acest lucru este necesar pentru a putea s ne asigurm c
reeaua nu va fi blocat. De exemplu, s considerm graful de mai jos:
424

Teoria grafurilor

Fig. 12.11.2. O reea de transport cu muchii de capaciti egale


S presupunem c se alege drumul de ameliorare 1 2 4 6.
Atunci la urmtorul pas nu vor mai exista alte drumuri de ameliorare,
deoarece nu vom putea ajunge de la nodul 1 la nodul 6 trecnd prin muchii
nesaturate. Se poate observa uor ns c reeaua de mai sus admite un flux
de valoare 2, dac se aleg drumurile de ameliorare 1 2 5 6 i
1 3 4 6.
Pentru a obine rspunsuri corecte indiferent de cum sunt alese
drumurile de ameliorare, este necesar ca atunci cnd trimitem min flux pe
un arc (P[x], x) s trimitem min flux pe arcul (x, P[x]). Altfel spus, pentru
fiecare x de la N la 1 trebuie efectuate operaiile:
F[ P[x] ][x] += min;
F[x][ P[x] ] -= min;

Efectund aceste operaii ne asigurm c reeaua nu se va bloca,


deoarece va fi posibil ca un drum de ameliorare s parcurg un arc inversat,
avnd efectul scderii cantitii de flux de pe muchia iniial, deblocndu-se
astfel reeaua n cazurile asemntoare cu cel de mai sus.
Figura urmtoare prezint modul de execuie al algoritmului pe
graful anterior. Cu albastru apar capacitile fiecrui arc, cu rou fluxul
curent de pe fiecare arc dat, cu portocaliu fluxul de pe arcele inversate, i
cu verde drumul curent de ameliorare. Arcele inverse au ntotdeauna
capacitatea 0.

Fig. 12.11.3. Modul de execuie al algoritmului Edmonds Karp

425

Capitolul 12
Aadar, cu o simpl scdere ne putem asigura c algoritmul va
funciona pe orice graf. Prezentm mai nti funciile relevante unei prime
implementri clasice. Am presupus c graful este dat prin list de muchii,
reinut ca graf neorientat in G ca pn acum i c C i F sunt matricele
menionate anterior. Funcia Drum gsete drumuri de ameliorare, iar
funcia Flux gsete fluxul maxim n graful G.
bool Drum(vector<int> G[], int N, int C[maxn][maxn],
int F[maxn][maxn], int P[], bool V[])
{
for ( int i = 2; i <= N; ++i )
V[i] = false;
queue<int> Q;
Q.push(1);
while ( !Q.empty() )
{
int nod = Q.front();
vector<int>::iterator i;
for ( i = G[nod].begin(); i != G[nod].end(); ++i )
if ( F[nod][*i] < C[nod][*i] && !V[*i] )
{
P[*i] = nod;
Q.push(*i);
V[*i] = true;
}
Q.pop();
}
return V[N];
}

426

Teoria grafurilor
int Flux(vector<int> G[], int N, int C[maxn][maxn])
{
int F[maxn][maxn], P[maxn];
bool V[maxn]; V[1] = true;
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
F[i][j] = 0;
P[1] = 0;
int flux_total = 0;
while ( Drum(G, N, C, F, P, V) )
{
int min = inf;
for ( int x = N; x != 1; x = P[x] )
if ( C[ P[x] ][x] - F[ P[x] ][x] < min )
min = C[ P[x] ][x] - F[ P[x] ][x];
flux_total += min;
for ( int x = N; x != 1; x = P[x] )
{
F[ P[x] ][x] += min;
F[x][ P[x] ] -= min;
}
}
return flux_total;
}

Dei algoritmul este mai rapid n practic dect ar sugera


complexitatea sa asimptotic, acesta nu este foarte eficient pe grafuri cu mii
de noduri i arce. Putem ns aduce nite optimizri metodei prezentate i
anume:
Dac am extras nodul N din coad, nu are rost s continum cu
verificarea vecinilor acestuia.
Putem reduce numrul de parcurgeri n lime efectuate
procednd n felul urmtor: pentru o parcurgere n lime, vom
considera toate drumurile gsite de aceasta pn la toi vecinii
nodului N. Evident, va exista cel mult un singur drum pentru
fiecare vecin, deoarece lucrm practic cu arborele BFS al
grafului dat. Pentru fiecare dintre aceste drumuri, vom calcula
cantitatea maxim de flux care poate fi trimis la nodul N i o
vom trimite. Este important s observm c trimiterea unei
427

Capitolul 12
cantiti de flux pe un anumit drum poate bloca celelalte
drumuri, aa c, dac min este 0, putem scpa de nc o
parcurgere a N noduri, deoarece nu are rost s trimitem cantitatea
0 de flux.
Prezentm doar secvenele de cod care se modific:
// ...
while ( !Q.empty() )
{
int nod = Q.front();
Q.pop();
if ( nod == N ) continue;
vector<int>::iterator i;
for ( i = G[nod].begin(); i != G[nod].end(); ++i )
if ( F[nod][*i] < C[nod][*i] && !V[*i] )
{
P[*i] = nod;
Q.push(*i);
V[*i] = 1;
}
}
// ...
while ( Drum(G, N, C, F, P, V) )
{
vector<int>::iterator i;
for ( i = G[N].begin(); i != G[N].end(); ++i )
if ( F[*i][N] < C[*i][N] && V[*i] )
{
P[N] = *i;
int min = inf;
for ( int x = N; x != 1; x = P[x] )
if ( C[ P[x] ][x] - F[ P[x] ][x] < min )
min = C[ P[x] ][x] - F[ P[x] ][x];
if ( !min )
continue;
flux_total += min;
for ( int x = N; x != 1; x = P[x] )
{
F[ P[x] ][x] += min;
F[x][ P[x] ] -= min;
}
}
}

428

Teoria grafurilor

b) Flux maxim de cost minim


n multe probleme cu flux poate s apar necesitatea de a gsi o
modalitate de distribuie a fluxului astfel nct costul total al distribuiei s
fie minim. Vom considera c avem o reea de transport n care fiecare arc
are asociat att o capacitate ct i un cost per unitate de flux. Ne intereseaz
s gsim un flux maxim de cost minim n aceast reea.
De exemplu, figura urmtoare reprezint o reea de transport
ponderat n care cu albastru apar capacitile i cu verde costurile. Fluxul
maxim este 1, iar costul minim este 8.

Fig. 12.11.4. O reea de transport ponderat


Problema se poate rezolva n mai multe moduri. Varianta clasic
presupune nlocuirea parcurgerii n lime cu algoritmul Bellman Ford,
care funcioneaz i n cazul existenei arcelor de cost negativ. Ne
intereseaz un algoritm care funcioneaz i dac exist arce de cost negativ
deoarece este necesar s considerm costurile arcelor inversate ca fiind
opusele arcelor date.
Dup ce am gsit un drum de ameliorare, datorit faptului c acesta a
fost gsit cu ajutorul unui algoritm de drumuri minime, putem fi siguri c
este un drum de cost minim (de la nodul 1 la nodul N). Fie min cantitatea
maxim de flux care poate fi trimis pe acest drum. Costul trimiterii
cantitii min de flux este minD[N], unde D reprezint vectorul distanelor.
Complexitatea acestui algoritm este O(N2M2), dar este din nou o
supraestimare, deoarece algoritmul Bellman Ford suport multe optimizri
i este eficient n practic.
Putem obine ns complexitatea O(NM2log N) folosind
algoritmul lui Dijkstra. Avem dou posibiliti. Fie folosim o
implementare a algoritmului care funcioneaz i n cazul existenei arcelor
de cost negativ, fie transformm graful dat n aa fel nct s nu existe arce
de cost negativ.
Pentru a transforma graful, este necesar s rulm mai nti algoritmul
Bellman Ford, care funcioneaz i dac exist arce de cost negativ, pentru
429

Capitolul 12
a calcula vectorul distanelor D. Dup ce avem calculat vectorul D, costul
fiecrui arc (x, y), de cost iniial c, va fi nlocuit cu valoarea
c + D[x] D[y]. Aceast valoare este pozitiv, aa cum vom arta n
continuare. S presupunem c c + D[x] D[y] < 0. Asta ar nsemna c
c + D[x] < D[y], contrazicndu-se astfel minimalitatea valorilor din D. Mai
mult, costurile difer printr-o constant, deci rezultatul nu va fi afectat de
aceast transformare. Putem acum aplica algoritmul lui Dijkstra n forma sa
clasic pentru determinarea drumurilor de ameliorare.
Implementarea prezentat conine doar funciile relevante. Am ales
algoritmul lui Dijkstra implementat cu priority_queue. Acest algoritm
funcioneaz i dac se face transformarea precizat mai sus i dac nu. Am
introdus o nou matrice numit CS care reine costurile arcelor. Funcia
Flux returneaz costul minim al unui flux maxim.
Restul implementrilor menionate sunt lsate ca exerciiu.
int Dijkstra(vector<int> G[], int N, int C[maxn][maxn],
int F[maxn][maxn], int CS[maxn][maxn], int P[], int D[])
{
for ( int i = 1; i <= N; ++i ) D[i] = inf;
D[1] = 0;
priority_queue<PER, vector<PER>, greater<PER> > Q;
Q.push( make_pair(D[1], 1) );
while ( !Q.empty() )
{
PER tmp = Q.top();
Q.pop();
int min = tmp.second;
if ( tmp.first != D[min] ) continue;
vector<int>::iterator i;
for ( i = G[min].begin(); i != G[min].end(); ++i )
if ( D[min] + CS[min][*i] < D[*i] &&
C[min][*i] - F[min][*i] > 0 )
{
D[*i] = D[min] + CS[min][*i];
P[*i] = min;
Q.push( make_pair(D[*i], *i) );
}
}
return D[N];
}

430

Teoria grafurilor
int Flux(vector<int> G[], int N, int C[maxn][maxn], int CS[maxn][maxn])
{
int F[maxn][maxn], P[maxn], D[maxn];
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
F[i][j] = 0;
P[1] = 0;
int cst_min = 0, tmp_cst;
while ( true )
{
tmp_cst = Dijkstra(G, N, C, F, CS, P, D);
if ( tmp_cst == inf )
break;
int min = inf;
for ( int x = N; x != 1; x = P[x] )
if ( C[ P[x] ][x] - F[ P[x] ][x] < min )
min = C[ P[x] ][x] - F[ P[x] ][x];
for ( int x = N; x != 1; x = P[x] )
{
F[ P[x] ][x] += min;
F[x][ P[x] ] -= min;
}
cst_min += tmp_cst * min;
}
return cst_min;
}

c) Cuplaj maximal n graf bipartit


O aplicaie important a reelelor de transport o reprezint
problemele de cuplaj. De exemplu, s presupunem c avem N angajai
(numerotai de la 1 la N) i M sarcini (numerotate de la 1 la M). tim pentru
fiecare angajat ce sarcini este capabil s efectueze. Ne intereseaz s
atribuim sarcini angajailor astfel nct s fie rezolvate un numr maxim de
sarcini. Unui angajat poate s i se atribuie cel mult o sarcin, iar o sarcin
poate fi atribuit cel mult unui singur angajat.
431

Capitolul 12
Putem modela problema cu ajutorul unui graf bipartit, n care
mulimile de noduri sunt st i dr, reprezentnd angajaii respectiv sarcinile
existente. De exemplu, graful urmtor reprezint un posibil set de date de
intrare. Am marcat cu rou muchiile unui cuplaj maximal.

Fig. 12.11.5. Un cuplaj maximal ntr-un graf bipartit


Cardinalitatea unui cuplaj este dat de numrul de muchii existente,
n acest caz 4. Un cuplaj este maximal dac cardinalitatea sa este maxim.
Pentru a rezolva problema vom folosi algoritmii de flux maxim
prezentai anterior. Vom considera c fiecare muchie a grafului bipartit dat
are capacitatea 1. Vom considera toi angajaii ca fiind surse i toate
sarcinile ca fiind destinaii. Deoarece fluxul maxim n aceast reea va satura
un numr maxim de muchii, este clar c fluxul maxim reprezint
cardinalitatea cuplajului maximal, iar muchiile saturate vor reprezenta
muchiile cuplajului maximal.
Trebuie discutate cteva detalii de implementare. Dac graful se d
aa cum sugereaz imaginea de mai sus, adic se dau N, M cu semnificaia
anterioar i E care reprezint numrul de muchii ale grafului, iar apoi E
perechi (x, y) semnificnd faptul c angajatul x poate efectua sarcina y,
atunci trebuie efectuate nite transformri nainte de aplicarea algoritmului
clasic de flux, deoarece trebuie s putem distinge ntre nodul x care
reprezint angajatul x i care reprezint sarcina x. Mai mult, este
inconvenabil s lucrm cu mai multe surse i destinaii, aa c vom aduga
alte dou noduri n graf, o supersurs i o superdestinaie. Supersursa va fi
conectat de fiecare angajat printr-o muchie de capacitate 1, iar
superdestinaia de fiecare sarcin printr-o muchie de capacitate 1. Vom
432

Teoria grafurilor
considera nodul 0 ca fiind supersursa i nodul N + M + 1 ca fiind
superdestinaia. Aceste noduri sigur nu vor face parte din graful iniial,
deoarece acesta are N + M noduri, numerotate de la 1 la N i de la 1 la M.
Pentru rezolvarea problemei n care dou noduri distincte au aceeai
etichet, vom renumerota nodurile. Astfel, nodurile care reprezint angajaii
vor fi numerotate de la 1 la N, iar nodurile care reprezint sarcinile vor fi
numerotate de la N + 1 la N + M. Practic, cnd citim o muchie (x, y), vom
aduga muchia (x, N + y). Problema se reduce aadar la gsirea fluxului
maxim de la nodul 0 la nodul N + M + 1. Modificarea algoritmilor de flux
maxim prezentai pentru a funciona pe grafuri neorientate nu prezint
dificulti prea mari, aa c este lsat ca exerciiu pentru cititor.
Graful de mai jos reprezint transformarea grafului iniial conform
indicaiilor anterioare. Fiecare muchie are capacitatea 1, iar muchiile
saturate apar colorate. Evident, din cuplajul maximal fac parte doar muchiile
saturate neincidente cu supersursa sau superdestinaia.

Fig. 12.11.6. Un graf bipartit transformat ntr-o reea de transport


Doarece cuplajul n graf bipartit este o particularizare a problemei de
flux maxim, exist un algoritm mai eficient dect metoda general i care
este i uor de implementat: algoritmul Hopcroft Karp. Acesta
determina un cuplaj maximal n timp O(E + ).
Algoritmul Hopcroft Karp are la baz parcurgeri n adncime ale
grafului dat. Nu sunt necesare transformrile prezentate. Vom lucra cu
graful iniial i vom considera c acesta este orientat, direcia arcelor fiind
de la mulimea angajailor la mulimea sarcinilor. Vom ine trei vectori st,
dr i V, unde st[i] = angajatul care execut sarcina i i 0 dac nu exist,
dr[i] = sarcina atribuit angajatului i i 0 dac nu exist i V[i] = true
dac nodul i din mulimea angajailor a fost vizitat la pasul curent.
433

Capitolul 12
Vom exemplifica algoritmul pe urmtorul graf:

Fig. 12.11.7. Un graf bipartit orientat


Parcurgem n ordine nodurile din mulimea din stnga, n cazul nostu
din mulimea angajailor. Cnd dm peste un nod i necuplat (dr[i] = 0),
apelm o funcie Cuplare(i) care ncearc s cupleze acest nod. Se apeleaz
funcia pentru nodul i = 1. Se marcheaz nodul 1 ca fiind vizitat
(V[i] = true) i se parcurg n ordine toi vecinii si v. Primul su vecin este
nodul care reprezint sarcina 1, adic v = 1. Se verific dac acesta este
cuplat cu cineva, iar dac nu este (st[v] = 0), se cupleaz cu nodul i, adic
st[v] = i i dr[i] = v. Dac am putut cupla un nod cu nodul i, atunci funcia
returneaz true.
Se trece la nodul 2. Se marcheaz ca fiind vizitat i se verific
vecinii si pentru a ncerca s gsim un cuplaj pentru nodul 2. Singurul
vecin al nodului i = 2 este nodul v = 1, dar acesta este cuplat cu nodul 1 din
stnga, aa c nu putem pur i simplu s l cuplm cu nodul 2 din stnga.
Vom apela recursiv aceeai funcie pentru nodul cu care este cuplat nodul 1
din dreapta. Ideea este c, dac putem gsi un alt nod pe care s-l grupm cu
nodul 1 din stnga, atunci vom putea cupla nodul 1 din dreapta cu nodul 2
din stnga, cardinalitatea cuplajului crescnd. Vom apela aadar recursiv
funcia Cuplare avnd ca parametrul nodul 1 din stnga. La acest pas,
V[1] = true, aa c funcia va ntoarce false. La revenire din recursivitate se
verific valoarea ntoars de funcie: dac ar fi true, ar nsemna c am putea
realiza cuplajul nodului 2 din stnga cu nodul 1 din dreapta, deoarece fostul
nod cuplat cu nodul 1 din dreapta a fost recuplat. Dar, deoarece valoarea
ntoars de funcie este false, acest lucru nu este posibil, cel puin nu la acest
pas.
Se trece la nodul 3, care se va cupla fr probleme cu nodul 3 din
dreapta. S-a ncheiat prima iteraie a algoritmului, iar cuplajul curent este
urmtorul:

434

Teoria grafurilor

Fig. 12.11.8. Rezultatele primei iteraii a


algoritmului Hopcroft Karp
Deoarece s-au efectuat cuplaje noi la iteraia trecut, se reseteaz
vectorul V i se reia algoritmul, n sperana c se va gsi un cuplaj mai bun
de aceast dat. Nodul 1 este deja cuplat, aa c nu se va apela funcia
Cuplare pentru acesta. Nodul 2 nu este cuplat, aa c se apeleaz
Cuplare(2). Singurul vecin al lui 2 este 1, care este cuplat cu nodul 1 din
stnga. Apelm recursiv Cuplare(1) n sperana c vom gsi un alt cuplaj
pentru nodul 1, elibernd nodul 1 din dreapta pentru a fi cuplat cu nodul 2.
Se gsete nodul 2 din dreapta n cadrul apelului recursiv, aa c nodul 1 din
stnga se recupleaz cu 2, iar la revenire din recursivitate, deoarece apelul
recursiv a returnat true de aceast dat, nodul 2 din stnga se cupleaz cu 1.
Nodul 3 este deja cuplat, aa c nu se efectueaz niciun apel al
funciei de cuplare. Se trece la urmtoarea iteraie, care nu va genera noi
cuplaje, aa c algoritmul se ncheie. Cuplajul maximal este dat de toate
perechile (i, dr[i]), pentru i de la 1 la N. Cuplajul maximal este urmtorul:

Fig. 12.11.9. Rezultatul final al algoritmului Hopcroft Karp


Implementarea algoritmului este una intuitiv, necesit mai puin
cod dect metoda general i funcioneaz rapid pe grafuri cu zeci de mii de
noduri. n implementare am presupus c se dau E perechi de noduri (x, y)
care semnific faptul c angajatul x poate efectua sarcina y.
435

Capitolul 12
#include <fstream>
#include <vector>
using namespace std;
const int maxn = 101;

bool Cuplare(vector<int> G[], int i,


int st[], int dr[],
bool V[])
{
if ( V[i] )
return false;
V[i] = true;

void citire(vector<int> G[],


int &N, int &M)
{
int E;
ifstream in("cuplaj.in");
in >> N >> M >> E;

vector<int>::iterator v;
for ( v = G[i].begin();
v != G[i].end(); ++v )
if ( !st[*v] )
{
st[*v] = i;
dr[i] = *v;
return true;
}

int x, y;
for ( int i = 1; i <= E; ++i )
{
in >> x >> y;
G[x].push_back(y);
}

for ( v = G[i].begin();
v != G[i].end(); ++v )
if ( Cuplare(G, st[*v], st, dr, V) )
{
st[*v] = i;
dr[i] = *v;
return true;
}

in.close();
}

return false;
}

436

Teoria grafurilor
void HopcroftKarp(vector<int> G[], int N)
{
int st[maxn], dr[maxn];
bool V[maxn], ok = true;
for ( int i = 1; i < maxn; ++i )
st[i] = dr[i] = 0;
do
{
ok = false;
for ( int i = 1; i <= N; ++i )
V[i] = false;
for ( int i = 1; i <= N; ++i )
if ( !dr[i] )
ok |= Cuplare(G, i, st, dr, V);
} while ( ok );
ofstream out("cuplaj.out");
for ( int i = 1; i <= N; ++i )
if ( dr[i] )
out << i << ' ' << dr[i] << '\n';
out.close();
}

int main()
{
int N, M;
vector<int> G[maxn];
citire(G, N, M);
HopcroftKarp(G, N);
return 0;
}

Problema cuplajului maximal n graf bipartit se poate extinde. De


exemplu, un angajat poate s cear o anumit sum de bani pentru a efectua
o sarcin. n acest caz, problema se transform ntr-o problem de flux
maxim de cost minim, a crei rezolvare am prezentat-o deja. Pentru cei
interesai, un alt algoritm de rezolvare a problemei cuplajului maximal de
cost minim este algoritmul ungar.
437

Capitolul 12

12.12. Arbore parial de cost minim


Considerm un graf neorientat, conex i ponderat G cu N noduri i
M muchii. Se numete arbore parial al grafului G un arbore care conine
toate nodurile lui G i N 1 muchii din G. Se numete arbore parial de
cost minim (A.P.M.) al lui G un arbore parial pentru care suma ponderilor
muchiilor sale este minim.
Gsirea arborelui parial de cost minim al unui graf are aplicaii n
diverse probleme. De exemplu, putem dori s eliminm legturi redundante
dintre nodurile unei reele, pstrnd un numr minim de legturi care nu
deconecteaz reeaua i al cror cost este minim.
De exemplu, arborele parial de cost minim al grafului de mai jos
este marcat cu rou.

Fig. 12.12.1. Arborele parial de cost minim al unui graf oarecare


n cele ce urmeaz vom prezenta doi algoritmi de determinare a unui
arbore parial de cost minim: algoritmul lui Kruskal i algoritmul lui
Prim. Acetia au complexiti diferite, iar alegerea dintre ei trebuie fcut n
funcie de natura problemei la care se dorete aplicarea unui algoritm pentru
determinarea A.P.M..
n implementrile oferite, am presupus c lucrm cu grafuri
ponderate cu N noduri i M muchii, citite dintr-un fiier prin lista muchiilor.

438

Teoria grafurilor

a) Algoritmul lui Kruskal


Algoritmul lui Kruskal este un algoritm de tip greedy care rezolv
problema determinrii unui A.P.M. n timp O(Ma(N) + Mlog M). Funcia
a reprezint inversa funciei Ackermann i crete foarte ncet, valoarea sa
putnd fi considerat o constant pentru toate valorile practice ale lui N.
Aadar, complexitatea este foarte apropiat de O(Mlog M). Memoria
folosit de algoritm este O(N + M). Algoritmul n pseudocod este
urmtorul:
Se sorteaz lista E a muchiilor dup ponderile acestora
Pentru fiecare i de la 1 la M execut
o Dac adugarea muchiei i n A.P.M. nu duce la formarea
unui ciclu, se adaug muchia i n A.P.M.
La finalul algoritmului se obine un arbore parial de cost minim.
Se pune problema determinrii dac adaugarea unei muchii n
A.P.M. duce la formarea unui ciclu sau nu. Un arbore parial poate fi privit
ca o reuniune de subgrafuri ale lui G care sunt la rndul lor arbori. Vom
folosi un vector de tai T, care ne va ajuta s codificm aceti arbori. Iniial
T[i] = i pentru fiecare i de la 1 la N. Cu alte cuvinte, iniial fiecare nod este
rdcina unui arbore format doar din acel nod. Mai mult, este clar c o
muchie (x, y) nu va forma un ciclu dect dac x i y fac parte din acelai
arbore. Fie Find(x) o funcie care returneaz rdcina arborelui din care face
parte nodul x. Cnd analizm o muchie (x, y), o vom aduga n A.P.M. dac
i numai dac Find(x) este diferit de Find(y). Funcia Find(x) poate fi
implementat recursiv astfel:
Dac T[x] == x returneaz x
Returneaz Find( T[x] )
Mai mult, atunci cnd adugm o muchie (x, y), reunim practic
arborele din care face parte x cu arborele din care face parte y. Deoarece un
arbore este identificat n mod unic prin rdcina sa, este de ajuns s unim
rdcinile arborilor lui x i y. Acest lucru l vom face cu ajutorul unei funcii
Merge(x, y) care fie seteaz T[x] = y fie T[y] = x, adic unul dintre arbori
devine subarbore al celuilalt, formndu-se astfel un singur arbore. Cnd se
adaug muchia (x, y), trebuie efectuat apelul Merge( Find(x), Find(y) ).
Pentru a obine ns timpul de execuie menionat este nevoie de
dou optimizri care se complementeaz reciproc:

439

Capitolul 12
1. Optimizarea reuniunii dup rang. Aceast optimizare const
n meninerea unui vector R cu semnificaia R[i] = rangul
(nlimea) arborelui cu rdcina n i. Acum, cnd reunim doi
arbori identificai prin rdcinile x i y, vom subordona arborele
cu nlime mai mic celui cu nlime mai mare, iar nlimea
ambilor arbori va rmne neschimbat. Dac nlimile celor doi
arbori sunt egale, atunci nu conteaz cum i subordonm, dar este
important s incrementm rangul pentru cel care i pstreaz
calitatea de arbore, deoarece nlimea noului arbore va crete cu
1.
2. Optimizarea comprimrii drumurilor. Pn acum, n cadrul
apelului Find(x), parcurgem arborele de la nodul x n sus pn
cnd ajungem la rdcin, trecnd prin mai multe noduri
intermediare, care nu ne intereseaz. Deoarece nodurile
intermediare nu prezint interes, putem s mai parcurgem o dat
arborele dinspre x spre rdcin i s unim toate nodurile
intermediare (inclusiv pe x) direct de rdcin. Dei acest lucru
afecteaz nlimea arborilor, nu vom actualiza vectorul R. Cu
aceast optimizare, la urmtoarea parcurgere a arborelui, vom
gsi rdcina unui nod ntr-un singur pas. Optimizarea aceasta se
poate implementa uor folosind recursivitate.
Programul urmtor afieaz costul i muchiile care formeaz
arborele parial de cost minim al unui graf citit.
#include <fstream>
#include <vector>
#include <algorithm>
using namespace std;
const int maxn = 101;
const int maxm = 201;
struct muchie { int x, y, c; };
void citire(muchie E[], int &N, int &M)
{
ifstream in("kruskal.in");
in >> N >> M;
for ( int i = 1; i <= M; ++i )
in >> E[i].x >> E[i].y >> E[i].c;
in.close();
}

440

Teoria grafurilor
bool operator<(const muchie &x,
const muchie &y)
{
return x.c < y.c;
}

void Kruskal(muchie E[], int N, int M)


{
int R[maxn], T[maxn], k = 0, cost = 0;
// APM va avea intotdeauna N - 1 noduri.
muchie APM[maxn - 1];

int Find(int x, int T[])


{
if ( T[x] == x )
return x;
T[x] = Find(T[x], T);

for ( int i = 1; i <= N; ++i )


T[i] = i, R[i] = 0;
sort(E + 1, E + M + 1);
for ( int i = 1; i <= M; ++i )
if ( Find(E[i].x, T) != Find(E[i].y, T) )
{
cost += E[i].c;
APM[++k] = E[i];

return T[x];
}
void Merge(int x, int y,
int T[], int R[])
{
if ( R[x] == R[y] )
{
T[y] = x;
++R[x];
}
else if ( R[x] < R[y] )
T[x] = y;
else
T[y] = x;
}

Merge(Find(E[i].x, T),
Find(E[i].y, T), T, R);
}
ofstream out("kruskal.out");
out << cost << '\n';
for ( int i = 1; i < N; ++i )
out << APM[i].x << ' ' <<
APM[i].y << '\n';
out.close();
}
int main()
{
int N, M;
muchie E[maxm];
citire(E, N, M);
Kruskal(E, N, M);
return 0;
}

441

Capitolul 12

b) Algoritmul lui Prim


Algoritmul lui Prim este un alt algoritm de tip greedy folosit pentru
determinarea arborelui parial de cost minim. Acesta are complexitile
O(N2) i O(Mlog N), n funcie de implementarea folosit. Vom prezenta
pe larg doar varianta de implementare n O(N2), deoarece aceasta este
preferabil celorlali algoritmi atunci cnd avem de a face cu un graf dens,
iar varianta n complexitate O(Mlog N) difer de complexitatea
algoritmului lui Kruskal doar printr-o constant i n plus algoritmul lui
Kruskal este mai uor de implementat.
Pentru a nelege algoritmul lui Prim, vom presupune situaia: avem
deja T < N 1 noduri care fac parte din arborele parial de cost minim i
vrem s introducem un nou nod n arbore. Procednd conform paradigmei
algoritmice greedy, la fel ca i la algoritmul lui Kruskal, vom aduga n
arbore nodul care se leag de subarborele curent (cel cu T noduri) prin
muchia de cost minim nc neselectat. O prim idee de implementare ar fi
s parcurgem pentru fiecare nod care se afl la un moment dat n A.P.M.
toate muchiile i s alegem muchia neselectat care are costul minim. Acest
algoritm are ns complexitatea O(NM), adic O(N3) pe cel mai defavorabil
caz.
Putem obine complexitatea O(N2) reinnd pentru fiecare nod care
nc nu face parte din arbore muchia de cost minim care l leag de
subarborele curent. Vom folosi aadar doi vectori D i vec unde
D[i] = costul muchiei de cost minim care l leag pe i de subarborele
existent pn la acest moment, iar vec[i] = nodul din subarborele curent de
care se leag i printr-o muchie de cost minim sau 0 dac nodul i face deja
parte din arbore. La nceput, D se iniializeaz cu infinit, iar vec se
iniializeaz cu 1, semnificnd faptul c niciun nod nu face nc parte din
arbore.
Trebuie ales la nceput un nod pe care s-l introducem n arbore
pentru ca algoritmul prezentat s poat fi apoi aplicat. Deoarece n final
toate nodurile vor face parte din A.P.M. nu conteaz ce nod alegem; vom
alege pentru simplitate nodul 1. Vom iniializa D[j] pentru toi vecinii j ai
nodului 1 cu costul muchiei (1, j).
La fiecare pas, algoritmul gsete n timp O(N) acel nod j care nu
face nc parte de arbore i pentru care D[j] este minim. Fie min acest nod.
Se adun D[min] la costul total al arborelui, se adaug muchia
442

Teoria grafurilor
(min, vec[min]) n arbore i vec[min] devin 0, deoarece l-am adugat pe
min n arbore. Mai trebuie actualizate muchiile de cost minim care leag
restul nodurilor de noul arbore. Evident, singurele noduri care pot cauza
schimbri n vectorii D i vec sunt vecinii nodului min. Parcurgem aadar
vecinii nodului min, iar dac dm de un vecin j care nu face nc parte din
arbore i D[j] este mai mare dect costul muchiei (min, j) atunci actualizm
corespunztor D[j] i vec[j].
Deoarece la fiecare pas se determin o nou muchie a arborelui,
avem nevoie de N 1 pai. De aici rezult aadar complexitatea O(N2).
Implementarea de complexitate O(Mlog N) este foarte similar cu
algoritmul lui Dijkstra implementat cu ajutorul heap-urilor. Vom folosi un
heap ordonat dup costul muchiei care leag fiecare nod care nc nu se afl
n arbore de un nod din arbore. La fiecare introducere a unui nod n arbore
este necesar actualizarea vecinilor n heap. Lsm aceast implementare ca
un exerciiu pentru cititor. De obicei, n practic algoritmul lui Kruskal se
comport similar cu aceast implementare. Avantajul principal al
algoritmului lui Prim implementat cu heap-uri este acela c memoria
suplimentar folosit este doar O(N) i nu O(M) ca n cazul algoritmului lui
Kruskal.
Figura 12.12.2. reprezint modul de execuie al algoritmului pe un
graf oarecare. Am marcat cu verde nodurile i i muchiile care fac parte din
A.P.M (vec[i] = 0) i cu rou muchiile de cost minim care leag restul
nodurilor i de subarborele curent (vec[i]). Apare ngroat muchia de cost
minim care va fi aleas la pasul urmtor (D[i] minim).
Implementarea propus reine graful prin liste de adiacen.
Abordarea i structurile folosite se regsesc i n cadrul algoritmilor de
drumuri minime prezentai. Am folosit cu aceast ocazie i noiunea de
prototip al unei funciei. n aceste cazuri folosirea unui prototip nu are
dect un scop strict didactic i stilistic, permindu-ne s scriem
implementarea funciei prim dup funcia main.

443

Capitolul 12

Fig. 12.12.2. Modul de execuie al algoritmului lui Prim


#include <fstream>
#include <vector>
#include <utility>
using namespace std;
const int maxn = 101, inf = 1 << 30;
typedef pair<int, int> PER;
int prim(vector<PER>[], int, PER[]);

444

Teoria grafurilor
void citire(vector<PER> G[],
int &N, int &M)
{
ifstream in("prim.in");
in >> N >> M;
int x, y, c;
for ( int i = 1; i <= M; ++i )
{
in >> x >> y >> c;
G[x].push_back(make_pair(y, c));
G[y].push_back(make_pair(x, c));
}
in.close();
}
int main()
{
int N, M;
vector<PER> G[maxn];
citire(G, N, M);

int prim(vector<PER> G[], int N,


PER APM[])
{
int D[maxn], vec[maxn], k = 0;
for ( int i = 1; i <= N; ++i )
D[i] = inf, vec[i] = 1;
vec[1] = 0;
vector<PER>::iterator j;
for ( j = G[1].begin();
j != G[1].end(); ++j )
D[ j->first ] = j->second;
int cost = 0, min = 1;
for ( int i = 1; i < N; ++i )
{
min = 1;
for ( int j = 2; j <= N; ++j )
if ( vec[j] && D[j] < D[min] )
min = j;
cost += D[min];
APM[++k] = make_pair(min,
vec[min]);
vec[min] = 0;
for ( j = G[min].begin();
j != G[min].end(); ++j )
if ( vec[j->first] &&
D[j->first] > j->second )
{
D[ j->first ] = j->second;
vec[ j->first ] = min;
}

PER APM[maxn - 1];


ofstream out("prim.out");
out << prim(G, N, APM) << '\n';
for ( int i = 1; i < N ; ++i )
out << APM[i].first << ' ' <<
APM[i].second << '\n';
out.close();

}
return cost;

return 0;
}

12.13. Concluzii
Am prezentat n acest capitol noiunile elementare care stau la baza
algoritmilor de grafuri. Cititorii experimentai poate c au observat lipsa
abordrii unor teme legate de arbori, cum ar fi determinarea L.C.A. sau cum
ar fi arborii binari de cutare. Aceste teme vor fi abordate n cadrul
capitolului Structuri avansate de date.
445

Capitolul 12
Cu ajutorul celor prezentate n acest capitol poate fi rezolvat o
gam larg de probleme. Propunem spre rezolvare urmtoarele probleme:
1. Dac nu avem de gnd sa prelucrm arborele parial de cost
minim, putem doar s-l afim muchie cu muchie pe msur ce l
determinm. Modificai algoritmii prezentai n acest scop.
2. Scriei programe care compar performanele algoritmilor
prezentai de-a lungul capitolului. Evident, comparai doar
algoritmii care rezolv aceeai problem.
3. Considerm toate numerele naturale de cel mult 4 cifre. Se dau T
perechi de numere prime de cel mult 4 cifre. S se ajung de la
primul numr prim la cel de-al doilea printr-un numr minim de
pai, tiind c un pas const din adugarea, tergerea sau
modificarea unei cifre.
4. Scriei un program care determin numrul de drumuri minime.
5. Scriei un program care determin dac se pot asocia costuri unui
graf astfel nct o matrice a drumurilor dat s fie valid.
6. Se d un graf neorientat ponderat. Scriei un program care
determin valoarea minim min astfel nct s existe un drum de
la nodul 1 la nodul N care s conin doar muchii cu ponderi cel
mult egale cu min.
7. Scriei un program care genereaz aleator grafuri conexe cu N
noduri i M muchii. Analog pentru grafuri bipartite.
8. Scriei un program care determin drumul elementar de cost
minim de la nodul 1 la nodul N i napoi.
9. Se d un graf ponderat. Se cere un drum de la nodul 1 la nodul
N. Se tie c unele noduri sunt blocate, adic nu se poate trece
prin ele. Ne intereseaz ca minimul distanelor dintre nodurile
drumului cerut i orice nod blocat s fie ct mai mare.
10. Se d o matrice ptratic de ordin N. S se determine drumul
minim de la (1, 1) la (N, N), tiind c ne putem deplasa doar n
sus, n jos, n stnga i n dreapta.
11. Se d un graf ponderat n care fiecare muchie are asociat o
culoare. S se determine un drum de cost minim n acest graf,
fr a se parcurge dou muchii de aceeai culoare una dup alta.
Alte exerciii ar fi ncorporarea algoritmilor prezentai ntr-o clas de
grafuri i folosirea clasei list n loc de vector. Avantajul clasei vector este
c putem accesa orice element rapid, pe cnd folosirea clasei list nu permite
dect parcurgerea secvenial a elementelor. Comparai performana
algoritmilor pe grafuri n cazul implementrii acestora cu ajutorul fiecrei
dintre cele dou clase.
446

Structuri avansate de date

13. Structuri
avansate de
date
Am prezentat pn acum o serie de algoritmi fundamentali nsoii de
aplicaii ale acestora i de probleme teoretice care se pot rezolva cu ajutorul
acestora. Au fost prezentate principalele tehnici de programare, biblioteca
S.T.L. i diverse metode de optimizare a algoritmilor. n acest ultim capitol
vom prezenta cteva structuri de date care joac un rol foarte important n
algoritmic, n special n probleme de optimizare.
Practic, acest capitol prezint metode de a rspunde eficient la
interogri de genul se gsete un anumit obiect ntr-o colecie de obiecte
dat anterior?, ntrebri nsoite i de actualizri de tipul adug un nou
obiect coleciei date atnerior. Vom analiza cazurile favorabile, medii i
defavorabile a mai multor structuri de date i vom discuta situaiile n care
fiecare structur este preferabil celorlalte.
Acest capitol va folosi noiuni de grafuri, liste, operaii pe bii,
recursivitate, tehnici de programare, matematic i S.T.L., aa c
recomandm cu trie stpnirea tuturor capitolelor anterioare nainte de
parcurgerea acestui capitol final.
Pe majoritatea structurile de date ce urmeaz a fi prezentate ne
intereseaz urmtoarele trei operaii de baz i timpul de execuie al
acestora:
1. Inserarea unui element (Insert)
2. tergerea unui element (Remove)
3. Cutarea unui element (Search)
Vom considera un element ca fiind un ntreg pentru a nu complica
inutil exemplele.
447

Capitolul 13

CUPRINS
13.1. Skip lists (liste de salt) .......................................................................... 449
13.2. Tabele de dispersie (Hash tables) ....................................................... 455
13.3. Arbori de intervale problema L.C.A. ................................................ 464
13.4. Arbori indexai binar ............................................................................ 474
13.5. Arbori de prefixe (Trie) ........................................................................ 481
13.6. Arbori binari de cutare (Binary Search Trees) .................................. 488
13.7. Arbori binari de cutare cutare echilibrai ....................................... 504
13.8. Concluzii ................................................................................................ 514

448

Structuri avansate de date

13.1. Skip lists (liste de salt)


Dei nceperea acestui capitol cu o structur de date care ofer
complexiti optime pentru toate operaiile de baz poate prea o introducere
prea abrupt n acest capitol, am considerat c aceast structur de date
folosete cele mai simple noiuni teoretice i este i cel mai uor de
implementat cu o calitate acceptabil, fiind necesare doar cunotiine despre
liste nlnuite.
O list de salt este un ansamblu de mai multe liste nlnuite sortate,
fiecare list desemnnd un nivel al acestui ansamblu. La nivelul cel mai de
jos (nivelul 0), fiecare element x al listei respective va avea un pointer ctre
elementul x + 1 (informal spus, x->link_ = x + 1). La urmtorul nivel
(nivelul 1) vom avea doar o parte a elementelor de la nivelul precedent (de
obicei n jur de 50%), aadar un element x nu va avea neaprat un pointer
ctre elementul x + 1, ci ctre un element mai ndeprtat, cum ar fi x + 2 sau
x + 3. Elementele de la nivelul precedent care se pstreaz la nivelul imediat
superior vor fi alese aleator. Aadar, o list de salt este o structur de date
probabilist.
Figura de mai jos prezint o posibil list de salt pentru datele de
intrare 8 9 1 3 7 10 4 6 0 2.
L[4]
L[3]
L[2]
L[1]
L[0]

1
1
1

4
4
4
4
4

6
6

8
8
8
8

10
10

NULL
NULL
NULL
NULL
NULL

Fig. 13.1.1. O list de salt pentru un anumit set de date

a) Cutarea unui element (Search)


Pentru a cuta un element n list vom ncepe de la cel mai nalt
nivel, L[4] pe exemplul de mai sus. Verificm dac valoarea elementului
ctre care indic elementul curent (la nceput, elementul curent este un
header al listei, adic un element care face parte din list doar pentru a
uura operaiile suportate de aceasta) este mai mare (sau este NULL) dect
valoarea cutat: dac da, atunci se scade nivelul (dar se pstreaz poziia)
pn cnd valoarea indicat de ctre elementul curent este valoarea cutat.
Dac n schimb valoarea indicat este mai mic dect cea cutat, pstrm
nivelul i avansm poziie.
449

Capitolul 13
Figura de mai jos prezint modul n care se caut valoarea 10:
L[4]
L[3]
L[2]
L[1]
L[0]

1
1
1

4
4
4
4
4

6
6

8
8
8
8

10
10

NULL
NULL
NULL
NULL
NULL

Fig. 13.1.2. Modul de cutare a unei valori ntr-o list de salt


Chiar i pe acest exemplu numrul de operaii efectuate este mai mic
dect ar fi fost dac am fi folosit o list nlnuit clasic. Dac am avea mai
multe elemente n list, diferena ar fi i mai evident.
Timpul mediu de execuie al acestei operaii ntr-o list de salt cu N
elemente este O(log N) i este operaia cea mai important ntr-o list de
salt, deoarece restul operaiilor au la baz acelai algoritm.

b) Inserarea unui element (Insert)


Pentru a insera un element n list trebuie mai nti s stabilim
numrul de nivele din care acesta va face parte. Vom impune ca un element
s fac parte din nivelul 0 cu probabilitatea 1 (deci fiecare element va face
1
parte din nivelul 0), din nivelul 1 cu probabilitatea , iar n cazul general din
2

nivelul k cu probabilitatea
. Evident, un element sigur nu va face parte
+1
din nivelul k dac nu face parte deja din toate nivelele anterioare lui k.
Deoarece la fiecare nivel superior avem aproximativ jumtate dintre
nodurile de la nivelul imediat anterior, nlimea maxim a unei liste cu N
elemente va fi O(log N).
Pentru a stabili nivelul maxim al noului nod, vom genera un numr
aleator pe 32 de bii, iar nivelul elementului va fi dat de numrul de bii
consecutivi de valoare 1 de la sfritul reprezentrii n baza doi a numrului
aleator.
Pentru a insera efectiv noul element vom folosi algoritmul de cutare
mpreun cu algoritmul de inserare ntr-o list nlnuit, deoarece, pe
fiecare nivel, noul element va fi inserat naintea celui mai mic element mai
mare dect acesta.
Va trebui s avem grij s inserm noul element pe toate nivelele
stabilite anterior.

450

Structuri avansate de date

c) tergerea unui element (Remove)


tergerea unui element (cu o anumit valoare) presupune gsirea
acestuia pe fiecare nivel i tergerea efectiv folosind algoritmul clasic de
tergere dintr-o list nlnuit. Practic, vom efectua operaia invers
inserrii. Dac elementul pe care vrem s-l tergem nu exist n list, nu se
va ntmpla nimic.

d) Detalii de implementare
Deoarece codul complet al tuturor operaiilor ar fi prea voluminos i
greu de neles la prima vedere, vom prezenta pe rnd i cu explicaii fiecare
structur i metod.
n primul rnd vom folosi o structur Node, care va reprezenta un
nod, caracterizat prin informaia reinut i legaturile acestuia.
n al doilea rnd vom folosi o structur List, care va conine
nceputul listei, pentru a putea evita nite cazuri particulare i a simplifica
implementarea. Aadar, structurile folosite sunt:
struct Node
{
int info;
Node **link_;

struct List
{
int H; // inaltimea curenta a listei
Node *Header;

Node(int v, int nivele)


{
info = v;
link_ = new Node*[nivele];

List()
{
// maxH = 32, suficient pentru
// 2^32 elemente, deoarece maxim
// este O(log N)
Header = new Node(0, maxH);
H = 1;
}

for ( int i = 0; i < nivele; ++i )


link_[i] = NULL;
}
};

};

Dei funcia de inserare nu este complicat din punct de vedere


conceptual, implementarea nu este chiar intuitiv. Vom scrie o funcie
Insert(v, L) care va insera un element cu valoarea v n lista L. Pentru acest
lucru, prima dat vom afla nivelul maxim al noului element conform
algoritmului descris anterior. Acel algoritm poate fi implementat n mod
naiv genernd aleator un numr i verificnd bit cu bit ci bii de valoare 1
are la final. O soluie mai eficient const n precalcularea unui tablou
451

Capitolul 13
lookup de 256 de ntregi unde lookup[i] = cu ci bii de valoare 1 se
termin numrul i. Valorile acestui tablou pot fi calculate folosind un
subprogram temporar, iar apoi copiate ntr-un vector constant la nceputul
programului:
const int lookup[256] = {0, 1, 0, 2, 0, 1, 0, ... };

Pentru a afla ce ne intereseaz pentru un ntreg rnd, vom considera


primii 8 bii ai si de la dreapta (care pot fi aflai prin rnd & 255) i vom
vedea, folosind tabloul lookup, ci bii terminali au valoarea 1. Dac toi
cei 8 bii au valoarea 1, atunci vom deplasa numrul rnd la dreapta cu 8
poziii i vom relua algoritmul, iar dac nu, atunci returnm totalul de pn
acum. Astfel se vor efectua maxim 4 operaii pentru fiecare inserare.
Urmeaz adugarea efectiv a noului element, lucru care se face
pornind de la cel mai nalt nivel al listei i adugnd noul element pe fiecare
nivel din care acesta am stabilit anterior c face parte, naintea celui mai mic
element mai mare sau egal cu acesta.
Funcia de inserare poate fi implementat n felul urmtor:
void Insert(int v, List *L)
{
int newH = 0, tmp;
unsigned int rnd = rand() * rand(); // returneaza o valoare pe 32 biti
do
{
tmp = lookup[rnd & 255];
rnd >>= 8;
newH += tmp;
} while ( tmp == 8 );
if ( newH >= L->H ) // trebuie actualizata inaltimea listei in acest caz
++L->H;
Node *newNode = new Node(v, L->H), *current = L->Header;
for ( int i = L->H - 1; i >= 0; --i )
{
for ( ; current->link_[i] != NULL; current = current->link_[i] )
if ( current->link_[i]->info >= v )
break;
if ( i <= newH )
{
newNode->link_[i] = current->link_[i];
current->link_[i] = newNode;
}
}
}

452

Structuri avansate de date


Prezentm n continuare funcia de cutare, care este foarte similar
cu a doua parte a funciei de inserare. Funcia Search(v, L) returneaz true
dac elementul v se afl n lista L i false n caz contrar.
bool Search(int v, List *L)
{
Node *current = L->Header;
for ( int i = L->H - 1; i >= 0; --i )
{
for ( ; current->link_[i] != NULL; current = current->link_[i] )
if ( current->link_[i]->info > v )
break;
else if ( current->link_[i]->info == v )
return true;
}
return false;
}

Urmtoarea funcie este funcia Remove(v, L) care terge elementul


cu valoarea v din lista L. Mai exact, funcia prezentat va terge un singur
element cu valoarea v din list, deoarece pot exista mai multe. Funcia nu va
returna nimic, dar un exerciiu pentru cititor este s modifice funcia astfel
nct s returnere true dac elementul v a fost gsit i ters din list i false
n caz contrar.
void Remove(int v, List *L)
{
Node *current = L->Header;
for ( int i = L->H - 1; i >= 0; --i )
{
for ( ; current->link_[i] != NULL; current = current->link_[i] )
if ( current->link_[i]->info > v )
break;
else if ( current->link_[i]->info == v )
{
Node *del = current->link_[i];
current->link_[i] = current->link_[i]->link_[i];
if ( i == 0 ) delete del;
break;
}
}
}

453

Capitolul 13
O ultim operaie de care se poate s avem nevoie este afiarea
elementelor listei n ordine sortat. Acest lucru l putem face afind
elementele de pe nivelul 0:
for ( Node *tmp = L->Header->link_[0]; tmp; tmp = tmp->link_[0] )
cout << tmp->info << ' ';

e) Analiza experimental a performanei


Complexitatea teoretic este O(log N) pe cazul mediu pentru fiecare
operaie elementar i evident O(N) pentru afiarea tuturor elementelor.
Tabelul de mai jos prezint timpii de execuie obinui de implementrile
oferite.
Un tabel similar va exista i pentru restul structurilor de date
discutate. Toate msurtorile au fost fcute pe acelai calculator, iar
numerele inserate, terse i cutate au fost furnizate de ctre expresia
rand() * rand(), furniznd numere pe 32 de bii. Eventualele rezultate
returnate de funcii au fost ignorate.
Tabelul 13.1.3. Performana orientativ a listelor de salt
Numr test Inserri
Cutri
tergeri Timp (secunde)
1
1 000
1 000
1 000
0.038
2
10 000
10 000
10 000
0.051
3
100 000
0
0
0.129
4
100 000
100 000
0
0.193
5
100 000
100 000
100 000
0.282
6
1 000 000
0
0
2.165
7
1 000 000 1 000 000
0
4.785
8
1 000 000 1 000 000 1 000 000
7.283

f) mbuntiri
Putem transforma listele de salt ntr-o structur de date determinist
n modul urmtor: renunm la promovarea unui element la un nivel
superior pe baza unor criterii aleatore i impunem ca la nivelul 0 fiecare salt
s sar peste 0 elemente, la nivelul 1 fiecare salt s sar peste 1 element, iar
n general la nivelul k fiecare salt s sar peste 2k 1 elemente.

454

Structuri avansate de date


Exerciii:
a) Implementai variant determinist a listelor de salt.
b) Implementarea prezentat pune accentul pe simplitate. De
exemplu, ideal ar fi s i eliberm memoria aferent unui nod
dup tergerea acestuia. Ce alte optimizri s-ar mai putea face i
cum ar putea fi acestea implementate?
c) Listele de salt pot fi folosite pentru sortarea unui ir de numere.
Comparai sortarea prin liste de salt cu restul algoritmilor de
sortare.
d) Scriei un program care citete un ir de numere rspunde la mai
multe ntrebri de genul care este al k-lea cel mai mic element
din ir? n timp O(log N) pentru fiecare ntrebare.
e) Scriei o funcie care returneaz poziia unui element n list.
f) Scriei o funcie care returneaz valoarea elementului de pe o
anumit poziie.
g) Implementai o clas numit SkipList.

13.2. Tabele de dispersie (Hash tables)


Un tabel de dispersie este o structur de date care efectueaz toate
cele trei operaii fundamentale n timp O(1) att n cazul favorabil ct i n
cazul mediu. Dezavantajul este c pentru operaii de cutare i tergere
timpul de execuie este O(N) n cel mai ru caz.
S presupunem c avem un ir de N numere naturale foarte mari i
c vrem s rspundem rapid la ntrebri de genul numrul x se afl n ir? i
s efectum rapid actualizri de genul insereaz numrul x n ir i terge o
apariie a numrului x din ir (sau toate apariiile). Aceeai problem pe
care am rezolvat-o de fapt cu ajutorul listelor de salt.
Ideea din spatele tabelelor de dispersie pornete de la rezolvarea
problemei prin vectori de caracterizare. Fie H[i] = true dac numrul i se
afl n ir i false n caz contrar. Avnd acest vector, putem efectua foarte
rapid toate cele 3 operaii de baz. Memoria folosit va fi ns O(maxV),
unde maxV este cel mai mare numr care poate aprea n ir. Am spus ns
la nceput c numerele sunt foarte mari (s presupunem cel mult 10 9), aa c
aceast abordare iese din discuie, deoarece un tablou de ntregi de
dimensiunea un miliard ar ocupa aproximativ 4 gB de memorie!
Pentru a folosi mai puin memorie i a pstra eficiena algoritmului,
vom folosi o funcie de dispersie h, iar H[i] va deveni mulimea (lista)
tuturor valorilor x pentru care h(x) = i. Vom alege funcia h n aa fel
nct s ne permitem memoria necesar, iar valorile din H s fie distribuite
455

Capitolul 13
ct mai uniform. Dimensiunea (numrul de mulime sau liste) tabloului H va
fi egal cu valoarea maxim care poate fi returnat de funcia h, iar
reuniunea tuturor mulimilor va fi chiar irul dat. Aadar, memoria folosit
devine O(N + maxh), unde maxh este valoarea maxim care poate fi
returnat de h. Deoarece funcia de dispersie este aleas de obicei la
nceputul programului, putem considera c maxh este o constant, memoria
folosit fiind de fapt O(N).
De exemplu, fie irul 132, 5, 10, 4, 13, 17, 29, 31, 1, 2, 8, 7, 0, 11 i
funcia h(x) = x % 5. Atunci tabela de dispersie va arta n felul urmtor:
H[0]
H[1]
H[2]
H[3]
H[4]

5
31
132
13
4

10
1
17
8
29

0
11
2

NULL
NULL
NULL
NULL
NULL

Fig 13.2.1. O tabel de dispersie (hash table)


Se observ c exist numere diferite care apar n aceeai list. Cnd
exist dou numere (nu neaprat diferite) care, trecute prin funcie de
dispersie, ajung n acelai loc, spunem c avem de a face cu o coliziune. O
tabel de dispersie eficient trebuie s aib ct mai puine coliziuni, iar
coliziunile existente s fie ct mai variate, adic apariia lor s fie uniform
distribuit n toate listele.

a) Cutarea unui element (Search)


Pentru a cuta un numr x ntr-o tabel de dispersie, mai nti trecem
acel numr prin funcia de dispersie aleas, dup care cutm secvenial
elementul x n lista H[h(x)]. Dac am ales o funcie bun, atunci cutarea
secvenial nu va parcurge dect un numr foarte mic de elemente.
n cazul defavorabil, n care avem dou distincte care se repet de
multe ori i care ajung n aceeai poziie, cutarea unuia dintre aceste
numere poate necesita timp O(N). Aadar, tabelele de dispersie sunt cele
mai folositoare pentru date ct mai variate.

b) Inserarea unui element (Insert)


Inserarea unui element este singura operaie care necesit
ntotdeauna timp O(1), deoarece pentru a insera un numr x l vom aduga
456

Structuri avansate de date


la nceputul listei H[h(x)], iar inserarea unui element la nceputul unei liste
nlnuite se face n timp constant.

c) tergerea unui element (Remove)


Pentru a terge un numr, se parcurge lista n care se afl acesta pn
la ntlnirea numrului respectiv, care apoi se terge efectiv, exact ca ntr-o
list nlnuit oarecare. Se pot terge toate elementele care au aceeai
valoare ntr-o singur parcurgere, sau se poate opta pentru tergerea unei
singura instane.

d) Detalii de implementare
n primul rnd trebuie s stabilim dimensiunea tabelei de dispersie i
funcia pe care o vom folosi. Pentru majoritatea problemelor se folosete o
funcie simpl de genul h(x) = x % P, unde P este un numr prim sau
h(x) = x % 2k, pentru a putea calcula mai rapid operaia modulo folosind
operaii pe bii. Vom alege cea de-a doua variant din motive de eficien.
Am putea folosi i de data aceasta dou structuri: una care va reine
doar un membru val, care va reprezenta numrul reinut de un anumit
obiect, i un pointer ctre urmtorul element din list, reprezentnd practic o
list nlnuit i o a doua structur care va declara i iniializa mai multe
liste nlnuite, constituind tabela de dispersie. Recomandm cititorilor s
incorporeze i funciile de gestiune a tabelei ntr-o clas, att pentru aceast
structur de date ct i pentru celelalte din acest capitol. n acest fel, vei
putea refolosi foarte uor i intuitiv codul.
struct Node
{
int val;
Node *link_;

struct Hash
{
Node **link_;
Hash(int size)
{
link_ = new Node*[size];
for ( int i = 0; i < size; ++i )
link_[i] = NULL;
}

Node(int val)
{
this->val = val;
}
};
};

Putem folosi ns clasele list sau vector din S.T.L. pentru a obine o
implementare mai scurt. La grafuri am folosit vector deoarece nu aveam
457

Capitolul 13
de-a face cu tergeri i aveam n unele cazuri nevoie de acces aleator la
elemente. n cazul tabelelor de dispersie, clasa list se potrivete mai bine,
deoarece tergerea unui element se efectueaz oricum n O(N) i avem
nevoie de eliberarea memoriei, deoarece dac doar am marca elementele
terse cu o anumit valoare, nu am reduce efectiv din ncrctura tabelei.
Vom prezenta implementarea funciilor de baz cu ajutorul tipului
list. Lsm ca exerciiu pentru cititor implementarea cu ajutorul tipului
vector sau cu ajutorul unei liste nlnuite implementate manual.
Prezentm un program complet care implementeaz cele trei operaii
de baz, adaug 2010 numere aleatoare n tabel iar apoi afieaz rezultatul
a 2010 cutri n tabel. Programul este explicat prin comentarii.
#include <iostream>
#include <cstdlib>
#include <ctime>
#include <list>
using namespace std;
const int maxH = 1 << 20; // 2 la 20
int h(int x) // functia de dispersie
// e h(x) = x % (2 la 20)
{
return x & (maxH - 1);
}

void Remove(int x, list<int> H[])


{
H[ h(x) ].remove(x);
}
int main()
{
// 2 la 20 poate fi prea mult pentru
// un tablou local
list<int> *H = new list<int>[maxH];
srand((unsigned)time(0));

void Insert(int x, list<int> H[])


{
H[ h(x) ].push_front(x);
}
bool Search(int x, list<int> H[])
{
// pentru cautare se parcurge
// secvential lista
int hash = h(x);
list<int>::iterator i;
for ( i = H[hash].begin();
i != H[hash].end(); ++i )
if ( *i == x )
return true;
return false;
}

for ( int i = 1; i <= 2010; ++i )


Insert(rand()*rand(), H);
for ( int i = 1; i <= 2010; ++i )
cout << Search(rand()*rand(), H)
<< '\n';
return 0;
}

458

Structuri avansate de date


Practic, folosind clasa list operaiile de baz devin simple apeluri de
metode ale obiectelor acestei clase. Implementarea este foarte rapid n
cazul n care se folosete o funcie de dispersie uoar.

e) Analiza experimental a performanei


Pe cazul mediu, fiecare operaie are complexitatea O(1). S vedem
cum se comport tabelele de dispersie pe date (numere pe 32 de bii)
generate aleator.
Tabelul 13.2.2. Performana orientativ a tabelelor de dispersie
Numr test Inserri
Cutri
tergeri Timp (secunde)
1
1 000
1 000
1 000
0.1
2
10 000
10 000
10 000
0.11
3
100 000
0
0
0.14
4
100 000
100 000
0
0.16
5
100 000
100 000
100 000
0.17
6
1 000 000
0
0
0.4
7
1 000 000 1 000 000
0
0.65
8
1 000 000 1 000 000 1 000 000
0.93
Se poate observa uor c tabelele de dispersie ctig deplasat n faa
listelor de salt.
Totui, tabelele de dispersie au anumite dezavantaje, fiind o structur
de date foarte specializat. n primul rnd, nu putem implementa operaii de
aflara a minimului, de afiare a elementelor n ordine i nu putem extinde
uor structura pentru alte tipuri de date. Aadar, chiar dac timpul de
execuie se dovedete a fi foarte bun, nu ntotdeauna un tabel de dispersie
este cea mai bun soluie.

f) mbuntiri i aplicaii
Putem extinde tabelele de disperse pentru numere raionale i pentru
iruri de caractere. O funcie de dispersie pentru numere reale poate fi
h(x) = [{Ax}P], unde:
0 < A < 1, preferndu-se (conform lui Knuth) =
0.618033989
{x} partea fracionar a lui x
459

51
2

Capitolul 13
[x] partea ntreag a lui x
P un numr natural oarecare, de obicei un numr prim sau o
putere a lui 2.
Funciile de dispersie pentru numere raionale au aplicaii n
probleme de geometrie computaional (de exemplu pentru cutarea rapid a
unui punct din plan).
Alt aplicaie este dat de tablouri asociative. Un tablou asociativ
este un tablou care poate fi indexat prin iruri de caractere. De exemplu,
dac vrem s implementm o agend telefonic, ar fi convenabil s putem
accesa rapid numrul de telefon al fiecrei persoane din agend tiind doar
numele acelei persoane. Astfel, numrul lui Ionescu Vlad ar putea fi accesat
(i setat) prin Numere[Ionescu Vlad].
Pentru a implementa o astfel de structur de date avem nevoie de o
funcie de dispersie care s asocieze un numr irului de caractere dat ca
parametru. Evident, o astfel de funcie va folosi valorile ASCII ale
caracterelor din ir.
O metod naiv de funcie de dispersie pentru iruri de caractere este
s adunm valorile ASCII a tuturor caracterelor din ir i s returnm suma
acestora modulo un numr prim sau putere a lui 2. Aceast funcie va genera
ns multe coliziuni. De obicei se folosete o funcie polinomal de genul:
h(C) = (C1Pk 1 + C2Pk 2 + ... + CkP0) % Q
Unde C este irul de caractere dat, iar P i Q sunt dou numere
prime sau Q o putere a lui 2. Q va da dimensiunea maxim a tabelei de
dispersie, iar P este de obicei un numr mic, n jur de 100. Alegerea
numerelor P i Q determin calitatea funciei.
Avnd acest model, implementarea unui tablou asociativ simplu
devine trivial. Trebuie doar construit o clas care suprancarc operatorul
[ ], care implementeaz funcia de dispersie dup modelul de mai sus i care
construiete tabela de dispersie dup modelul anterior. De data aceasta
listele vor reine iruri de caractere, sau string-uri.
Putem folosi proprietile operaiei modulo pentru a calcula eficient
funcia de dispersie n O(k). Prezentm un model de implementare:

460

Structuri avansate de date


const int P = 83, Q = 104729;
// ...
int h(const string &C)
{
int hash = 0;
for ( int i = 0; i < C.length(); ++i )
{
hash = (hash * P) + C[i]; hash %= Q;
}
return hash;
}

O alt aplicaie important a acestui gen de funcii de dispersie este


n rezolvarea problemei de potrivire a irurilor. Am prezentat ntr-un capitol
anterior algoritmul de rezolvare K.M.P., precum i metoda naiv pe scurt:
bool Search(const string &S1, const string &S2)
{
for ( int i = 0; i < S1.length() - S2.length() + 1; ++i )
{
bool found = true;
for ( int j = 0; j < S2.length(); ++j )
if ( S1[i + j] != S2[j] )
found = false;
if ( found )
return true; // S2 este subsecventa a lui S1
}
return false; // S2 nu este subsecventa a lui S1
}

Fie N i M lungimile celor dou iruri. Folosind o funcie de


dispersie pentru iruri de caractere construit dup modelul prezentat
anterior putem optimiza soluia naiv astfel nct fie s funcioneze
ntotdeauna n timp O(N), dar s exist posibilitatea de apariie a unor aanumite fals-pozitive, adic returnarea valorii true n cazul n care irul S2 de
fapt nu este o subsecven a irului S1, fie s funcioneze n timp O(N) doar
pe cazul favorabil (i eventual mediu), dar s rmn O(NM) n cel mai ru
caz, evitndu-se ns orice fals-pozitive.
Observm n secvena de cod prezentat anterior c ne intereseaz la
fiecare pas i dac subsecvena S1[i, i + M 1] este egal cu irul S2. Acest
lucru l facem comparnd caracter cu caracter cele dou iruri. Putem
461

Capitolul 13
compara ns doar rezultatele funciei de dispersie aplicate asupra
acestor dou iruri, transformnd astfel cel de-al doilea for ntr-o simpl
condiie. Dac ne intereseaz s nu obinem fals-pozitive, atunci n caz c
h(S1[i, i + M 1]) = h(S2), vom efectua o comparaie caracter cu caracter
ntre cele dou iruri pentru a ne asigura c egalitatea este adevrat. n caz
c cele dou valori sunt diferite, tim sigur c cele dou iruri sunt diferite.
Funcia de dispersie trebuie s poate fi calculat rapid, n O(1),
pentru toate subsecvenele de lungime M ale lui S1. Pentru acest lucru vom
calcula mai nti h(S2) i h(S1[0, M 1]) i vom ncepe prin a compara
aceste dou valori separat de partea principal a algoritmului. S vedem cum
putem obine h(S1[1, M]) tiind h(S1[0, M 1]):
h(S1[0, M 1]) = (S1[0]PM 1 + S1[1]PM 2 +...+ S1[M 1]P0 )%Q
h(S1[1, M]) = (S1[1]PM 1 + S1[2]PM 2 + ... + S1[M]P0) % Q
= ((h(S1 [0, M 1]) ((S1[0]PM 1) % Q) + Q)P +
+ S1[M]) % Q
Iar n general:
h(S1[i+1, i+M]) = ((h(S1[i , i+M1]) ((S1[i]PM 1) % Q) + Q)P +
+ S1[M]) % Q
Practic, aplicnd aceast formul se elimin termenul care nu mai
face parte din secvena curent, se nmulesc restul termenilor cu P,
restabilindu-se puterile, i se adun codul caracterului nou intrat n secven.
Astfel, am obinut funcia de dispersie aplicat noii secvene n O(1).
Acest algoritm poart numele de algoritmul Rabin Karp. n
practic se prefer folosirea algoritmului K.M.P. datorit faptului c
tabelele de dispersie fie nu ofer garanii asupra corectitudinii rezultatului
final, fie nu ofer garanii asupra timpului de execuie, care poate degenera
uor.
Se mai pot face diverse optimizri pentru a ne asigura c vom obine
ct mai puine fals-pozitive n cazul n care nu verificm potrivirile rezultate
din egalitatea valorilor returnate de funcia de dispersie. Putem de exemplu
s folosim mai multe funcii de dispersie i s considerm o potrivire doar
atunci cnd toate funciile de dispersie indic o potrivire. Pentru iruri
formate din litere ale alfabetului englez i dou funcii, probabilitatea de
apariie a unor fals-pozitive este suficient de mic pentru majoritatea
scopurilor.
462

Structuri avansate de date


Evident, conteaz i ce funcii dispersie alegem. Din pcate, nu
exist nicio reet de succes pentru gsirea unor funcii de calitate, care s
nu genereze multe fals-pozitive. Trebuie testate mai multe variante pe ct
mai multe date de intrare i analizat comportamentului fiecrei funcii n
parte.
Prezentm n final o funcie care implementeaz algoritmul de
potrivire a irurilor Rabin Karp.
bool RabinKarp(const string &S1, const string &S2)
{
int N = S1.lengtH(), M = S2.length();
if ( M > N )
return false;
int Pputere = 1;
// calculez functia de dispersie pentru S2 si in acelasi timp P la M - 1
int hashS2 = 0;
for ( int i = 0; i < M; ++i )
{
hashS2 = (hashS2 * P + S2[i]) % Q;
if ( i )
Pputere = (Pputere * P) % Q;
}
// calculeaza functia de dispersie pentru primele M caractere ale lui S1
int hashS1 = 0;
for ( int i = 0; i < M; ++i )
hashS1 = (hashS1 * P + S1[i]) % Q;
if ( hashS1 == hashS2 )
return true; // PROBABIL true
// continua cautarea potrivirilor
for ( int i = M; i < N; ++i )
{
hashS1 = ((hashS1 - (S1[i - M]*Pputere) % Q + Q)*P + S1[i]) % Q;
if ( hashS1 == hashS2 )
return true;
}
return false;
}

463

Capitolul 13
Exerciii:
a) Modificai funcia de mai sus astfel nct s afieze toate poziiile
de potrivire.
b) Propunei algoritmi pentru testarea calitii unei funcii de
dispersie, att pentru numere naturale ct i pentru iruri de
caractere.
c) Se d un ir de N numere naturale aleatoare. Se cere a gsirea a
patru numere din ir a cror sum este S. Cum se poate rezolva
problema n O(N2) cu ajutorul tabelelor de dispersie?
d) Elaborai un test pe care soluia gsit pentru problema
anterioar s aib timpul de execuie O(N4).

13.3. Arbori de intervale problema L.C.A.


Un arbore de intervale este un arbore binar folosit n general pentru
rezolvarea problemelor care presupun interogri i, eventual, actualizri.
Vom exemplifica arborii de intervale pe o variant a problemei R.M.Q. care
suport i actualizri.
Considerm un numr N i un ir A de N elemente numere ntregi.
Se dau T triplete de forma (op, x, y) unde:
op = 1 semnific operaia de aflare a minimului din subsecvena
A[x, y].
op = 2 semnific operaia A[x] = y.
Pentru fiecare interogare (op == 1) se va afia rezultatul acesteia.
Exemplu:
RMQ2.in
RMQ2.out
73
-6
1 -6 8 10 13 5 4 1
125
251
146

a) Prezentarea ideii de rezolvare


Pentru a rezolva noua problema vom folosi o structur de date care
ne permite s efectum ambele operaii n timp O(log N). Memoria folosit
va fi O(N), ceea ce este o mbuntire fa de rezolvarea prin programare
dinamic, rezolvare pe care nu o mai putem folosi din cauza actualizrilor.
464

Structuri avansate de date


n primul rnd s definim concret arborele de intervale. Un astfel de
arbore este un arbore binar n care ficare nod are asociat un interval (sau o
subsecven n cazul acestei probleme). Nodul 1 va avea asociat
subsecvena [1, N], nodul 2 subsecvena [1, (N+1) / 2], nodul 3 subsecvena
[(N + 1) / 2 + 1, N], iar n cazul general fiul stng al unui nod are asociat
prima jumtate a intervalului printelui su, iar fiul drept are asociat a doua
jumtate. Frunzele vor fi asociate unor intervale cu capetele egale. Figura
urmtoare reprezint un arbore de intervale pentru intervalul [1, 7]:

Fig. 13.3.1 Un arbore de intervale asociat intervalului [1, 7]


Fiecare interval asociat unui nod al unui arbore de intervale conine
informaii despre acesta, informaii care pot fi calculate pe baza
informaiilor reinute n fiii nodului. Pentru aceast problem, fiecare nod va
reine valoarea minim din intervalul asociat acestuia.
Calculul acestor valori se va face de jos n sus ntr-o manier
recursiv. Cazurile de baz vor fi frunzele arborelui, deoarece acestea rein
intervale (secvene) de lungime 1, a cror minime sunt chiar acel unic
element al intervalului. Pentru a afla minimul secvenei asociate unui nod
oarecare, este de ajuns s considerm minimul celor doi fii ai acestui nod.
Pentru a afla minimul unei secvene [x, y] vom considera doar
secvenele reinute de arbore care sunt incluse n [x, y]. Rspunsul va fi dat
de cel mai mic minim al acestor secvene.
Pentru a afla secvenele arborelui incluse n [x, y] se folosete un
algoritm recursiv care verific mai nti dac intervalul asociat nodului
curent se intersecteaz (are elemente comune) cu [x, y]:
465

Capitolul 13
dac nu, atunci nu are rost s verificm fiii nodului curent,
deoarece nu au nicio ans s fie inclui n [x, y].
dac da, atunci se verific dac intervalul asociat nodului curent
este inclus n [x, y]:
o dac da, atunci se actualizeaz eventual minimul
intervalului [x, y] (considerat iniial infinit) i nu se mai
verific niciun fiu (deoarece nodul curent acoper
intervalele fiilor).
o dac nu, atunci se efectueaz apeluri recursive pentru
ambii fii.
Procednd n acest fel obinem complexitatea O(log N), datorat
faptului c nlimea unui arbore de intervale este O(log N) i datorit
faptului c se vor alege intervale de lungime maxim a arborelui pentru
acoperirea intervalului [x, y] (pornind de sus n jos, primul interval gsit
care e inclus n [x, y] va fi folosit, iar subintervalele acestuia nu).
Figura de mai jos prezint un arbore de intervale pentru exemplul
dat. Cu rou apar minimele fiecrui interval reinut de arbore, iar cu verde
nodurile parcurse pentru aflarea minimului secvenei [4, 6]. ngroat apar
nodurile a cror intervale sunt incluse n [4, 6], restul nodurilor fiind noduri
intermediare. Cu portocaliu apar nodurile respinse deoarece intervalele
asociate nu intersecteaz intervalul cutat.

Fig. 13.3.2. Modul de interogare a unui arbore de intervale


Se poate observa c minimul secvenei [4, 6] este min(10, 5) = 5.
466

Structuri avansate de date


Pentru a opera o actualizare, adic modificarea valorii elementului x,
se procedeaz similar. Se identific, ntr-o manier recursiv, nodul care
trebuie actualizat. Se elimin intervalele care nu conin elementul x i se
continu parcurgerea doar acelor intervale care l conin pe x. Putem face
acest lucru fie terminnd apelurile recursive pentru intervalele care nu l
conin pe x, fie punnd nite condiii n aa fel nct s nici nu se efectueze
aceste apeluri.
ntr-un final se va ajunge la un nod care are asociat un interval
format dintr-un singur element, acel element fiind chiar x. Nodul respectiv
va primi noua valoare, iar la revenire din recursivitate se vor actualiza, dac
este cazul, strmoii acestui nod.
Figura de mai jos prezint operaia de actualizare a elementului 5,
acesta lund valoarea 1. Actualizrile apar cu verde, la fel i nodurile a
cror intervale l conin pe 5.

Fig. 13.3.3. Modul de actualizare a unui arbore de intervale


Se pune problema construirii arborelui pentru irul iniial de numere.
Avem dou posibiliti:
1. Pentru fiecare element citit actulizm arborele folosind
procedura de actualizare descris anterior. Aceast metod este
mai convenabil, dar mai puin eficient, deoarece unele noduri
vor fi parcurse de mai multe ori.
2. Folosim o procedur separat care va construi arborele iniial.
Aceasta va fi similar cu procedura de actualizare, doar c nu vor
exista condiii de ieire prematur din recursivitate n cazul n

467

Capitolul 13
care intervalul curent nu conine un anumit element, deoarece ne
intereseaz toate intervalele n aceast prim faz.
Astfel obinem o structur de date eficient care ne ajut s
rspundem la ntrebri n timp O(log N) i s actualizm setul de date tot n
O(log N).

b) Detalii de implementare
Ne mai intereseaz modalitatea de memorare a unui arbore de
intervale. Vom folosi aceeai idee ca la heap-uri: dac A este arborele de
intervale, fiii unui nod k vor fi A[2k] respectiv A[2k + 1]. A va trebui s
fie de dimensiune cel puin 2N 1 (exist N + N / 2 + N / 4 + ... noduri).
Arborele nu este neaprat s fie complet ns, aa c va trebui s verificm
dac apelurile recursive se fac pentru un nod care chiar exist n arbore.
Pentru a evita aceste verificri putem declara tabloul A ca fiind de
dimensiune 2P 2N 1. Practic, vom completa arborele cu nite
pseudonoduri pn cnd acesta va deveni un arbore binar complet.
#include <fstream>
using namespace std;
const int maxN = 101;
const int maxArb = 1 << 8;
const int inf = 1 << 30;
void citire(int &N, int &T, int A[],
ifstream &in)
{
in >> N >> T;
for ( int i = 1; i <= N; ++i )
in >> A[i];
}

void build(int Arb[], int A[], int nod,


int st, int dr)
{
if ( st == dr )
{
Arb[nod] = A[st];
return;
}
int m = (st + dr) / 2, fiu = 2*nod;
build(Arb, A, fiu, st, m);
build(Arb, A, fiu + 1, m + 1, dr);
Arb[nod] = min(Arb[fiu], Arb[fiu + 1]);
}

468

Structuri avansate de date


int query(int Arb[], int nod, int st,
int dr, int x, int y)
{
if ( st > y || dr < x ) // interval invalid
return INT_MAX;

int main()
{
int N, T;
int A[maxN], Arb[maxArb];
ifstream in("RMQ2.in");
citire(N, T, A, in);

if ( x <= st && dr <= y ) // solutie


return Arb[nod];

ofstream out("RMQ2.out");
build(Arb, A, 1, 1, N);
while ( T-- )
{
int op, x, y;
in >> op >> x >> y;

int m = (st + dr) / 2, fiu = 2*nod;


return min(query(Arb, fiu, st, m, x, y),
query(Arb, fiu+1, m+1, dr, x, y));
}
void update(int Arb[], int nod, int st,
int dr, int x, int y)
{
if ( st > x || dr < x )
return;

if ( op == 1 )
out<<query(Arb, 1, 1, N, x, y)
<< '\n';
else
update(Arb, 1, 1, N, x, y);
}

if ( st == dr )
{
Arb[nod] = y;
return;
}

in.close();
out.close();
return 0;
}

int m = (st + dr) / 2, fiu = 2*nod;


update(Arb, fiu, st, m, x, y);
update(Arb, fiu + 1, m + 1, dr, x, y);
Arb[nod] = min(Arb[fiu], Arb[fiu+1]);
}

c) Analiza experimental a performanei


De data aceasta operaiile se schimb, neexistnd clasicele inserri,
tergeri sau cutri. Vom testa aadar doar o singur operaie build
mpreun cu mai multe operaii query i update. Testele au fost rulate pe
implementarea prezentat anterior, pe date generate aleator i cu afiarea
scoas. A fost cronometrat de fiecare dat i generarea datelor.

469

Capitolul 13
Tabelul 13.3.4. Performana orientativ a arborilor de intervale
Numr test
N
query
update Timp (secunde)
1
1 000
1 000
1 000
0.02
2
10 000
10 000
10 000
0.04
3
10 000
100 000
100 000
0.16
4
100 000
100 000
100 000
0.19
5
1 000 000 100 000
100 000
0.272
6
1 000 000 1 000 000
0
0.778
7
1 000 000
0
1 000 000
1.193
8
1 000 000 1 000 000 1 000 000
1.913
Se poate observa c arborii de intervale sunt foarte eficieni att n
teorie ct i n practic.

d) Aplicaii n geometria computaional


Arborii de intervale pot fi folosii pentru a rezolva probleme care
conin interogri i actualizri care trebuie efectuate foarte rapid. O aplicaie
important a arborilor de intervale este n geometria computaional. Se dau
N segmente paralele cu axele sistemului de coordonate i se cere
determinarea numrului total de intersecii dintre acestea.
Problema se poate rezolva trivial n O(N 2) folosind algoritmul
prezentat n cadrul capitolului de geometrie computaional. Putem ns
profita de faptul c segmentele sunt paralele cu axele sistemului de
coordonate.
Pentru a rezolva eficient aceast problem vom folosi un algoritm
de baleiere. Ne imaginm o dreapt vertical care parcurge planul n care se
afl segmentele de la stnga la dreapta. Avem urmtoarele cazuri:
1. Dreapta de baleiere se intersecteaz cu captul stng al unui
segment orizontal, caz n care acest segment este introdus ntr-o
structur de date care reprezint strile dreptei de baleiere sau
lista punct-eveniment sau lista strilor.
2. Dreapta de baleiere se intersecteaz cu captul drept al unui
segment orizontal, caz n care acest segment este scos din lista
punct-eveniment.
3. Dreapta de baleiere se intersecteaz cu un segment vertical, caz
n care putem afla numrul de intersecii generate de acesta
determinnd cte dintre segmentele orizontale prezente n lista
punct-eveniment au ordonata cuprins ntre ordonatele
segmentului vertical curent.
470

Structuri avansate de date


S observm n primul rnd c introducerea unui segment orizontal
n lista strilor nseamn doar introducerea ordonatei acestuia, deoarece
interogrile se vor face asupra ordonatelor. Aadar operaia 1. reprezint a
adunarea valorii +1 elementului y, unde y este ordonata segmentului, iar
operaia 2. reprezint adunarea valorii -1 elementului y. Operaia 3.
reprezint aflarea sumei intervalului [y1, y2], unde y1 i y2 reprezint
ordonatele segmentului vertical. Arborele de intervale va trebui s fie de
dimensiunea valorii maxime pe care o poate avea o absicis i o ordonat,
sau de dimensiunea N cu unele optimizri. Complexitatea final va fi de
O(Nlog N). Capetele segmentelor vor trebui mai nti s fie sortate
cresctor dup abscise, pentru a putea fi parcurse secvenial n mod eficient.

e) Problema L.C.A. (Lowest Common Ancestor)


Se d un arbore oarecare cu N noduri. Se cere s se rspund rapid la
ntrebri de genul considernd nodurile x i y ale arborelui dat, care este
cel mai jos nod din arbore care este strmo att pentru x ct i pentru y?
n figura de mai jos am marcat cu albastru cel mai de jos strmo
comun al nodurilor marcate cu rou.

Fig. 13.3.5. Vizualizarea problemei L.C.A.


Vom arta n continuare c problema L.C.A. se reduce la problema
R.M.Q. clasic, problem care poate fi rezolvat fie prin programare
dinamic, fie cu ajutorul arborilor de intervale. Folosind programare
dinamic vom folosi memorie O(Nlog N) i vom putea rspunde la o
ntrebare n timp O(1), iar folosind arbori de intervale vom folosi memorie
O(N), dar vom avea nevoie de timp O(log N) pentru a rspunde la o
interogare. Ambele implementri sunt clasice i au fost deja prezentate, aa
c vom discuta doar modul de folosire al acestora.
471

Capitolul 13
Pentru a rezolva problema vom folosi parcurgerea eulerian a
arborelui dat. Parcurgerea eulerian a unui arbore este o parcurgere n
adncime care adaug fiecare nod parcurs ntr-o list Euler, eventual de mai
multe ori. Mai exact:
Dac nodul curent este o frunz, atunci este adugat parcurgerii.
Dac nodul curent are fii, acesta este adugat la nceputul
parcurgerii euleriene a fiilor si, la sfritul acestei parcurgeri i
ntre fiecare parcurgere eulerien a fiilor.
Mai mult, pentru fiecare nod i se va calcula i H[i] = adncimea
nodului i n arbore i Poz[i] = poziia primei apariii a nodului i n Euler.
Pentru arborele de mai sus avem:
Tabelul 13.3.6. Parcurgerea eulerian a unui arbore
i Euler[i] H[i] Poz[i]
1
0
1
1
2
1
2
2
1
1
4
3
3
1
12
4
5
2
5
5
3
2
7
6
6
2
13
7
9
2
15
8
6
3
8
9
3
10
1
11
4
12
7
13
4
14
8
15
4
16
1
17
Avnd aceti trei vectori calculai putem reduce problema la o
interogare de minim pe interval. Se observ c fiecare nod i este n interiorul
intervalelor formate din dou apariii consecutive a strmoilor nodului i n
parcurgerea eulerian. De exemplu, nodul 5 se afl n intervalul format din
dou apariii consecutive ale lui 3 n parcurgerea eulerian i din dou
apariii consecutive ale lui 1. Asta nseamn c nodurile 3 i 1 sunt strmoi
472

Structuri avansate de date


ai lui 5. Deci, un anumit nod apare n parcurgerea eulerian att nainte de
fiii si ct i dup.
Aadar, pentru a determina cel mai jos strmo comun a dou noduri
x i y este de ajuns s determinm nodul cu adncimea minim din
intervalul [ Euler[Poz[x]], Euler[Poz[y]] ]. Pentru nodurile 5 i 9 din
exemplu, se determin nodul care are adncimea minim din intervalul
[ Euler[Poz[5]], Euler[Poz[9]] ] = [Euler[5], Euler[8]]. Se poate observa
din tabelul anterior c acest nod este 3, deci 3 este cel mai jos strmo
comun al nodurilor 5 i 9. Prezentm o funcie care calculeaz valorile
necesare. Am presupus c arborele este orientat pentru a simplifica
implementarea.
void ParcurgereEuler(list<int> G[], int nod, int Euler[], int H[],
int Poz[], int &k)
{
Euler[k] = nod;
Poz[nod] = k++;
for ( list<int>::iterator it = G[nod].begin(); it != G[nod].end(); ++it )
{
H[*it] = H[nod] + 1;
ParcurgereEuler(G, *it, Euler, H, Poz, k);
Euler[k++] = nod;
}
}

Trebuie iniializat k cu 1, H[1] cu 0, iar Euler trebuie s poat


conine 2N 1 elemente. Rezolvarea problemei se reduce acum la
implementarea arborilor de intervale (sau a algoritmului de programare
dinamic pentru rezolvarea problemei R.M.Q.), lucru lsat ca exerciiu
pentru cititor.
Exerciii:
a) Scriei un program care citete un arbore ponderat i rspunde
eficient la ntrebri de genul care este lungimea drumului dintre
nodurile x i y?
b) Scriei un program care citete un ir de numere ntregi i
rspunde eficient la ntrebri de genul care este subsecvena de
sum maxim dintre poziiile x i y? Implementai i actualizri.
c) Scriei un program care citete un tablou cu N elemente din
mulimea {0, 1}. Numrul 0 reprezint faptul c acea poziie este
473

Capitolul 13

d)
e)
f)

g)

liber, iar numrul 1 c acea poziie este ocupat. Programul


trebuie s rspund eficient la ntrebri de genul care este a k-a
poziie ocupat a tabloului? De exemplu, pentru tabloul 1 | 0 | 0 |
1 | 0 | 1 | 1 | 0 | 1, a 3-a poziie ocupat este poziia 6.
Problema de mai sus, dar implementai i actualizri.
Implementai iterativ funciile de gestiune a unui arbore de
intervale.
Scriei un algoritm pentru care o interogare const n aflarea
sumei unei subsecvene a unui ir, iar o actualizare n adunarea
unei valori la un element al irului.
Problema anterioar, doar c acum o actualizare const n
adunarea unei valori unui ntreg interval dat.

13.4. Arbori indexai binar


Arborii indexai binar reprezint o alt structur de date cu ajutorul
creia putem rezolva eficiente probleme cu interogri i actualizri.
Avantajul acestora asupra arborilor de intervale este c, dei teoretic sunt cel
mult la fel de eficieni, n practic sunt mai rapizi datorit naturii lor
nerecursive, algoritmilor simpli de gestiune i a memoriei folosite.
Dezavantajul este c acetia sunt mai specializai, adic se pot aplica unei
game mai restrnse de probleme. Vom prezenta arborii de intervale cu
ajutorul unei probleme.
Se d un ir A de N numere naturale. Se dau T triplete (op, x, y) cu
semnificaia:
op = 1 semnific determinarea i afiarea sumei:
A[x] + A[x + 1] + ... + A[y].
op = 2 semnific adunarea valorii ntregi y numrului A[x].
Exemplu:
sume.in

sume.out
83
22
1 -3 8 7 9 1 3 4 20
126
2 4 -4
135

474

Structuri avansate de date

a) Prezentarea ideii de rezolvare


O prim idee de rezolvare ar fi s folosim vectorul sumelor
pariale. Fie S un vector, S[1] = A[1] i S[i > 1] = S[i 1] + A[i]. Aadar,
S[i] = suma primelor i elemente a irului A. Pentru a rspunde unei
interogri, este de ajuns s afim S[y] S[x 1], obinnd astfel suma
cerut n O(1). Pentru a efectua o actualizare ns, trebuie s recalculm
toate valorile vectorului S, de la poziia actualizat pn la ultimul element.
Aadar, timpul necesar unei actualizri este O(N). Aceast soluie nu este
bun dect dac numrul de actualizri este foarte mic.
Putem obine i timpul O(N) pentru o interogare i O(1) pentru o
actualizare parcurgnd pentru fiecare interogare intervalul asociat acesteia i
adunnd pentru fiecare actualizare valoarea asociat acesteia poziiei
corespunztoare.
Folosind arbori indexai binar vom obine timpul O(log N) pentru
ambele operaii. Acest timp este identic cu cel pe care l-am obine dac am
rezolva problema cu ajutorul arborilor de intervale. Aa cum am mai spus
ns, arborii indexai binar nu folosesc recursivitatea, ci, aa cum vom
vedea, doar operaii pe bii i adunri. Memoria folosit este i aceasta mai
puin. Din aceste motive, acetia vor fi mai eficieni n practic. Iat
totodat un exemplu care pune n eviden natura teoretic a notaiei
asimptotice. Dou structuri de date echivalente asimptotic difer destul de
mult la performan n practic.
Un arbore indexat binar, particularizat pentru aceast problem, nu
este dect un vector S unde S[i] = Sum[i 2k + 1, i], unde k reprezint
numrul zerourilor terminale din reprezentarea binar a lui i. Cu alte
cuvinte, S[i] este suma unei subsecvene de lungime 2k care se termin pe
poziia i. Se poate observa c nu este necesar s impunem condiii speciale
lui i (evident, i nu poate fi mai mare dect N sau mai mic dect 1), deoarece
semnificaia lui k ne asigur c nu vom scdea prea mult sau prea puin.
Tabelul urmtor prezint arborele indexat binar corespunztor
exemplului dat.

475

Capitolul 13
Tabelul 13.4.1. Construirea unui arbore indexat binar
i
i2
A[i] S[i]
neles
Explicaie
1 0001 1
1 Sum[1, 1] [1 20 + 1, 1] = [1, 1]
2 0010 -3
-2 Sum[1, 2] [2 21 + 1, 2] = [1, 2]
3 0011 8
8 Sum[3, 3] [3 20 + 1, 3] = [3, 3]
4 0100 7
13 Sum[1, 4] [4 22 + 1, 4] = [1, 4]
5 0101 9
9 Sum[5, 5] [5 20 + 1, 5] = [5, 5]
6 0110 1
10 Sum[5, 6] [6 21 + 1, 6] = [5, 6]
7 0111 3
3 Sum[7, 7] [7 20 + 1, 7] = [7, 7]
8 1000 4
30 Sum[1, 8] [8 23 + 1, 8] = [1, 8]
Se pot observa cteva lucruri din acest tabel: n primul rnd
S[i] = A[i] pentru orice i impar. Acest lucru se datoreaz faptului c bitul cel
mai puin semnificativ al oricrui numr impar este 1, deci nu exist zerouri
terminale. n al doilea rnd, orice poziie putere a lui doi reprezint, n
arbore, suma tuturor elementelor pn la acea poziie.
Se mai poate observa c putem afla, pe baza tabelului, suma oricrei
subsecvene care ncepe pe prima poziie. Acest lucru este suficient pentru a
rspunde la o interogare, deoarece putem folosi urmtoarea formul de
calcul: Sum[x, y] = Sum[1, y] Sum[1, x 1].
Algoritmul de calculare a valorii Sum[1, i] se bazeaz pe ideea c
scznd din i pe 2k atta timp ct i este mai mare ca 0, vom obine la fiecare
pas o poziie care reprezint suma unei subsecvene disjuncte, dar adiacente
cu subsecvena anterioar. Adunnd fiecare S[i], vom obine suma cerut.
Algoritmul de implementare al funciei Suma(1, i) este urmtorul:
rez = 0
Ct timp i mai mare dect 0 execut
o rez = rez + S[i]
o i = i 2k, unde k = numrul zerourilor terminale ale lui i
Returneaz rez
De exemplu, pentru a calcula Suma(1, 7) vom aduna valorile S[7],
S[6] i S[4]. Se poate observa din tabelul de mai sus c adunarea acestor
valori va furniza rspunsul corect.
Pentru a actualiza arborele indexat binar, algoritmul este aproape
identic. Pentru a implementa funcia de actualizare Actual(i, v), care adun
elementului i valoarea v, va trebui s cretem valoarea fiecrui element al
arborelui a crui subsecven asociat l conine pe i. Astfel, vom aduna la

476

Structuri avansate de date


S[i] pe v, unde i crete cu 2k la fiecare pas, atta timp ct i este mai mic sau
egal cu N.
Algoritmul funciei Actual(i, v) este:
Ct timp i <= N execut
o S[i] = S[i] + v
o i = i + 2k, unde k = numrul zerourilor terminale ale lui i
De exemplu, pentru a aduna o valoare numrului A[4] vom actualiza
valorile S[4] i S[8].
Putem observa c nu este necesar nici mcar pstrarea vectorului A,
cel puin pentru aceast problem. Spre deosebire de arborii de intervale,
aici nu avem o funcie dedicat pentru construirea arborelui, aa c se va
apela pentru fiecare numr citit funcia de actualizare.

b) Detalii de implementare
O implementare naiv ar parcurge fiecare bit al lui i pentru a
determina valoarea k, sau cel puin va parcurge bii atta timp ct acetia
sunt 0:
int Calcul_k(int i)
{
int k = 0;
while ( (i & 1) == 0 ) // cat timp cel mai putin semnificativ bit (cel mai
// din dreapta) e 0
{
++k;
i >>= 1;
}
return k;
}

Aceast operaie are complexitatea O(log i), aa c, folosind aceast


abordare, se va obine complexitatea total O(log2 N) pentru fiecare
operaie. Vom prezenta o metod de a calcula valoarea 2k n timp constant.
Presupunem i = ...1000...02, unde dup 1 apar doar valori de 0. Ne
intereseaz setarea tuturor biilor din stnga bitului de valoare 1 pe valoarea
0, astfel nct restul biilor sa rmn neschimbai. Dac putem face acest
lucru, atunci vom avea calculat valoarea 2k. Avem nevoie de urmtoarele
propoziii:
477

Capitolul 13
Operaia i & (i 1) are ca efect setarea celui mai puin
semnificativ bit de valoare 1 al lui i pe valoarea 0.
De exemplu:
i
1011000 &
i 1
1010111

i & (i 1)
1010000
Operaia i ^ i are ca efect setarea tuturor biilor lui i pe valoarea
0.
De exemplu: 10110 ^ 10110 = 00000.
n plus: 1 ^ 0 = 0 ^ 1 = 1.
Ne intereseaz o secven de operaii care s seteze toi biii unei
valori pe 0, n afar de cel mai puin semnificativ bit de valoare 1. Folosind
propoziiile de mai sus, obinem formula de calcul 2k = i ^ (i & (i 1)),
unde k are semnificaia sa de pn acum. De exemplu:
i
i1
i & (i 1)

1011000 &
1010111

1010000

i ^ (i & (i 1))

0001000

Aadar, pentru fiecare i din pseudocodurile anterioare, adunarea


respectiv scderea valorii 2k se face adunnd, respectiv scznd
i ^ (i & (i 1)), lucru care se face n O(1).
#include <fstream>
using namespace std;
const int maxN = 101;
void Actual(int, int, int, int[]);

478

Structuri avansate de date


void citire(int &N, int &T, int A[],
int S[], ifstream &in)
{
in >> N >> T;
// S trebuie initializat cu 0
// se poate folosi si memset
for ( int i = 1; i <= N; ++i )
S[i] = 0;

int main()
{
int N, T, A[maxN], S[maxN];
ifstream in("sume.in");
citire(N, T, A, S, in);
ofstream out("sume.out");
while ( T-- )
{
int op, x, y;
in >> op >> x >> y;

for ( int i = 1; i <= N; ++i )


{
in >> A[i];
Actual(N, i, A[i], S);
}

if ( op == 1 )
out << Suma(y, S) - Suma(x-1, S)
<< '\n';
else
Actual(N, x, y, S);

}
int Suma(int i, int S[]) // query
{
int rez = 0;
for ( ; i > 0; i -= i ^ (i & (i - 1)) )
rez += S[i];

}
in.close();
out.close();
return 0;

return rez;

}
// update
void Actual(int N, int i, int v, int S[])
{
for ( ; i <= N; i += i ^ (i & (i - 1)) )
S[i] += v;
}

Implementarea este mult mai simpl dect implementarea arborilor


de intervale. Se poate observa deja de ce am fcut afirmaiile de la nceput
referitoare la eficiena arborilor indexai binar. Operaiile de arborii indexai
binar sunt operaii care se execut foarte rapid pe orice calculator, pe cnd
operaiile de gestiune a arborilor de intervale presupun mpriri,
recursivitate, mai muli parametri transmii funciilor i mai multe condiii
n cadrul fiecrei funcii. n plus, memoria folosit este mai mult dect dubl
n cazul arborilor de intervale.
Dei arborii indexai binar nu sunt aplicabili n unele probleme care
se pot rezolva cu ajutorul arborilor de intervale, pentru problemele n care
sunt aplicabili, acestia sunt mai eficieni.
479

Capitolul 13

c) Analiza experimental a performanei


De data aceasta nu mai avem o funcie special dedicat construirii
arborelui, aa c, pentru a compensa, msurtorile iau n calcul i apelurile
funciei de actualizare care se fac n timpul citirii datelor.
Tabelul 13.4.2. Performana orientativ a arborilor indexai binar
Numr test
N
query
update Timp (secunde)
1
1 000
1 000
1 000
0.04
2
10 000
10 000
10 000
0.04
3
10 000
100 000
100 000
0.05
4
100 000
100 000
100 000
0.06
5
1 000 000 100 000
100 000
0.149
6
1 000 000 1 000 000
0
0.259
7
1 000 000
0
1 000 000
0.252
8
1 000 000 1 000 000 1 000 000
0.385
Nite simple teste demonstreaz aadar cele spuse nainte: arborii
indexai binar sunt cu mult mai eficieni dect arborii de intervale. Pot exista
ns probleme care s nu se poat rezolva uor (sau deloc) cu ajutorul
arborilor indexai binar, necesitnd arbori de intervale.

d) Extinderi
Putem extinde ideea prezentat pentru a funciona i n cazul
bidimensional, adic n cazul n care actualizrile i interogrile se
efectueaz asupra unei matrici. Presupunem c se d o matrice cu N linii i
M coloane. O interogare presupune aflarea sumei submatricii cu colul
stnga-sus n elementul (x, y) i colul dreapta-jos n elementul (p, q). O
actualizare presupune adunarea unei valori elementului (x, y). Putem
implementa algoritmi care au timpul de execuie O(NM) pentru una dintre
operaii i O(1) pentru cealalt operaie. Acetia sunt similari cu algoritmii
naivi de rezolvare a problemei unidimensionale i nu vom insista asupra lor.
Pentru a obine timpul de execuie O((log N)(log M)) pentru fiecare
operaie este necesar s folosim un arbore de arbori indexai binar. Vom
folosi o matrice S, unde S[i][j] semnific suma submatricii cu colul
stnga-sus n elementul (i 2k + 1, j 2l + 1) i colul dreapta-jos n
elementul (i, j), unde k reprezint numrul de zerouri terminale ale lui i, iar
l numrul de zerouri terminale ale lui j.
Aceast matrice va fi gestionat exact dup modelul unidimensional,
480

Structuri avansate de date


doar c se vor folosi dou structuri repetitive mbricate. Implementarea este
foarte similar cu cea anterioar, aa c o lsm pe seama cititorului. Pentru
a afla rspunsul la o interogare, vor trebui fcute mai multe interogri n
arbore (indiciu: gsii o metod de rezolvare, pornind, eventual, de la
rezolvarea naiv n care S[i][j] = suma submatricii cu colul stnga-sus n
(1, 1) i dreapta-jos n (i, j)).
Exerciii:
a) Se consider problemele prezentate, dar de data aceasta n loc de
sum se cere produsul elementelor din subsecven, respectiv
submatrice. Cum se poate evita lucrul cu numere mari?
b) Extindei arborii indexai binar pentru rezolvarea unei probleme
similare n spaiul tridimensional.
c) Gsii forme echivalente ale expresiei i ^ (i & (i - 1)).

13.5. Arbori de prefixe (Trie)


Am prezentat pn acum structuri de date care lucreaz n principal
cu numere. Ne propunem n continuare s implementm un dicionar, adic
o structur de date cu ajutorul creia s putem manipula eficient o mulime
de cuvinte (sau, mai general, iruri de caractere).

a) Prezentarea general a structurii de date


Figura urmtoare prezint un trie asociat cuvintelor info, mate, inel,
mare, mat, imn.

Fig. 13.5.1. Un trie asociat unui set de cuvinte


481

Capitolul 13
Din aceast figur putem observa deja caracteristicile i proprietile
de baz ale acestei structuri de date.
n primul rnd, un trie este un arbore.
Nodul rdcin este un nod special, care nu are afecteaz coninutul
structurii de date, ci doar uureaz reprezentarea (i implementarea) acestei
structuri.
Fiecare frunz are eticheta \0, care semnific sfritul unui cuvnt.
Verificarea existenei unui cuvnt n dicionar se face ncepnd
parcurgerea arborelui de la rdcin i mergnd succesiv pe fiii etichetai cu
litera de pe poziia corespunztoare a cuvntului cutat. Dac la un moment
dat am ajuns pe caracterul \0, care semnific sfritul unui cuvnt, atunci
cuvntul cutat exist n trie. Dac am ajuns n situaia n care nu exist
niciun fiu al nodului curent care s fie etichetat cu litera de pe poziia
curent a cuvntului cutat, atunci cuvntul cutat nu se afl n trie. Operaia
de cutare se execut n timp O(L), unde L este lungimea cuvntului cutat.
Inserarea unui cuvnt se face n mod similar cu verificarea
existenei unui cuvnt, i are acelai timp de execuie. Singura diferen este
c, atunci cnd nodul curent nu are un fiu etichetat cu litera corespunztoare
poziiei curente a cuvntului care trebuie inserat, un astfel de fiu este creat.
Se procedeaz n acest fel pn cnd au fost create noduri (dac a fost cazul)
pentru toate literele cuvntului. La final, se adaug un nod cu eticheta \0,
semnificnd sfritul cuvntului.
n cele ce urmeaz ne propunem s scriem un program care citete
din fiierul trie.in N operaii de forma op cuv, unde:
op = 0 nseamn adugarea cuvntului cuv n dicionar.
op = 1 nseamn afiarea numrului de apariii a cuvntului cuv
n dicionar.
op = 2 nseamn tergerea unei apariii a cuvntului cuv din
dicionar. Nu se va afia nimic. Exist posibilitatea ca
argumentul acestei operaii (cuvntul care trebuie ters) s nu
existe n dicionar.
Vom explica pe larg modul de funcionare al fiecrei operaii,
precum i implementarea fiecreia.

b) Detalii de implementare
n primul rnd s vedem cum vom reine acest arbore. Fiind vorba de
un arbore n care fiecare nod poate avea un numr relativ mare de fii
482

Structuri avansate de date


(considerm c fiecare nod poate avea 26 de fii, cte unul pentru fiecare
liter din alfabet), vom folosi o structur nod cu urmtoarele cmpuri:
rasp folosit doar de nodurile terminale, ne indic numrul
cuvintelor din trie care au acest nod terminal, adic numrul de
apariii al unui anumit cuvnt.
nrf folosit de toate nodurile, ne indic numrul de fii nevizi ai
nodului curent.
next[26] folosit de toate nodurile, reprezint un vector de
pointeri, fiecare indicnd un anumit fiu. next[0] va indica fiul
etichetat cu a, next[1] fiul etichetat cu b i aa mai departe pn
la next[25] care va indica fiul etichetat cu z.
Aceast structur arat n felul urmtor n C++:
const int maxa = 26;
struct nod
{
int rasp, nrf;
nod *next[maxa];
nod() // constructorul initializeaza campurile de fiecare data cand se
// creeaza o variabila de tip nod
{
rasp = nrf = 0;
memset(next, 0, sizeof(next));
}
};

n acest fel putem accesa ntr-un mod convenabil informaiile


reinute de fiecare nod. Cmpul nrf ne va ajuta s decidem dac un anumit
nod trebuie ters din memorie sau nu.
n continuare vom prezenta implementarea funciilor de gestiune a
arborelui.
Funcia de inserare, Insert(rad, cuv), insereaz cuvntul cuv n
trie-ul cu rdcina n rad. Acest lucru se face traversnd nodurile etichetate
cu caracterul de pe poziia curent a cuvntului. De exemplu, la primul apel
al funciei se verific fiul nodului rad etichetat cu caracterul cuv[0]. Dac
acest fiu nu exist, el este creat. Se apeleaz recursiv funcia pentru acest
fiu, iar urmtoarea verificare se va face cu caracterul cuv[1] (practic, se va
incrementa un pointer, deoarece cuv va fi un pointer ctre char). Se
483

Capitolul 13
procedeaz n acest fel pn cnd cuv va indica sfritul cuvntului, adic \0
(terminatorul de ir). n acest moment se actualizeaz numrul de apariii
(cmpul rasp) al acestui nod terminal i funcia se termin.
Implementarea este urmtoarea:
void Insert(node *rad, const char *cuv)
{
if ( *cuv == '\0' ) // daca am ajuns la terminatorul de sir (deci si la
// nodul terminal)
{
++rad->rasp; // incrementeaza numarul de aparitii al cuvantului cuv
return;
}
int val = *cuv - 'a'; // retine eticheta nodului urmator: 'a' - 'a' = 0,
// 'b' - 'a' = 1 etc.
if ( rad->next[val] == 0 ) // daca nodul nu exista, el trebuie creat
{
rad->next[val] = new nod;
++rad->nrf; // trebuie incrementat numarul de fii al nodului curent
}
Insert(rad->next[val], cuv + 1); // apel recursiv pentru litera urmatoare
}

Funcia de aflare a numrului de apariii ale unui cuvnt,


Apar(rad, cuv), funcioneaz asemntor. Parcurgem arborele pe drumul
dat de caracterele din irul cuv. Fie vom ajunge pe un nod terminal
(etichetat cu \0) i vom afia valoarea cmpului rasp al acestui nod, fie vom
ncerca s accesm un nod care nu exist, caz n care rspunsul va fi 0
(cuvntul nu se afl n dicionar / trie).
Implementarea este urmtoarea:

484

Structuri avansate de date


int Apar(nod *rad, const char *cuv)
{
if ( *cuv == '\0' )
return rad->rasp;
int val = *cuv - 'a';
if ( rad->next[val] )
return Apar(rad->next[val], cuv + 1); // apel recursiv pentru fiul dat
// de litera curenta a cuvantului
return 0; // cuvantul nu exista in dictionar
}

Funcia de tergere a unei apariii a unui cuvnt din dicionar,


Del(radInit, rad, cuv), funcioneaz asemntor, dar trebuie s avem mai
mult grij la implementare.
n primul rnd, trebuie s fim ateni s nu tergem rdcina arborelui
trie, dat de radInit. Chiar dac se terg toate cuvintele din trie, acest nod
rdcin (etichetat, conceptual, cu #) trebuie s rmn pentru a putea
efectua inserri n viitor.
n al doilea rnd, observm c un nod nu poate fi ters efectiv dect
dac acesta nu mai are fii, adic dac nrf este 0 pentru nodul respectiv, iar
rasp este la rndul lui 0, deoarece nu vrem s tergem un nod terminal dect
dac acesta reprezint finalul unui cuvnt care nu mai face parte din
dicionar.
Aadar, funcia del va returna o valoare boolean: true dac am
reuit s tergem efectiv nodul curent i false n caz contrar. Funcia Del va
verifica valoarea ntoars de apelul recursiv efectuat: dac este true, se scade
cu 1 valoarea nrf a nodului curent i se marcheaz fiul respectiv cu 0
(nefolosit, adic nul). Se verific apoi dac nrf este 0, dac rasp este 0 i
dac radInit este diferit de rad, iar dac toate aceste trei condiii sunt
ndeplinite, se terge nodul rad i se returneaz valoarea true. n caz contrar,
se returneaz false.
Modul de parcurgere al arborelui este identic cu modul de parcurgere
folosit de celelalte dou funcii de gestiune.
Implementarea este urmtoarea:

485

Capitolul 13
bool Del(nod *radInit, nod *rad, const char *cuv)
{
int val = *cuv - 'a';
if ( *cuv == '\0' ) // am ajuns la un nod final, scade numarul de aparitii
--rad->rasp;
else if ( Del(radInit, rad->next[val], cuv + 1) ) // daca putem sterge fiul
{
rad->next[val] = 0; // marcam fiul respectiv ca fiind sters
--rad->nrf; // scadem numarul de fii ai nodului curent
}
if ( rad->nrf == 0 && rad->rasp == 0 && rad != radInit )
{
delete rad; // sterge nodul curent daca sunt indeplinite cele 3 conditii
return true; // am putut sterge efectiv nodul curent
}
return false; // nu s-a putut sterge efectiv nodul curent
}

Un inconvenient al acestei abordri este c utilizatorul poate s nu


doreasc returnarea unei valori booleene de care nici mcar nu se poate
folosi (deoarece aceasta nu ne spune dac cuvntul care s-a vrut a fi ters a
existat sau nu n trie). O soluie este s avem o funcie ajuttoare care
apeleaz la rndul su funcia de tergere efectiv i care apeleaz funcia
apar pentru a verifica dac argumentul se afl sau nu n trie. Aceast
metod este folosit mai ales atunci cnd se lucreaz cu clase, unde funciile
care vrem s fie ascunse de utilizatorii clasei pot fi fcute uor private.
Prezentm n final i funcia main:
int main()
{
int N, cod;
string cuv;
nod *trie = new nod;
ifstream in("trie.in");
in >> N;
while ( N-- )
{
in >> cod >> cuv;

486

Structuri avansate de date


switch ( cod )
{
case 0:
Insert(trie, cuv.c_str());
break;
case 1:
cout << Apar(trie, cuv.c_str()) << '\n';
break;
case 2:
Del(trie, trie, cuv.c_str());
break;
}
}
in.close();
return 0;
}

c) Aplicaii
n primul rnd, un trie poate fi folosit ca o alternativ la tabelele de
dispersie. n cel mai ru caz, verificarea existenei unui cuvnt ntr-un tabel
de dispersie are timpul de execuie O(NL), unde N este numrul de cuvinte,
iar L este lungimea cuvntului cutat. Acest caz are loc atunci cnd toate
cuvintele ajung pe aceeai poziie n tabel, iar cuvntul cutat ajunge la
sfritul listei asociate poziiei respective. Cutarea unui cuvnt ntr-un trie
se efectueaz ntotdeauna n timp O(L). n plus, implementarea unui
dicionar cu ajutorul tabelelor de dispersie este mai dificil.
O alt aplicaie important a unui trie este posibilitatea de a sorta
lexicografic cuvintele inserate n acesta ntr-un mod eficient i elegant.
Putem efectua aceast sortare parcurgnd arborele n adncime i innd la
fiecare pas o stiv cu etichetele nodurilor parcurse. Dac avem grij s
efectum apelurile recursive n mod cresctor al etichetelor asociate fiilor
(prima dat pentru fiul etichetat cu a, apoi pentru cel cu b dac exist
etc.), atunci este suficient s afim stiva o dat ajuni pe un nod terminal i
vom obine cuvintele n ordine lexicografic.
Dac ignorm costul afirii (o considerm o operaie care se
execut n O(1) cu alte cuvinte), atunci complexitatea acestui algoritm de
sortare este O(C), unde C reprezint numrul total de noduri din trie, adic
suma caracterelor tuturor cuvintelor din trie.
487

Capitolul 13

d) Alte structuri pentru gestiunea irurilor


Pentru cei interesai, urmtoarele structuri de date sunt foarte
folositoarea n lucrul cu iruri de caractere. Nu le vom prezenta n aceast
ediie, dar le menionm pentru a v putea documenta individual:
iruri de sufixe (suffix arrays)
Arbori de sufixe (suffix trees)
Arbori radix (radix trees, PATRICIA trees)
Exerciii:
a) Scriei o funcie care afieaz toate cuvintele dintr-un trie n
ordine lexicografic.
b) Scriei o funcie care primete ca argumente rdcina unui trie i
un cuvnt cuv i afieaz cel mai lung prefix comun dintre
cuvntul cuv i orice alt cuvnt din trie.
c) Se d un vector A cu N numere naturale. Scriei un program care
gsete o subsecven Ai, Ai + 1, ..., Aj, cu 1 i j N astfel
nct valoarea Ai xor Ai + 1 xor ... xor Aj s fie maxim.
Reamintim tabelul de adevr al operaiei xor:
x
1
0
1
0

y x xor y
0
1
1
1
1
0
0
0

d) Scriei un program care implementeaz un dicionar cu ajutorul


unui trie i cu ajutorul unui tabel de dispersie. Comparai
performanele celor dou structuri de date. Similar, comparai
sortarea unui vector de cuvinte cu ajutorul algoritmilor clasici de
sortare cu sortarea aceluiai vector cu ajutorul unui trie.
e) Elaborai propria voastr analiz experimental a performanei.

13.6. Arbori binari de cutare (Binary Search Trees)


Arborii binari de cutare reprezint o structur de date util n
rezolvarea problemelor de optimizare, suportnd urmtoarele operaii n
timp O(log N) pe cazul favorabil. Cazul defavorabil al acestor operaii este
O(N), dar vom prezenta n capitolul urmtor o structur de date mai
avansat care suport aceste operaii n timp O(log N) n orice caz:
488

Structuri avansate de date


Search(x, T) returneaz o valoare boolean care indic dac
exist sau nu un nod cu valoarea x n arborele cu rdcina n
nodul T.
Insert(x, T) insereaz un nod cu valoarea x n arborele binar de
cutare cu rdcina n nodul T.
Remove(x, T) terge nodul cu valoarea x din arborele cu
rdcina n T.
Un arbore indexat binar cu rdcina n nodul T se definete astfel:
Subarborele stng al nodului T este fie nul, fie conine doar
noduri care au asociate valori mai mici dect valoarea asociat
nodului T.
Subarborele drept al nodului T este fie nul, fie conine doar
noduri care au asociate valori mai mari dect sau egale cu
valoarea asociat nodului T.
Cei doi subarbori ai lui T trebuie s fie la rndul lor arbori binari
de cutare.
De obicei, valorile coninute de un arbore binar de cutare sunt
distincte, dar majoritatea operaiilor funcioneaz fie exact la fel i n cazul
existenei unor valori duplicate, fie necesit doar mici modificri pentru a
funciona i n acest caz. Secvenele de cod prezentate vor presupune c
arborele binar de cutare pe care lucreaz conine doar valori distincte.
Figura urmtoare prezint un arbore binar de cutare pentru irul
9, 3, 6, 10, 1, 11, 8, 4.

Fig. 13.6.1. Un arbore binar de cutare oarecare


489

Capitolul 13
Se poate observa uor c acest arbore respect toate condiiile
menionate mai sus.
n continuare vom prezenta pe rnd fiecare operaie amintit
anterior, iar la sfrit vom prezenta codul unui program complet care rezolv
o problem cu ajutorul unui astfel de arbore.

a) Cutarea unui element (Search)


Funcia Search(x, T) funcioneaz asemntor cu o cutare binar.
Se pornete de la rdcina arborelui i se compar x cu valoarea reinut de
rdcin: dac acestea sunt egale, atunci returnm true (valoarea x exist n
arbore). Dac x este mai mare dect rdcina, atunci este clar c nu poate
exista un nod cu valoarea x dect n subarborele drept, deoarece subarborele
stng conine doar valori mai mici dect rdcina, deci mai mici i dect x.
Dac x este mai mic dect rdcina, atunci putem restnge cutarea la
subarborele stng.
Figura urmtoare prezint modul de cutare al valorii 4 n arborele
anterior:

Fig. 13.6.2. Modul de execuie al algoritmului de cutare

490

Structuri avansate de date


Funcia Search(x, T) poate fi descris n pseudocod astfel:
Dac T este nul execut
o returneaz fals
Dac T.valoare == x execut
o returneaz adevrat.
Dac T.valoare < x execut
o returneaz Search(x, T.dreapta)
Altfel
o returneaz Search(x, T.stnga)
Timpul de execuie al acestei funcii este O(log N), deoarece n
cazuri favorabile arborele este balansat (nlimea sa este O(log N), unde N
este numrul de noduri) i la fiecare pas algoritmul merge cu un nivel n jos.

b) Inserarea unui element (Insert)


Pentru a implementa funcia Insert(x, T), vom proceda ntr-un mod
similar cu funcia de cutare. De fapt, singura diferen dintre aceste dou
funcii este c, dac ajungem pe un nod nul (care nu exist), nu mai
returnm fals, ci crem nodul respectiv, atribuindu-i valoarea x.
Deoarece am presupus c arborele nu va conine dect valori
distincte, nu mai este necesar nici s verificm dac un nod curent are
valoarea x sau nu.
Algoritmul de inserare pentru funcia Insert(x, T) poate fi descris n
pseudocod astfel:
Dac T este nul execut
o T.valoare = x
o ieire din funcie
Dac T.valoare < x execut
o apeleaz recursiv Insert(x, T.dreapta)
Altfel
o apeleaz recursiv Insert(x, T.stnga)
Complexitatea acestei funcii este tot O(log N), din exact aceleai
motive enunate pentru funcia de cutare.

491

Capitolul 13

c) tergerea unui element (Remove)


Operaia de tergere a unui nod cu o anumit valoare din arbore este
o operaie puin mai dificil, deoarece trebuie s inem cont de structura
arborelui, structur care trebuie s se pstreze dup tergerea oricrui nod.
Se pot identifica trei cazuri care apar atunci cnd vrem s tergem un
anumit nod:
1. Nodul pe care vrem s-l tergem nu are fii.
2. Nodul pe care vrem s-l tergem are un singur fiu.
3. Nodul pe care vrem s-l tergem are doi fii.
Bineneles c trebuie mai nti s identificm nodul pe care vrem sl tergem. Aceast identificare se face exact la fel ca i n cadrul
algoritmului de cutare, aa c nu vom insista asupra acestui aspect.
Vom prezenta n continuare modul de gestionare al fiecrui caz n
parte.

Cazul I
Nodul pe care vrem s-l tergem nu are fii
Acesta este cel mai convenabil caz. Tot ce trebuie s facem este s
identificm nodul care trebuie ters i s-l eliminm din arbore.

Fig. 13.6.3. tergerea unui nod fr fii

492

Structuri avansate de date


Cazul II
Nodul pe care vrem s-l tergem are un singur fiu
Nici acest caz nu prezint probleme prea mari. Deoarece nodul n
cauz are un singur fiu, putem s copiem fiul n nodul pe care vrem s-l
tergem, iar apoi s tergem fiul.

Fig. 13.6.4. tergerea unui nod cu un singur fiu


Cazul III
Nodul pe care vrem s-l tergem are doi fii
Acesta este cel mai complex caz al algoritmului de tergere dintr-un
arbore binar de cutare. De aceast dat nu mai putem terge nodul prin
simple operaii cu pointeri, ci va trebui s gsim o metod de a reduce acest
caz la unul dintre cazurile anterioare.
Pentru aceasta, s analizm ce nseamn efectiv tergerea unui nod
cu doi fii. Aceast tergere nsemn:
1. nlocuirea nodului care se vrea a fi ters cu un nod a crui
valoare este cea mai mare valoare mai mic dect valoarea
nodului pe care vrem s-l tergem.
2. nlocuirea nodului care se vrea a fi ters cu un nod a crui
valoare este cea mai mic valoare mai mare dect valoarea
nodului pe care vrem s-l tergem.
Alegnd un nod care respect una dintre cele dou condiii de mai
sus ne asigurm c nu va exista niciun nod n arbore care s nu respecte
493

Capitolul 13
proprietile arborilor binari de cutare. Acest lucru se poate demonstra uor
prin reducere la absurd.
Ne punem aadar problema determinrii unui nod a crui valoare se
ncadreaz n unul dintre cele dou cazuri de mai sus. Pentru acest lucru,
vom reaminti algoritmul de parcurgere n ordine a unui arbore,
specializat pentru arbori binari:
Fie InOrdine(T) o funcie care afieaz parcurgerea n ordine a
arborelui cu rdcina n T. Aceast funcie poate fi implementat astfel:
Dac T este nenul execut
o apeleaz recursiv InOrdine(T.stnga)
o afieaz T.valoare
o apeleaz recursiv InOrdine(T.dreapta)
Datorit structurii arborilor binari de cutare, aceast parcurgere are
prorietatea de a afia valorile inserate ntr-un arbore n ordine cresctoare.
De exemplu, pentru arborele binar de cutare dat ca exemplu, funcia va
afia: 1 3 4 6 8 9 10 11.
Pornind de la acest algoritm de afiare n ordine cresctoare a
valorilor din arbore, problema iniial se reduce la a gsi fie valoarea care
precede o valoare fixat n cadrul acestei parcurgeri, fie valoarea care
urmeaz dup o valoare fixat n cadrui acestei parcurgeri. Altfel spus, va
trebuie s gsim fie predecesorul unui nod n parcurgerea n ordine, fie
succesorul unui nod n parcurgerea n ordine.
Predecesorul unui nod T n parcurgerea n ordine este cel mai din
dreapta nod al subarborelui stng al lui T. De exemplu, figura de mai jos
prezint modul de aflare al predecesorului nodului cu valoarea 9 n
parcurgerea n ordine:

Fig. 13.6.5. Aflarea predecesorului unui nod din parcurgerea n ordine


494

Structuri avansate de date


Succesorul unui nod T n parcurgerea n ordine este cel mai din
stnga nod al subarborelui drept al lui T. De exemplu, figura de mai jos
prezint modul de aflare al succesorului nodului cu valoarea 3:

Fig. 13.6.6. Aflarea succesorului unui nod din parcurgerea n ordine


Se poate observa din cele dou figuri anterioare c 8 este cea mai
mare valoare din arbore mai mic dect 9, iar 4 este cea mai mic valoare
din arbore mai mare dect 3.
Pentru a rezolva problema iniial, adic tergerea unui nod care are
doi fii, vom nlocui aadar nodul respectiv fie cu predecesorul su n
parcurgerea n ordine, fie cu succesorul su n aceast parcurgere, care se
poate determina uor aa cum am artat. Predecesorul sau succesorul cu care
nlocuim nodul pe care vrem s-l tergem va fi la rndul su ters conform
algoritmilor afereni primelor dou cazuri.

Fig. 13.6.7. tergerea predecesorului conform cazului I


495

Capitolul 13
Sau:

Fig. 13.6.7. tergerea succesorului conform cazului II


Deoarece n cadrul acestui caz avem dou posibiliti, este
recomandat s alegem aleator dac vom nlocui nodul care trebuie ters cu
predecesorul su din parcurgerea n ordine sau cu succesorul su, deoarece
alegnd de fiecare dat acelai lucru cresc ansele ca arborele s degenereze
ntr-o list nlnuit, lucru care scade foarte mult performana acestei
structuri de date, aa cum vom vedea n continuare.

d) Cazuri defavorabile
Am afirmat la nceput c operaiile de cutare, inserare i tergere
ntr-un arbore binar de cutare au complexitatea O(log N) pe cazuri
favorabile. Un caz favorabil este dat de un arbore a crui nlime este O(log
N), iar un caz defavorabil de un arbore a crui nlime este O(N). Fiecare
operaie prezentat are proprietatea de a lucra la fiecare pas cu un singur
element dintr-un singur nivel al arborelui, deci dac arborele are nlimea
O(log N), atunci i aceste operaii se vor executa n aceeai complexitate.
Dac nlimea arborelui este mai apropiat de N ns, atunci fiecare
operaie va avea o complexitate liniar.
De exemplu, privii cum se construiete un arbore binar de cutare
pentru valorile 1 2 3 4 5:

496

Structuri avansate de date

Fig. 13.6.8. Modul de construcie al unui


arbore binar de cutare dezechilibrat
Aa cum se poate vedea, fiecare operaie pe un astfel de arbore va
trebui s parcurg toate nodurile n cel mai ru caz.
Din aceste caz, arborii binari de cutare nu se folosesc de obicei n
practic, cel puin nu n forma prezentat aici. n practic se folosesc arbori
echilibrai de cutare, adic arbori a cror nlime este ntotdeauna
proporional cu logaritmul numrului de noduri. Am prezentat deja o
structur de date probabilist care are aceast proprietate: listele de salt.

e) Detalii de implementare
Vom prezenta pe rnd implementarea fiecrei funcii pentru care am
furnizat pn acum doar pseudocod. n primul rnd, pentru a reine un
arbore binar de cutare avem nevoie de o structur nod care va reprezenta
un nod al arborelui. Aceasta va conine trei cmpuri.
struct nod
{
int val; // valoarea aferenta nodului curent
nod *st, *dr; // pointeri la subarborele stang respectiv drept
nod(int v) : val(v) // val se initializeaza cu v, iar st si dr vor fi nuli initial
{
st = dr = NULL;
}
};

497

Capitolul 13
Avnd aceast structur, operaiile de inserare i de cutare nu
prezint mari probleme la implementare. Funcia de inserare poate fi
implementat astfel:
void Insert(int x, nod *&T) // pointerul trebuie transmis prin referinta,
// deoarece se va modifica
{
if ( T == NULL )
T = new nod(x);
else if ( T->val < x )
Insert(x, T->dr);
else
Insert(x, T->st);
}

Iar funcia de cutare rmne la rndul ei fidel pseudocodului:


bool Search(int x, nod *T)
{
if ( T == NULL )
return false;
else if ( T->val == x )
return true;
else if ( T->val < x )
return Search(x, T->dr);
else
return Search(x, T->st);
}

O alt functie important este cea de parcurgere n ordine a


arborelui. Apelarea acestei funcii cu rdcina unui arbore binar de cutare
ca parametru va avea ca rezultat afiarea n ordine cresctoare a valorilor din
acel arbore.
void InOrdine(nod *T)
{
if ( T != NULL )
{
InOrdine(T->st);
cout << T->val << ;
InOrdine(T->dr);
}
}

498

Structuri avansate de date


Pentru algoritmul de terge a unui nod vom folosi patru funcii:
RemoveCazI(T), RemoveCazII(T), RemoveCazIII(T), care vor gestiona
tergerea nodului T conform fiecrui caz aferent i o funcie Remove(T)
care va cuta nodul care trebuie ters i va decide n care dintre cele trei
cazuri se ncadreaz acesta, apelnd funcia corespunztoare de tergere
efectiv.
void RemoveCazI(nod *&T) { delete T; T = NULL; }
void RemoveCazII(nod *&T)
{
nod *fiu; // salvam fiul nenul
if ( T->st == NULL )
fiu = T->dr;
else
fiu = T->st;

void Remove(int x, nod *&T)


{
if ( T == NULL )
return;
// se foloseste algoritmul de cautare
// intr-un arbore pentru a gasi nodul
// care trebuie sters.
if ( T->val == x )
{
if ( T->st == NULL &&
T->dr == NULL )
RemoveCazI(T);
else if ( T->st == NULL ||
T->dr == NULL )
RemoveCazII(T);
else
RemoveCazIII(T);
}
else if ( T->val < x )
Remove(x, T->dr);
else
Remove(x, T->st);

// T este inlocuit cu fiul sau nenul


delete T;
T = fiu;
}
void RemoveCazIII(nod *T)
{
// vom inlocui nodul T cu
// predecesorul sau in parcurgerea
// in ordine, adica cel mai din
// dreapta nod al subarborelui stang
// al lui T. Implementarea prezentata
// este iterativa. Implementarea
// recursiva necesita mai putine
// operatii cu pointeri.
}
nod **pred = &T->st;
while ( (*pred)->dr != NULL )
pred = &(*pred)->dr;
T->val = (*pred)->val;
if ( (*pred)->st == NULL )
RemoveCazI(*pred);
else
RemoveCazII(*pred);
}

499

Capitolul 13
Pentru simplitate, implementarea prezentat nlocuiete ntotdeauna,
n cadrul cazului III, nodul care trebuie ters cu predecesorul su din
parcurgerea n ordine. Aa cum am spus mai devreme ns, acest lucru nu
este indicat deoarece poate contribui la debalansarea arborelui.
Recomandm cititorilor s implementeze o variant care alege aleator ntre
predecesorul i succesorul nodului pe care vrem s-l tergem.
Menionm c pentru folosirea acestor funcii, trebuie declarat i
iniializat cu NULL o variabil de tip nod * prin instruciunea:
nod *T = NULL;

care poate fi transmis apoi funciilor prezentate.

f) Ali algoritmi
Am prezentat pn acum algoritmii de baz afereni acestei structuri
de date. Vom prezenta n continuare dou probleme importante care se pot
rezolva cu ajutorul arborilor binari de cutare i anume:
1. identificarea celei mai mici valori din arbore n timp mediu
O(log N).
2. identificarea celei de-a k-a cea mai mic valoare din arbore n
timp mediu O(log N).
1. Identificarea celei mai mici valori n timp mediu O(log N)
O prim idee de rezolvare a problemei ar fi s parcurgem arborele n
ordine i s returnm primul element din cadrul acestei parcurgeri. De fapt,
putem astfel s rezolvm ambele probleme, doar c timpul de execuie va fi
O(N).
Putem ns s oprim parcurgerea n ordine imediat dup ce aceasta a
furnizat prima valoare, deoarece tim sigur c aceasta este valoarea minim.
Vom arta n continuare c n acest fel se execut un numr de pai
proporional cu nlimea arborelui, adic O(log N) pe cazul mediu. S
considerm urmtorul arbore:
Se poate observa uor c funcia de parcurgere n ordine se
autoapeleaz avnd ca parametru fiul stnd al nodului curent. Acest lucru se
face pn cnd se ajunge pe un nod nul. La revenire din recursivitate se va
afia valoarea nodului curent. Aadar, prima valoare afiat de ctre
algoritmul de parcurgere n ordine este cea mai din stnga valoare a
arborelui, sau, altfel spus, nodul pe care se ajunge pornind din rdcin i
mergnd la fiecare pas pe fiul stng al nodului curent, dac acesta exist.

500

Structuri avansate de date


Aadar, putem afla cea mai mic valoare din arbore n timp mediu
O(log N) cu ajutorul urmtoarei funcii:
int Minim(nod *T)
{
while ( T->st != NULL )
T = T->st;
return T->val;
}

Putem afla cea mai mare valoare din arbore aflnd care este cel mai
din dreapta nod al arborelui. Acest lucru este corect deoarece algoritmul de
parcurgere n ordine furnizeaz ultimul rezultat umplnd stiva cu apeluri
recursive pentru fiul drept al nodului curent.
int Maxim(nod *T)
{
while ( T->dr != NULL )
T = T->dr;
return T->val;
}

Astfel am rezolvat problema n timp O(log N) i memorie


suplimentar O(1).
2. Identificarea celei de-a k-a cea mai mic valoare n timp
mediu O(log N)
Pentru rezolvarea acestei probleme va fi necesar s modificm puin
structura arborelui. Vom mai aduga un cmp numit nr care va reine,
pentru fiecare nod, numrul de noduri din subarborele stng al su (lund n
considerare i nodul n sine). Acest cmp poate fi actualizat cu o simpl
modificare a funciei de inserare, modificare lsat ca exerciiu pentru
cititor.
Avnd aceast informaie n fiecare nod, algoritmul de rezolvare
const ntr-o funcie kMinim(T, k) implementat astfel:
Dac T.nr == k execut
o returneaz T.val
Dac T.nr < k execut
o returneaz kMinim(T.dr, k T.nr)
501

Capitolul 13
Altfel
o returneaz kMinim(T.st, k)
Raionamentul care ne conduce la acest algoritm este urmtorul:
dac ne aflm la un nod T i acesta are nr fii n subarborele su stng (n
acest subarbore intr i T), adic nr noduri mai mici sau egale cu valoarea
lui T, iar k este egal cu nr, atunci evident al k-lea cel mai mic element din
arbore este T.
Dac n schimb nr este mai mic dect k, atunci tim c al k-lea cel
mai mic element este mai mare dect valoarea lui T i se afl undeva n
subarborele drept al lui T. Putem aadar s facem abstracie de subarborele
stng al lui T i s reducem problema la gsirea celui x-lea cel mai mic
element din subarborele drept, unde x = k nr.
Altfel este clar c nr < k, deci elementul cutat se afl undeva n
subarborele stng al nodului curent. Putem aadar s reducem problema la
gsirea celui de-al k-lea cel mai mic element din subarborele stng.
De exemplu, s considerm urmtorul arbore binar de cutare, n
care am marcat valorile nr pentru fiecare nod:

Fig. 13.6.9. Un arbore binar de cutare favorabil rezolvrii eficiente a


problemei prezentate
S presupunem c vrem s gsim a 7-a cea mai mic valoare din
arbore. Pornim de la rdcin. 6 < 7, aa c vom reduce problema la gsirea
celui mai mic element din subarborele drept al rdcinii (cel format din
nodurile 10 i 11). 1 == 1, deci 10 este valoarea cutat.
502

Structuri avansate de date

g) Analiza experimental a performanei


Fiecare operaie testat are complexitatea O(log N) pe cazul mediu.
Cea mai relevant comparaie se poate face cu listele de salt. Teoretic, listele
de salt sunt mai puin probabile s degenereze n complexitatea O(N), dar
deoarece folosim numere strict aleatoare pentru testare, acest lucru nu este
foarte important n practic.
Tabelul 13.6.10. Performana orientativ a arborilor binari de cutare
Numr test
1
2
3
4
5
6
7
8

Inserri

Cutri

tergeri

1 000
1 000
1 000
10 000
10 000
10 000
100 000
0
0
100 000
100 000
0
100 000
100 000
100 000
1 000 000
0
0
1 000 000 1 000 000
0
1 000 000 1 000 000 1 000 000

Timp (s)
0.020
0.030
0.065
0.103
0.140
0.758
1.600
2.434

Fa de
liste de salt
mai bine
mai bine
mai bine
mai bine
mai bine
mai bine
mai bine
mai bine

Dup cum se vede, pe testele cu numere aleatoare arborii binari de


cutare sunt mai eficieni dect listele de salt. La o comparare direct a
acestor dou structuri de date pe un test format din 50 000 de inserri a unor
valori distincte de la 0 la 50 000, urmate de 1 000 de cutri ale unor valori
aleatoare, obinem ns urmtoarele rezultate:
Arbori binari de cutare: aproximativ 13 secunde.
Liste de salt: aproximativ 0.1 secunde.
Mai mult, dac mrim numrul de valori inserate, implementarea
prezentat pentru arbori binari de cutare poate depi dimensiunea stivei,
cauznd o eroare de execuie, iar o implementare iterativ a tuturor
funciilor este mai dificil. Aadar, arborii binari de cutare nu sunt rentabili
dect atunci cnd tim ct se poate de sigur c datele gestionate nu vor cauza
atingerea cazului defavorabil.
Exerciii
a) Scriei o funcie care determin al k-lea cel mai mare element
dintr-un arbore binar de cutare.
b) Prezentai dou abordri pentru ca un arbore binar de cutare s
suporte inserarea mai multor valori identice. Care este mai
avantajoas?
503

Capitolul 13
c) Scriei un program care afieaz parcurgerea n preordine i n
postordine a unui arbore binar de cutare.
d) Scriei un program care determin ci arbori binari de cutare
distinci din punct de vedere structural exist avnd ca elemente
numere distincte din mulimea {1, 2, ..., N}. De exemplu, pentru
N = 4 rspunsul este 14, pentru N = 5 este 42, iar pentru N = 6
este 132.
e) Scriei un program care determin dac un arbore binar dat ca
date de intrare este sau nu arbore binar de cutare. Gsii un
algoritm eficient.
f) Scriei un program care determin numrul de noduri dintr-un
arbore binar de cutare cu valori mai mici dect o valoare dat
(nu este obligatoriu ca valoarea dat s se regseasc n arbore).

13.7. Arbori binari de cutare cutare echilibrai


Am prezentat n seciunea anterioar o structur de date care suport
operaiile de inserare, cutare i tergere a unui element n timp O(log N) n
cazuri favorabile. Am gsit ns foarte uor exemple n care arborii binari de
cutare degenereaz n liste nlnuite.
Vom prezenta n continuare o structur de date probabilist care
reprezint un arbore binar de cutare echilibrat, adic a crui nlime s
fie, cu o probabilitate foarte mare (ca i n cazul listelor de salt, pentru toate
scopurile practice vom putea spune ntotdeauna) O(log N) n toate cazurile.
Un treap este un arbore binar n care fiecare nod are asociate dou
entiti: o valoare (sau cheie) i o prioritate. Valorile nodurilor treap-ului
vor respecta proprietile unui arbore binar de cutare, iar prioritile
nodurilor vor respecta proprietile unui heap. Valorile reprezint datele
inserate de ctre utilizator, iar prioritile vor fi nite numere aleatoare
atribuite fiecriui nod.
Vom presupune i aici c oricare dou valori din arbore sunt
distincte.
Figura urmtoare prezint un treap. Valorile sunt marcate cu rou,
iar prioritile cu albastru.

504

Structuri avansate de date

Fig. 13.7.1. Un treap oarecare


Se poate observa c valorile roii descriu un arbore binar de cutare,
iar cele albastre respecta ordinea unui heap (un max-heap, dar se poate
folosi la fel de bine i un min-heap).
Pentru a implementa operaiile de inserare i stergere pe un treap, ne
punem problema pstrrii structurii de heap i de arbore binar de cutare att
dup execuia unei operaii de tergere ct i dup execuia unei operaii de
inserare. Pentru acest lucru vom folosi rotaii, operaii care vor sta la baza
algoritmilor de inserare i de tergere. Figura de mai jos prezint cele dou
tipuri de rotaii pe care le vom folosi:

Fig. 13.7.2. Rotaiile folosite n cadrul treap-urilor


Dup cum se poate deduce din figura anterioar, vom efectua o
rotaie atunci cnd un nod nu respect proprietatea de heap. Prin aceste
rotaii vom pstra proprietatea de arbore binar de cutare i vom restaura i
proprietatea de heap.

505

Capitolul 13
Ne vom referi n continuare la arborele din partea stng, rotaia spre
stnga explicndu-se analog. S presupunem c nodul cu valoarea 7 din
figur nu respect proprietatea de heap, adic prioritatea nodului cu valoarea
7 (b) este mai mare dect prioritatea nodului cu valoarea 9 (a). Este clar c
rotind nodul cu valoarea 7 spre stnga se restituie proprietatea de heap a
arborelui, deoare n arborele din dreapta nodul 7 va fi tatl nodului 9, iar
b > a.
Vom arta n continuare c o rotaie spre dreapta pstreaz
propritatea de arbore binar de cutare.
n arborele stng avem urmtoarele inegaliti (fiecare identificator
va descrie valoarea rdcinii subarborelui respectiv):
A< 7<B
9<C
A, 7, B < 9
Combinnd aceste relaii obinem inegalitile: A < 7 < B < 9 < C.
n arborele drept avem urmtoarele inegaliti:
A< 7
B<9<C
7 < 9, B, C
Combinnd aceste relaii obinem inegalitile: A < 7 < B < 9 < C.
Aadar, deoarece am obinut acelai ir de inegaliti ntre noduri
att nainte ct i dup rotaie, am demonstrat pstrarea invariantului
arborilor binari de cutare dup efectuarea unei rotaii spre dreapta.
Demonstraia n sens invers este identic.
Vom prezenta n continuare pseudocod pentru operaiile necesare n
lucrul cu treap-uri.

a) Echilibrarea arborelui
Echilibrarea arborelui este necesar atunci cnd inserarea sau
tergerea unui nod face ca un nod al arborelui s nu mai respecte
proprietatea de heap. Pentru a restabili aceast proprietate vom efectua o
rotaie a acelui nod spre dreapta sau spre stnga, dup caz:
dac prioritatea fiului stng al lui T este mai mare dect
prioritatea lui T, atunci se efectueaz o rotaie spre dreapta a
fiului stng.
dac prioritatea fiului drept al lui T este mai mare dect
prioritatea lui T, atunci se efectueaz o rotaie spre stnga a fiului
drept.
506

Structuri avansate de date


Funcia RotDr(T), care rotete fiul drept al lui T spre stnga poate fi
scris astfel:
temp = T.dr
T.dr = temp.st
temp.st = T
T = temp
Iar funcia RotSt(T), care rotete fiul stng al lui T spre dreapta
poate fi scris astfel:
temp = T.st
T.st = temp.dr
temp.dr = T
T = temp
n final, funcia Echilibrare(T), care restabiliete proprietatea de
heap, poate fi scris astfel:
Dac T.st nu este nul i T.st.prioritate > T.prioritate execut
o apeleaz RotSt(T)
Altfel dac T.dr nenul i T.dr.prioritate > T.prioritate execut
o apeleaz RotDr(T)
Complexitatea acestei funcii este O(1).
b) Cutarea unui element (Search)
Cutarea unui element ntr-un treap se face exact ca ntr-un arbore
binar de cutare, deoarece nu trebuie s inem cont dect de valori, nu i de
prioriti.
Pseudocodul funciei Search(x, T) este urmtorul:
Dac T este nul execut
o returneaz false
Dac T.valoare == x execut
o returneaz true.
Dac T.valoare < x execut
o returneaz Search(x, T.dreapta)
Altfel
o returneaz Search(x, T.stnga)

507

Capitolul 13

c) Inserarea unui element (Insert)


Inserarea unui element este, la rndul su, o operaie aproape
identic inserrii ntr-un arbore binar de cutare. O deosebire este c, la
revenire din recursivitate, se va apela funcia Echilibrare(T) pentru a
restabili, dac este cazul, proprietatea de heap. Alt deosebire const n
atribuirea unei prioriti aleatore elementului nainte ca acesta s fie inserat
n arbore. Pseudocodul funciei Insert(x, T) este urmtorul:
Dac T este nul execut
o T.valoare = x
o T.prioritate = numr ales aleator
o ieire din funcie
Dac T.valoare < x execut
o apeleaz recursiv Insert(x, T.dreapta)
Altfel dac T.valoare > x execut
o apeleaz recursiv Insert(x, T.stnga)
apeleaz Echilibrare(T)
S urmrim modul de execuie al funciei pe urmtorul arbore dac
dorim s inserm un nod cu valoarea 5 i prioritatea 19.
n prima faz se gsete poziia nodului ignornd prioritile i lund
n considerare doar valorile din arbore, dup care se echilibreaz arborele
folosind rotaii.
Echilibrarea arborelui prin rotaii se face la revenirea din
recursivitate, prin apelul funciei Echilibrare(T), aa cum se poate vedea n
pseudocod. Recomandm cititorului s se familiarizeze cu aceast funcie
nainte de a merge mai departe, deoarece acea funcie i implicit rotaiile
prezentate anterior stau la baza gestionrii unui treap.

508

Structuri avansate de date

Fig. 13.7.3. Modul de execuie al algoritmului


de inserare ntr-un treap
Complexitatea operaiei de inserare a unui element n treap este
O(log N), deoarece numrul de operaii este limitat de nlimea arborelui.

d) tergerea unui element (Remove)


Operaia de tergere a unui nod cu o anumit valoare este, practic,
inversa operaiei de inserare. Dup ce am identificat nodul care trebuie ters,
vom roti n locul su fiul cu prioritatea cea mai mare. Complexitatea este
O(log N). Pseudocodul funciei Remove(x, T) este urmtorul:
Dac T este nul execut
o ieire din funcie
Dac T.valoare < x execut
o apeleaz recursiv Remove(x, T.dreapta)
Altfel dac T.valoare > x execut
o apeleaz recursiv Remove(x, T.stnga)
Altfel execut
509

Capitolul 13
o Dac T.stnga i T.dreapta sunt nuli execut
terge T
o Altfel dac T.stnga e nul sau T.dreapta e nul execut
Dac T.stnga e nul execut RotDr(T)
Altfel execut RotSt(T)
o Altfel execut
Dac T.st.prioritate > T.dr.prioritate execut
RotSt(T)
Altfel execut RotDr(T)
o Apeleaz recursiv Remove(x, T)

e) Detalii de implementare
Structura asociat nodurilor unui treap este asemntoare cu cea de
la arbori binari de cutare, singura diferen fiind c mai avem un cmp ce
reprezint prioritatea:
struct nod
{
int val; // valoarea nodului curent
int pr; // prioritatea nodului curent
nod *st, *dr; // fiul stang respectiv drept
nod(int v) : val(v)
{
pr = rand(); // fiecare nod primeste o prioritate aleatoare
st = dr = NULL;
}
};

Att funcia de cutare ct i funcia de parcurgere n ordine a


arborelui au exact aceeai implementare ca la arbori binari de cutare, aa c
nu vom prezenta din nou aceste implementri.
Menionm c nainte de folosirea unui treap trebuie iniializat
generatorul de numere aleatoare prin includerea fiierelor antet <cstdlib> i
<ctime> i executarea instruciunii srand((unsigned)time(0));
Funcia de echilibrare, care asigur pstrarea proprietii de heap n
timpul operaiilor de inserare i tergere se poate implementa, mpreun cu
funciile de rotaie, astfel:

510

Structuri avansate de date


void RotDr(nod *&T)
{
nod *temp = T->dr;
T->dr = temp->st;
temp->st = T;
T = temp;
}

void Echilibrare(nod *&T)


{
if ( T->st != NULL && T->st->pr > T->pr )
RotSt(T);
else if ( T->dr != NULL && T->dr->pr > T->pr )
RotDr(T);
}

void RotSt(nod *&T)


{
nod *temp = T->st;
T->st = temp->dr;
temp->dr = T;
T = temp;
}

Funciile de inserare respectiv de tergere pot fi implementate


astfel:
void Insert(int x,
nod *&T)
{
if ( T == NULL )
{
T = new nod(x);
return;
}

void Remove(int x, nod *&T)


{
if ( T == NULL )
return;
if ( T->val < x )
Remove(x, T->dr);
else if ( T->val > x )
Remove(x, T->st);
else
{
if ( T->st == NULL && T->dr == NULL )
{
delete T;
T = NULL;
}
else if ( T->st == NULL || T->dr == NULL )
T->st != NULL ? RotSt(T) : RotDr(T);
else
T->st->pr > T->dr->pr ? RotSt(T) : RotDr(T);

if ( T->val < x )
Insert(x, T->dr);
else if ( T->val > x )
Insert(x, T->st);
Echilibrare(T);
}

Remove(x, T);
}
}

511

Capitolul 13
Se poate observa c, spre deosebire de implementarea funciei de
inserare din cadrul arborilor binari de cutare, implementarea prezentat aici
nu permite inserarea unei valori care exist deja n treap. Acest lucru se
datoreaz faptului c algoritmul de tergere ar intra ntr-un ciclu infinit dac
ar exista valori duplicate.
n cazul arborilor binari de cutare nu este obligatorie impunerea
unicitii elementelor, dar acest lucru este oricum recomandat. Pentru a
suporta valori duplicate, cea mai bun soluie este adugarea unui cmp
nrVal nodurilor, care s indice de cte ori apare valoarea respectiv. Astfel,
algoritmii de gestionare nu necesit dect modificri minime.
Funcia de parcurgere n ordine a unui treap se poate implementa
exact ca la arbori binari de cutare, deoarece aceasta nu are nevoie dect de
valorile nodurilor, nu i de prioritile acestora.
Algoritmii de determinare a minimului i de determinare a celui deal k cel mai mic element sunt, la rndul lor, similari n implementare.
Algoritmul de determinare al minimului este identic, iar cel de determinare a
celui de-al k cel mai mic element necesit doar modificarea modului de
calcul a valorii nr. Va trebui s fim ateni s actualizm aceast valoare
dup fiecare rotaie efectuat.

f) Analiza experimental a performanei


S vedem cum se comport treap-urile n comparaie cu listele de
salt i arborii binari de cutare. Comparaiile cu celelalte structuri de date
prezentat nu sunt foarte relevante, ntruct acestea sunt de obicei folosite n
rezolvarea unor probleme diferite.
Tabelul 13.7.4. Performana orientativ a arborilor treap
Nr.
1
2
3
4
5
6
7
8

Inserri

Cutri

tergeri

1 000
1 000
1 000
10 000
10 000
10 000
100 000
0
0
100 000
100 000
0
100 000
100 000
100 000
1 000 000
0
0
1 000 000 1 000 000
0
1 000 000 1 000 000 1 000 000

512

Timp (s)
0.022
0.033
0.108
0.144
0.191
1.669
2.823
3.978

Fa de
liste de salt
mai bine
mai bine
mai bine
mai bine
mai bine
mai bine
mai bine
mai bine

Fa de
BST
mai ru
mai ru
mai ru
mai ru
mai ru
mai ru
mai ru
mai ru

Structuri avansate de date


Tabelul prezint rezultatele testelor de performan a structurilor de
date pe date aleatoare, generate cu ajutorul funciei rand(). Se poate observa
c pe astfel de date cel mai bine se comport arborii binari de cutare,
urmai de treap-uri, iar apoi de listele de salt.
Supunnd treap-urile aceluiai test format din inserarea a 50 000 de
valori distincte de la 0 la 50 000, urmate de cutarea a 1 000 de valori
aleatoare, obinem un rezultat foarte bun: 0.07 secunde, mult mai bine dect
arborii binari de cutare i mai bine chiar i dect listele de salt. Aadar,
treap-ul este cea mai bun alternativ atunci cnd nu ne permitem cazuri
defavorabile i dorim totodat o implementare accesibil.
Mai mult, deoarece nlimea unui treap este, cu o probabilitate
foarte mare, O(log N), nu exist riscul ca implementarea recursiv a
operaiilor de gestiune s depeasc memoria alocat stivei. De exemplu,
dac rulm acelai test cu 1 000 000 de inserri a unor valori distincte,
timpul de execuie este de 0.8 secunde.
Exerciii:
a) Scriei un program care determin numrul de treap-uri distincte
cu N valori de la 1 la N i cu prioriti distincte de la 1 la N. De
exemplu, pentru N = 3 exist 6 astfel de treap-uri. Dou treap-uri
T1 i T2 se consider diferite dac:
T1.valoare este diferit de T2.valoare sau T1.prioritate
este diferit de T2.prioritate.
Treap-ul T1.stnga difer de T2.stnga sau T1.dreapta
difer de T2.dreapta.
b) Rezolvai aceleai probleme de la arbori binari de cutare
folosind treap-uri.
c) Scriei o funcie Split care primete ca argument un numr ntreg
x i ntoarce dou treap-uri A i B astfel nct A s conin doar
valori mai mici dect x i B doar valori mai mari dect x.
d) Scriei o funcie Join care primete ca argumente dou treap-uri
A, B i o valoare x, cu semnificaia de mai sus i unete treapurile A i B ntr-un singur treap.

513

Capitolul 13

13.8. Concluzii
Sperm c acest ultim capitol, ct i ntreaga lucrare, v-au fost i v
vor fi n continuare folositoare n studiul algoritmilor. Cititorii care au
parcurs temeinic materialul pus la dispoziie n aceast carte ar trebui s aib
deja o nelegere clar a noiunilor algoritmice elementare i a metodelor de
rezolvare a problemelor aferente acestui domeniu.
Cititorii care simt c nu i-au nsuit n totalitate toate temele
abordate nu trebuie s-i fac griji. Aceast carte poate fi folosit i ca o
referin asupra algoritmilor i a implementrilor acestora n limbajul C++.
Mai mult, unele capitole nici nu sunt scrise cu gndul de a putea fi nelese
ntr-un timp foarte scurt de ctre nceptori acest lucru ar fi imposibil de
realizat fr a pierde din rigoare.
n ncheiere, dorim tuturor cititorilor perseveren n studii i succes
n orice demersuri ntreprinse!
Profitm de aceste ultime rnduri pentru a v aduce la cunotin
publicarea, n viitorul apropiat, a unei cri intitulate Tehnici de
programare aplicate, care se va axa exclusiv pe rezolvarea unor
probleme date la concursuri naionale, olimpiade i site-uri de evaluare
online.
Sperm s ne rmnei fideli n continuare!
Autorii

514

Bibliografie

BIBLIOGRAFIE
1. Adrian Alexandrescu Programarea modern n C++. Programare
generic i modele de proiectare aplicate, Teora, Bucureti, 2002.
2. Alfred V. Aho, John E. Hopcroft, Jeffrey D. Ullman, Data Structures
and Algorithms, Addison-Wesley, 1983.
3. Alfred V. Aho, John E. Hopcroft, Jeffrey D. Ullman, The Design and
Analysis of Computer Algorithms, Addison-Wesley, 1974.
4. Bla Bollobs, Random Graphs, Academic Press, 1985.
5. C. A. R. Hoare, Algorithm 63 (partition) and algorithm 65 (find),
Communications of the ACM, 4(7):321-322, 1961.
6. C. A. R. Hoare, Quicksort, Computer Journal, 5(1):10-15, 1962.
7. C. Y. Lee, An algorithm for path connection and its applications, IRE
Transactions on Electronic Computers, EC-10(3):346-365, 1961.
8. Cay Horstmann, Practical Object - Oriented Development in C++ and
Java, Wiley Computer Publishing, 2000, New York.
9. Cecilia R. Aragon, Raimund Seidel, Randomized Search Trees,
Algorithmica 16 (4/5): 464497, 1996.
10. Cecilia R. Aragon, Raimund Seidel, Randomized Search Trees,
Proceedings of the 30th Symposium on Foundations of Computer
Science (FOCS 1989), Washington, D.C.: IEEE Computer Society
Press, pp. 540545, 1989.
11. Constantin Popescu, Dan Noje, Ioan Mang, Horea Oros, Programarea
n limbajul C, Editura Universitii din Oradea, 2002.
12. David E. Goldberg, The Design of Innovation: Lessons from and for
Competent Genetic Algorithms, Addison-Wesley, Reading, MA., 2002.
13. Donald E. Knuth, James H. Morris, Jr., Vaughan R. Pratt, Fast pattern
matching in strings, SIAM Journal on Computing, 6(2):323-350, 1977.
14. Edward F. Moore, The shortest path through a maze, Proceedings of the
International Symposium on the Theory of Switching, pages 285-292.
Harvard University Press, 1959.
15. Edward M. Reingold, Jrg Nievergelt, Narsingh Deo, Combinatorial
Algorithms: Theory and Practice, Prentice-Hall, 1977.
16. Eric Bach, Number-theoretic algorithms, n Annual Review of Computer
Science, volume 4, pages 119- 172. Annual Reviews, Inc., 1990.
17. Frank Harary, Graph Theory, Addison-Wesley, 1969.
515

Algoritmic
18. G. H. Gonnet, Handbook of Algorithms and Data Structures, AddisonWesley, 1984.
19. Harry R. Lewis, Christos H. Papadimitriou, Elements of the Theory of
Computation, Prentice-Hall, 1981.
20. Ivan Niven, Herbert S. Zuckerman, An Introduction to the Theory of
Numbers, John Wiley & Sons, fourth edition, 1980.
21. J. A. Bondy, U. S. R. Murty, Graph Theory with Applications, American
Elsevier, 1976.
22. J. B. Kruskal, On the shortest spanning subtree of a graph and the
traveling salesman problem, Proceedings of the American Mathematical
Society, 7:48-50, 1956.
23. J. W. J. Williams, Algorithm 232 (heapsort), Communications of the
ACM, 7:347-348, 1964.
24. Jack Edmonds, Richard M. Karp, Theoretical improvements in the
algorithmic efficiency for network flow problems, Journal of the ACM,
19:248-264, 1972.
25. John D. Dixon, Factorization and primality tests, The American
Mathematical Monthly, 91(6):333-352, 1984.
26. John E. Hopcroft, Jeffrey D. Ullman, Set merging algorithms, SIAM
Journal on Computing, 2(4):294-303, 1973.
27. John E. Hopcroft, Richard M. Karp, An n5/2 algorithm for maximum
matchings in bipartite graphs, SIAM Journal on Computing, 2(4):225231, 1973.
28. John E. Hopcroft, Robert E. Tarjan, Efficient algorithms for graph
manipulation, Communications of the ACM, 16(6):372-378, 1973.
29. John H. Holland, Adaptation in Natural and Artificial Systems,
University of Michigan Press, Ann Arbor, 1975.
30. Jon L. Bentley, Programming Pearls, Addison-Wesley, 1986.
31. Jon L. Bentley, Writing Efficient Programs, Prentice-Hall, 1982.
32. Jon L. Bentley, Writing Efficient Programs, Prentice-Hall, 1982.
33. Kendall A. Atkinson, An introduction to Numerical Analysis (2nd ed.),
John Wiley & Sons, New York, 1989.
34. Knuth D. E. Arta programrii calculatoarelor vol.2, Algoritmi
seminumerici, Editura Teora, Bucureti, 2000.
35. Knuth D. E. Arta programrii calculatoarelor, vol.1, Algoritmi
fundamentali, Editura Teora, Bucureti, 1999.
36. Knuth D. E. Arta programrii calculatoarelor, vol.3, Sortare i cutare,
Editura Teora, Bucureti, 2001.
37. Kurt Mehlhorn, Graph Algorithms and NP-Completeness, volumul 2 al
Data Structures and Algorithms, Springer-Verlag, 1984.
516

Bibliografie
38. Kurt Mehlhorn, Sorting and Searching, volumul 1 al Data Structures
and Algorithms, Springer-Verlag, 1984.
39. Leonard M. Adleman, Carl Pomerance, Robert S. Rumely, On
distinguishing prime numbers from composite numbers, Annals of
Mathematics, 117: 173-206, 1983.
40. Lestor R. Ford, Jr., D. R. Fulkerson, Flows in Networks, Princeton
University Press, 1962.
41. Liviu Negrescu, Limbajul C++, editura Albastr, Cluj Napoca, 1999.
42. Louis Monier, Evaluation and comparison of two efficient probabilistic
primality testing algorithms, Theoretical Computer Science, 12(1): 97108, 1980.
43. Manuel Blum, Robert W. Floyd, Vaughan Pratt, Ronald L. Rivest,
Robert E. Tarjan, Time bounds for selection, Journal of Computer and
System Sciences, 7(4):448-461, 1973.
44. Michael O. Rabin, Probabilistic algorithm for testing primality. Journal
of Number Theory, 12:128-138, 1980.
45. Mihai Oltean, Proiectarea i implementarea algoritmilor, Computer
Libris Agora, 1999.
46. Mihai Scoraru, Arbori indexai binar, revista Ginfo nr. 13/1, ianuarie,
2003.
47. Mircea D. Popvici, Mircea I. Popvici C++ Tehnologia orientat spre
obiecte, Aplicaii Editura Teora, Bucureti 2000.
48. P. van Emde Boas, Preserving order in a forest in less than logarithmic
time, n Proceedings of the 16th Annual Symposium on Foundations of
Computer Science, paginile 75-84, IEEE Computer Society, 1975.
49. R. A. Jarvis, On the identification of the convex hull of a finite set of
points in the plane, Information Processing Letters, 2:18-21, 1973.
50. R. C. Prim, Shortest connection networks and some generalizations, Bell
System Technical Journal, 36:1389-1401, 1957.
51. R. L. Graham, An efficient algorithm for determining the convex hull of
a finite planar set, Information Processing Letters, 1:132-133, 1972.
52. Richard Bellman, Dynamic Programming, Princeton University Press,
1957.
53. Richard M. Karp, Michael O. Rabin, Efficient randomized patternmatching algorithms, Technical Report TR-31-81, Aiken Computation
Laboratory, Harvard University, 1981.
54. Robert E. Tarjan, Data Structures and Network Algorithms, Society for
Industrial and Applied Mathematics, 1983.
55. Robert E. Tarjan, Depth first search and linear graph algorithms, SIAM
Journal on Computing, 1(2):146-160, 1972.
517

Algoritmic
56. Robert E. Tarjan, Jan van Leeuwen, Worst-case analysis of set union
algorithms, Journal of the ACM, 31(2):245-281, 1984.
57. Robert S. Boyer, J. Strother Moore, A fast string-searching algorithm,
Communications of the ACM, 20(10):762-772, 1977.
58. Robert Sedgewick Implementing quicksort programs, Communications
of the ACM, 21(10):847-857, 1978.
59. Robert Sedgewick, Algorithms, Addison-Wesley, second edition, 1988.
60. Robert W. Floyd, Algorithm 97 (SHORTEST PATH), Communications
of the ACM, 5(6):345, 1962.
61. Robert W. Floyd, Ronald L. Rivest, Expected time bounds for selection,
Communications of the ACM, 18(3):165-172, 1975.
62. Sara Baase, Computer Algorithms: Introduction to Design and Analysis.
Addison-Wesley, second edition, 1988.
63. Shimon Even, Graph Algorithms, Computer Science Press, 1979.
64. Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford
Stein Introduction to Algorithms, second edition, The MIT Press,
Cambridge, Massachusetts, 2001.
65. William Pugh, Skip lists: a probabilistic alternative to balanced trees,
Communications of the ACM 33 (6): 668-676, 1990.
66. Wolfgang Banzhaf, Peter Nordin, Robert Keller, Frank Francone,
Genetic Programming An Introduction, Morgan Kaufmann, San
Francisco, CA., 1998.

518

S-ar putea să vă placă și