Sunteți pe pagina 1din 516

Cuprins

CUPRINS
Prefaţă ..................................................................................vii
1. Introducere .......................................................................... 11
1.1. Noţiuni despre limbaj ........................................................................13
1.2. Noţiuni despre notaţia asimptotică..................................................14
2. Algoritmi de sortare............................................................. 21
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
3. Tehnici de programare......................................................... 57
3.1. Recursivitate ......................................................................................59
3.2. Backtracking .......................................................................................68
3.3. Divide et impera ................................................................................82
3.4. Greedy ................................................................................................89
3.5. Programare dinamică ........................................................................96
4. Algoritmi matematici ......................................................... 109
4.1. Noţiuni 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. Exponenţierea logaritmică ............................................................. 136
4.7. Inverşi modulari, funcţia totenţială ............................................... 143
4.8. Teorema chineză a resturilor ......................................................... 145
4.9. Principiul includerii şi al excluderii ................................................ 150
iii
Algoritmică

4.10. Formule şi tehnici folositoare ........................................................ 151


4.11. Operaţii cu numere mari ................................................................ 154
5. Algoritmi backtracking ....................................................... 167
5.1. Problema labirintului ...................................................................... 169
5.2. Problema săriturii calului ............................................................... 173
5.3. Generarea submulţimilor ............................................................... 175
5.4. Problema reginelor ......................................................................... 177
5.5. Generarea partiţiilor unei mulţimi ................................................ 180
6. Algoritmi generali .............................................................. 183
6.1. Algoritmul K.M.P. (Knuth – Morris – Pratt)................................... 185
6.2. Evaluarea expresiilor matematice ................................................. 190
7. Introducere în S.T.L. ........................................................... 197
7.1. Containere secvenţiale ................................................................... 199
7.2. Containere adaptoare .................................................................... 205
7.3. Containere asociative ..................................................................... 210
7.4. Algoritmi S.T.L. ................................................................................ 220
8. Algoritmi genetici............................................................... 227
8.1. Descrierea algoritmilor genetici .................................................... 229
8.2. Problema găsirii unei expresii ........................................................ 236
8.3. Rezolvarea sistemelor de ecuaţii ................................................... 241
9. Algoritmi de programare dinamică.................................... 245
9.1. Problema labirintului – algoritmul lui Lee..................................... 247
9.2. Problema subsecvenţei de sumă maximă .................................... 258
9.3. Problema subşirului crescător maximal ........................................ 262
9.4. Problema celui mai lung subşir comun ......................................... 269
9.5. Problema înmulţirii optime a matricelor ...................................... 273
9.6. Problema rucsacului 1 .................................................................... 276
9.7. Problema rucsacului 2 .................................................................... 279
9.8. Problema plăţii unei sume 1 .......................................................... 280
9.9. Problema plăţii unei sume 2 .......................................................... 283
9.10. Numărarea partiţiilor unui număr ................................................. 284
9.11. Distanţa Levenshtein ...................................................................... 286
9.12. Determinarea strategiei optime într-un joc ................................. 289
9.13. Problema R.M.Q. (Range Minimum Query) .................................. 292
9.14. Numărarea parantezărilor booleane............................................. 296

iv
Cuprins

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


10. Algoritmi de geometrie computaţională ......................... 301
10.1. Convenţii de implementare ........................................................... 303
10.2. Reprezentarea punctului şi a dreptei ............................................ 304
10.3. Panta şi ecuaţia unei drepte .......................................................... 305
10.4. Intersecţia a două drepte ............................................................... 306
10.5. Intersecţia a două segmente ......................................................... 308
10.6. Calculul ariei unui poligon .............................................................. 311
10.7. Determinarea înfăşurătorii convexe (convex hull) ....................... 313
11. Liste înlănţuite ................................................................. 323
11.1. Noţiuni introductive ....................................................................... 325
11.2. Tipul abstract de date listă simplu înlănţuită ............................... 327
11.3. Aplicaţii ale listelor înlănţuite ........................................................ 339
11.4. Tipul abstract de date listă dublu înlănţuită ................................. 343
11.5. Dancing Links .................................................................................. 354
12. Teoria grafurilor ............................................................... 355
12.1. Noţiuni teoretice............................................................................. 357
12.2. Reprezentarea grafurilor în memorie ........................................... 360
12.3. Probleme introductive ................................................................... 364
12.4. Parcurgerea în adâncime ............................................................... 369
12.5. Parcurgerea în lăţime ..................................................................... 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. Reţele de transport......................................................................... 423
12.12. Arbore parţial de cost minim ......................................................... 438
12.13. Concluzii .......................................................................................... 445
13. Structuri avansate de date............................................... 447
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 indexaţi binar....................................................................... 474
13.5. Arbori de prefixe (Trie) ................................................................... 481
13.6. Arbori binari de căutare (Binary Search Trees) ............................ 488
v
Algoritmică

13.7. Arbori binari de căutare căutare echilibraţi ................................. 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 programării calculatoarelor,
îmbinând principalele direcţii 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 colecţie de probleme demonstrative a
căror rezolvare acoperă elemente de programare procedurală, tehnici de
programare, algoritmi şi structuri de date, inteligenţă artificială şi nu în
ultimul rând programare dinamică.
Pentru fiecare problemă în parte sunt construiţi algoritmii clasici de
rezolvare, completaţi cu explicaţia funcţionării acestora, iar în completare,
acolo unde este necesar, problemele dispun şi de prezentarea noţiunilor
teoretice, a conceptelor generale şi particulare aferente construirii unui
algoritm optimizat.

Organizare

Cartea este structurată pe 13 capitole, fiecare dintre acestea tratând


una dintre temele specifice ale algoritmicii:

Capitolul 1 cuprinde noţiunile 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 cunoscuţi (reprezentativi).


Fiecare dintre aceştia a fost prezentat din punct de vedere al complexităţii
asimptotice, eficienţei, memoriei suplimentare folosite, stabilităţii şi a
optimizărilor suportate.

Capitolul 3 descrie tehnicile de programare şi principalele probleme


asociate acestora: recursivitate cu dezvoltarea completă a problemei
turnurile din Hanoi, backtracking cu generarea permutărilor,
aranjamentelor, combinărilor, ... divide et impera, căutarea binară, tehnica

vii
Algoritmică

greedy cu problema spectacolelor..., noţiuni şi tehnici de programare


dinamică.

Capitolul 4 prezintă o serie de algoritmi care au la bază noţiuni elementare


de matematică şi teoria numerelor dintre care amintim cei mai cunoscuţi:
algoritmul lui Euclid, algoritmii de determinare a numerelor prime,
algoritmul lui Gauss şi alţi algoritmi mai puţin cunoscuţi cum ar fi teorema
chineză a resturilor. Am completat acest capitol cu un paragraf destinat
numerelor mari şi operaţiile asociate acestora.

Capitolul 5 tratează problemele clasice asociate tehnicii de programare


backtracking: problema labirintului, problema săriturii calului, generarea
submulţimilor, problema reginelor, generarea partiţiilor unei mulţimi...(Î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 aceştia nu pot fi încadraţi într-o
categorie aparte şi sunt totuşi 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 câteva situaţii concrete.

Capitolul 8 prezintă algoritmii genetici, modul de construire a acestora,


conceptele de evoluţie şi optimizare ce stau la baza construirii unui astfel de
algoritm, de asemenea şi implementarea, atât din punct de vedere
demonstrativ, în analogie cu o problemă clasică, cât şi implementarea în
probleme a căror rezolvare se pretează la aceste clase de algoritmi.

Capitolul 9 prezintă mai multe aplicaţii ale programării dinamice. Tot în


acest capitol se insistă mai mult pe facilităţile limbajului C++.

Capitolul 10 prezintă metode de rezolvare a unor probleme de geometrie


computaţională. Această ramură a informaticii are aplicaţii practice
importante în programe de grafică, aplicaţii CAD, aplicaţii de modelare,
proiectarea circuitelor integrate şi altele.

viii
Prefaţă

Capitolul 11 prezintă noţiunile elementare despre liste înlănţuite, atât la


nivel teoretic, modul de construire, tipuri, cât şi implementarea acestora.
Deşi listele înlănţuite există deja implementate în cadrul librăriei 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 folosiţi pentru rezolvarea
problemelor cu grafuri.

Capitolul 13 prezintă, în încheiere, structurile avansate de date, deoarece


acestea necesită noţiuni de grafuri, liste, operaţii pe biţi, recursivitate,
tehnici de programare, matematică şi S.T.L., aşa că recomandăm stăpânirea
tuturor capitolelor anterioare înainte de parcurgerea acestui capitol final.

Convenţii utilizate

Liniile de cod sursă prezentate în această carte respectă standardele


C++ în vigoare la data publicării. 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 biţi.

Programele prezentate sunt scrise în aşa fel încât să fie uşor de


înţeles pentru cineva care cunoaşte bine bazele limbajului de programare
C++.

Fragmentele de cod vor fi scrise cu italic şi colorate sintactic pentru


a fi uşor de recunoscut.

ix
Algoritmică

Despre Autori
La data publicării acestei cărţi

Laslo E. Eugen este asistent la Facultatea de Ştiinţe a Universităţii 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 locuieşte în Oradea împreună cu
soţia şi fiica lui.

Ionescu Vlad – Sebastian este student în anul II la Facultatea de Ştiinţe a


Universităţii din Oradea. Începând cu clasa a X-a a obţinut diverse premii şi
menţiuni la olimpiade şi concursuri judeţene şi naţionale de informatică.
Este pasionat de informatică încă din clasele primare. Domeniile sale
principale de interes sunt optimizarea algoritmilor, programarea funcţională
şi inteligenţa artificială.

x
Introducere

1. Introducere
Acest prim capitol are ca scop familiarizarea cititorului cu
elementele constructive ale cărţii, cunoştiinţele iniţiale necesare înţelegerii
materialul de faţă, convenţiile de scriere şi prezentare a secvenţelor de cod,
tot aici sunt cuprinse noţiunile generale privind analiza complexităţii
algoritmilor prin studiul timpului de execuţie şi cantităţii de memorie
utilizată de către aceştia (notaţia asimptotică).

11
Capitolul 1

CUPRINS

1.1. Noţiuni despre limbaj ...............................................................................13


1.2. Noţiuni despre notaţia asimptotică .........................................................14

12
Introducere

1.1. Noţiuni despre limbaj


Secvenţele de cod prezentate în această carte respectă standardele
C++ în vigoare la data publicării. 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 biţi.
Deşi este aproape imposibilă testarea codului pe toate compilatoarele
C++ existente, programele prezentate ar trebui să funcţioneze pe orice
compilator care respectă standardele limbajului C++.
Atenţie: programele prezentate nu vor funcţiona 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 aşa fel încât să fie uşor de


înţeles pentru cineva care cunoaşte relativ bine bazele limbajului de
programare C++. Nu se va pune accent pe explicarea limbajului, ci pe
înţelegerea algoritmilor, aşa că sunt necesare cunoştiinţe despre limbajul
C++.
Implementările fiecărui algoritm respectă, în mare parte, nescrise de
calitate a codului. Am încercat clarificarea implementărilor prin evitarea
variabilelor globale, ceea ce este o marcă a calităţii codului, dar totodată am
numerotat tablourile începând de la 1, nu de la 0 aşa cum este normal în
contextul limbajelor din familia C. Această decizie a fost luată din două
motive: în primul rând calculele devin mai naturale şi mai uşor de înţeles,
programele devenind mai apropiate de modul natural de rezolvare a
problemelor şi de pseudocodul prezentat, cu atât mai mult cu cât poziţia 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 tratând
aceste cazuri particulare.
În al doilea rând, numerotarea de la 0 serveşte ca un exerciţiu
permanent pentru cititorii acestei cărţi: să modifice fiecare implementare
prezentată astfel încât numerotarea să se facă de la 0 şi nu de la 1. Uneori
acest lucru nu este foarte uşor.
De cele mai multe ori, implementările încap pe o singură pagină,
astfel încât să fie uşor de urmărit şi de înţeles. Mai mult, vom evita uneori
prezentarea unor lucruri care se consideră cunoscute, cum ar fi fişierele antet
care trebuie incluse, a modului de apelare a unor funcţii etc.

13
Capitolul 1

Implementările 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, funcţiile, stilul
de scriere a codului etc.). Acest lucru se datorează faptului că scopul acestei
lucrări este să dezvolte o gândire algoritmică liberă şi deschisă la nou. Nu
trebuie niciodată învăţată pe de rost o anumită metodă de rezolvare, ci
trebuie înţeles un algoritm, care apoi poate fi implementat în mai multe
moduri. Considerăm că prin diversificarea implementărilor contribuim la
educarea cititorului în acest scop.

Se va evita, pe cât posibil, folosirea conceptelor avansate de


programare orientată pe obiecte. Implementările prezentate vor folosi, în
general, doar partea procedurală a limbajului C++. Unele programe care
sunt simplificate prin folosirea claselor sau structurilor vor folosi aceste
facilităţi, dar nu sunt necesare decât cunoştiinţe de bază a programării
orientate pe obiecte pentru înţelegerea acestora.

Fragmentele de cod vor fi scrise cu italic şi colorate sintactic pentru


a fi uşor de recunoscut.

1.2. Noţiuni despre notaţia asimptotică


În matematică, notaţia asimptotică (cunoscută şi sub denumirile de
notaţia Landau şi notaţia O-mare) descrie comportamentul unei funcţii
atunci când argumentele sale tind către anumite valori sau către infinit,
folosind alte funcţii mai simple.
În informatică, această notaţie ne permite să exprimăm eficienţa unui
algoritm (timpul său de execuţie şi cantitatea de memorie folosită de către
acesta) fără a ţine cont de resursele unui anumit sistem de referinţă. Aşadar,
este o modalitate de a exprima eficienţa 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 obţinute folosind notaţia asimptotică vor fi exprimate în
funcţie de dimensiunile datelor de intrare când acestea tind la infinit. Notaţia
asimptotică ne oferă o funcţie care reprezintă o limită superioară numărului
de operaţii efectuate de către algoritmul analizat.

Formal, fie f o funcţie definită pe mulţimea numerelor naturale, cu


valori în aceeaşi mulţime, iar f(N) numărul exact de operaţii efectuate de

14
Introducere

către 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 număr real
pozitiv C şi un număr natural N0 astfel încât:
|f(N)| ≤ C∙|g(N)|, ∀ N ≥ N 0, unde g este o funcţie care (de obicei) nu
conţine constante.
Când funcţia f este o constantă, complexitatea algoritmului se scrie
O(1).

Pentru a înţelege mai bine această notaţie şi pentru a evidenţia modul


de folosire al acesteia în această carte, vom prezenta câteva secvenţe de cod
pentru care vom calcula complexitatea.

Secvenţa 1

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


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

Analiza complexităţii aestei secvenţe este foarte uşoară: pentru


fiecare din cele N valori ale lui i, variabila j va lua la rândul ei tot N valori,
afişându-se aşadar N2 linii, fiecare linie conţinând 6 atomi lexicali (i, “ * “,
j, “ = “, i * j, „\n‟). Aşadar, f(N) = 6∙N2. Pentru C = 6, obţinem
complexitatea algoritmului O(N2). Nu întotdeauna putem preciza cu
exactitate numărul de operaţii efectuate de către algoritm. Chiar şi pe acest
exemplu, nu putem fi siguri că instrucţiunea cout efectuează exact 6
operaţii, deoarece nu ştim cum este implementată această funcţie (sau, mai
corect spus, obiect). În orice caz, nu ne interesează decât termenul care îl
conţine pe N la puterea cea mai mare, aşa cum va reieşi din secvenţa
următoare.

Secvenţa 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 numărul de operaţii introduse de cout,


deoarece acest număr este oricum constant şi nu va influenţa în niciun fel
rezultatul, deoarece constantele nu au nicio semnificaţie atunci când N tinde

15
Capitolul 1

la infinit. Vom număra doar numărul de incrementări ale variabilelor din


cadrul celor două for-uri.
Când i = 1, j se va incrementa de N – 1 ori.
Când i = 2, j se va incrementa de N – 2 ori.
...
Când i = N, j se va incrementa de 0 ori.
Se observă ca i se va incrementa de N ori.

Aşadar, numărul de incrementări ale ambelor variabile este egal cu


N + (N – 1) + (N – 2) + ... + 2 + 1, sumă egală cu

𝑵∙(𝑵−𝟏)
= 0.5 ∙ 𝑁 2 − 0.5 ∙ 𝑁.
𝟐

Când N tinde la infinit, singurul termen care prezintă interes este


2
0.5∙N , aşa că este de ajuns să găsim o funcţie care, înmulţită cu o constantă,
să mărginească superior doar acest termen. Această funcţie poate fi chiar N 2 ,
iar constanta 1. Aşadar, complexitatea acestui algoritm este tot O(N2).

Secvenţa 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, aşa că am putea fi tentaţi 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, aşa că acest algoritm are complexitatea O(N). O altă
modalitate de a argumenta această complexitate este prin faptul că în tabloul
st se reţin valori din tabloul A, iar la fiecare pas i se scot elemente din st
atâta timp cât valoarea acestora este mai mare decât A[i]. Aşadar, fiecare
element va fi introdus în st şi şters din st cel mult o singură dată, deci se vor
efectua cel mult 2∙N operaţii.

16
Introducere

Făcând o analogie cu timpul de execuţie, putem spune că memoria


folosită de către algoritm este de ordinul lui N, sau că memoria folosită
este O(N), deoarece tabloul st va conţine N elemente în cel mai rău caz.

Secvenţa 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).

Secvenţa 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 tentaţi să scriem complexitatea ca O(log2 N), dar acest lucru ar
fi o greşeală, deoarece ştim că:

𝑙𝑜𝑔𝑏 𝑥
𝑙𝑜𝑔𝑎 𝑥 = , ∀ 𝑎, 𝑏 ≠ 1 ş𝑖 𝑎, 𝑏 > 0
𝑙𝑜𝑔𝑏 𝑎

Aşadar, orice logaritm diferă de un un logaritm în altă bază printr-o


contantă, iar în notaţia asimptotică nu se trec constante, aşa că, dacă apar
logaritmi în notaţia asimptotică, se va folosi logaritmul nedefinit,
semnificând un logaritm într-o bază oarecare.
Mai trebuie menţionat că, din modul în care am definit notaţia
asimptotică deducem că am putea spune că toţi algoritmii prezentaţi au
complexitatea O(N3) sau O(N4) sau chiar O(N2010). Putem într-adevăr să
facem acest lucru, deoarece notaţia asimptotică reprezintă o limită
superioară oarecare şi nu o limită strânsă. Pentru limite inferioare şi limite
strânse există două notaţii diferite, dar mai puţin folosite:
1. Notaţia Theta: spunem că f(N) = Θ(g(N)) dacă are loc, pentru
nişte constante pozitive C1 şi C2 , dubla inegalitate:
C1∙|g(N)| ≤ |f(N)| ≤ C2 ∙|g(N)|, ∀ N ≥ N0
2. Notaţia 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, notaţia Theta înseamnă că funcţia f este mărginită


atât superior cât şi inferior de funcţia g, iar notaţia Omega înseamnă că
funcţia f este mărginită inferior de către funcţia g. Notaţia O-mare înseamnă
că funcţia f este mărginită superior de către funcţia g.

În această carte vom folosi, pentru simplitate, doar notaţia O-mare,


dar vom da de fiecare dată limite superioare strânse (exacte) în cadrul
acesteia. Această notaţie are avantajul de a fi mai uşor de calculat decât
notaţia Theta, deoarece de multe ori este mai uşor de găsit o limită
superioară oarecare decât o limită superioară exactă. Totuşi, pentru
algoritmii prezentaţi în această carte găsirea unei limite exacte nu va fi un
lucru foarte dificil, aşa că recomandăm cititorilor să exprime toate
complexităţile prezentate atât în notaţia Theta cât şi în notaţia Omega, pe
lângă notaţia asimptotică oferită.

Exerciţii – precizaţi complexităţile următoarelor secvenţe de cod,


folosind toatecele trei notaţii 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 cărţii va fi


însoţit de complexitatea sa asimptotică, uneori fără a mai prezenta
deducerea acesteia!
Tabelul de mai jos prezintă câteva categorii asimptotice, denumirile
acestora şi exemple de algoritmi pentru fiecare categorie. Majoritatea
algoritmilor menţionaţi se vor regăsi în capitolele următoare.

Tabelul 1.2.1. – Principalele categorii de complexitate a algoritmilor


Denumirea
Complexitate Exemple de algoritmi
complexităţii
Determinarea minimului dintr-un
O(N) liniară şir, afişarea unui şir, problema
majorităţii votului.
Sortări naive, prelucrarea
O(N2) pătratică matricelor, generarea tuturor
subsecvenţelor unui şir.
Algoritmul Roy-Floyd, înmulţirea
O(N3) cubică
optimă a matricelor.
O(sqrt(N)) fracţională Determinarea primalităţii.
Căutarea binară, căutarea într-un
arbore binar de căutare echilibrat,
O(log N) logaritmică
aproximarea unor funcţii
matematice.
supraliniară,
Majoritatea algoritmilor divide et
O(N∙log N) liniaritmică,
impera.
pseudoliniară
Determinarea tuturor
N
O(c ), c submulţimilor, găsirea tuturor
exponenţială
constantă ieşirilor dintr-un labirint, probleme
la care se cer toate soluţiile.
Accesarea unui element dintr-un
tablou, interschimbarea a două
valori, apelarea unei funcţii,
O(1) constantă
efectuarea unei operaţii de un
număr finit de ori care nu depinde
de N.
inversa
Operaţii optime pe mulţimi
O(α(N)) funcţiei
disjuncte.
Ackermann
O(N∙log(log N)) Ciurul lui Eratosthenes

20
Algoritmi de sortare

2. Algoritmi de
sortare
Problema sortării 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ă, cât şi nişte
rezultate teoretice importante cu privire la corectitudinea şi eficienţa acestor
algoritmi.
Acest capitol prezintă detaliat o serie de algoritmi de sortare
reprezentativi pentru clasele din care aceştia fac parte. Fiecare algoritm este
prezentat din punct de vedere al complexităţii asimptotice, al eficienţei
practice, al memoriei suplimentare folosite, al stabilităţii, al
optimizărilor suportate şi este însoţit de o implementare în limbajul C++.
Demonstraţiile unor rezultate la care se face referire nu vor fi prezentate,
punându-se accentul pe întelegerea modului de funcţionare al algoritmilor şi
al implementării acestora într-un limbaj de programare.
Prin memorie suplimentară înţelegem memoria necesară execuţiei
algoritmului, fără să luăm în considerare vectorul ce reţine numerele ce
trebuiesc sortate.
Prin stabilitate înţelegem proprietatea unui algoritm de sortare de a
păstra ordinea relativă a două elemente cu chei de sortare identice. De
exemplu, dacă ar trebui să sortăm 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 când un algoritm care ar produce orice altă sortare
sigur nu ar fi stabil.

Implementările algoritmilor vor fi prezentate sub forma unei funcţii


ce poartă numele algoritmului descris, funcţie care, dacă nu se precizează
alfel, acceptă ca parametri un şir de numere întregi A, reprezentând şirul
care trebuie sortat crescător şi un număr natural N, reprezentând numărul de
elemente ale şirului A. Funcţia sortează crescător elementele şirului A. Pot
exista şi alte funcţii ajutătoare, dar nu va fi prezentat un program întreg,
considerând 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 păcate, acesta este totodată şi unul dintre cei mai
ineficienţi algoritmi, chiar şi comparându-l cu algoritmi de aceiaşi
complexitate asimptotică. Bubble sort suportă însă nişte optimizări care îl
fac să se comporte destul de bine pe seturi de date generate aleator.
Numele de Bubble sort vine de la modul de funcţionare 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, comparând mereu două elemente adiacente,
în poziţia sa finală, adică la sfârşitul vectorului. Acest lucru ne permite să
parcurgem la fiecare pas tot mai puţine elemente, deoarece ştim că avem
atâtea elemente aflate pe poziţia lor finală câţi paşi avem deja efectuaţi.
Mai putem face o optimizare şi anume să oprim algoritmul dacă la
un anumit pas nu s-a mai făcut nicio interschimbare. Acest lucru înseamnă
că vectorul a fost deja sortat şi nu mai are rost să continuăm parcurgerile.
Obţinem astfel o optimizare care aduce îmbunătăţire substanţiale
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. – Proprietăţile algoritmului Bubble Sort


Caz favorabil Caz mediu Caz defav.
Timp de execuţie O(N) O(N2) O(N2)
Memorie suplimentară O(1)
Stabil DA

23
Capitolul 2

2.2. Insertion sort


Insertion sort, sau sortarea prin inserţie, este unul dintre cei mai
rapizi algoritmi de sortare de complexitate O(N2), depăşind cu mult în
practică alţi algoritmi precum bubble sort şi selection sort.
Paşii algoritmului sunt următorii:
 Pentru fiecare element i al vectorului, începând de la al doilea,
execută
o Deplasează toate elementele cu indici mai mici decât i
care sunt mai mari ca A[i] cu o poziţie către dreapta.
o Inserează elementul i în locul rămas liber.
 Returnează vectorul sortat.

Să luăm ca exemplu următorul 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ă deplasăm toate elementele cu indici mai mici ca 2
şi a căror valoare este mai mare ca V = 4 cu o poziţie către dreapta. În cazul
acesta, elementul de pe poziţia 1, A[1] = 6, va trece pe poziţia 2, iar pe
poziţia 1 va fi inserat fostul element de pe poziţia 2, adică V = 4. Observăm
că primele două elemente sunt deja sortate crescător. Precizăm că, pentru a
efectua eficient deplasările, vom reţine elementul care trebuie inserat într-o
variabilă auxiliară V, după care vom suprascrie acest element efectuând
deplasările necesare.

Vectorul arată acum în felul următor (elementul proaspăt inserat


apare cu roşu):
i 1 2 3 4 5
A 4 6 3 8 7

Se trece la elementul cu indice 3 şi se procedează similar. Vom


reţine V = A[3], adică V = 3, şi vom deplasa primele două elemente cu o
poziţie spre dreapta, ştergând astfel valoarea din A[3]. După deplasarea
elementelor, vectorul va arăta astfel (elementele deplasate apar în albastru):

i 1 2 3 4 5
A 4 4 6 8 7

24
Algoritmi de sortare

Observăm că, practic, deplasarea constă în operaţia de atribuire


fiecărui element a valorii elementului precedent. Valoarea iniţială a lui A[3]
a fost reţinută în variabila V. Se inserează V pe poziţia 1, rezultând
următorul vecctor:

i 1 2 3 4 5
A 3 4 6 8 7

Se trece la elementul de pe poziţia 4, care se inserează tot pe poziţia


4, rezultând următorul vector:

i 1 2 3 4 5
A 3 4 6 8 7

Se trece la ultimul element, cel de pe poziţa 5, care va fi inserat pe


poziţia 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 inserţie este cel mai


„natural” algoritm de sortare şi cel mai des folosit în viaţa de zi cu zi. De
exemplu, dacă avem de sortat nişte cărţi de joc, probabil că vom folosi
(chiar şi fără să ne dăm seama) sortarea prin inserţie.
Alt aspect interesant al algoritmului este faptul că nu efectuează
nicio interschimbare. Acesta este şi motivul superiorităţii sale faţă de alţi
algoritmi de aceiaşi complexitate. Performanţa sa pentru vectori de
dimensiune mică poate fi exploatată de algoritmul Quicksort, care poate
folosi sortarea prin inserţie când ajunge la intervale foarte mici, eliminând
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. – Proprietăţile algoritmului Insertion sort


Caz favorabil Caz mediu Caz defav.
Timp de execuţie 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 parţial sortate.
Mai mult, modul de parcurgere al şirului de date conferă
algoritmului posibilitatea de a sorta date pe măsură ce acestea devin
accesibile. De exemplu, dacă avem de sortat notele unor elevi la nişte
examene naţionale, ne putem aştepta să nu primim rezultatele din toate
judeţele în acelaşi timp, ci cu decalări de câteva ore sau chiar zile. În acest
caz, poate fi mai eficient să aplicăm algoritmul de sortare prin inserţie de
fiecare dată când primim date dintr-un judeţ, decât să aplicăm unul dintre
algoritmii mai performanţi ce urmează a fi prezentaţi.
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 ceilalţi algoritmi de aceiaşi complexitate. Din păcate,
pentru a fi cu adevărat performant atât pe cazul mediu cât şi pe cazul cel mai
defavorabil, algoritmul necesită anumite optimizări care complică puţin
codul, rezultând un program mai complex decât pentru celelalte sortări.
Sortarea rapidă este un algoritm de tip divide et impera şi
funcţionează astfel:
 Fie Quicksort(A, st, dr) o funcţie care sortează intervalul [x, y]
al vectorului A.
 Fie Partitie(A, st, dr) o funcţie care reordonează intervalul [x, y]
al vectorului A astfel încât 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 sfârşitul vectorului.
Elementul A[st] se numeşte element pivot.
 Funcţia 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 aşadar problema implementării funcţiei Partitie. Eficienţa


şi corectitudinea algoritmului depind în cea mai mare parte de această
funcţie.
Funcţia Partitie(A, st, dr) poate fi implementată, într-o primă
formă, astfel:
 Fie V = A[st]
 st = st – 1 şi dr = dr + 1
 Cât timp st < dr execută
o Execută
 dr = dr – 1
o Cât timp st < dr şi A[dr] > V
o Execută
 st = st + 1
o Cât timp st < dr şi A[st] < V
o Dacă st < dr execută
 Interschimbă A[st] cu A[dr]
 Returnează poziţia pivotului, adică dr.

27
Capitolul 2

Deja devine clară ideea din spatele algoritmului: la fiecare pas se


împarte subsecvenţa [st, dr] în alte două subsecvenţe (pe care le vom numi
subsecvenţa stângă respectiv subsecvenţa dreaptă): prima cu elemente mai
mici sau egale cu pivotul, iar cealaltă cu elemente mai mari sau egale cu
pivotul. Acest lucru este făcut de către funcţia Partitie, care returnează la
sfârşit poziţia care delimitează împărţirea menţionată mai sus. Atenţie:
algoritmul nu oferă nicio informaţie folositoare despre poziţia pe care
ajunge elementul pivot!
După ce funcţia Partitie returnează poziţia ce delimitează împărţirea
făcută în funcţie de pivot, funcţia Quicksort se autoapelează pentru
subsecvenţa stângă şi pentru subsecvenţa dreaptă. Datorită recursivităţii, şi
aceste subsecvenţe vor trece prin funcţia Partitie, fapt ce va duce în final la
sortarea vectorului.
Funcţia 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(N·log N),
elementul pivot împărţind o subsecvenţă în două subsecvenţe 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ă decât cea a algoritmului de sortare prin inserţie! Mai mult, şi
memoria suplimentară folosită este O(N), mai mult decât a algoritmilor
pătratici.
Pentru a vă convinge de complexitatea pătratică a algoritmului
Quicksort în cazul în care partiţionarea şirului se face întotdeauna
dezechilibrat, urmăriţi modul de funcţionare al algoritmului pe vectorul:

i 1 2 3 4
A 1 2 3 4

La primul pas, se apelează funcţia Quicksort(A, 1, 4), care va apela


Partitie(A, 1, 4). Funcţia Partitie va alege ca pivot pe V = A[1] = 1, iar st şi
dr se vor iniţializa cu 0 respectiv 5. Acum, se va executa prima buclă
execută ... cât timp, ajungându-se în final la dr = 1. Elementele marcate cu
roşu în tabelele ce urmează se compară cu V = 1. La primul pas, se
decrementează dr, luând 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 ieşi din prima buclă


execută ... cât timp. A doua buclă nu va apuca decât să incrementeze
variabila st, ajungându-se la st = dr şi ieşindu-se şi din această buclă.
Condiţia st < dr nu se verifică, deci nu se efectuează nicio interschimbare şi
se iese şi din bucla principală cât timp ... execută. Se returnează dr = 1.
După ieşirea din funcţia Partitie, se atribuie valoarea returnată
variabilei P, după care se efectuează două apeluri recursive ale funcţiei
Quicksort. Mai întâi se apelează Quicksort(A, 1, 1), funcţie din care se va
ieşi foarte rapid, deoarece nu va fi respectată condiţia st < dr. Se reveni la
pasul precedent şi se apelează funcţia Quicksort(A, 2, 4). Aceast apel se va
comporta similar cu apelul iniţial. Lăsăm desluşirea tuturor paşilor efectuaţi
pe seama cititorului.

Exemplul prezentat ascunde o deficienţă majoră a algoritmului. Se


poate observa că, pentru un şir care este deja sortat, funcţia partiţie va
împărţi întotdeauna o subsecvenţă [st, dr] în două subsevenţe de lungime 1
respectiv dr – st. Cum am spus mai devreme, acest lucru duce la o
complexitate pătratică, adică O(N2). Se va returna întotdeauna un pivot care
va împărţi subsecvenţa curentă într-o subsecvenţă de dimensiune 1, care
poate fi considerată sortată (orice şir cu un singur element este gata sortat) şi
subsecvenţa iniţială, mai puţin un singur element. Acest lucru se repetă de N
ori. Se efectuează aşadar N + (N – 1) + … + 1 operaţii. Această sumă este o
progresie aritmetică clasică, având valoarea N * (N + 1) / 2. Complexitatea
timp este aşadar O(N2). Memoria suplimentară este O(N), deoarece avem N
niveluri de recursivitate, iar informaţia memorată pe fiecare nivel este O(1).

29
Capitolul 2

Vom prezenta forma clasică a algoritmului, aşa cum a fost prezentat


în pseudocod, după care vom prezenta metode de îmbunătăţire a timpului de
execuţie şi a memoriei folosite pe orice şir de intrare posibil.

Metoda clasica poate fi implementată astfel:

int Partitie(int A[], int st, int dr) void Quicksort(int A[], int st, int dr)
{ {
int V = A[st]; if ( st < dr )
--st; ++dr; {
while ( st < dr ) int P = Partitie(A, st, dr);
{ Quicksort(A, st, P);
do Quicksort(A, P+1, dr);
--dr; }
while ( st < dr && A[dr] > V ); }
do
++st; Execiţiu: modificaţi funcţia Partitie
while ( st < dr && A[st] < V ); astfel încât aceasta să returneze, în
O(N), al k-lea cel mai mic element al
if ( st < dr )
{ vectorului A. De exemplu, dacă
int tmp = A[st]; A = {1, 7, 5, 2, 4} şi k = 3, se va
A[st] = A[dr]; returna 4.
A[dr] = tmp;
}

}
return dr;
}

Aşa cum am mai spus, algoritmul suportă diverse optimizări,


reducând complexitatea timp la O(N·log N) pentru marea majoritate a
datelor de intrare, iar memoria la O(log N) pentru toate datele de intrare
posibile. Acest lucru poate fi făcut folosind generarea de numere aleatoare.
O primă idee ar fi să folosim o funcţie care „amestecă” vectorul A, după
care să aplicăm algoritmul de sortare rapidă. Această metodă nu este însă
atât de eficientă precum alegearea aleatoare a pivotului folosit la fiecare pas
al algoritmului.
Dacă alegem aleator la fiecare pas un element pivot din subsecvenţa
[st, dr], probabilitatea ca acesta să fie o alegere proastă este atât de mică
încât putem considera că algoritmul are complexitatea O(N·log N) pentru
toate datele de intrare posibile. Trebuie menţionat însă că această

30
Algoritmi de sortare

complexitate este una probabilistă şi că, în cazul în care cineva cu intenţii


maliţioase 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(N·log N).

Aşa cum am arătat mai devreme, memoria suplimentară folosită de


algoritm este O(N) pe cazuri defavorabile. Putem reduce memoria la
O(log N) pentru toate cazurile, schimbând ordinea apelurilor recursive şi
reducând numărul acestora. Observăm ca apelurile recursive se fac la
sfârşitul algoritmului, neexistând nicio instrucţiune care să se execute după
acestea. Acest lucru ne permite să folosim, atunci când este posibil, tehnici
iterative în loc de apeluri recursive.
Primul lucru pe care îl vom face este să apelăm funcţia recursiv
pentru subsecvenţa mai mică. Al doilea lucru este să renunţăm la al doilea
apel recursiv, pentru cea de-a doua secvenţă, şi să rezolvăm această
subsecvenţă iterativ. Astfel, memoria folosită va fi întotdeauna O(log N).
Precizăm că pentru a folosi funcţia rand() trebuie inclus fişierul
antet <cstdlib>, iar înainte de apelarea funcţiei Quicksort, trebuie iniţializat
generatorul de numere aleatoare folosind apelul srand((unsigned)time(0)).
Algoritmul optimizat arată acuma în felul următor:

31
Capitolul 2

int Partitie(int A[], int st, int dr) void Quicksort(int A[], int st, int dr)
{ // numar aleator din [st, dr] {
int poz = st + rand() % (dr-st+1); while ( st < dr )
int tmp = A[poz]; {
A[poz] = A[st]; int P = Partitie(A, st, dr);
A[st] = tmp; if ( P - st < dr - P - 1 )
{
int V = A[st]; Quicksort(A, st, P);
--st; ++dr; st = P + 1;
while ( st < dr ) }
{ else
do {
--dr; Quicksort(A, P + 1, dr);
while ( st < dr && A[dr] > V ); dr = P;
do }
++st; }
while ( st < dr && A[st] < V ); }

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

}
return dr;
}

Tabelul 2.3.1. – Proprietăţile algoritmului Quicksort


Caz favorabil Caz mediu Caz defav.
Timp de execuţie O(N·log N)
Memorie suplimentară O(1) O(log N) O(log N)
Stabil NU în forma dată.

Trebuie menţionat faptul că mai există şi alte optimizări posibile. De


exemplu, Quicksort se poate combina cu algoritmul Heapsort, rezultând
timp de execuţie şi mai buni în practică. Acest algoritm se numeşte
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(N·log N).
Această complexitate se obţine pentru orice date de intrare şi nu este
condiţionată de numere aleatoare. Deşi, teoretic, algoritmul pare să fie mai
bun decât Quicksort, în practică se obţin timpi de execuţie mai mari decât
în cazul sortării rapide. Mai mult, memoria folosită este întotdeauna O(N),
variantele care folosesc memorie constantă fiind şi mai dificil de
implementat şi mai puţin eficiente în practică.
Algoritmul foloseşte două funcţii: o funcţie principală,
Merge_sort(A, st, dr), care este responsabilă de ordonarea subsecvenţei [st,
dr] a vectorului A şi o funcţie secundară Interclasare(A, st, m, dr) care are
rolul de a interclasa subsecvenţele [st, m] şi [m+1, dr] a vectorului A,
subsevenţe care sunt deja sortate. Interclasarea a două subsecvenţe înseamnă
formarea unei singure secvenţe care conţine toate elementele din ambele
subsecvenţe astfel încât acestea să fie la rândul lor sortate.
Funcţia 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)

Funcţia 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
 Cât 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 secvenţa [st, m] sau [m+1, dr] care
să nu fi fost adăugat în vectorul B, se adaugă şi acesta.
 Suprascrie conţinutul vectorului auxiliar B peste subsecvenţa [st,
dr] a vectorului A.

33
Capitolul 2

Funcţia Interclasare efectuează întotdeauna dr – st + 1 operaţii,


deci complexitatea sa este O(N) pe nivel de recursivitate. Funcţia
Merge_sort împarte întotdeauna subsecvenţa curentă în două subsecvenţe
de dimensiuni egale sau care diferă prin cel mult 1. Aşadar, arborele
apelurilor recursive va avea O(log N) niveluri, rezultând timpul O(N·log N).
Se observă că, pentru a interclasa două subsecvenţe, 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
decât memoria folosită de orice alt algoritm prezentat până acum. Totuşi,
asta nu înseamnă că sortarea prin interclasare este întotdeauna inferioară
algoritmilor precedenţi. De multe ori, dacă ne permitem timpul de execuţie
pentru a sorta N obiecte, ne permitem şi memoria auxiliară.
Pentru a înţelege mai bine cum am dedus complexitatea algoritmului
şi pentru a vizualiza modul de funcţionare al acestuia, urmăriţi apelurile
funcţiilor în următorul exemplu. Acesta se execută conform cu numerotarea
de pe desen. Apelurile recursive şi secvenţele pe care acestea se fac sunt
marcate cu roşu, iar apelurile funcţiei de interclasare, intervalele asociate
acesteia şi ordinea elementelor rezultată după interclasare sunt marcate cu
albastru.

Fig. 2.4.1. Modul de execuţie a sortării prin interclasare

34
Algoritmi de sortare

Deşi este mai dificil de dedus numărul de operaţii efectuate folosind


acest exemplu, putem observa că arborele rezultat în urma aplicării
algoritmului de sortare prin interclasare are doar două niveluri complete pe
care se apelează funcţia Interclasare. Astfel, avem două niveluri pe care se
execută O(N) operaţii, unde N = 5 în cazul de faţă, iar 𝑙𝑜𝑔2 5 = 2, deci
putem folosi acest exemplu pentru a intui complexitatea algoritmului ca
fiind O(N·log N), iar memoria auxiliară folosită ca fiind O(N).
Am putea fi tentaţi să argumentăm că se efectuează, de fapt, patru
apeluri ale funcţiei de interclasare, fiecare executând O(N) operaţii,
rezultând astfel o complexitate timp de O(N2). Acest lucru este fals însă,
deoarece, deşi se fac într-adevăr patru apeluri ale acestei funcţii, doar apelul
de pe primul nivel efectuează N operaţii. Apelurile de pe un nivel oarecare
efectuează împreună N operaţii. Deoarece algoritmul împarte întotdeauna
subsecvenţa curentă în două subsecvenţe egale, adâncimea arborelui va fi
întotdeauna direct proporţională cu 𝑙𝑜𝑔2 (𝑛 + 1), rezultând complexitatea
menţionată. Trebuie însă ţinut cont de faptul că, în practică, acest algoritm
este mai puţin eficient decât Quicksort.
Pentru a observa mai bine numărul de operaţii efectuate, construiţi
un arbore similar cu cel prezentat anterior, dar pentru N o putere a lui 2, de
exemplu 32 sau 64. Aşa se va observa mult mai clar complexitatea
algoritmului.
Sortarea prin interclasare are aplicabilităţi şi în unele probleme de
numărare. Un exemplu clasic este aflarea numărului de inversiuni ale unei
permutări. O inversiune a unei permutări

1 2 … 𝑁
𝑃=
𝑃(1) 𝑃(2) … 𝑃(𝑁)

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


Folsind sortarea prin interclasare putem număra, în cadrul funcţiei
Interclasare, numărul de inversiuni ale unei permutări în felul următor: ne
interesează să numărăm, pentru fiecare element j din subsecvenţa [m+1, dr]
câte elemente există în subsecvenţa [st, m] care sunt mai mari decât A[j],
număr pe care îl vom aduna la numărul total de inversiuni. Acest lucru se
poate face atunci când A[i] > A[j], adunând la soluţie numărul m – i + 1,
deoarece, dacă A[i] > A[j], orice A[k] unde i < k ≤ m va fi mai mare decât
A[j].
În implementarea ce urmează, sortarea prin interclasare poate fi
optimizată declarând vectorul B înaintea rulării algoritmului şi scăpând de
alocările de memorie din cadrul funcţiei de interclasare.

35
Capitolul 2

void Interclasare(int A[], void Merge_sort(int A[],


int st, int m, int dr) int st, int dr)
{ {
// folosim numerotare de la 1, if ( st < dr )
// deci trebuie declarat un element {
// in plus int m = (st + dr) / 2;
int *B = new int[dr - st + 2]; Merge_sort(A, st, m);
Merge_sort(A, m+1, dr);
int i = st, j = m + 1, k = 0; Interclasare(A, st, m, dr);
while ( i <= m && j <= dr ) }
if ( A[i] <= A[j] ) }
B[++k] = A[i++];
else Numărarea inversiunilor poate
B[++k] = A[j++]; fi facută modificând primul
while astfel:
// copiaza ce a mai ramas
while ( i <= m )
while ( i <= m && j <= dr )
B[++k] = A[i++];
if ( A[i] <= A[j] )
while ( j <= dr )
B[++k] = A[i++];
B[++k] = A[j++];
else
{
// copiaza la loc secventa sortata
B[++k] = A[j++];
for ( i = 1; i <= k; ++i )
NrInv += m - i + 1;
A[st + i - 1] = B[i];
}
// sterge memoria auxiliara folosita
Unde NrInv este transmis prin
delete []B;
} referinţă sau global.

Tabelul 2.4.1. – Proprietăţile algoritmului Merge sort


Caz favorabil Caz mediu Caz defav.
Timp de execuţie O(N·log 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 execuţie O(N·log N) şi memorie
auxiliară O(1). Algoritmul este, practic, o optimizare a algoritmului de
sortare prin selecţie (Selection sort), algoritm care funcţionează
determinând la fiecare pas elementul de valoarea maximă şi mutându-l pe
ultima poziţie liberă a vectorului. Deoarece trebuie să determinăm N
maxime, iar determinarea unui maxim implică verificarea tuturor
elementelor vectorului, complexitatea acestui algoritm este O(N2). Heapsort
foloseşte structura de date numită heap pentru a determina cele N maxime,
rezultând un timp de execuţie de O(N·log N). Pentru a înţelege mai bine ce
este acela un heap, vom începe prin prezentarea unor noţiuni teoretice.

Definiţia 1: Un arbore binar este un arbore în care fiecare nod are


cel mult doi descendenţi direcţi.

Definiţia 2: Un arbore binar complet este un arbore binar în care


fiecare nivel al arborelui, eventual mai puţin ultimul, are număr maxim de
noduri (nivelul h al arborelui are 2h noduri, numerotarea începând de la
zero). În cazul în care ultimul nivel nu are număr maxim de noduri,
completarea cu noduri a ultimului nivel trebuie să se facă de la stânga spre
dreapta.

Definiţia 3-4: Un max-heap este un arbore binar complet în care


orice nod are asociată o valoare mai mare sau egală (nu neapărat în sensul
clasic) cu valorile asociate descendenţilor acestui nod, dacă acest nod are
descendenţi. Dacă orice nod are asociată o valoare mai mică sau egală cu
valorile asociate descendenţilor săi, structura de date poartă numele de
min-heap. De exemplu, desenul următor reprezintă un max-heap:

Fig. 2.5.1. – Un max-heap oarecare

37
Capitolul 2

Această structură de date suportă operaţiile de inserare a unui nod şi


de ştergere a rădăcinii în compexitate O(log N), unde N este numărul
elementelor din heap. Operaţia de aflare a maximului (sau a minimului) are
complexitatea O(1), deoarece tot ce trebuie să facem este să verificăm nodul
rădăcină.

Un heap poate fi reprezentat foarte uşor folosind un vector A cu N


elemente, fiecare element reprezentând valoarea unui nod al heap-ului.
Rădăcina va fi reţinută în A[1], iar descendenţii direcţi ai acesteia în A[2]
pentru fiul stâng şi A[3] pentru fiul drept. În cazul general, fiii unui nod
reprezentat prin A[k] se vor afla în A[2·k] pentru fiul stâng şi A[2·k+1]
pentru fiul drept. Tatăl unui nod A[k] se va afla pe poziţia A[k / 2] (se ia
întotdeauna partea întreagă a rezultatului împărţirii).
De exemplu, heap-ul din figura precedentă poate fi reprezentat
printr-un vector în modul următor:

i 1 2 3 4 5 6 7
A 19 13 15 5 6 12 14

Pentru a putea implementa algoritmul Heapsort, avem nevoie de


următoarele trei operaţii: inserarea unei valori în heap, ştergerea rădăcinii
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 următor:
 Se crează un nod cu valoarea x la sfârşitul heap-ului.
 Cât timp x este mai mare decât valoarea tatălui nodului asociat
lui x execută
o Interschimbă tatăl nodului lui x cu nodul lui x.
De exemplu, dacă vrem să adăugăm în heap-ul prezentat anterior
valoarea 18, se procedează ca în desenul de mai jos.

Fig. 2.5.2. – Adăugarea 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 poziţia pe care trebuie.
Inserarea unui element într-un heap are complexitatea O(log N),
deoarece înălţimea unui heap este dată de logaritmul binar al lui N.

Ştergerea rădăcinii se poate face folosind următorul algoritm:


 Se înlocuieşte rădăcina cu ultimul nod al heap-ului şi se şterge
ultimul nod. Fie x noua rădăcină.
 Cât timp valoarea lui x este mai mică decât cel puţin unul dintre
fiii săi execută
o Interschimbă x cu fiul care are valoarea cea mai mare.
Este important să efectuăm interschimbarea cu fiul care
are valoarea cea mai mare, deoarece în caz contrar am
obţine un heap invalid, existând un nod care este părinte
pentru un nod cu valoare mai mare. Astfel, se reface
structura de heap.

Datorită înălţimii unui heap, această operaţie are complexitatea tot


O(log N).
În figura următoare puteţi vizualiza modul în care se şterge rădăcina
unui heap.

39
Capitolul 2

Fig. 2.5.3. – Ştergerea rădăcinii 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 roşu trece pe prima poziţie, devenind rădăcină:

i 1 2 3 4 5 6
A 14 13 15 5 6 12

Acum se compară valoarea elementului marcat cu roşu, adică


A[1] = 14 cu acel fiu al său care are valoarea maximă. Fiii elementului 1 se
află pe poziţiile 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], rezultând 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 rădăcinii extragem practic elementul de valoare


maximă din heap. Astfel, noua rădăcină va avea a doua cea mai mare
valoare din heap-ul iniţial. Dacă ştergem şi această rădăcină, elementul care
în va lua locul va avea a treia cea mai mare valoare din heap-ul iniţial şi aşa
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. Având vectorul
transformat într-un heap, putem să extragem la fiecare pas maximul şi să îl
poziţionăm pe ultima poziţie, adică N, iar noul heap să fie format din

40
Algoritmi de sortare

primele N – 1 poziţii ale vectorului. Procedând în acest fel până ce am


extras N maxime, vectorul va ajunge să fie sortat.

Pentru a transforma un vector oarecare într-un heap, vom considera


o funcţie Downheap(A, poz, N) care aplică algoritmul de ştergere a
rădăcinii, prezentat anterior, subarborelui cu rădăcina pe poziţia poz a
vectorului A. N reprezintă numărul de noduri ale heap-ului.
Având această funcţie, putem construi o altă funcţie,
Transformare(A, N), care transformă vectorul A (cu N elemente) într-un
heap. Această funcţie poate fi implementată în felul următor:
𝑁
 Pentru fiecare i de la până la 1 execută
2
o Apelează Downheap(A, i, N)

Funcţia 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 reprezentând heap-uri formate
dintr-un singur element.
Să demonstrăm că aceste elemente sunt frunze: vom folosi metoda
𝑁
reducerii la absurd şi vom presupune că elementul + 1 nu este frunză,
2
adică are cel puţin un fiu. Asta înseamnă că acest fiu se află pe poziţia
𝑁 𝑁
2∙ +1 = 2∙ + 2 > 𝑁. Dar asta ar însemna că fiul se află în afara
2 2
vectorului, deci presupunerea făcută este falsă, elementul analizat fiind deci
frunză. Rezultă de aici că şi celelalte elemente sunt frunze, având indici şi
mai mari.
O proprietate exploatată de acest algoritm este aceea că orice
subarbore al unui heap este la rândul său heap. Acestă afirmaţie se poate
demonstra prin inducţie. Astfel, ne propunem să transform vectorul dat într-
un heap considerând că acesta reprezintă la început un arbore binar complet
oarecare şi transformând pe rând fiecare subarbore într-un heap. Pentru
acest lucru, apelăm funcţia Downheap pentru fiecare element care nu
reprezintă o frunză. Aşa ajungem la algoritmul prezentat anterior în
pseudocod. Complexitatea acestei funcţii este O(N), deşi am putea fi tentaţi
să spunem că este O(N·log N). Demonstraţia acestei afirmaţii este lăsată pe
seama cititorului.

41
Capitolul 2

Avem acum toate noţiunile necesare pentru a implementa eficient


algoritmul Heapsort. Deşi teoria din spatele algoritmului poate fi mai greu
de înţeles decât teoria din spatele algoritmilor prezentaţi anterior, merită
făcut efortul necesar înţelegerii acesteia, Heapsort fiind un algoritm care,
deşi este mai încet în practică decât Quicksort, are avantajul de a folosi
memorie suplimentară constantă şi de a nu folosi funcţii recursive. Aceste
lucruri pot fi foarte importante dacă avem nevoie de un algoritm de
complexitate O(N·log N) pe cel mai rău caz şi care să folosească memorie
cât mai puţină.
Funcţia Heapsort(A, N) poate fi implementată în felul următor:
 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 menţionat la algoritmul Quicksort existenţa unui algoritm care


combină Quicksort cu Heapsort, rezultând un algoritm foarte eficient numit
Introsort. Ideea din spatele acestui algoritm este să începem prin a folosi
Quicksort, dar numai până când nivelul recursivităţii nu depăşeşte o anumită
limită, egală, de exemplu, cu 𝑙𝑜𝑔2 𝑁 , unde N este numărul de elemente ale
vectorului care trebuie sortat. Dacă nivelul recursivităţii depăşeşte această
valoare, vom folosi Heapsort pentru a sorta subsecvenţa curentă.
Pentru cele mai bune rezultate, este recomandat să se testeze mai
multe limite pe cât mai multe date de intrare.

Dacă se ajunge la subsecvenţe de dimensiuni mici, dar adâncimea


recursivităţii nu a depăşit limita impusă, Introsort poate folosi algoritmul de
sortare prin inserţie pentru a sorta aceste subsecvenţe, eliminându-se şi mai
multe apeluri recursive.

Un fapt ce merită menţionat este că funcţia std::sort din fişierul


antet algorithm este, pe majoritatea compilatoarelor, implementată folosind
algoritmul Introsort. Un model de implementare este dat în secţiunea
următoare.

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 următor. Trebuie


modificat algoritmul Heapsort astfel încât să sorteze o subsecvenţă
transmisă prin doi parametri, la fel ca la Quicksort. Acest lucru este lăsat pe

43
Capitolul 2

seama cititorului. Partitie este funcţia 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 numărul de apeluri recursive este limitat la O(log N), nu


mai este atât de important să aplicăm optimizările de reducere a memoriei
prezentate anterior. Într-o implementare cu adevărat eficientă, am elimina de
tot apelurile recursive, implementând manual o stivă în care se depun
subsecvenţele ce trebuiesc sortate. În practică, Introsort este cel mai rapid
algoritm prezentat până acum. Acest lucru se poate testa comparând
algoritmii prezentaţi cu funcţia std::sort din antetul algorithm. Această
funcţie se poate folosi în felul următor pentru a sorta secvenţa [1, N] a
vectorului A: std::sort(A + 1, A + N + 1);

Proprietăţile algoritmului Heapsort se regăsesc ş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ă reţinem N elemente
pentru a le sorta, ne permitem să reţinem şi memoria auxiliară.

Tabelul 2.5.4. – Proprietăţile algoritmului Heapsort


Caz favorabil Caz mediu Caz defav.
Timp de execuţie O(N·log N)
Memorie suplimentară O(1)
Stabil NU

44
Algoritmi de sortare

Am prezentat până acum doi algoritmi de complexitate pătratică şi


patru de complexitate liniar-logaritmică, aceştia patru fiind cei mai des
folosiţi algoritmi în practică. Aşa cum probabil că aţi observat,
complexitatea acestor algoritmi nu a scăzut niciodată sub O(N·log N) pe
cazurile medii şi defavorabile. Altă asemănare a algoritmilor prezentaţi până
în acest moment este că fiecare se bazează pe comparaţii între elemente.
Un algoritm de sortare bazat pe comparaţii trebuie să efectueze întotdeauna
minim O(N·log N) operaţii pe cazul defavorabil, deci putem considera
algoritmii Quicksort, Merge sort, Heapsort şi Introsort ca fiind optimi.
Aceşti algoritmi sunt însă optimi doar în cadrul clasei de algoritmi
bazaţi pe comparaţii. Aşa cum vom vedea, putem obţine algoritmi mai
eficienţi dacă folosim alte tehnici de sortare care nu compară elementele.
Dezavantajul acestor tehnici este că se bazează pe anumite
particularităţi ale datelor ce trebuiesc sortate şi ale criteriului după care
acestea trebuiesc sortate. Sortările bazate pe comparaţii sunt uşor de
modificat pentru a sorta tipuri de date neelementare, singura schimbare
majoră ce trebuie făcută este înlocuirea operatorilor de comparare cu funcţii
care compară tipurile de date ce trebuiesc sortate. O altă posibilitate este
supraîncărcarea acestor operatori pentru tipurile date necesare.
Pentru algoritmii ce urmează a fi prezentaţi, Counting sort şi Radix
sort, modificările necesare pentru a sorta orice altceva în afară de numere
naturale sunt mai dificil de realizat, sau chiar imposibile în unele situaţii.
Aceşti doi algoritmi au însă avantajul de a fi mult mai rapizi dacă datele ce
trebuiesc sortate au anumite particularităţi.

2.6. Counting sort


Counting sort, sau sortarea prin numărare, este un algoritm ce
sortează un vector de numere naturale A în timp O(N + MaxV) şi memorie
O(MaxV), unde N are semnificaţia sa obişnuită, iar MaxV reprezintă
valoarea maximă a unui element din A. Algoritmul este aşadar eficient doar
dacă avem de sortat un număr foarte mare de elemente, dar a căror valoare
numerică ştim că este foarte mică. Dacă MaxV nu este mult mai mic decât
N, nu are rost să folosim această sortare.
Modul de funcţionare al algoritmului este următorul:
 Se declară un vector V cu MaxV+1 elemente, care se
iniţializează cu 0.
 Pentru fiecare i de la 1 la N execută
o V[ A[i] ] = V[ A[i] ] + 1
 Goleşte 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 sfârşitul vectorului A.

La finalul execuţiei acestor instrucţiuni, vectorul A va conţine


elemente iniţiale în ordine crescătoare. Algoritmul numără practic câte
elemente există din fiecare valoare posibilă, după care parcurge în ordine
fiecare valoare posibilă şi o adaugă în vectorul soluţie de câte ori este
nevoie. V[i] reprezintă aşadar numărul 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 iniţializează cu 0 şi se
construieşte în felul următor: primul element analizat este A[1], deci
V[ A[1] ] primeşe valoarea V[ A[1] ] + 1. A[1] = 4, deci V[4] pimeşte
valoarea V[4] + 1 = 0 + 1 = 1. Al doilea element analizat este A[2], deci,
procedând ca şi la primul element, V[7] va primi tot valoarea 1.
În final, V va arăta în felul următor:

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 poziţiile i
ale vectorului V şi punem în A valoarea i de V[i] ori.
Este clar că sortarea prin numărare este foarte eficientă atunci când
avem un număr mare de valori mici ce trebuiesc sortate. Dezavantajele
acestei metode sunt că nu putem sorta decât numere naturale.
Pentru a extinde metoda la numere întregi din intervalul
[-MaxV, MaxV], putem aduna fiecărui element valoarea MaxV,
transformând 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 număr X de cifre după virgulă, putem să le înmulţim pe fiecare cu
10X, după care să le sortăm 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ă creşte de
10X ori, iar timpul de execuţie 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 nişte şiruri de caractere folosind această
metodă? Dar nişte 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 lucrăm cu numere naturale, am putea folosi tipul de date


unsigned int, dar acest lucru nu este obligatoriu ci ţine, în acest caz, de
preferinţele personale ale programatorului.
Putem optimiza algoritmul dacă avem de sortat numai elemente
distincte sau dacă prin sortare dorim să eliminăm elementele care se repetă.
Acest lucru îl putem face lucrând pe biţi, vectorul V având în acest caz
semnificaţia: 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ă numărul de bytes (octeţi) folosiţi de tipul de date


int pe sistemul pe care se lucrează. Deoarece 1 byte = 8 biţi, 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 înţeles. Detalii despre efecutarea operaţiilor pe
biţi puteţi găsi în secţiunea următoare, la algoritmul Radix sort.

47
Capitolul 2

Tabelul 2.6.1. – Proprietăţile algoritmului de sortare prin numărare


Caz favorabil Caz mediu Caz defav.
Timp de execuţie O(N+MaxVal)
Memorie suplimentară O(MaxVal)
Stabil DA

Deşi am considerat algoritmul ca fiind optim pe toate cazurile,


trebuie ţinut cont de faptul ca acest lucru nu se aplică decât dacă MaxVal
este cu mult mai mic decât N.

2.7. Radix sort


Radix sort poate fi considerat o optimizare a sortării prin
numărare, deoarece algoritmul are la bază acelaşi mod de funcţionare, dar
aplicat de mai multe ori în aşa fel încât memoria folosită să fie constantă şi
timpul de execuţie să fie aproximativ la fel de bun ca al sortării prin
numărare. Pentru a putea fi aplicat altor tipuri de date decât numerelor
naturale, Radix sort necesită cel puţin aceleaşi modficări ca sortarea prin
numărare.
Ideea din spatele algoritmului este să sortăm mai întâi numerele după
cea mai puţin semnificativă cifră (cifra unităţilor), după care după cifra
zecilor, cifra sutelor ş.a.m.d. Dacă numerele nu au toate acelaşi număr de
cifre, vom considera că numerele cu cifre mai puţine au zerouri în faţă.
De exemplu, dacă ne propunem să sortăm următorul 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 întâi numerele după cifra unităţilor. Va
rezulta următorul 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 uşor că numerele sunt ordonate crescător după cifra


unităţilor. Următorul pas este să sortăm 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ă sortăm 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ă fără prea mari complicaţii. Ar trebui doar să
modificăm sortarea prin numărare 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 execuţie. Dacă am implementa algoritmul
în forma sa actuală, timpul de execuţie ar fi O(N·log MaxVal), unde
MaxVal reprezintă valoarea maximă a numerelor din vector. Acestă
complexitate se datorează faptului că aplicăm sortarea prin numărare de
NrCif ori, unde NrCif reprezintă numărul de cifre ale celui mai mare număr
din vector, iar 𝑁𝑟𝐶𝑖𝑓 = 1 + 𝑙𝑜𝑔10 𝑀𝑎𝑥𝑉𝑎𝑙 . Deoarece sortarea prin
numărare se aplică întotdeauna unor numere de o singură cifră, putem
considera timpul de execuţie al acesteia ca fiind O(N). Memoria
suplimentară folosită este O(N), având nevoie, aşa 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 aşa cum este în
cazul algoritmilor prezentaţi până acum.
Putem însă obţine un algoritm şi mai rapid. Pentru acest lucru, vom
prezenta mai întâi câteva noţiuni teoretice despre reprezentarea numerelor în
sistemul zecimal şi sistemul binar cu ajutorul căror vom putea optimiza
numărul de apeluri ale sortării prin numărare.

Definiţia 1: Un număr natural care are cea mai mare cifră C poate fi
considerat un număr în toate bazele mai mari decât C. De exemplu, numărul
5213 poate fi considerat un număr în toate bazele mai mari decât 5. Sistemul
(baza) în care un număr este scris se marchează prin trecerea bazei ca indice
al numărului.
Definiţia 2: O cifră a unui număr natural în baza 2 se numeşte bit. 8
biţi = 1 byte.

Proprietatea 1: Orice număr natural notat în felul următor:

𝑋 = 𝑎1 𝑎2 … 𝑎𝑘 , 𝑎𝑖 ∈ {0,1, … ,9}

este scris în sistemul zecimal (baza 10), în următoarea formă:

49
Capitolul 2

𝑋10 = 𝑎1 ∙ 10𝑘 −1 + 𝑎2 ∙ 10𝑘 −2 + ⋯ + 𝑎𝑘 ∙ 100

De exemplu, numărul 362 este scris în felul următor:


36210 = 3102 + 610 + 2

Proprietatea 2: Orice număr natural

𝑋 = 𝑎1 𝑎2 … 𝑎𝑘 , 𝑎𝑖 ∈ {0,1, … ,9}

care reprezintă un număr în sistemul binar (baza 2) poate fi


transformat în echivalentul său din baza 10 în felul următor:

𝑋2 = (𝑎1 ∙ 2𝑘−1 + 𝑎2 ∙ 2𝑘−2 + ⋯ + 𝑎𝑘 ∙ 20 )10


.
De exemplu, 10112 = 1110

Proprietatea 3: Orice număr natural notat în felul următor:

𝑋 = 𝑎1 𝑎2 … 𝑎𝑘 , 𝑎𝑖 ∈ {0,1, … ,9}

care reprezintă un număr în baza 10, poate fi transformat în baza 2


prin următorul algoritm:
 Cât timp X diferit de 0 execută
o Notează restul împărţirii lui X la 2.
o X=X/2
 Resturile notate, citite de la dreapta la stânga, reprezintă
numărul transformat din baza 10 în baza 2.

De exemplu, dacă vrem să transformăm numărul 1110 în baza 2, vom


proceda în felul următor: 11 este diferit de 0, deci notăm restul împărţirii
sale la 2, acesta fiind 1. Îl împărţim pe 11 la 2 şi reţinem partea întreagă a
împărţirii, adică 5. 5 este diferit de 0, deci notăm restul împăţirii sale la 2,
care este 1. Reţinem partea întreagă a împărţirii lui 5 la 2, adică 2. Restul
împărţirii lui 2 la 2 este 0, care se notează. 2 împărţit la 2 este 1, iar restul
împărţirii lui 1 la 2 este 1. Partea întreagă a împărţirii lui 1 la 2 este 0, deci
am terminat. Reprezentarea binară este dată de citirea resturilor obţinute: 1,
1, 0, 1 de la dreapta la stânga 10112 = 1110.

Cele două transformări 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ă efectuăm anumite operaţii logice pe reprezentarea binară a numerelor
date spre sortare. Calculatorul reţine automat numerele în baza 2, deci nu va
trebui să efectuăm vreo transformare, ci doar să aplicăm operaţiile necesare.
Aceste operaţii sunt:
Operaţia ŞI (AND):
Operaţia ŞI (operatorul „&”) se aplică asupra a două valori numere
naturale (unsigned int). Rezultatul este un număr natural obţinut prin
conjuncţia tuturor biţilor primului număr cu biţii de pe aceleaşi poziţii ai
celui de-al doilea număr. Tabelul de adevăr al conjuncţiei este:

p q p&q
1 1 1
1 0 0
0 1 0
0 0 0

Operaţia SAU (OR):


Operaţia SAU (operatorul „|”) funcţionează la fel ca operaţia ŞI,
doar că în loc de conjuncţie se aplică disjuncţia. Tabelul de adevăr este
următorul:

p q p|q
1 1 1
1 0 1
0 1 1
0 0 0

Operaţiile de deplasare (operatorii „>>” şi „<<”)


Operaţiile de deplasare au ca efect deplasarea tuturor biţilor de
valoare 1 a unui număr cu una sau mai multe poziţii către stânga, în cazul
operatorului „<<”, sau către dreapta, în cazul operatorului „>>”. Biţii rămaşi
liberi se înlocuiesc cu biţi de valoare 0, iar biţii care ies din numărul de biţi
alocaţi reprezentării se pierd.
Exemple: 1011 << 1 = 10110, 10011 >> 1001, 11001 << 3 =
11001000, 110 >> 2 = 1. Dacă impunem, de exemplu, o limită de 4 biţi
reprezentării, atunci 0110 << 2 = 1000.

În cele ce urmează vom lucra cu tipul de date unsigned int, care


vom considera că poate reţine numere naturale din intervalul [0, 232 – 1],

51
Capitolul 2

deci este reprezentat pe 32 de biţi. De fiecare dată când avem de gând să


efectuăm operaţii pe biţi este de preferat să lucrăm cu tipuri de date fără
semn (unsigned), pentru a nu avea grija bitului de semn şi pentru a putea
profita astfel de toţi cei 32 de biţi ai tipului de date int.
Operaţiile pe biţi ne ajută să optimizăm algoritmul Radix sort,
aducându-l la complexitatea timp O(N) pentru numere naturale
reprezentabile pe 32 de biţi. Nu vom mai sorta numerele după valorile
fiecarei cifre, ci după valorile fiecărei grupe de biţi. Vom alege un număr
natural MaxG care va reprezenta numărul de biţi dintr-o grupă. Algoritmul
prezentat anterior rămâne nemodificat, doar că de data aceasta vom
considera o „cifră” ca fiind formată din MaxG biţi. Vom avea deci nevoie
de un vector de dimensiune 2MaxG în cadrul sortării prin numărare, 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ă aplicăm sortarea prin
numărare de două ori. În cazul acesta, timpul de execuţie poate fi considerat
O(N), dar în cazul general, când nu ştim cât de mari sunt numerele pe care
trebuie să le sortăm, timpul de execuţie este O(k·N), unde k reprezintă de
câte ori trebuie apelată procedura de sortare prin numărare.
De exemplu, pe următorul vector, în care numerele sunt date în baza
2, reprezentate pe 8 biţi, iar MaxG = 4:

i 1 2 3
A 01101110 00010111 11101001

Sortăm mai întâi numerele după ultimii MaxG = 4 biţi, rezultând


următorul vector:

i 1 2 3
A 00010111 11101001 01101110

Deoarece 01112 = 710, 10012 = 910 şi 11102 = 1410. Vom sorta acum
numerele după următoarea grupă de de patru biţi:

i 1 2 3
A 00010111 01101110 11101001

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


sortat în doi paşi. Numerele în baza 10 sunt: 23, 110, 233.

52
Algoritmi de sortare

Mai trebuie să rezolvăm doar subproblema sortării numerelor după o


anumită grupă de biţi. Pentru aceasta vom folosi o funcţie ajutătoare numită
Sortare(A, N, T, Gr, V, Poz) care sortează numerele din vectorul A, de
dimensiune N, după grupa de biţi cu numărul Gr şi care reţine rezultatul în
vectorul T. Funcţia va folosi doi vectori de caracterizare de dimensiune
MaxG: un vector V, unde V[i] reprezintă câte numere există în vectorul A
care au grupa Gr de biţi egală cu i şi un vector Poz, unde Poz[i] reprezintă
poziţia pe care trebuie pus în vectorul T primul număr 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 următorul vector V:

i 0 1 2 3 4 5 6
A 3 4 3 2 1 1 7

Atunci vectorul Poz va fi următorul:

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 poziţiile 1, 2 şi 3. Cele patru numere cu grupa
Gr egală cu 1 vor fi puse în vectorul T pe poziţiile 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 stânga începând de la 0 şi vom folosi operaţiile şi operatorii pe
biţi prezentate anterior pentru a afla valoarea grupei curente. Mai exact, vom
folosi o funcţie AflaGrupa(Nr, Gr) care va returna valoarea X grupei Gr a
numărului Nr. Această valoare poate fi calculată folosind următoarea
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 biţi cu un singur bit de valoare 1 urmat numai de
biţi de valoare 0. Astfel, scăzându-l pe 1 dintr-o putere a lui 2, reprezentarea
binară a rezultatului va fi alcătuită numai din biţi de valoare 1. Efectuând
operaţia ŞI între un număr X şi un alt număr care are toţi biţii de valoare 1,
rezultatul va fi întotdeauna numărul X.

53
Capitolul 2

De exemplu, dacă ne propunem să aflăm valoarea primilor cinci biţi


(de la dreapta spre stânga) ai numărului 11010110110 2, tot ce trebuie să
facem este să aplicăm operaţia ŞI între acest număr şi numărul 11111 2:

110101101102 &
000000111112
000000101102 = 2210

Pentru a afla valoarea următorilor cinci biţi, vom deplasa mai întâi
numărul iniţial cu cinci poziţii către dreapta şi vom aplica operaţia ŞI pe
numărul 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ă declarăm toate variabilele fără


semn, dar acest lucru nu afectează în vreun fel algoritmul.
Menţionăm că, în practică, Quicksort rămâne în continuare
algoritmul mai rapid, Radix sort fiind mai rapid doar dacă numărul
elementelor ce trebuie sortate este foarte mare.

Tabelul 2.7.1. – Proprietăţile algoritmului Radix sort


Caz favorabil Caz mediu Caz defav.
Timp de execuţie O(k·N)
Memorie suplimentară O(N)
Stabil DA

2.8. Concluzii
Am prezentat în acest capitol opt algoritmi de sortare, metode de
implementare a acestora, optimizări şi situaţiile în care fiecare se potriveşte
cel mai bine.

Tabelul 2.8.1. – Comparaţie între toţi algoritmii de sortare prezentaţi


Timp de execuţie 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(N·log N)1 O(1) O(log N) O(log N) NU
Merge
O(N·log N) O(N) DA
sort
Heapsort O(N·log N) O(1) NU
Introsort O(N·log N) O(log N) NU
Counting
O(N + MaxVal) O(MaxVal) 2 DA
sort
Radix
O(k·N) 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 sortării vectorilor de dimensiuni
foarte mari, dar a căror elemente au valori mici.
3
Considerat optim deoarece pentru numere întregi pe 32 de biţi, k = 2.

55
Capitolul 2

Problema sortării unor date este aşadar o problemă studiată de foarte


mult timp şi pentru care s-au găsit mulţi algoritmi, unii eficienţi indiferent
de natura datelor care trebuie sortate, alţii proiectaţi special pentru anumite
tipuri de date. Algoritmii de sortare reprezintă o introducere perfectă în
informatică datorită faptului că sunt uşor de înţeles şi pot fi implementaţi
fără prea mari dificultăţi. Implementarea acestora nu necesită decât
cunoştinţe elementare ale limbajului de programare în care se lucrează, în
acest caz C++, fapt care permite studierea şi înţelegerea acestora şi de către
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 însoţite de
aplicaţii practice clasice, cum ar fi problema turnurilor din Hanoi şi
generarea permutărilor, aranjamentelor, combinărilor etc.
Acest capitol este foarte important, întrucât orice problemă poate fi
rezolvată printr-un algoritm care se încadrează într-una dintre tehnicile
menţionate. Recomandăm aşadar stăpânirea 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 definiţia de bază: o funcţie este recursivă dacă în
definiţia ei se foloseşte o referire la ea însăşi.
Din această definiţie 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 condiţie de oprire dată de modificările parametrilor
formali:
rec(param_formali)
{
if ( conditie_iesire(param_formali) ) { instructiuni finale }
else rec(param_formali_modificati)
}

Există o serie de funcţii matematice definite prin recursivitate, dintre


care amintim:

 Funcţia factorial

1 𝑑𝑎𝑐ă 𝑛 = 0
𝑓 𝑛 =
𝑓 𝑛−1 ∗𝑛 𝑑𝑎𝑐ă 𝑛 > 0

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

 Funcţia Ackermann (Ackermann-Peter)

𝑛+1 𝑑𝑎𝑐ă 𝑚 = 0
𝐴 𝑚, 𝑛 = 𝐴(𝑚 − 1, 1) 𝑑𝑎𝑐ă 𝑚 > 0 ş𝑖 𝑛 = 0
𝐴(𝑚 − 1, 𝐴 𝑚, 𝑛 − 1 ) 𝑑𝑎𝑐ă 𝑚 > 0 ş𝑖 𝑛 > 0

59
Capitolul 3

int A(int m, int n)


{
if ( m == 0 ) return n + 1;
else if ( n == 0 ) return A(m - 1, 1);
else return A(m - 1, A(m, n - 1));
}

 Şirul lui Fibonacci

0 𝑑𝑎𝑐ă 𝑛 = 0
𝐹: 𝑁 → 𝑁, 𝐹 𝑛 = 1 𝑑𝑎𝑐ă 𝑛 = 1
𝐹 𝑛 − 1 + 𝐹(𝑛 − 2) 𝑑𝑎𝑐ă 𝑛 ≥ 2

Conform definiţiei:
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 definiţia şi în felul următor:


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)

𝑥 𝑑𝑎𝑐ă 𝑦 = 0
𝑐𝑚𝑚𝑑𝑐 𝑥, 𝑦 =
𝑐𝑚𝑚𝑑𝑐(𝑦, 𝑥 % 𝑦) 𝑑𝑎𝑐ă 𝑦 > 0

int cmmdc(int x, int y)


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

60
Tehnici de programare

a) Fractali

O problemă importantă a recursivităţii o constituie fractalii. În


această secţiune vom aminti doar sumar conceptul fractalilor geometrici în
care modelul de bază al funcţiei recursive va genera figuri geometrice (în
diferite ipostaze şi de diferite dimensiuni), iar condiţia de oprire va fi dată de
posibilitatea desenării acestor figuri geometrice (latura > 1 pixel)

Să presupunem un exemplu în care figura de bază este un pătrat


(x, y, l). Procedura patrat desenează figura geometrică numită pătrat cu
centrul în (x, y) şi de latură l. (Fig. 3.1.1. a, b, c)

Fig. 3.1.1. a)

Următorul subprogram:
fractal (x, y, l)
{
patrat(x, y, l);
fractal((x – l) / 2, (y – l) / 2, l / 2)
}

Va genera următoarea figură:

Fig. 3.1.1. b)

61
Capitolul 3

Generând eroare datorită lipsei condiţiei de oprire.


Să implementăm condiţia 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 acelaşi mod se obţin şi figurile clasice Koch, Sierpinski, Kepler,


Cantor...

b) Turnurile din Hanoi

Nu putem vorbi de recursivitate fără să tratăm problema turnurilor


din Hanoi. Această problemă presupune existenţa unui set de n discuri de
diferite mărimi (în Fig. 3.1.2. avem n = 4 discuri), aşezate în ordine pe o tijă
numită sursă (discul cu circumferinţa cea mai mare se găseşte cel mai jos).
Există de asemenea încă două tije numite intermediar şi destinaţie.

62
Tehnici de programare

Obiectivul problemei (jocului) constă în mutarea celor n discuri de


pe tija sursă pe tija destinaţie folosind tija intermediar cu următoarele trei
restricţii:
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
roşie.
2. Nu se poate pune un disc de dimensiune mai mare peste un disc
de dimensiune (circumferinţă) mai mică.
3. Numărul de mutări 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 destinaţie,


lucru ce se poate face foarte uşor.

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ă mutăm aceste n-1 discuri de pe “intermediar” pe “destinaţie” obţinând
astfel configuraţia finală (Fig. 3.1.5.)

Fig. 3.1.5. – A treia etapă de rezolvare a problemei

Dacă ne uităm la restricţiile iniţiale observăm 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 obţinut un număr minim de paşi. Nu am
îndeplinit însă condiţia 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ă când n – 1 > 1 ca în figura 3.1.3. şi să rearanjăm


tijele, făcând abstracţie de cel mai mare disc (nu intră în calcul decât la
etapa a doua), şi obţinem problema turnurilor din Hanoi, dar cu n – 1 discuri
şi altă tijă numită intermediar (C) şi altă tijă numită destinaţie (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 următorul model care va constitui şi


baza de funcţionare a algoritmului recursiv.
Funcţia de rezolvare Hanoi(n, A, B, C) se poate descompune în trei
subprobleme în ordinea următoare:
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 soluţia 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 înţelege mai bine conceptul de recursivitate aplicat la acest


tip de probleme, vom analiza în continuare problema turnurilor din Hanoi cu
aceleaşi condiţii iniţiale, dar vom introduce încă o tijă intermediară.
Pentru a obţine o singură soluţie la un număr dat de discuri, vom introduce
restricţia de a alege ca şi tijă intermediară pe care să mutăm piesa curentă
totdeauna cea mai din stânga 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 funcţionare 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) soluţia trivială AC
3. Hanoi (1, A, B, C, D) soluţia trivială AD
4. Hanoi (1, C, A, B, D) soluţia trivială CD
5. Hanoi (n – 2, B, A, C, D)

67
Capitolul 3

Mai trebuie să construim condiţia de oprire a algoritmului recursiv.


Acesta va trebui oprit fie când n ajunge la valoarea 1 fie când ajunge la 2, în
funcţie de valoarea iniţială 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 construieşte treptat soluţia unei probleme, iar în
cazul în care soluţia construită se dovedeşte a fi invalidă (sau ne interesează
mai multe soluţii), revine la un pas precedent pentru a schimba o alegere
făcută. Acest lucru se continuă, de obicei, până când au fost explorate toate
posibilităţile sau până când am găsit una sau mai multe soluţii valide. De
multe ori nu este necesară explorarea tuturor posibilităţilor, putând elimina
68
Tehnici de programare

alegeri care ar conduce la o soluţie invalidă fără a genera o soluţie completă.


Astfel, se reduce foarte mult numărul de operaţii 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 făcută la acel pas. Astfel, stiva va reprezenta întotdeauna soluţia
(eventual parţială) la care s-a ajuns într-un anumit moment.
Forma generală a metodei poate fi scrisă în pseudocod în felul
următor: definim o funcţie 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 soluţie, P reprezintă numărul
maxim de alegeri existente la un anumit pas, X reprezintă vectorul ce
conţine alegerile posibile pentru fiecare pas, iar Sol reprezintă stiva folosită,
adică un vector. Funcţia poate fi implementată în felul următor:
 Dacă K > N execută
o Dacă soluţia reprezentată de vectorul Sol este validă, se
afişează vectorul Sol şi se încheie algoritmul (în caz că ne
interesează o singură soluţie).
o Altfel, dacă ne interesează mai multe soluţii sau soluţia
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 execuţie al unui algoritm de tip backtracking este O(N·PN ),
deoarece avem P posibilităţi pentru fiecare din cei N paşi, iar validarea unei
soluţie are, de obicei, complexitatea O(N). În practică însă, această
complexitate este de multe ori supraestimată, existând diverse optimizări
euristice pentru problemele care nu admit decât rezolvări 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 mulţime care se poate accesa şi dacă nu
este reţinută într-un vector, cum ar fi mulţimea numerelor naturale.

În cele ce urmează vom prezenta detaliat patru probleme a căror


rezolvare nu se poate face decât folosind această tehnică de programare:
generarea permutărilor unei mulţimi, generarea aranjamentelor, generarea
combinărilor şi generarea partiţiilor unui număr.

69
Capitolul 3

a) Generarea permutărilor unei mulţimi

Ne propunem să generăm toate cele N! permutări ale mulţimii


X = {1, 2, 3, …, N}, pentru N citit din fişierul perm.in. Permutările se vor
afişa în fişierul 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 căror elemente vor fi numerele care
reprezintă o permutare. Când am depus N elemente în stivă, avem o soluţie.
Această soluţie este validă doar dacă toate numerele din stivă sunt distincte,
în caz contrar neavând 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 verificării la O(1), folosind un vector
boolean Fol, tot de dimensiune N, cu semnificaţia Fol[i] = false, dacă
numărul i nu a fost încă depus în stivă şi true în caz contrar. Când vrem să
depunem un număr i în stivă, vom verifica mai întâi valoarea lui Fol[i]: dacă
aceasta este false, vom depune numărul în stivă, vom atribui lui Fol[i]
valoarea true şi vom merge mai departe. În caz contrar, dacă Fol[i] este
true, numărul i se află deja în stivă pe o poziţie anterioară şi nu îl mai putem
pune încă o dată. Trebuie avut grijă ca la revenirea din recursivitate să setăm
valoarea lui Fol[i] pe false înainte de a depune alt număr pe acea poziţie a
stivei, deoarece numărul i va putea fi folosit în următoarele soluţii.
Astfel, verificarea validităţii nu mai trebuie efectuată în momentul în
care stiva are N elemente depuse. Când ajungem la o stivă cu N elemente,
putem să afişăm pur şi simplu conţinutul stivei. Complexitatea algoritmului
este O(NN), dar aceasta este supraestimată, în practică eliminându-se foarte
multe posibilităţi invalide datorită verificării pe care o efectuăm atunci când
încercăm să depunem un număr în stivă.

70
Tehnici de programare

Priviţi cum funcţionează algoritmul pentru N = 3. Iniţial, vectorul


Fol se iniţializează cu 0, iar vectorul Sol poate rămâne neiniţializat.
La primul pas, K = 1, se depune mai întâi în Sol[K] valoarea i = 1,
care se marchează apoi ca fiind folosită:

1 1
K Sol

i 1 2 3
Fol 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 următoarea valoare, adică
2: Sol[K] = 2, iar Fol[2] = true:

2 2
1 1
K Sol

i 1 2 3
Fol 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 poziţia 3 a stivei
şi Fol[3] ia valoarea true:

3 3
2 2
1 1
K Sol

i 1 2 3
Fol true true true

Se trece la pasul K = 4: K > N, deci afişăm conţinuturile 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 3
2 2
1 1
K Sol

i 1 2 3
Fol 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 aşadar la pasul K = 2, Fol[2] devine
false şi se depune în Sol[K] următoarea valoare nefolosită, adică 3, iar
Fol[3] devine true:
2 3
1 1
K Sol

i 1 2 3
Fol true false true

Se continuă în acest mod până când nu se mai pot depune valori noi
pe nicio poziţie a stivei, lucru ce se va întâmpla după generarea permutării 3
2 1.
Se poate observa că algoritmul acesta generează permutările în
ordine lexicografică. Spunem că un şir (Xn) este mai mic lexicografic decât
un şir (Y n) dacă şi numai dacă există un i (1 < i ≤ n) astfel încât:
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 main()


int Sol[], ofstream &out) {
{ int N, Sol[maxN];
if ( K > N ) bool Fol[maxN];
{
for ( int i = 1; i <= N; ++i ) citire(N);
out << Sol[i] << ' ';
out << endl; for ( int i = 1; i <= N; ++i )
return; Fol[i] = false;
}
ofstream out("perm.out");
for ( int i = 1; i <= N; ++i ) perm(1, N, Fol, Sol, out);
if ( !Fol[i] ) out.close();
{
Sol[K] = i; return 0;
}
Fol[i] = true;
perm(K + 1, N, Fol, Sol, out);
Fol[i] = false;
}
}

Exerciţii:
a) Cât de rapid este algoritmul pentru valori mai mari decât 8?
b) Mai puteţi găsi optimizări pentru acest algoritm? Dar alt
algoritm, care abordează diferit problema? (Indiciu: folosiţi
interschimbări)
c) Modificaţi algoritmul astfel încât să găsească numai a P-a
permutare în ordine lexicografică.
d) Modificaţi algoritmul astfel încât să genereze toate permutările
unei mulţimi citite din fişier, mulţime care poate avea elemente
care se repetă.

b) Generarea aranjamentelor
Dându-se două numere naturale N şi P (1 ≤ P ≤ N), ne propunem să
generăm toate aranjamentele de N luate câte P ale mulţimii numerelor
𝑁!
naturale. Aranjamente de N luate câte P se notează 𝐴𝑃𝑁 , iar 𝐴𝑃𝑁 =
𝑁−𝑃 !
(numărul aranjamentelor de N luate câte P). Reamintim că 𝐴𝑃𝑁 reprezintă
modalităţile de a aranja N obiecte în P poziţii, ţinând 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 fişierul aran.in, iar aranjamentele găsite se scriu


în fişierul aran.out, fiecare pe câte 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 permutărilor. Vom folosi aceeaşi funcţie, doar că, de
data aceasta, vom avea o soluţie (un aranjament) atunci când K > P,
deoarece avem P poziţii pe care trebuiesc aranjate obiectele, nu N, ca în
cazul permutărilor. Complexitatea algoritmului va fi O(NP), dar şi aceasta
este supraestimată, în practică efectuându-se un număr de operaţii mai
apropiat de numărul aranjamentelor.

Priviţi modul de funcţionare al algoritmului pentru exemplul dat: Se


iniţializează Fol cu false, iar la pasul K = 1, se depune în stivă valoarea 1 şi
Fol[1] devine true:
1 1
K Sol

i 1 2 3
Fol true false false

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


imposibil deoarece Fol[1] = true. Se depune aşadar valoarea 2, iar Fol[2]
devine true:

2 2
1 1
K Sol

i 1 2 3
Fol true true false
74
Tehnici de programare

Se trece la pasul K = 3, moment în care condiţia K > P devine


adevărată, deci afişăm conţinuturile stivei: 1 2.

Se revine la pasul K = 2, Fol[2] devine false, iar pe poziţia 2 a stivei


se depune următoarea valoare, adică 3, iar Fol[3] devine true:

2 3
1 1
K Sol

i 1 2 3
Fol true false true

Se trece la pasul K = 3, moment în care se afişează următorul


aranjament: 1 3. Se procedează în acest mod până când nu mai există
posibilităţi la niciun pas al algoritmului, adică până când au fost afişate toate
aranjamentele existente.

Este uşor 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 main()


int Sol[], ofstream &out) {
{ int N, P, Sol[maxP];
if ( K > P ) bool Fol[maxN];
{
for ( int i = 1; i <= P; ++i ) citire(N, P);
out << Sol[i] << ' ';
out << endl; for ( int i = 1; i <= N; ++i )
return; Fol[i] = false;
}
for ( int i = 1; i <= N; ++i ) ofstream out("aran.out");
if ( !Fol[i] ) aran(1, N, P, Fol, Sol, out);
{ out.close();
Sol[K] = i;
Fol[i] = true; return 0;
aran(K + 1, N, P, Fol, Sol, out); }
Fol[i] = false;
}
}

c) Generarea combinărilor

Dându-se două numere naturale N şi P (1 ≤ P ≤ N), dorim să


generăm toate combinările de N luate câte P ale mulţimii numerelor
𝑁!
naturale. Combinări de N luate câte P se notează 𝐶𝑁𝑃 , iar 𝐶𝑁𝑃 =
𝑃!∙ 𝑁−𝑃 !
(numărul combinărilor de N luate câte P). Reamintim că, spre deosebire de
aranjamente, 𝐶𝑁𝑃 reprezintă modalităţile de a aranja N obiecte în P poziţii,
ordinea în care se face aranjarea lor neavând importanţă (de exemplu,
aranjarea 1 2 3 este acelaşi lucru cu aranjarea 2 1 3).
N şi P se citesc din fişierul comb.in, iar combinările generate se
scriu în fişierul comb.out, câte 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]. Considerăm Sol[0] = 0. Deoarece ordinea elementelor nu
contează, putem depune numere pe poziţia actuală a stivei începând 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 crescător şi nu vor avea cum să se repete. Mai mult,
acest artificiu ne asigură şi că nu vom genera mai multe combinări decât
este nevoie.
Algoritmul este cel mai rapid de până acum, complexitatea sa fiind
O(𝐶𝑁𝑃 ), datorită faptului că nu se va încerca niciodata depunerea unei valori
invalide în stivă.

Pentru exemplul dat, algoritmul funcţionează în felul următor: mai


întâi se iniţializează Sol[0] cu 0. La pasul K = 1, se depune mai întâi
valoarea 1 în stivă:

1 1
K Sol

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 afişează conţinuturile stivei, deoarece
K > P.

Se revine la pasul K = 2 şi se continuă numărătoarea, depunându-se


în stivă valoarea 3. La următorul pas, se va afişa din nou stiva. Se continuă
în acest mod până ce vor fi afişate toate combinările.

Şi de data aceasta, algoritmul va genera combinările în ordine


lexicografică.

77
Capitolul 3

#include <fstream> int main()


using namespace std; {
int N, P, Sol[maxP];
const int maxN = 12, maxP = 12;
citire(N, P);
void citire(int &N, int &P)
{ Sol[0] = 0;
ifstream in("comb.in");
in >> N >> P; ofstream out("comb.out");
comb(1, N, P, Sol, out);
in.close(); out.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);


}
}

Exerciţii:
a) Cât de rapidă este generarea aranjamentelor şi a combinărilor
pentru valori mari, dar apropiate, ale lui N şi P?
b) Scrieţi un program care afişează toate numerele naturale de trei
cifre care pot forma cu cifrele 1, 3, 4 şi 5. Ce algoritm veţi
folosi?
c) Modificaţi ultimii doi algoritmi astfel încât să afişeze
aranjamentele, respectiv combinările, în ordine invers-
lexicografică.

78
Tehnici de programare

d) Complexitatea algoritmilor prezentaţi creşte de N (pentru


permutări) respectiv P (pentru aranjamente şi combinări) ori
datorită faptului că afişarea necesită parcurgerea stivei.
Remediaţi acest lucru dacă este posibil.

d) Generarea partiţiilor unui număr

O partiţie a unui număr natural N este o modalitate de a-l scrie pe N


ca sumă de unul sau mai multe numere naturale nenule. Două partiţii în care
termenii diferă doar prin ordinea lor se consideră identice.
Ne propunem să generăm toate partiţiile unui număr N citit din
fişierul part.in. Acestea se vor afişa în fişierul part.out, câte o partiţie pe
linie, într-o ordine oarecare. O partiţie este formată din unul sau mai multe
numere naturale nenule a căror sumă este N.

Exemplu:

part.in part.out
4 1111
112
13
22
4

Explicaţie: 1 + 1 + 1 + 1 = 2 + 1 + 1 = 3 + 1 = 4

Problema este similară cu problema generării combinărilor. Vom


folosi o stivă Sol în care vom depune termenii partiţiilor. De data aceasta nu
avem un număr prestabilit de paşi, cum a fost cazul la problemele
anterioare, aşa că ne trebuie altă modalitate de a afla când am ajuns la o
soluţie. În momentul în care găsim un termen i, îl vom scădea din N şi vom
apela funcţia recursiv pentru N = N – i. Astfel, când N ajunge să fie 0, ştim
că am găsit o partiţie. Mai mult, deoarece ordinea termenilor nu contează,
putem începe generarea valorilor pentru un anumit pas începând cu valoarea
de la pasul precedent, similar cu modul de generare al combinărilor. Un
termen i este valid dacă N – i  0.

Pentru exemplul dat, algoritmul va executa următorii paşi: mai întâi


vom iniţializa Sol[0] cu 1, deoarece cel mai mic termen posibil într-o
partiţie este 1.

79
Capitolul 3

La pasul K = 1 se depune mai întâi î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 următorul 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ă funcţia pentru N = 2. Se continuă în acest mod până când se ajunge
la N = 0 şi K = 5, după care se afişează conţinuturile stivei, care va arăta în
felul următor:
5
4 1
3 1
2 1
1 1
K Sol
N=0

Se revine la pasul K = 4 şi se încearcă depunerea valorii 2 pe această


poziţie 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 arăta
în felul următor la acest pas:
3 2
2 1
1 1
K Sol
N=2

Se trece înapoi la pasul K = 4, cu N = N – 2 = 2 – 2 = 0, deci am mai


găsit o soluţie. Se procedează în acest mod până ce am găsit toate soluţiile,
adică până ce pe prima poziţie a stivei se depune chiar valoarea N.

80
Tehnici de programare

#include <fstream> int main()


{
using namespace std; int N, P, Sol[maxN];
const int maxN = 20;
citire(N);
void citire(int &N) Sol[0] = 1;
{
ifstream in("part.in"); ofstream out("part.out");
in >> N; part(1, N, Sol, out);
in.close(); 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);


}
}

Exerciţii:
a) Modificaţi algoritmul astfel încât doar să numere partiţiile
existente, nu să le şi afişeze.
b) Impuneţi condiţia ca numerele folosite într-o partiţie să fie
distincte.
c) Impuneţi condiţia ca diferenţa în modul a doi termeni consecutivi
să fie cel puţin 2

81
Capitolul 3

e) Concluzii

Am prezentat patru algoritmi reprezentativi pentru tehnica de


programare backtracking. Aceştia pun în evidenţă cel mai bine structura
generală a acestei metode şi stau la baza majorităţii 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 ineficienţi, metode precum divide et impera,
greedy, programare dinamică, tehnici aleatoare sau algoritmi genetici
fiind de multe ori de preferat atunci când o problemă poate fi rezolvată
printr-una din aceste metode, chiar dacă implementarea unui algoritm
backtracking este de multe ori mai uşoară. Metoda backtracking se
foloseşte, de obicei, fie când o rezolvare eficientă folosind alte metode
(polinomiale sau probabiliste) nu este cunoscută, fie când dorim să aflăm
toate soluţiile unei probleme, aşa cum a fost cazul problemelor prezentate
anterior.
Aşa cum am văzut, putem optimiza de multe ori un algoritm de tip
backtracking, eliminând astfel multe soluţii care s-ar dovedi la un moment
dat a fi invalide. Aceste optimizări depind foarte mult de natura problemei
pe care o rezolvăm. De multe ori, un algoritm backtracking optimizat poate
fi cu mult mai eficient în practică decât unul neoptimizat.

3.3. Divide et impera


Tehnica divide et impera (tradus: dezbină şi cucereşte) este o
tehnică de programare care se bazează pe împărţirea succesivă a unei
probleme în subprobleme din ce în ce mai mici până când acestea pot fi
rezolvate foarte uşor, iar apoi combinate în aşa fel încât să obţinem soluţii la
subprobleme din ce în ce mai complicate, ajungându-se în final la o soluţie
pentru problema iniţială. Am văzut 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 uşoară şi chiar de
preferat.
Deşi majoritatea algoritmilor pot fi scrişi ca algoritmi divide et
impera, aceasta este o tehnică aplicabilă numai anumitor probleme care
chiar necesită o asemenea abordare, deoarece alţi 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, aşa cum vom
vedea.

Structura generală a unui algoritm divide et impera este următoarea:


 Dacă problema curentă este suficient de uşoară 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ă.

Aceşti algoritmi au, de obicei, complexitatea O(N·log 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 obţinut se efectuează O(N) operaţii.
Nu este neapărat 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 aceşti algoritmi
este chiar căutarea binară.

a) Determinarea minimului

Se dau N numere naturale. Se cere determinarea celui mai mic


număr dintre cele N.
Datele de intrare se citesc din fişierul minim.in: pe prima linie
numărul N, iar pe următoarea linie N numere naturale. Rezultatul se va afişa
în fişierul minim.out.

Exemplu:

minim.in minim.out
7 2
9 7 8 3 4 2 11

Rezolvarea clasică este evidentă şi nu vom insista asupra ei.


Problema se poate rezolva însă şi folosind tehnica divide et impera. Vom
folosi o funcţie Minim(A, st, dr) care va returna valoarea minimă din
subsecveţa [st, dr] a vectorului A, unde A este vectorul care conţine

83
Capitolul 3

numerele din fişierul de intrare. Rezultatul care ne interesează va fi dat de


apelul Minim(A, 1, N). Funcţia poate fi implementată în felul următor:
 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 funcţionare al algoritmului de


sortare prin interclasare, aşa 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

Exerciţiu: desenaţi arborele de recursivitate al algoritmului pentru


exemplul dat.

b) Căutarea binară
Se dau N numere naturale ordonate crescător şi M întrebări de
forma „numărul Xi se găseşte sau nu printre cele N numere?” la care trebuie
să se răspundă cât mai eficient.
Datele de intrare se citesc din fişierul cbinara.in: pe prima linie
numerele N şi M separate prin spaţiu, pe următoarea linie cele N numere
naturale separate prin spaţiu, iar pe următoarele M linii cele M întrebări. Pe
linia i a fişierului de ieşire cbinara.out veţi afişa DA sau NU, în funcţie de
răspunsul la întrebarea respectivă.

Exemplu:

cbinara.in cbinara.out
95 DA
4 6 8 9 12 15 15 16 21 NU
4 DA
5 DA
9 DA
21
15

O primă idee de rezolvare este să parcurgem întregul şir de numere


pentru fiecare întrebare, rezultând complexitatea O(N·M). Folosind
căutarea binară putem reduce timpul de execuţie la O(M·log N).
Pseudocodul pentru căutarea binară poate fi scris în felul următor: fie
cbinara(A, st, dr, val) o funcţie care returnează true dacă numărul val se
găseşte în subsecvenţa [st, dr] a vectorului A şi false în caz contrar. Această
funcţie poate fi implementată în felul următor:
 Cât 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 uşor că am ales o abordare nerecursivă.


Algoritmul poate fi implementat şi recursiv, dar acest lucru nu are niciun
scop practic.
Modul de funcţionare al căutării binare este foarte simplu: la fiecare
pas, se fixează mijlocul subsecvenţei [st, dr] curente.
Dacă valoarea căutată este egală cu elementul din mijlocul secvenţei,
se returnează true. În caz contrar, dacă valoarea căutată este mai mică decât
elementul din mijloc, datorită faptului că numerele sunt ordonate crescător,
este clar că orice element de după elementul din mijloc va fi şi el mai mare
decât valoarea căutată, deci putem elimina toate aceste elemente, adică
putem atribui lui dr valoarea m.
Dacă valoarea căutată este mai mare decât elementul din mijloc, se
procedează similar: este clar că toate elementele cu indici mai mici decât
mijlocul secvenţei sunt mai mici şi ele decât valoarea căutată, deci putem
face atribuire st = m + 1.
La sfârşit, când st == dr, verificăm dacă A[st] este elementul căutat.
Priviţi modul de funcţionare al algoritmului pentru primele două
întrebări din exemplul dat. Am marcat cu roşu elementele care sigur nu pot
conţine valoarea căutată.
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 putem elimina toate elementele de după


m, adică putem seta 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 = 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
A 4 6 8 9 12 15 15 16 21

86
Tehnici de programare

A[m] = val, deci algoritmul se opreşte. S-au efectuat patru paşi.

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
A 4 6 8 9 12 15 15 16 21

A[m] = 4 < val = 5, deci st = m + 1:


st dr
i 1 2 3 4 5 6 7 8 9
A 4 6 8 9 12 15 15 16 21

Deja st = dr, iar A[st] != val, deci valoarea căutată nu se regăseşte în


şir.
Complexitatea algoritmului este uşor de dedus: O(log N), deoarece
la fiecare pas reducem spaţiul de căutare la jumătate.

#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] ) return true;


else if ( val < A[m] ) dr = m;
else 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

Exerciţii:
a) Modificaţi algoritmul astfel încât să returneze cea mai mare
poziţie a unui element căutat.
b) Modificaţi algoritmul astfel încât să lucreze cu un şir sortat
descrescător.
c) Modificaţi algoritmul de sortare prin inserţie astfel încât să
folosească algoritmul de căutare binară pentru determinarea
poziţiei în care trebuie inserat un element.

a) Concluzii

Algoritmii divide et impera pot fi foarte folositori în găsirea unor


soluţii optime la o problemă. Dacă acest lucru nu este prea dificil,
implementarea acestor algoritmi trebuie făcută nerecursiv, deoarece
apelurile recursive pot încetini în practică performanţa algoritmilor.
Aceşti algoritmi au aplicabilitate atunci când 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
lăcomiei) se bazează pe selectarea succesivă a unor optime locale pentru a
determina într-un final optimul global. Tehnica se foloseşte, de obicei, în
cazul problemelor în care se cere determinarea unui minim sau maxim
respectând anumite constrângeri dependente de problemă.
Datorită faptului că algoritmii greedy iau la fiecare pas decizia cea
mai favorabilă existentă la acel pas, fără a lua în considerare cum ar putea
afecta această decizie întreg traseul algoritmului, există posibilitatea ca
aceşti algoritmi să nu determine întotdeauna un optim, ci doar o soluţie care
respectă constrângerile problemei, dar care nici măcăr nu este neapărat
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
găsirea 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, având 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 găsi un contraexemplu pentru metoda aleasă, este
recomandat să folosim alte metode de rezolvare a problemei.

Forma generală a algoritmilor greedy este următoarea:


 Fie X1, X2, …, XN datele de intrare ale problemei şi S un optim
care trebuie găsit, iniţializat cu o valoare oarecare, sau cu
mulţimea vidă în caz că se cere găsirea unei mulţimi.
 Pentru fiecare i de la 1 la N execută
o Dacă la pasul i putem lua o decizie care îmbunătăţeşte
optimul S, vom lua această decizie.

În continuare vom prezenta două probleme abordabile prin metoda


greedy: problema spectacolelor şi problema plăţii unei sume. Cea din urmă
va evidenţia şi cazul în care metoda greedy nu furnizează întotdeauna
răspunsul optim.

a) Problema spectacolelor

Patronul unei săli de spectacole doreşte să organizeze, într-un


interval de timp oarecare, un număr cât mai mare de spectacole. Ştiind că
există N artişti interesaţi să susţină un spectacol şi că fiecare artist i poate să
suţină spectacolul doar în intervalul de timp [ai, bi], determinaţi numărul
maxim de spectacole care pot fi organizate astfel încât intervalele de
desfăşurare a oricăror două spectacole să nu se suprapună.
Datele de intrare se citesc din fişierul spectacole.in: pe prima linie
numărul natural N, iar pe următoarele N linii se află perechi de numere [ai,
bi] având semnificaţia din enunţ. În fişierul spectacole.out veţi afişa
numărul maxim de spectacole care pot fi programate respectând condiţiile
problemei.

Exemplu:

spectacole.in spectacole.out
5 3
14
47
35
89
67

90
Tehnici de programare

Explicaţie: se vor organiza spectacolele cu numerele de ordine 1, 4


şi 5.

Vom folosi următorul algoritm greedy, a cărui corectitudine va fi şi


demonstrată:
 Se sortează spectacolele date după momentul terminării 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 decât momentul terminării ultimului spectacol
programat atunci
 Programează spectacolul i.
 Afişează numărul spectacolelor programate.

În continuare, vom demonstra faptul că acest algoritm găseşte


întotdeauna numărul maxim de spectacole care pot avea loc.

Fie X1, X2, ..., XK spectacolele programate de către algoritmul de


mai sus şi Y1 , Y2, ..., YL spectacolele programate de către 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ă poziţie în cadrul soluţiei optime. Dacă Y1 = X 1 ,
atunci acest pas nu mai este necesar. Soluţia X1, Y2 , ..., YL rămâne aşadar în
continuare optimă.

Fie 1 < P ≤ K primul indice pentru care YP  XP. Soluţia X1, X2 ,


..., XP – 1, YP, …, YL este optimă dintr-un raţionament asemănător cu cel de
mai sus.

XP nu face parte din soluţia optimă. Dacă XP ar face parte din soluţia
optimă, ar trebui să se afle cel puţin pe poziţia P + 1, ceea ce înseamnă că ar
începe după YP, contrazicându-se astfel modul de funcţionare al
algoritmului.

Astfel, la un moment dat se va ajunge la o soluţie de forma X1 , X2,


..., XK, ..., YL. Asta ar însemna că după XK mai este posibil să selectăm
91
Capitolul 3

spectacole, dar conform algoritmului, acest lucru nu este posibil, deci am


ajuns la o contradicţie. Rezultă ca presupunerea făcută este falsă şi L = K.

Priviţi modul de funcţionare al algoritmului pentru exemplul dat.


Mai întâi se sortează spectacolele după momentul de terminare al acestora,
rezultând următorul ş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 decât momentul terminării ultimului spectacol selectat
(4), deci nu putem selecta acest spectacol.
Se trece la al treilea spectacol, dar nici acesta nu poate fi selectat din
acelaşi motiv.
Se trece la al patrulea spectacol, care poate fi selectat, deoarece
6 > 4.
Se trece la ultimul spectacol, care iarăşi poate fi selectat, deoarece
8 > 7.

Astfel s-au selectat trei spectacole, număr care se afişează.


Complexitatea algoritmului este O(N·log N) datorită sortării. 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


funcţia pusă la dispoziţie de către limbajul C++ sort, funcţie prezentată pe
scurt şi în cadrul capitolului despre algoritmi de sortare.
În cazul acestei probleme vom avea nevoie să sortăm un tip de date
definit de către utilizator, deoarece vom folosi o structură pentru a reţine
momentele de început şi de sfârşit ale fiecărui spectacol. Pentru a putea
sorta structuri folosind funcţia sort, trebuie să avem o funcţie de comparare
care primeşte doi parametri (de tipul datelor pe care vrem să le sortăm) şi
returnează true dacă primul parametru se consideră mai mic decât al doilea,
şi false în caz contrar. Funcţia aceasta se transmite ca şi parametru funcţiei
sort.
Este foarte important ca această funcţie de comparare să folosească
parametri de tip referinţă (de obicei referinţă constantă, dar acest lucru este
92
Tehnici de programare

mai puţin important) atunci când se lucrează cu structuri, mai ales dacă
acestea sunt foarte mari, deoarece dacă parametrii nu sunt de tip referinţă,
fiecare apel al funcţiei va lucra cu o copie a obiectelor transmise ca
argument funcţiei, iar această copiere poate afecta drastic performanţa unui
program.

#include <fstream> void rezolvare(int N, spectacol A[])


#include <algorithm> {
spectacol precedent = A[1];
using namespace std; int nr = 1;

const int maxN = 1001; for ( int i = 2; i <= N; ++i )


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

bool cmp(const spectacol &x, ofstream out("spectacole.out");


const spectacol &y) out << nr << endl;
{ out.close();
return x.sf < y.sf; }
}
int main()
void citire(int &N, spectacol A[]) {
{ int N;
ifstream in("spectacole.in"); spectacol A[maxN];
in >> N;
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();
}

Exerciţii:
a) Modificaţi algoritmul astfel încât să afişeze indicii iniţiali ai
spectacolelor selectate.
b) Implementaţi un algoritm de sortare pentru sortarea datelor.
c) Dacă fiecare spectacol ar avea asociat un cost şi am dori să
organizăm un număr maxim de spectacole, dar care să coste cât

93
Capitolul 3

mai puţin, ar mai funcţiona strategia greedy? Dacă da, ce ar


trebui modificat?
d) Se dă un interval [P, Q] şi se cere programarea unui număr
minim de spectacole care să acopere intervalul [P, Q]. Elaboraţi
un algoritm greedy care rezolvă problema.

b) Problema plăţii unei sume

Presupunem că trebuie să plătim o sumă de bani S şi că avem la


dispoziţie un număr infinit de monede de valoare 25, 10, 5 şi 1. Se cere să se
determine numărul minim de monede necesare pentru a plăti suma S.
S se citeşte din fişierul suma.in, iar numărul minim de monede se va
scrie în fişierul suma.out.

Exemplu:

suma.in suma.out
39 6

Explicaţie: se foloseşte 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ă,
începând de la cel mai mare la cel mai mic, vom folosi acest tip de monedă
de câte ori este posibil.

Pentru exemplul dat, algoritmul funcţionează în felul următor:


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 rămâne decât să plătim 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 cerinţa este aceeaşi. De
exemplu, dacă am avea la dispoziţie monede de valoare 9, 6, 2 şi 1 şi ar
trebui să plătim 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. Soluţia optimă este însă folosirea a două monede de
valoare 6. Acesta este un contraexemplu ce demonstrează că strategia
greedy nu funcţionează decât 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)


{
using namespace std; int nr = 0;
for ( int i = 0; monede[i]; ++i )
const int monede[] = {25, 10, 5, 1, 0}; while ( S - monede[i] >= 0 )
{
void citire(int &S) S -= monede[i];
{ ++nr;
ifstream in("suma.in"); }
in >> S; ofstream out("suma.out");
in.close(); out << nr << endl;
} out.close();
}
int main()
{
int S;
citire(S);
rezolvare(S);
return 0;
}

c) Concluzii

Algoritmii greedy sunt de obicei mai rapizi decât alţi algoritmi, mai
ales decât metodele exhaustive cum este metoda backtracking. Un
dezavantaj al acestora este că programatorul trebuie să se bazeze foarte mult
pe intuiţie şi pe experienţă pentru a putea ajunge la un algoritm corect, iar
aparenta corectitudine a unui algoritm poate fi înşelătoare, aşa cum am
arătat la ultima problemă.
Dacă aveţi de ales între un algoritm greedy a cărui corectitudine
poate fi demonstrată şi un algoritm mai puţin eficient, este clar că alegerea
trebuie făcută în favoarea algoritmului greedy. Dacă nu se poate demonstra
corectitudinea algoritmului greedy însă, iar importanţă găsirii unui optim
pentru fiecare caz posibil este foarte mare, atunci este de preferat folosirea
altui algoritm, chiar dacă este mai puţin eficient.

95
Capitolul 3

O metodă uzuală de verificare a unui algoritm greedy, când nu


putem găsi o demonstraţie şi nici un contraexemplu, este implementarea
unui algoritm de tip backtracking care rezolvă aceeaşi problemă. Generaţi
aleator date de intrare şi rezolvaţi-le atât cu programul backtracking cât şi cu
programul greedy. Dacă nu apar diferenţe î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 soluţie optimă folosind soluţiile optime
găsite pentru subproblemele existente.
Problemele care prezintă subprobleme suprapuse sunt acelea
pentru care o abordare recursivă clasică ar rezolva aceeaşi subproblemă de
mai multe ori, cum este cazul şirului lui Fibonacci. Folosind programarea
dinamică, soluţiile acestor probleme pot fi îmbunătăţite substanţial având
grijă să rezolvăm fiecare subproblemă o singură dată. Acest lucru poate fi
realizat fie folosind metoda înainte (bottom-up), fie folosind metoda înapoi
(top-down) şi aplicând tehnica memoizării.

Prin metoda înainte se rezolvă mai întâi subproblemele mici, a căror


rezultate se folosesc după aceea în rezolvarea subproblemelor care depind
de acestea, până când se ajunge la rezolvarea problemei iniţiale. Această
metodă este implementată, de obicei, iterativ.
Metoda înapoi este implementată, de obicei, recursiv, presupunând
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 uşor de implementat în unele cazuri şi poate fi
optimizată folosind tehnica memoizării, care va fi prezentată în acest
capitol. Datorită recursivităţii, este recomandată folosirea metodei înainte
atunci când acest lucru este posibil.

Algoritmii de programare dinamică sunt folosiţi, de obicei, pentru a


rezolva probleme în care se cere găsirea unui optim sau probleme de
combinatorică.

96
Tehnici de programare

Spre deosebire de tehnica greedy, programarea dinamică păstrează


toate stările necesare luării unor decizii care vor conduce sigur la găsirea
unui optim global. Programarea dinamică are aşadar o aplicabilitate mai
largă decât tehnicile prezentate până acum, rămânând de cele mai multe ori
şi o alternativă eficientă.

În cele ce urmează vom prezenta câteva concepte şi probleme


elementare rezolvabile folosind tehnici de programare dinamică: şirul
Fibonacci, problema triunghiului, triunghiul lui Pascal şi tehnica
memoizării.

a) Şirul Fibonacci

Şirul Fibonacci, sau numerele Fibonacci sunt numerele generate


de funcţia:
0 𝑑𝑎𝑐ă 𝑛 = 0
𝐹 𝑛 = 1 𝑑𝑎𝑐ă 𝑛 = 1
𝐹 𝑛 − 1 + 𝐹(𝑛 − 2) 𝑑𝑎𝑐ă 𝑛 ≥ 2

Aşa cum am văzut la capitolul despre recursivitate, această funcţie


poate fi implementată recursiv într-un mod foarte uşor. Practic doar se
traduce definiţia matematică a funcţiei în C++. Timpul de execuţie al unei
astfel de implementări este foarte mare, deoarece se vor efectua multe
apeluri recursive care vor rezolva aceeaşi subproblemă. De exemplu, dacă
am folosi o asemenea funcţie pentru a calcula F(7), ar rezulta următorul
arbore de recursivitate (apelurile care s-au mai efectuat deja cel puţin o dată
apar în roşu):

Fig. 3.5.1. – Arborele de recursivitate asociat funcţiei fibonacci

97
Capitolul 3

Se poate vedea foarte uşor că această metodă efectuează un număr


mult mai mare de operaţii decât este necesar. Pentru a vă convinge că
această metodă este ineficientă, folosiţi implementarea recursivă şi apelaţi
F(100).
Ne propunem să scriem un program eficient care citeşte din fişierul
fibo.in un număr natural N şi afişează în fişierul fibo.out valoarea F(N).

Exemplu:

fibo.in fibo.out
6 8

Pentru a rezolva eficient problema, vom folosi metoda programării


dinamice. Fie F un vector a căror elemente au următoarea semnificaţie:
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), iniţializând primele două elemente şi însumând
apoi, pentru fiecare poziţie i, valorile de pe poziţiile i – 1 şi i – 2. Pentru
N = 6 se va construi următorul vector:

i 0 1 2 3 4 5 6
F 0 1 1 2 3 5 8

Răspunsul 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, aşa cum am văzut, o
abordare recursivă va rezolva aceeaşi subproblemă de mai multe ori. Acest
lucru se întâmplă 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 foloseşte metoda înainte, deoarece valorile funcţiei 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) făcând următoarea observaţie destul de evidentă: fiecare
termen al şirului Fibonacci nu depinde decât de cei doi termeni precedenţi.
Astfel, nu mai avem nevoie de vector, ci doar de două variabile care să
reţină ultimii doi termeni, variabile care se vor actualiza corespunzător de
fiecare dată când generăm un nou termen.

Exerciţii:
a) Implementaţi algoritmul care foloseşte memorie O(1).
b) Numerele Fibonacci devin mari foarte rapid. Care este cel mai
mare număr Fibonacci care poate fi reţinut de tipul de date int?
c) Dacă s-ar da mai multe numere pentru care trebuie calculată
funcţia F, cel mai mare dintre acestea fiind K, cum s-ar putea
calcula funcţia eficient pentru fiecare?

b) Problema triunghiului

Se dă un triunghi de numere naturale de latură N pe care se joacă


următorul joc: se alege numărul din colţul de sus al triunghiului. După
aceea, la fiecare pas, ne putem deplasa fie cu o poziţie în jos, fie cu o poziţie
în jos şi una către dreapta faţă de poziţia ultimului număr ales. Numărul pe
care se face deplasarea este ales. Acest lucru se continuă până când 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 fişierul triunghi.in: pe prima linie N,


iar pe fiecare linie i a următoarelor linii, câte i numere naturale,
reprezentând o linie a triunghiului. În fişierul triunghi.out se va afişa, pe
prima linie, suma maximă găsită. Pe a doua linie, se vor afişa în ordine
numerele alese, separate printr-un spaţiu.

Exemplu:

triunghi.in triunghi.out
5 50
4 4 5 2 20 19
35
142
7 5 6 20
1 1 7 19 9

Vom folosi o matrice A care va reţine numerele date, adică


A[i][j] = numărul de pe linia i şi coloana j a triunghiului de numere. Din
poziţia A[i][j] ne putem deplasa pe poziţiile A[i + 1][j] şi A[i + 1][j + 1],
atâta timp cât acestea nu depăşesc limitele matricei. Problema se reduce la a
găsi un drum în matricea A a cărui elemente să aibă suma maximă, folosind
numai aceste două tipuri de deplasări.
La prima vedere, problema pare abordabilă folosind metoda greedy.
Vom începe prin a iniţializa S cu primul număr, după care vom selecta
numărul de valoare maximă dintre numerele A[i + 1][j] şi A[i + 1][j + 1] şi
ne vom deplasa pe poziţia numărului de valoare maximă. Această soluţie nu
funcţionează însă întotdeauna, deoarece nu ţinem cont de faptul că o alegere
neoptimă la pasul curent ne poate duce în final la soluţia optimă, aşa cum
este cazul în exemplul dat.
Problema poate fi rezolvată folosind metoda programării dinamice.
Vom calcula o matrice B, unde B[i][j] = suma maximă a unui traseu care
se termină cu numărul A[i][j]. Relaţiile de recurenţă sunt uşor 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 câte 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 adăugăm numărul A[i][j] la traseul optim care se termină cu
A[i – 1][j], fie adăugăm A[i][j] la traseul optim care se termină cu
A[i – 1][j – 1].
Acest lucru poate fi scris matematic în felul următor:

100
Tehnici de programare

𝐴 𝑖 [𝑗] 𝑑𝑎𝑐ă 𝑖 = 𝑗 = 1
𝐵 𝑖 𝑗 =
max 𝐵 𝑖 − 1 𝑗 , 𝐵 𝑖 − 1 𝑗 − 1 + 𝐴 𝑖 [𝑗] 𝑎𝑙𝑡𝑓𝑒𝑙

Pentru a nu avea grija indicilor care depăşesc graniţele 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 către relaţia de
recurenţă).
Pentru exemplul dat, matricea B arată în felul următor:

i\j 0 1 2 3 4 5
0
1 – inf 4 – inf
2 – inf 7 9 – inf
3 – inf 8 13 11 – inf
4 – inf 15 18 19 31 – inf
5 – inf 16 19 26 50 40

Răspunsul problemei este dat de cea mai mare valoare de pe ultima


linie a matricei B, fie aceasta X. Mai rămâne problema determinării
numerelor care alcătuiesc acest traseu. Pentru a efectua reconstituirea
soluţiei, vom porni de la elementul cu valoarea X al matricii B, care să
zicem că este B[Xi][X j]. Asta înseamnă că sigur numărul A[Xi][Xj] aparţine
traseului, deci vom reţine acest număr î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 adăugat 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ă condiţii 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 până când se ajunge la primul element selectat.

Pentru exemplul dat, algoritmul funcţionează în felul următor:


Se verifică X = 50 valoarea maximă de pe ultima linie a matricei B.
Se reţine î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 reţine î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 reţine î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 reţine î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 reţine î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 afişată stiva.

În practică, reconstituirea soluţiei se poate face şi recursiv. Deoarece


numărul apelurilor recursive este mic, această abordare este preferabilă de
multe ori, datorită simplităţii implementării.

Timpul de execuţiei 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 pătratice de
dimensiune N.
Rezolvarea foloseşte metoda înainte. Problema se poate rezolva şi
folosind metoda înapoi, dar în acest caz timpul de execuţie este exponenţial,
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 main()


int B[maxN][maxN]) {
{ int N, A[maxN][maxN];
for ( int i = 1; i <= N; ++i ) int B[maxN][maxN];
B[i][0] = inf;
for ( int i = 1; i < N; ++i ) citire(N, A); init(N, B);
B[i][i + 1] = inf;
} rez(N, A, B);

void rez(int N, ofstream out("triunghi.out");


int A[maxN][maxN], int Xj = 1;
int B[maxN][maxN]) for ( int i = 2; i <= N; ++i )
{ if ( B[N][i] > B[N][Xj] )
B[1][1] = A[1][1]; Xj = i;
for ( int i = 2; i <= N; ++i ) out << B[N][Xj] << endl;
for ( int j = 1; j <= i; ++j )
B[i][j] = max(B[i - 1][j], reconst(N, Xj, A, B, out);
B[i - 1][j - 1]) + A[i][j];
} out.close();
return 0;
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;
}

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] << ' ';
}

Exerciţiu: modificaţi algoritmul astfel încât în loc de matricea B să


folosiţi 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ă. Puteţi
𝐾!∙ 𝑁−𝐾 !
considera că rezultatele se încadrează întotdeauna pe tipul de date int.
Perechile se citesc din fişierul pascal.in, câte una pe linie, până la
sfârşitul fişierului. Rezultatele se vor afişa în fişierul pascal.out, pe linia i
răspunsul pentru perechea i din fişierul de intrare.

Exemplu:

pascal.in pascal.out
32 3
73 35
10 8 45

O primă idee de rezolvare constă în calcularea combinărilor folosind


formula pentru numărul acestora. Acestă soluţie nu este însă foarte eficientă.
Deoarece numerele date sunt cel mult 12, putem calcula triunghiul
lui Pascal până la linia 12. Al j-lea număr de pe linia i a triunghiului lui
𝑗
Pascal (numerotarea începe de la zero) reprezintă 𝐶𝑖 . Astfel, având calculat
triunghiul, putem afişa în O(1) răspunsul pentru fiecare întrebare.

Reamintim că triunghiul lui Pascal se construieşte după


următoarele reguli:
 Prima linie (linia 0) conţine numărul 1.
 Linia i conţine i + 1 numere.
 Primul şi ultimul număr de pe fiecare linie este numărul 1.
 Fiecare număr, î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


următoarele:
1
11
121
1331
14641

104
Tehnici de programare

De aici rezultă şi formula de recurenţă a combinărilor:

𝐶𝑁𝐾 = 𝐶𝑁−1
𝐾 𝐾−1
+ 𝐶𝑁−1

Vom folosi această formula pentru a calcula triunghiul lui Pascal


până la linia 12 într-o matrice A, după care, pentru fiecare pereche (N, K)
vom afişa valoarea A[N][K]. Triunghiul va fi aliniat la stânga în această
matrice, ca la problema anterioară.

Implementare
#include <fstream> int main()
{
using namespace std; int N, K, A[maxN][maxN];
const int maxN = 13; preprocesare(A);

void preprocesare(int A[maxN][maxN]) ifstream in("pascal.in");


{ ofstream out("pascal.out");
for ( int i = 0; i < maxN - 1; ++i )
A[i][0] = 1, A[i][i + 1] = 0; while ( in >> N >> K )
out << A[N][K] << endl;
A[0][0] = 1;
for ( int i = 1; i < maxN; ++i ) in.close();
for ( int j = 1; j <= i; ++j ) out.close();
A[i][j]=A[i - 1][j] + A[i - 1][j - 1]; return 0;
} }

Exerciţii:
a) Explicaţi de ce folosirea formulei combinărilor este o metodă
mai puţin eficientă.
b) Scrieţi o funcţie recursivă care foloseşte recurenţa combinărilor
pentru a calcula fiecare răspuns. Este această abordare eficientă?
c) Dacă am calcula mai multe linii ale triunghiului lui Pascal, care
este prima linie care conţine rezultate greşite? De ce se întâmplă
acest lucru?

d) Tehnica memoizării
Am prezentat până acum doi algoritmi care folosesc metoda înainte
pentru rezolvarea problemelor. Aşa cum am menţionat la începutul
capitolului, metoda înapoi poate fi îmbunătăţită folosind tehnica

105
Capitolul 3

memoizării. Această tehnică presupune folosirea unui tabel în care se


memorează rezultatele fiecărui apel recursiv. La efectuarea unui apel
recursiv, vom verifica mai întâi dacă rezultatul acelui apel se află în tabel:
dacă da, atunci returnăm pur şi simplu acest rezultat, iar dacă nu, continuăm
în modul obişnuit, iar la sfârşit memorăm rezultatul funcţiei în acest tabel.
Structura generală a algoritmilor recursivi care folosesc memoizare
este următoarea: fie F(X1 , X2, ..., XN) o funcţie recursivă pentru care se
aplică memoizare. Această funcţie poate fi implementată în felul următor:
 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 reţine R ca răspunsul
pentru apelul (starea) curentă şi se returnează acest răspuns.

De exemplu, putem implementa o funcţie recursivă care foloseşte


memoizare pentru a calcula eficient al N-lea număr fiboacci în felul
următor:

#include <fstream> int main()


using namespace std; {
const int maxN = 1001; int N, memo[maxN];
for ( int i = 0; i < maxN; ++i )
int fibo(int N, int memo[]) memo[i] = -1;
{
if ( N == 0 || N == 1 ) ifstream in("fibo.in");
return N; in >> N;
// daca rezultatul e deja calculat in.close();
if ( memo[N] != -1 )
return memo[N]; ofstream out("fibo.out");
// altfel il calculam si il salvam out << fibo(N, memo) << endl;
memo[N] = fibo(N - 2, memo) + out.close();
fibo(N - 1, memo);
return memo[N]; return 0;
} }

Acum, această funcţie are complexitatea O(N), la fel ca varianta ce


foloseşte metoda înainte. Deşi această abordare este, de cele mai multe ori,
mai ineficientă în practică datorită apelurilor recursive şi a unei verificări în
plus, implementarea unei asemenea funcţie este mai uşoară, de obicei, decât
implementarea unei funcţii iterative. Acest lucru se poate întâmpla când
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 funcţia F


returnează al N-lea număr Fibonacci poate fi desenat în felul următor.
Apelurile pentru care se returnează direct rezultatul calculat deja apar în
albastru.

Fig. 3.5.2. – Arborele de recursivitate asociat funcţiei fibonacci memoizate

Se poate observa uşor că numărul de apeluri efectuate este cu mult


mai mic decât în implementarea clasică.

Prezentăm şi o funcţie recursivă ce foloseşte tehnica memoizării


pentru problema triunghiului. Parametrii funcţiei, variabilele folosite şi
funcţiile apelate au semnificaţia lor de până 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 uşor că, în acest caz, folosirea acestei tehnice nu


aduce mari beneficii, ba chiar poate părea mai complicată, deoarece trebuie

107
Capitolul 3

să fim atenţi la mai multe cazuri particulare. Funcţia va trebui apelată pentru
fiecare element al ultimei linii din triunghiul dat.

Exerciţii:
a) Cum putem reconstitui soluţia la problema triunghiului dacă
folosim funcţia recursivă anterioară?
b) Dacă ne interesează drumul minim numai până la un singur
element de pe ultima linie a triunghiul, care abordare este mai
eficientă?
c) Scrieţi o funcţie recursivă ce foloseşte memoizare pentru calculul
combinărilor.
d) Explicaţi de ce memoizarea nu îmbunătăţeşte funcţia factorial
sau funcţia de rezolvare a problemei turnurilor din Hanoi.
e) Comparaţi o funcţie recursivă memoizată cu o funcţie iterativă
echivalentă acesteia. Care este mai eficientă?

e) Concluzii
Programarea dinamică este o tehnică de programare foarte
folositoare pentru rezolvarea problemelor de numărare sau de găsire a
optimelor. Este o alternativă mai rapidă decât metoda backtracking şi mai
corectă decât 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 implementări corecte merită făcut,
datorită eficienţei acestor algoritmi.
Atunci când implementarea iterativă a unei formule de recurenţă ar fi
prea dificilă, se poate folosi tehnica memoizării pentru a păstra eficienţa
algoritmului şi a simplifica implementarea.
Corectitudinea unui algoritm de programare dinamică poate fi
verificată cu un algoritm backtracking, iar la rândul său, 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, având aplicaţii în toate ramurile şiinţelor exacte:
teoria grafurilor, teoria numerelor, biologie computaţională, combinatorică
şi altele.

108
Algoritmi matematici

4. Algoritmi
matematici
Acest capitol prezintă algoritmii care au la bază noţiuni elementare
de matematică. Aceşti algoritmi sunt folosiţi, de cele mai multe, ori pentru
rezolvarea unor probleme strict matematice, cum ar fi rezolvarea unor
ecuaţii sau sisteme de ecuaţii, determinarea numerelor cu anumite
proprietăţi, rezolvarea unor probleme de geometrie sau calculul unor
formule complexe cu ajutorul calculatorului.
Algoritmii prezentaţi se axează mai mult pe teoria numerelor, aceştia
fiind cei mai des întâlniţi în domeniul informaticii şi totodată cei mai
studiaţi.

109
Capitolul 4

CUPRINS

4.1. Noţiuni 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. Exponenţierea logaritmică .................................................................... 136
4.7. Inverşi modulari, funcţia totenţială ...................................................... 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. Operaţii cu numere mari ..................................................................... 154

110
Algoritmi matematici

4.1. Noţiuni despre aritmetica modulară


Aritmetica modulară este un sistem de aritmetică pentru numerele
întregi în care numerele revin la o valoare precedentă după ce depăşesc o
anumită limită. Un exemplu foarte des întâlnit în viaţa de zi cu zi este modul
de funcţionare al unui ceas. Să presupunem că avem un ceas electronic care
afişează orele în format de 24 de ore. Numerotarea orelor începe de la ora
0:00 până la ora 23:59. Dacă ceasul arată ora 22:00, cât va arăta peste exact
3 ore? Deoarece cunoaştem modul de funcţionare al unui ceas, ştim că
răspunsul corect este ora 1:00, dar o abordare matematică a problemei ne-ar
putea duce la răspunsul 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 următor:
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 citeşte „X este congruent cu Y modulo
N”.

Matematic, propoziţia X ≡ Y (mod N) este adevărată dacă şi numai


dacă X şi Y au acelaşi rest la împărţirea cu N. De exemplu,
25 ≡ 1 (mod 24) este adevărat, deoarece 25 : 24 = 1 rest 1 şi 1 : 24 = 0 rest
1.
Aşadar, prin X modulo (prescurtat de obicei mod) Y înţelegem
restul împărţirii 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 câteva formule şi proprietăţi utile în


rezolvarea problemelor de aritmetică modulară.
𝑋
1. Formulă uzuală de calcul: 𝑋 𝑚𝑜𝑑 𝑌 = 𝑋 − 𝑌 ∙
𝑌
2. 𝑋 ± 𝑌 𝑚𝑜𝑑 𝑁 = 𝑋 𝑚𝑜𝑑 𝑁 ± 𝑌 𝑚𝑜𝑑 𝑁 𝑚𝑜𝑑 𝑁
3. 𝑋 ∙ 𝑌 𝑚𝑜𝑑 𝑁 = 𝑋 𝑚𝑜𝑑 𝑁 ∙ 𝑌 𝑚𝑜𝑑 𝑁 𝑚𝑜𝑑 𝑁
4. Dacă 𝑋 ≡ 0 𝑚𝑜𝑑 𝑁 𝑎𝑡𝑢𝑛𝑐𝑖 𝑁 𝑒𝑠𝑡𝑒 𝑑𝑖𝑣𝑖𝑧𝑜𝑟 𝑎𝑙 𝑙𝑢𝑖 𝑋
5. Existenţa inversului faţă de înmulţire: nu există întotdeauna
𝑋 −1 astfel încât 𝑋𝑋 −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 4−1 = 7,


deoarece 4∙7 ≡ 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 scădem pe 𝑌 din 𝑋 atâta timp
cât 𝑌 este mai mare sau egal cu 𝑋.

Exerciţiu: scrieţi un program care citeşte un şir de numere întregi şi


calculează suma lor modulo un alt număr citit. În ce caz vă poate ajuta
proprietatea 2?

În cele ce urmează vom prezenta algoritmi care au la bază aceste


formule şi proprietăţi. Calculul inversului modular este o problemă
netrivială pentru care există mai mulţi algoritmi, a căror 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 număr 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 numărul mai mare
îl scădem pe cel mai mic. Acest lucru este uşor de demonstrat: să
presupunem că c.m.m.d.c. a numerelor X şi Y este d. Atunci, X = d∙p şi
Y = d∙q, unde p şi q sunt numere întregi corespunzătoare ecuaţiilor scrise.
Presupunem că X > Y. În acest caz putem scrie:
X – Y = d∙p – d∙q = d∙(p – q), deci d este cel mai mare divizor comun şi
pentru Y şi X – Y. Se repetă acest procedeu până când cele două numere
devin egale, moment în care am găsit c.m.m.d.c. al numerelor iniţiale.
De exemplu, modul de calcul al c.m.m.d.c. pentru 12 şi 8 poate fi
descris grafic în felul următor:

Fig. 4.2.1. – Modul de execuţie al algoritmului lui Euclid prin scăderi

Scăzând la fiecare pas numărul mai mic din numărul mai mare,
rămânem în final cu două numere egale cu 4, iar acest număr reprezintă
c.m.m.d.c. pentru 12 şi 8.

112
Algoritmi matematici

Deşi acest algoritm este uşor de implementat şi intuitiv, există cazuri


în care este chiar mai ineficient decât algoritmul naiv, care parcurge
numerele de la min(X, Y) în jos şi se opreşte când găseşte un număr care
divide atât pe X cât şi pe Y. Un astfel de caz este X = 1 000 000 şi Y = 1. La
fiecare pas, vom scădea din X valoarea 1, efectuând în total un milion de
operaţii! Complexitatea în cel mai rău caz este aşadar O(max(X, Y)). Să
vedem cum putem îmbunătăţi algoritmul.
Aşa cum am precizat în secţiunea anterioară, putem calcula X mod
Y folosind scăderi repetate ale lui Y din X. Aceste scăderi se efectuează şi
în cazul algoritmului prin scăderi repetate, fiind chiar cauza ineficienţei
acestuia pe anumite cazuri. Vom elimina aceste scăderi folosind operaţia
modulo. Noul algoritm devine acum:
 Cât timp Y diferit de 0 execută:
o Fie r = X mod Y
o X=Y
o Y=r
 Returnează X

Demonstraţia acestui algoritm este foarte similară cu demonstraţia


algoritmului iniţial, deoarece ideea este aceeaşi, doar că efectuăm mai multe
scăderi o dată folosind operaţia modulo. Numărul de paşi efectuaţi de acest
algoritm nu este niciodată mai mare decât de cinci ori numărul de cifre al
numărului mai mic (acest lucru a fost demonstrat de către 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ă iteraţie a ciclului cât timp).

Algoritmul poate fi scris şi recursiv în felul următor: fie


cmmdc(X, Y) o funcţie care returnează cel mai mare divizor comun al celor
două numere avute ca parametri. Această funcţie 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 uşor


de ţinut minte. Mai mult, această implementare este folositoare la extinderea
algoritmului, aşa cum vom vedea în secţiunea următoare.
Prezentăm doar funcţiile relevante. Fiecare funcţie 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) int euclid_optim(int X, int Y)


{ {
while ( X != Y ) while ( Y )
if ( X > Y ) {
X -= Y; int r = X % Y;
else X = Y;
Y -= X; Y = r;
}
return X; return X;
} }

int euclid_rec(int X, int Y) void euclid_ref(int X, int Y, int &cmmdc)


{ {
if ( !Y ) if ( !Y )
return X; cmmdc = X;
else
return euclid_rec(Y, X % Y); euclid_ref(Y, X % Y, cmmdc);
} }

4.3. Algoritmul lui Euclid extins


Algoritmul lui Euclid extins este folosit pentru a găsi numerele a şi
b din ecuaţia diofantică: a∙X + b∙Y = cmmdc(X, Y). Algoritmul este
folositor atunci când avem de determinat un invers multiplicativ modulo Y,
deoarece a este inversul multiplicativ al lui X modulo Y. Cu alte cuvinte,
a∙X ≡ 1 (mod Y). Trebuie menţionat că inversul multiplicativ al lui X
modulo Y există dacă şi numai dacă X şi Y sunt coprime!
Algoritmul poate fi implementat în felul următor: fie
euclid_extins(X, Y) o funcţie care returnează o pereche (a, b) cu
semnificaţia precizată anterior. Această funcţie poate fi implementată în
felul următor:
 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ă demonstrăm că
a∙X + b∙Y = d. Procedăm în felul următor:

114
Algoritmi matematici

 Dacă X mod Y = 0, înseamnă că d = Y, iar din valorile returnate


de algoritm în acest caz observăm că ecuaţia devine
0∙X + 1∙Y = d, adică d = Y, ceea ce este corect.
 În caz contrar, datorită apelului recursiv efectuat, ştim că
a∙Y + b∙(X mod Y) = d. Acest lucru a fost demonstrat anterior.
o Folosind formula uzuală de calcul, putem scrie ecuaţia de
mai sus în felul următor:
𝐗
 a∙Y + b∙(X – Y∙ ) = d.
𝐘
o Noua ecuaţie poate fi rescrisă astfel:
𝐗
 a∙Y + b∙X - b∙Y∙ = d.
𝐘
o Dăm factor comun pe Y:
𝐗
 b∙X + (a – b∙ ) ∙Y = d.
𝐘
o De aici tragem concluzia ca noua pereche (a, b) este
𝐗
 (b, a – b∙ ).
𝐘

Urmăriţi modul de funcţionare al algoritmului pentru ecuaţia


a∙23 + b∙51 = 1. Tabelul prezintă valorile variabilelor folosite de algoritm
după fiecare apel al funcţiei recursive prezentate anterior. Apelul iniţial este
cel de pe ultima linie, dar, datorită recursivităţii, valorile finale pentru a şi b
nu se calculează decât după efectuarea tuturor apelurilor recursive
prezentate. Tabelul se poate citi aşadar 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 execuţie 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ă 20∙23 + (-9)∙51 = 1, deci a = 20 şi b = -9.


Am menţionat că acest algoritm ne ajută să găsim inversul
multiplicativ modulo Y al numărului X. Acest invers este numărul a
determinat de către algoritm, iar a∙X ≡ 1 (mod Y). Folosind exemplul
anterior, deoarece 23 şi 51 sunt coprime, numărul 23 are un invers
multiplicativ modulo 51, iar acest invers este chiar numărul 20. 23∙20 = 460,

115
Capitolul 4

iar 460 ≡ 1 (mod 51). Aşa cum vom vedea în secţiunile ce urmează, acest
lucru are diverse aplicaţii şi în alţi 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);
}

Funcţia returnează, prin intermediul parametrilor a şi b, numerele ce


trebuie determinate.

Exerciţii:
a) Modificaţi funcţia astfel încât să returneze şi c.m.m.d.c. al
numerelor X şi Y.
b) Modificaţi funcţia astfel încât să returneze soluţiile ecuaţiei
a∙X + b∙Y = c, unde c este un număr dat, iar restul variabilelor au
aceeaşi semnificaţie de până acum. Există întotdeauna soluţie?
c) Scrieţi o funcţie echivalentă, dar care să nu folosească apeluri
recursive.
d) Scrieţi un program care citeşte două numere X şi Y şi determină
inversul multiplicativ al lui X modulo Y dacă acesta există, iar
dacă nu afişează un mesaj corspunzător.

4.4. Numere prime


Un număr prim este un număr natural mai mare ca 1 care nu se
divide decât cu 1 şi cu el însuşi. De exemplu, primele numere prime sunt 2,
3, 5, 7, 11, …. Există o infinitate de numere prime.
Atenţie! numărul 1 nu se consideră prim!

116
Algoritmi matematici

Numerele prime au numeroase aplicaţii în criptografie şi în teoria


numerelor în general. Există diverse metode de a determina dacă un număr
este sau nu prim (aceste verificări poartă numele de teste de primalitate) şi
de a determina toate numerele prime până la o anumită limită. Algoritmii de
testare a primalităţii se împart în două mari categorii: algoritmi
determinişti, care determină cu o probabilitate de 100% dacă un număr este
sau nu prim şi algoritmi probabilişti, care determină cu o probabilitate mai
mică de 100% dacă un număr este sau nu prim. De obicei, algoritmii
probabilişti sunt cu mult mai eficienţi, fiind aplicabili numerelor cu sute de
cifre, dar există posibilitatea ca un număr găsit ca fiind prim de un astfel de
algoritmi să nu fie cu adevărat prim.
O proprietate importantă a numerelor prime este că orice număr are
un invers multiplicativ modulo orice număr prim. Acest lucru se datorează
faptului că orice număr este coprim cu un număr prim.
Alt rezultat important care implică numerele prime este conjectura
lui Goldbach, care afirmă că orice număr 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 clasificări şi rezultate teoretice şi practice în
domeniul numerelor prime. De exemplu, la momentul scrierii acestei cărţi,
cel mai mare număr prim cunoscut are aproape 13 milioane de cifre. Acesta
e un număr 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 notaţie importantă care va fi folosită pe parcursul acestei secţiuni


este funcţia π, unde π(N) reprezintă câte numere prime cel mult egale cu N
există. O aproximaţie pentru această funcţie este următoarea:

𝑁
𝜋 𝑁 ≅
ln 𝑁

Nu se cunoaşte nicio formulă exactă de calcul a funcţiei, dar există


aproximări mai bune.

În continuare, vom prezenta diverşi algoritmi de determinare a


primalităţii şi de generare a numerelor prime. Algoritmii vor fi prezentaţi de
la cei mai ineficienţi la cei mai eficienţi. Fiecare algoritm presupune fie o
funcţie care returnează 1 dacă un număr N dat ca parametru este prim şi 0 în
caz contrar, fie o funcţie care determină toate numerele prime până la N.

117
Capitolul 4

a) Metode clasice

Prima metodă la care ne gândim pentru a determina dacă un număr


N
N este sau nu prim este să parcurgem toate numerele de la 2 până la şi să
2
verificăm dacă numărul N se divide la vreunul dintre aceste numere. Dacă
da, atunci N nu este prim, iar dacă nu, atunci N este prim. Astfel, o funcţie
e_prim1(N) poate fi implementată în felul următor:
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,
numărul sigur este prim).

Complexitatea acestei metode este O(N) pe cel mai rău caz. Dacă ar
fi să folosim această funcţie pentru a determina toate numerele prime până
la N am obţine complexitatea O(N2), ceea ce ar fi indezirabil pentru N mai
mare ca ~1 000.

Putem obţine un algoritm mai eficient observând că pentru orice


număr natural N nu are rost să verificăm dacă este divizibil cu numere mai
mari decât N. Acest lucru este uşor 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. Aşadar, este de
X
ajuns să verificăm doar numerele până la radicalul numărului a cărui
primalitate ne interesează.
Într-un limbaj de programare se obişnuieşte să se folosească
următorul algoritm (e_prim2(N)), deoarece nu necesită folosirea unei
funcţii de aflare a radicalului:
 Pentru fiecare i de la 2 până când 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( 𝐍).
Deşi din punct de vedere asimptotic această limită este greu de îmbunătăţit
pentru un algoritm determinist, mai putem aduce o îmbunătăţire practică
substanţială.
Se poate observa că nu are rost să verificăm dacă un număr este
divizibil cu numere pare mai mari decât 2, deoarece dacă este divizibil cu 2
atunci nu este prim (decât dacă numărul 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 până când 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 oricărui număr reprezentabil pe tipurile de date existente în
C++ şi pentru a genera toate numerele prime până la o anumită limită (sau
pentru a calcula funcţia π).

Funcţiile prezentate returnează true dacă numărul transmis ca


parametru este prim şi false în caz contrar. Funcţia pi returnează câte
numere prime există mai mici ca parametrul său, folosind toate optimizările
menţionate.

bool e_prim1(int N) bool e_prim2(int N)


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

return true; return true;


} }

bool e_prim3(int N) int pi(int N)


{ {
if ( N == 2 ) int nr = 0;
return true;
for ( int i = 2; i <= N; ++i )
if ( N % 2 == 0 ) if ( e_prim3(i) )
return false; ++nr;

for ( int i = 3; i*i <= N; i += 2 ) return nr;


if ( N % i == 0 ) }
return false;

return true;
}

119
Capitolul 4

Primele două funcţii au un scop pur didactic, în practică cea de-a


treia funcţie fiind cu mult mai eficientă şi la fel de uşor de implementat.
Funcţia pi nu prea se foloseşte în practică, decât dacă N nu este foarte mare.

b) Ciurul lui Eratosthenes

Ciurul lui Eratosthenes este un algoritm care găseşte toate


numerele prime mai mici sau egale cu un număr N. Algoritmul îi este
atribuit lui Eratosthenes, un matematician al Greciei antice şi funcţionează
în felul următor:
 Se creează o listă cu toate numerele naturale de la 2 până la N.
 Fie i un număr care reprezintă la fiecare pas un număr prim
determinat de către algoritm. Iniţial, i = 2.
 Cât timp i2 ≤ N execută:
o Se elimină toţi multiplii lui i mai mici sau egali cu N din
listă, începând, eventual, de la i2, deoarece restul
multiplilor au fost deja eliminaţi.
o i devine următorul număr din listă.

La sfârşitul algoritmului, toate numerele rămase în listă sunt prime.


De exemplu, dacă vrem să aflăm toate numerele prime până la 25 folosind
ciurul lui Eratosthenes vom proceda în felul următor (cu roşu sunt marcate
numerele care urmează să fie şterse):
Iniţial, i = 2 şi eliminăm toţi multiplii lui i începând 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 roşu, i devine următorul număr neşters


ş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 13
17 19 23 25

120
Algoritmi matematici

Se şterge numărul marcat şi i devine 7. Deja i2 = 49 > 25, deci


algoritmul se încheie. Numerele prime mai mici sau egale cu 25 sunt
următoarele (adică cele rămase):

2 3 5 7 11 13
17 19 23

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


decât folosind algoritmii clasici prezentaţi anterior.
Ciurul lui Eratosthenes poate fi folosit şi pentru a verifica
primalitatea unor numere foarte mari într-un mod eficient, în felul următor:
ştim că pentru a verifica dacă un număr N este prim nu avem nevoie să
verificăm dacă se divide cu numere mai mari decât N. Mai mult, nu are
rost să verificăm dacă se divide cu numere neprime, deoarece orice număr
neprim (numerele neprime se mai numesc şi numere compuse) este un
produs de numere prime. Aşadar, putem genera o singură dată, folosind
ciurul, toate numerele prime până la radical din valoarea maximă pe care
ştim că o pot lua numerele a căror primalitate ne interesează, numerele pe
care le vom folosi apoi în verificarea primalităţii unui anumit număr: dacă
există un număr prim mai mic decât radicalul numărului verificat la care
numărul 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 implementări ale ciurului


lui Eratosthenes şi ale unor subprograme care folosesc acest algoritm. O
implementare clasică arată în felul următor:

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 execuţie, ţinând cont de faptul că nu are rost să
folosim tipul de date bool, care ocupă de fapt 8 biţi, pentru a reţine valori de
0 şi 1. Astfel, putem folosi de 8 ori mai puţină memorie dacă folosim
operaţii pe biţi pentru a accesa biţii individuali ai unei variabile de tip
unsigned char.
Vom declara vectorul lista de dimensiune N / 8 + 1 şi vom folosi
operaţiile pe biţi pentru a accesa biţii de care avem nevoie la fiecare pas în
felul următor:
Pentru a seta al k-lea bit pe valoarea 0 vom folosi funcţia:
void del(unsigned char lista[], int nr)

Care va executa următoarea operaţie:


lista[nr / 8] &= ~(1 << (nr % 8 )).

Aceste instrucţiuni au efectul următor: bitul nr % 8 (de la dreapta la


stânga) al elementului nr / 8 al vectorului lista va primi valoarea 0
122
Algoritmi matematici

(operatorul ~ schimbă toţi biţii unui număr în opusul lor, adica 1 devine 0 şi
0 devine 1. Operatorul & se consideră cunoscut de la secţiunea Radix sort).
Pentru a verifica valoarea unui bit, vom folosi o funcţie
bool verif(unsigned char lista[], int nr)

Care va returna valoarea bitului nr al vectorului lista. Funcţia va fi


implementată în felul următor:
return lista[nr / 8] & (1 << (nr % 8));

Poate părea ciudat la prima vedere numerotarea biţilor de la dreapta


la stânga în cadrul unui element al vectorului. Practic, folosind aceste
operaţii, al doilea bit este de fapt al şaselea. Acest lucru nu afectează
corectitudinea algoritmului dacă suntem consecvenţi în folosirea acestei
metode şi simplifică foarte mult implementarea.

Să vedem cum funcţionează algoritmul pentru N = 12 dacă folosim


aceste operaţii. Vom declara un vector lista de dimensiune 2 a cărui biţi îi
vom iniţializa pe toţi cu valoarea 1. Această iniţializare se poate face
atribuind fiecaărui element al vectorului valoarea 2 8 – 1, deoarece tipul de
date unsigned char este implementat pe 8 biţi. Acest lucru se poate face
folosind următoarea formulă: 2k = 1 << k, unde k este un număr natural.
Formula este uşor de dedus observând 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 biţi.
Vectorul lista arată în felul următor (biţii apar în acest tabel
numerotaţi normal, de la stânga 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 numărul 3, care ştim sigur că este prim. Se


elimină multiplii lui 3 din listă (se setează biţii corespunzători pe 0)
începând de la 32 = 9. Trebuie să aflăm aşadar bitul corespunzător lui 9.
Înlocuindu-l pe 9 în formula prezentată anterior, obţinem următoarea
instrucţiune: lista[9 / 8] &= ~(1 << (9 % 8)), adică lista[1] &= ~(1 << 1).
lista[1] în baza 2 are toţi biţii setaţi pe 1. 1 << 1 = 000000102, iar
~000000102 = 111111012.

123
Capitolul 4

11111111 &
11111101
––––––––––
11111101

Deci noul vector lista arată acum în felul următor:

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 următorii multiplii ai lui 3: 12 şi 15,


fiecare având un bit care le corespunde conform algoritmului prezentat.
Vectorul lista va arăta în final în felul următor:

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 căror biţi
nu sunt şterşi reprezintă numere prime. Numerele pare nu vor fi luate în
considerare, aşa 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;
}

Deşi această implementare foloseşte de opt ori mai puţină memorie


decât precedenta, este posibil să nu observăm nicio îmbunătăţire a vitezei.
Acest lucru se datorează faptului că efectuăm operaţii mai complicate decât
până acum. Aceste operaţii sunt operaţiile de împărţire şi modulo din cadrul
funcţiilor del şi verif. Putem înlocui aceste operaţii folosind operaţii pe biţi
aplicând următoarele 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 funcţii sunt aşadar:

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 eliminăm din listă, folosind astfel de
două ori mai puţină memorie. Semnificaţia iniţială a vectorului lista devine
lista[i] = true dacă 2∙i + 1 este prim şi false în caz contrar. Algoritmul
final arată în felul următor (restul funcţiilor rămân 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 părea ciudată la prima vedere,


dar aceasta are o explicaţie foarte simplă. Deoarece lucrăm cu i, dar ne
referim la 2∙i + 1, avem nevoie de a determina i astfel încât (2∙i + 1)2 să fie
cel mult N. Formula folosită face exact acest lucru şi poate fi dedusă
𝑖−1
considerând funcţia 𝑓 𝑖 = 2 ∙ 𝑖 + 1, găsindu-i inversul 𝑓 −1 𝑖 = şi
2
4∙𝑖 2 +4∙𝑖+1−1
calculând 𝑓 −1 2∙𝑖+1 2
= 𝑖2
= 2 ∙ + 2 ∙ 𝑖. Transformând
2
înmulţirile cu 2 în operaţii be biţi rezultă formula folosită.

Următorul tabel prezintă timpii de execuţie a celor trei variante ale


algoritmului prezentate pentru mai multe valori ale lui N. Măsurătorile au
fost făcute pe un calculator perfomant, cu afişarea numerelor prime scoasă.

126
Algoritmi matematici

Tabelul 4.4.1. – Comparaţie între variantele de implementare


a ciurului lui Eratosthenes
Timpul de execuţie în secunde
N eratosthenes_clasic eratosthenes_biţi 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 optimizările posibile, iar memoria folosită este de 16 ore mai
mică decât N.

Ciurul lui Eratosthenes poate fi implementat şi folosind clasa


bitset din Standard Template Library. Prezentăm o implementare ce nu
conţine şi ultimele optimizări, acestea fiind lăsate 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
asemănător cu ce am prezentat până acum. Mai întâi vom genera o listă de
numere prime folosind algoritmul clasic prezentat deja. Numerele prime
generate nu trebuie să fie mai mari decât radicalul capătului 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ă numărul i + st
este prim şi false în caz contrar. Pentru fiecare număr prim i generat
anterior, vom marca toţi multiplii acestuia care sunt mai mari sau egali cu st
şi care sunt diferiţi de i ca fiind numere neprime. La sfârşit, parcurgem
127
Capitolul 4

intervalul şi afişăm numerele prime, ţinând cont de semnificaţia vectorului


prim.
Se pune problema determinării celui mai mic multiplu al lui i care
este mai mare sau egal cu st. Fie acest număr m. Putem folosi următorul
algoritm pentru a-l calcula eficient pe m:
 Fie r = st mod i
 Atunci m = st + [(i – r) mod i]

Implementarea prezentată poate fi îmbunătăţită ţinând cont de


aspectele menţionate pe parcursul acestei secţiuni. Codul ar putea fi împărţit
şi în mai multe funcţii, de exemplu o funcţie care generează numerele prime
până la radical din dr şi o funcţie 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 primalităţii
unor numere.

Optimizările care se pot aduce metodei prezentate sunt lăsate ca


exerciţiu 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 ecuaţii liniare,
cunoscut şi ca metoda eliminării a lui Gauss, este o generalizare a metodei
de rezolvare a sistemelor prin eliminarea uneia sau mai multor necunoscute,
metodă clasică învăţată în clasele mici. De exemplu, dacă avem sistemul de
două ecuţii liniare:

𝐸1 : 2𝑥 + 3𝑦 = 4
𝐸2 : 𝑥 + 2𝑦 = 3

Cel mai convenabil mod de rezolvare este să înmulţim ecuaţia E2 cu


–2 şi să o adunăm primei ecuaţii. Vom obţine o nouă ecuţie şi anume
0 ∙ 𝑥 − 𝑦 = −2 de unde rezultă că 𝑦 = 2. Înlocuind acest rezultat în una din
ecuaţii, de exemplu în a doua, obţinem 𝑥 = −1.

Metoda se complică însă dacă avem de rezolvat sisteme mai


complicate, aşa că avem nevoie de o metodă generală care să fie uşor de
implementat în C++ şi cu ajutorul căreia să putem rezolva orice sistem.
Vom considera mai întâi un sistem de trei ecuaţii cu trei necunoscute pe care
îl vom rezolva folosind metoda eliminării a lui Gauss, după care vom
prezenta formal algoritmul. Fie următorul sistem:

𝐸1 : 3𝑥 + 2𝑦 + 2𝑧 = −2
𝐸2 : 𝑥 + 𝑦 + 3𝑧 = −1
𝐸3 : 4𝑥 + 3𝑦 + 𝑧 = 5

Ne propunem să eliminăm necunoscuta 𝑥 din toate ecuaţiile de sub


𝐸1 şi necunoscuta 𝑦 din toate ecuaţiile 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
următoarele operaţii:

1
𝐸2 ← − 𝐸1 + 𝐸2
3
4
𝐸3 ← − 𝐸1 + 𝐸3
3

Sistemul va arăta în felul următor după efectuarea acestor operaţii:

130
Algoritmi matematici

𝐸1 : 3𝑥 + 2𝑦 + 2𝑧 = −2
1 7 1
𝐸2 : 𝑦+ 𝑧=−
3 3 3
1 5 23
𝐸3 : 𝑦− 𝑧=
3 3 3

Iar pentru a elimina necunoscuta 𝑦 din 𝐸3 vom efectua operaţia:


𝐸3 ← −𝐸2 + 𝐸3

Sistemul devine:

𝐸1 : 3𝑥 + 2𝑦 + 2𝑧 = −2
1 7 1
𝐸2 : 𝑦+ 𝑧=−
3 3 3
𝐸3 : −4𝑧 = 8

Deja sistemul este foarte simplu de rezolvat. Ştim că 𝑧 = −2 din


ultima ecuaţie. Înlocuindu-l pe 𝑧 în a doua ecuaţie îl putem afla pe 𝑦, după
care îl putem afla pe 𝑥 din prima ecuaţie. Calculele devin prea complicate
pentru a fi efectuate de mână, deci prezentăm doar rezultatele finale:

𝑥 = −8
𝑦 = 13
𝑧 = −2

În cazul general, procedăm în felul următor. Considerăm un sistem


de N ecuaţii 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


număr real. Vom reprezenta sistemul sub formă de matrice în felul următor:

131
Capitolul 4

𝑎11 𝑎12 … 𝑎1𝑁 𝑏1


𝑎21 𝑎22 … 𝑎2𝑁 𝑏2
. .
𝐴= . .
. .
𝑎𝑁1 𝑎𝑁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 operaţii elementare asupra matricei în aşa fel încât
necunoscuta 𝑥𝑖 să fie eliminată din toate ecuaţiile de după ecuaţia cu
numărul 𝑖. Pentru a elimina necunoscuta 𝑥𝑖 dintr-o ecuaţie 𝐸𝑗 ,𝑗>𝑖 procedăm
𝑎 𝑗𝑖
în felul următor: scădem din ecuaţia 𝐸𝑗 valoarea ∙ 𝐸𝑖 . Procedând în acest
𝑎 𝑖𝑖
mod pentru toate necunoscutele, vom ajunge în final la o matrice de genul
următor:

𝑎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 aplicând operaţii
elementare asupra matricei 𝐴. Analizând ultima linie a matricei, observăm
𝑏′ 𝑁
că putem afla valoarea ultimei necunoscute: 𝑥𝑁 = . Pentru aflarea
𝑎′ 𝑁𝑁
celorlalte necunoscute procedăm în felul următor: pentru fiecare linie 𝑖,
începând de la 𝑁, parcurgem toate liniile (sau ecuaţiile, deoarece o linie a
matricei descrie o ecuaţie a sistemului iniţial) 𝑗 de deasupra acesteia şi
efectuăm următoarele operaţii:
𝑏′
 𝑏′𝑖 ← 𝑖
𝑎 𝑖𝑖
 𝑎𝑖𝑖 ← 1
 𝑏′𝑗 ← 𝑏′𝑗 − 𝑏′𝑖 ∙ 𝑎𝑗𝑖
 𝑎𝑗𝑖 ← 0

132
Algoritmi matematici

Practic, se înlocuieşte la fiecare pas necunoscuta determinată în


restul ecuaţiilor. La sfârşitul algoritmului, coloana termenilor liberi va
conţine valorile necunoscutelor, deci sistemul va fi rezolvat. Matricea finală
va arăta în felul următor:

1 0 0…0 𝑥1
0 1 0…0 𝑥2
0 0 1…0 𝑥3
𝐴= . .
. .
. .
0 0 0…1 𝑥𝑁

În prezentarea algoritmului am presupus că toţi coeficienţii


sistemului sunt nenuli şi că sistemul are întotdeauna soluţie. Dacă dorim să
luăm în considerare cazul în care sistemul poate să nu aibă soluţie, putem
verifica dacă efectuăm vreodată o împărţire la zero, caz în care sistemul nu
are soluţie.

În cadrul implementării presupunem că datele de intrare se citesc din


fişierul gauss.in. Pe prima linie se află un număr natural N care reprezintă
rangul sistemului. Pe fiecare din următoarele N linii se află câte N + 1
numere naturale: primele N reprezintă coeficienţii necunoscutelor din
ecuaţia descrisă de linia curentă, iar ultimul număr reprezintă termenul liber
asociat ecuţiei curente. Astfel, vom folosi o matrice cu N linii şi N + 1
coloane. La finalul algoritmului, ultima coloana va conţine valorile
necunoscutelor, de la 𝑥1 la 𝑥𝑁 , care se vor scrie în fişierul gauss.out.

Ţineţi cont de faptul că implementarea presupune că sistemul are


întotdeauna soluţie şi că toţi coeficienţii sunt nenuli!

Complexitatea algoritmului este O(N3 ), dar în practică algoritmul se


comportă mai bine decât ar sugera acest rezultat, fiind aplicabil chiar şi pe
sisteme cu rangul ~1 000.

133
Capitolul 4

#include <fstream> int main()


using namespace std; {
const int maxn = 101; int N;
double A[maxn][maxn];
void citire(double A[maxn][maxn], int &N)
{ citire(A, N);
ifstream in("gauss.in");
in >> N; Gauss(A, N);
for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N + 1; ++j ) ofstream out("gauss.out");
in >> A[i][j]; for ( int i = 1; i<=N; ++i )
out << A[i][N+1] << ' ';
in.close(); out.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;
}
}
}

Deşi de multe ori algoritmul prezentat funcţionează corect, există


posibilitatea apariţiei unor erori de precizie datorate lucrului cu numere
reale. Datorită acestui lucru, unele implementări folosesc următoarea
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 următoarele ecuaţii are valoarea maximă. Această operaţie nu afectează

134
Algoritmi matematici

cu nimic corectitudinea algoritmului, deoarece ordinea ecuaţiilor în sistem


este irelevantă.

Noua funcţie Gauss este următoarea:

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;
}
}
}

Exerciţii:
a) Adăugaţi condiţii care verifică dacă un sistem nu are soluţie şi
afişează un mesaj corespunzător în acest caz.
b) Încercaţi să găsiţi un sistem pentru care cele două variante
prezentate al algoritmului dau răspunsuri diferite.

135
Capitolul 4

4.6. Exponenţierea logaritmică


Prin exponenţierea unui număr înţelegem ridicarea acestuia la o
anumită putere. De exemplu, numărul a ridicat la puterea b se notează ab,
iar procesul se mai numeşte şi exponenţiere. a se numeşte bază, iar b se
numeşte exponent. În cele ce urmează ne propunem să găsim un algoritm
eficient pentru exponenţierea unui număr. Vom presupune că atât baza (a)
cât şi exponentul (b) sunt numere naturale.
Deoarece putem ajunge să lucrăm cu numere foarte mari în cazul
unor baze sau exponenţi mari, vom calcula ab modulo N.

O primă idee de rezolvare este să declarăm o variabilă de tip întreg


iniţializată cu 1 care va fi înmulţită de b ori cu numărul a şi care va păstra la
fiecare pas doar restul împărţirii la N. O astfel de funcţie poate fi
implementată în felul următor:

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 obţine un algoritm


mai eficient folosind următoarea formulă de recurenţă:

1 𝑑𝑎𝑐ă 𝑏 = 0
𝑏 𝑏
𝑎𝑏 = 𝑎2 ∙ 𝑎2 𝑑𝑎𝑐ă 𝑏 𝑛𝑢𝑚ă𝑟 𝑝𝑎𝑟
𝑎 ∙ 𝑎𝑏−1 𝑑𝑎𝑐ă 𝑏 𝑛𝑢𝑚ă𝑟 𝑖𝑚𝑝𝑎𝑟

Folosind această formulă obţinem un algoritm de complexitate O(log


b), mult mai rapid decât algoritmul precedent. Funcţia poate fi implementată
în modul următor:

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 atenţie deosebită variabilei N. Pentru ca


algoritmul să funcţioneze corect, este necesar ca (N – 1)2 să nu depăşească
valoarea maximă care poate fi reţinută de tipul de date int.
De exemplu, dacă presupunem că tipul de date int poate reţine valori
întregi din intervalul [–231, 231 – 1], iar N este egal cu 100 000, există
posibilitatea ca în cadrul instrucţiunii return (temp * temp) % N; variabila
temp să fie egală cu valoarea maxim posibilă N – 1 (deoarece, datorită
recursivităţii, ş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ă situaţie, de cele mai multe ori se foloseşte
tipul de date pe 64 de biţi long long (sau __int64) care poate reţine valori
întregi din intervalul [–263, 263 – 1]. Deşi 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, deşi este eficient, nu efectuează întotdeauna un număr


minim de operaţii (prin operaţii înţelegem înmulţiri) De exemplu, pentru
b = 15, se efectuează următoarele înmulţiri:

Tabelul 4.6.1. – Înmulţirile efectuate de către algoritmul de exponenţiere


logaritmică
Nr. b înmulţiri
1 15 a∙a14
2 14 a7∙a7
3 7 a∙a6
4 6 a3∙a3
5 3 a∙a2
6 2 a∙a

137
Capitolul 4

În total şase operaţii de înmulţire. Am putea folosi însă numai cinci:

Tabelul 4.6.2. – O serie optimă de înmulţiri pentru a


ridica un număr la puterea 15
Nr b înmulţiri
1 15 a3∙a12
2 12 a2∙a6
3 6 a2∙a3
4 3 a∙a2
5 2 a∙a

Nu se cunoaşte niciun algoritm care să efectueze un număr minim de


operaţii. O problemă des studiată este găsirea numărului minim de înmulţiri
necesare pentru un anumit exponent, problemă care suportă diverse
optimizări, dar pentru care nu se cunoaşte niciun algoritm polinomial.

Exponenţierea logaritmică are diverse aplicaţii în implementarea


algoritmilor din teoria numerelor, aşa cum vom vedea în cele ce urmează.

a) Teste probabiliste de primalitate


Am prezentat până în acest moment două abordări în determinarea
primalităţii unui număr: folosind împărţiri repetate la toate numerele care ar
putea fi divizori şi folosind ciurul lui Eratosthenes. Din păcate, ambele
abordări devin foarte încete sau chiar inaplicabile atunci când avem de testat
primalitatea unui număr sau a unor numere foarte mari.
Există algoritmi mai rapizi, dar care nu furnizează întotdeauna un
răspuns corect, adică pot găsi un număr compus ca fiind prim (de obicei
inversa acestei afirmaţii nu este adevărată). Aceşti algoritmi sunt folosiţi
adesea în criptografie, unde astfel de erori nu prezintă un inconvenient
foarte mare din anumite motive, sau atunci când intervalul pe care lucrăm
nu conţine numere pe care metoda probabilistă folosită să furnizeze
răspunsuri greşite.

Primul astfel de algoritm este testul de primalitate a lui Fermat.


Acesta foloseşte următoarea teoremă a lui Fermat: dacă p este un număr
prim şi 1 < a < p, atunci:

𝑎𝑝 −1 ≡ 1 (𝑚𝑜𝑑 𝑝)

138
Algoritmi matematici

Algoritmul presupune generarea aleatoare a mai multor valori pentru


a şi testarea congruenţei. Dacă aceasta se verifică pentru mai multe valori
ale lui a, atunci p este probabil prim (sau pseudoprim). Dacă în schimb
găsim un a care nu verifică ecuaţia, atunci p sigur nu este prim.
De exemplu, dacă vrem să verificăm primalitatea lui 15, cu
a ∈ {4, 11, 12}, vom găsi:
414 ≡ 1 𝑚𝑜𝑑 15 → 15 𝑝𝑟𝑜𝑏𝑎𝑏𝑖𝑙 𝑝𝑟𝑖𝑚
1114 ≡ 1 𝑚𝑜𝑑 15 → 15 𝑝𝑟𝑜𝑏𝑎𝑏𝑖𝑙 𝑝𝑟𝑖𝑚
1214 ≡ 9 𝑚𝑜𝑑 15 → 15 𝑠𝑖𝑔𝑢𝑟 𝑛𝑢 𝑒 𝑝𝑟𝑖𝑚

Este uşor de observat că dacă am fi verificat doar 4 şi 11, răspunsul


ar fi fost greşit.
Complexitatea algoritmului este O(k∙log p) folosind exponenţiere
logaritmică, unde k este numărul de valori pe care le încercăm 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 răspunsuri greşite relativ des în comparaţie cu alţi algoritmi. De
exemplu, există unele numere compuse p numite numere Carmichael care
pentru orice valoare a astfel încât cmmdc(a, p) = 1 sunt găsite de algoritm
ca fiind prime.

Un algoritm care greşeşte mai rar este testul de primalitate


Miller-Rabin, care este totodată folosit mai des în practică. Acesta are şi o
variantă deterministă, care îşi păstrează într-o oarecare măsură eficienţa.

Testul Miller-Rabin determină dacă un număr natural impar p este


prim în felul următor:

139
Capitolul 4

 Se scrie p – 1 în forma 2s∙d, unde s este maxim.


 Se repetă de k ori (unde k are aceeaşi semnificaţia ca la testul
Fermat; reprezintă practic acurateţea 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 următoarea iteraţie a lui k
o Pentru r = 1 până la s – 1 execută
 x = x2 mod p
 Dacă x == 1 returnează NEPRIM
 Dacă x == p – 1 se trece la următoarea iteraţie a
lui k
o Returnează NEPRIM
 Returnează PROBABIL PRIM

Algoritmul se bazează pe următoarele observaţii: fie p > 2 este un


număr prim. Observăm că 1 şi -1, ridicate la pătrat, vor fi întotdeauna
congruente cu 1 modulo p. În alte cuvinte, putem scrie:
𝑥 2 ≡ 1 𝑚𝑜𝑑 𝑝 ⇒ 𝑥 − 1 ∙ 𝑥 + 1 ≡ 0 𝑚𝑜𝑑 𝑝 ⇒
⇒ 𝑥 ≡ +1 𝑚𝑜𝑑 𝑝 𝑠𝑎𝑢 𝑥 ≡ −1 (𝑚𝑜𝑑 𝑝)

Deoarece p este un număr prim impar, înseamnă că p – 1 va fi


întotdeauna par şi poate fi scris ca 2s∙d, unde s este maxim, iar d este evident
impar. Pentru orice a natural din [2, p – 1], una dintre următoarele afirmaţii
trebuie să fie adevărată:
1. 𝑎𝑑 ≡ 1 𝑚𝑜𝑑 𝑝
𝑟
2. ∃ 0 ≤ 𝑟 < 𝑠 𝑎. î. 𝑎2 ∙𝑑 ≡ −1 ≡ 𝑝 − 1 (𝑚𝑜𝑑 𝑝)

Demonstraţia acestor afirmaţii se bazează pe torema lui Fermat:

𝑎𝑝 −1 ≡ 1 (𝑚𝑜𝑑 𝑝)

Din observaţia de mai sus, dacă continuăm să extragem radical din


𝑎𝑝 −1 , vom rămâne la sfârşit fie cu -1 (adică p – 1) fie cu 1, modulo p. Dacă
obţinem -1, atunci a doua egalitate este adevărată. În caz că a doua egalitate
nu a fost niciodată adevărată, înseamnă că prima egalitate trebuie să fie
0
adevărată, deoarece avem 𝑎2 ∙𝑑 = 𝑎𝑑 ≢ −1 (𝑚𝑜𝑑 𝑝).

Testul Miller-Rabin se bazează pe opusele celor afirmate mai sus.


Dacă putem găsi un a astfel încât

140
Algoritmi matematici

𝑎𝑑 ≢ 1 𝑚𝑜𝑑 𝑝 (1)
şi
𝑟 ∙𝑑
𝑎2 ≢ −1 𝑚𝑜𝑑 𝑝 ∀ 0 ≤ 𝑟 < 𝑠 (2)

Atunci a se numeşte martor pentru neprimalitatea lui p şi, în


consecinţă, p sigur nu este număr prim. Algoritmul prezentat în pseudocod
este o implementare eficientă a acestei metode. Complexitatea sa este
O(k∙log p), dar, deoarece algoritmul are în primul rând aplicabilitate pe
numere foarte mari (cu sute sau mii de cifre), trebuie ţinut cont şi de
complexitatea acelor operaţii.
Nu este necesar să testăm aleator un număr mare de valori pentru a
pentru a fi siguri de corectitudinea rezultatelor. Au fost găsite anumite
praguri X astfel încât pentru orice p < X, să fie de ajuns testarea unor
anumite valori pentru a, în aşa fel încât să putem fi siguri de răspunsurile
date de către algoritm. Câteva dintre aceste praguri sunt prezentate în tabelul
următor:
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 biţi fără semn, este de ajuns efectuarea a trei iteraţii!

În implementarea prezentată, se consideră numere reprezentabile pe


32 de biţi cu semn. Trebuie precizat că, în forma prezentată, algoritmul
poate da răspunsuri greşite pentru numere al căror pătrat depăşeşte valoarea
maximă reprezentabilă pe 32 de biţi cu semn! Pentru a scăpa de acest
neajuns se recomandă folosirea numerelor pe 64 de biţi 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. Inverşi modulari, funcţia totenţială


Am văzut anterior că inversul unui număr X modulo Y este acel
număr X-1 pentru care are loc congruenţa X∙X-1 ≡ 1 (mod Y). Acest invers
nu există decât dacă X şi Y sunt numere prime între ele (coprime sau relativ
prime). De exemplu, dacă X = 6 şi Y = 13, putem găsi X-1 = 11. Acest
rezultat este corect deoarece 6∙11 = 66, iar 66 ≡ 1 (mod 13).
Putem determina un invers modular foarte uşor în complexitate
O(Y), parcurgând toate numerele de la 1 la Y – 1. În continuare, vom
prezenta o metodă uşoară şi eficientă de a determina un invers modular în
complexitate O(log Y).
Prin funcţia totenţială (sau indicatorul lui Euler) înţelegem funcţia
ϕ (phi), unde ϕ(Y) reprezintă câte numere naturale mai mici decât Y sunt
coprime cu Y. Funcţia totenţială se poate calcula în felul următor: dacă
𝑒 𝑒 𝑒
𝑌 = 𝑝1 1 ∙ 𝑝2 2 ∙ … ∙ 𝑝𝑘 𝑘 , 𝑝𝑖 𝑛𝑢𝑚ă𝑟 𝑝𝑟𝑖𝑚 ∀ 1 ≤ 𝑖 ≤ 𝑘 atunci indicatorul lui
Euler poate fi găsit cu ajutorul formulei:

𝑘
𝑝𝑖 − 1
𝜑 𝑌 =𝑌∙
𝑝𝑖
𝑖=1

De exemplu, dacă luăm Y = 18, găsim:


1 2
𝜑 18 = 𝜑 2 ∙ 32 = 18 ∙ ∙ = 6. Cele şase numere coprime cu 18
2 3
şi mai mici decât 18 sunt: 1, 5, 7, 11, 13 şi 17.
Deoarece pentru calculul funcţiei totenţiale pentru un anumit număr
nu avem nevoie decât de descompunerea acestui număr în factori primi,
această funcţie se poate calcula în O( Y) folosind un algoritm similar cu
ultimul algoritm clasic de testare a primalităţii prezentat. Algoritmul este
următorul:
 Fie R = Y şi Yt = Y
 Pentru fiecare i de la 2 până când i2 > Y execută
o Dacă Yt mod i = 0 execută
 R = (R / i)∙(i – 1)
 Cât 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. Explicaţia


parcurgerii până la radical din Y este intuitivă: un număr poate avea cel mult
un singur factor prim mai mare decât radicalul său. Găsind toţi factorii primi
mai mici decât radicalul şi eliminându-i (împărţind numărul la aceştia de
câte ori este posibil), vom rămâne la sfârşit doar cu acest factor prim, în caz
că există. Acelaşi algoritm poate fi folosit pentru descompunerea efectivă a
unui număr î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 găsirii unui
algoritm eficient de determinare a inverşilor 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 număr prim, atunci
𝜑 𝑌 = 𝑌 − 1, deoarece toate numerele mai mici decât un număr prim sunt
coprime cu acesta. În acest caz, inversul multiplicativ este 𝑋 𝜑 𝑌 −2 , valoare
care se poate calcula în timp O(log Y) folosind exponenţiere logaritmică. În
cazul în care Y nu este număr prim, este necesar calculul indicatorului lui
Euler, ceea ce necesită timp O( Y).

De exemplu, dacă dorim să calculăm inversul lui 6 modulo 13, vom


proceda de data aceasta în felul următor: ştim ca 𝜑 13 = 12 deoarece 13
este număr prim. Inversul modular al lui 6 modulo 13 este aşadar
611 ≡ 11 (𝑚𝑜𝑑 13). Un calculator ajunge la acest rezultat în mai puţin de
12 operaţii, câte sunt necesare pentru metoda clasică.

În general, algoritmului lui Euclid extins este mai rapid pentru


găsirea inverşilor modulari, dar această metodă are avantajul de a fi mai
uşor de implementat.

Prezentăm două funcţii: o funcţie phi, care calculează indicatorul lui


Euler pentru un număr dat ca parametru şi o funcţie phi_interval, care
calculează valorile indicatorului lui Euler pentru toate numerele mai mici
decât numărul dat ca parametru.

144
Algoritmi matematici

int phi(int Y) void phi_interval(int Y, int A[])


{ {
int R = Y, Yt = Y; for ( int i = 2; i <= Y; ++i )
for ( int i = 2; i*i <= Y; ++i ) A[i] = i;
if ( Yt % i == 0 )
{ for ( int i = 2; i <= Y; ++i )
R /= i; if ( A[i] == i )
R *= i – 1; for ( int j = i; j <= Y; j += i )
{
while ( Yt % i == 0 ) A[j] /= i;
Yt /= i; A[j] *= i - 1;
} }
}
if ( Yt > 1 )
{
R /= Yt;
R *= Yt – 1;
}

return R;
}

Funcţia phi_interval este similară cu ciurul lui Eratosthenes,


deoarece la fiecare pas eliminăm numărul curent (care este prim) din toate
numerele care îl au pe acesta ca divizor.

Exerciţii:
a) Scrieţi, folosind funcţia phi, un program care calculează inverşi
modulari. Verificaţi dacă această metodă este mai rapidă sau nu
decât folosirea algoritmului lui Euclid extins.
b) Scrieţi un program care calculează câte fracţii ireductibile cu
numitorul şi numărătorul mai mici decât un număr N există.

4.8. Teorema chineză a resturilor


Teorema chineză a resturilor este un rezultat important cu privire
la congruenţe simultane modulo mai multe numere. De exemplu, să
presupunem că avem de găsit un număr X care satisface următorul 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 următor: ne propunem să
găsim trei numere naturale a căror sumă să fie o soluţie a sistemului. De
exemplu, această sumă ar putea fi 35 + 63 + 30 = 128. Se poate verfica uşor
că X = 128 este o soluţie a sistemului. Vom vedea în continuare cum am
ajuns la această soluţie.
Primul număr, 35, dă restul 2 la împărţirea cu 3 şi este un multiplu al
numerelor 5 şi 7, deci nu va afecta celelalte două congruenţe. Al doilea
număr, 63, dă restul corect la împărţirea cu 5 şi este un multiplu al
numerelor 3 şi 7, deci nu va afecta celelalte două congruenţe. Al treilea
număr, 30, dă restul corect la împărţirea cu 7 şi este un multiplu al
numerelor 3 şi 5, deci nici acesta nu va afecta restul congruenţelor. Aşadar,
problema se reduce la a găsi, pentru fiecare congruenţă, un număr care
satisface acea congruenţă şi care este un multiplu al numerelor modulo care
se cer rezolvate celelalte congruenţe.

Pentru exemplul dat, găsim prima dată 5∙7 = 35, care verifică prima
relaţie şi nu le afectează pe celelalte. Pasul următor este să găsim un
multiplu al numerelor 3 şi 7 care verifică a doua relaţie şi nu le afectează pe
prima şi pe ultima. Un astfel de număr este 63. La fel se găseşte şi numărul
30.

În cazul general, se dă următorul sistem:

𝑋 ≡ 𝑎1 (𝑚𝑜𝑑 𝑛1 )
𝑋 ≡ 𝑎2 (𝑚𝑜𝑑 𝑛2 )
.
.
.
𝑋 ≡ 𝑎𝑘 (𝑚𝑜𝑑 𝑛𝑘 )

Conform teoremei chineze a resturilor, acesta are întotdeauna soluţie


dacă 𝑛𝑖 , 𝑛𝑗 sunt coprime pentru orice i şi j între 1 şi k, i ≠ j. În caz contrar,
sistemul are soluţie dacă şi numai dacă 𝑎𝑖 ≡ 𝑎𝑗 (𝑚𝑜𝑑 𝒄𝒎𝒎𝒅𝒄 𝑛𝑖 , 𝑛𝑗 ).
O soluţie a sistemului poate fi găsită, aşa cum am văzut, adunând k
numere care satisfac proprietatea discutată mai sus. Totuşi, găsirea unei
soluţii a implicat ghicirea termenilor acestei sume, lucru care nu este
întotdeauna uşor realizabil. În continuare vom prezenta o metodă mai
exactă.

146
Algoritmi matematici

𝑁
Fie 𝑁 = 𝑛1 ∙ 𝑛2 ∙ … ∙ 𝑛𝑘 . Fie 𝑃𝑖 = ∀ 1 ≤ 𝑖 ≤ 𝑘. Calculăm
𝑛𝑖
𝑄𝑖 = 𝑃𝑖−1 (𝑚𝑜𝑑 𝑛𝑖 ). Soluţia sistemului este

𝑋= (𝑎𝑖 ∙ 𝑃𝑖 ∙ 𝑄𝑖 ) 𝑚𝑜𝑑 𝑁.
𝑖=1

Să vedem cum funcţionează 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 obţinut o soluţie diferită faţă de cea anterioară. Se


poate observa uşor că toate soluţiile sistemului sunt de forma
𝑋 = 23 ∙ 𝑡 + 105, deoarece t şi 105 nu influenţează cu nimic relaţiile.

În continuare vom demonstra corectitudinea acestei metode. Trebuie


să demonstrăm că

(𝑎𝑖 ∙ 𝑃𝑖 ∙ 𝑄𝑖 ) 𝑚𝑜𝑑 𝑁 𝑚𝑜𝑑 𝑛𝑖 = 𝑎𝑖


𝑖=1

Lucru echivalent cu

(𝑎𝑖 ∙ 𝑃𝑖 ∙ 𝑄𝑖 ) 𝑚𝑜𝑑 𝑛𝑖 = 𝑎𝑖
𝑖=1

𝑁
deoarece 𝑛𝑖 | 𝑁. Din 𝑃𝑖 = rezultă 𝑃𝑗 ≡ 0 𝑚𝑜𝑑 𝑛𝑖 ş𝑖
𝑛𝑖
𝑎𝑗 ∙ 𝑃𝑗 ∙ 𝑄𝑗 ≡ 0 𝑚𝑜𝑑 𝑛𝑖 , ∀ 𝑗 ≠ 𝑖. Este de ajuns aşadar să
demonstrăm că 𝑎𝑖 ∙ 𝑃𝑖 ∙ 𝑄𝑖 ≡ 𝑎𝑖 (𝑚𝑜𝑑 𝑛𝑖 ), lucru adevărat deoarece
𝑄𝑖 ≡ 𝑃𝑖−1 (𝑚𝑜𝑑 𝑛𝑖 ), iar 𝑃𝑖 ∙ 𝑃𝑖−1 ≡ 1 𝑚𝑜𝑑 𝑛𝑖 .

Pentru calcularea inverşilor modulari vom folosi almoritmul lui


Euclid extins, deoarece pentru a folosi teorema lui Euler am putea fi nevoiţi

147
Capitolul 4

să calculăm funcţia totenţială (în caz că numerele 𝑛𝑖 nu sunt prime), caz în


care algoritmul devine mai încet.

Programul prezentat presupune că datele de intrare se citesc din


fişierul TCR.in, fişier ce are următorul format: pe prima linie numărul k, iar
pe următoarele k linii perechi de numere 𝑎𝑖 𝑛𝑖 , având semnificaţia din
enunţ. În fişierul de ieşire TCR.out se va afişa soluţia sistemului.

Trebuie precizat că programul prezentat nu ţine cont de faptul că un


anumit set de date poate să nu aibă soluţie sau poate să determine unele
calcule intermediare să depăşească valoarea maximă reprezentabilă pe tipul
de date int. Tratarea acestor cazuri este lăsată ca exerciţiu 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]. Câte numere din acest interval sunt divizibile
100
cu 5 sau cu 6? Putem afla uşor că există = 20 de numere divizibile cu
5
100
5 şi = 16 numere divizibile cu 6. Am putea fi tentaţi să dăm răspunsul
6
20 + 16 = 36 de numere divizibile cu 5 sau cu 6, dar acest răspuns este
greşit, deoarece am numărat anumite numere de două ori. Mai exact, am
numărat toate numerele care sunt multipli atât pentru 5 cât şi pentru 6, adică
100
multiplii numărului 5∙6 = 30. Există = 3 numere divizibile cu 30.
30
Pentru ca răspunsul nostru să fie corect, trebuie să scădem această cantitate
din rezultatul obţinut anterior, obţinând astfel 20 + 16 – 3 = 33 de numere
divizibile cu 5 sau cu 6 în intervalul [1, 100].
Să luăm încă un exemplu: câte numere din acelaşi 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ă
răspunsul nu este 50 + 33 + 14 = 97, deoarece am numărat multiplii
numerelor 6, 14 şi 21 de două ori. Trebuie să scădem aşadar 16 multipli de
6, 7 multipli de 14 şi 4 multipli de 21. Am putea fi tentaţi să considerăm
răspunsul final ca fiind 50 + 33 + 14 – 16 – 7 – 4 = 70, dar acest rezultat este
greşit, deoarece multiplii numărului 2∙3∙7 = 42 au fost adunaţi de trei ori (o
dată pentru fiecare dintre numerele 2, 3 şi 7) şi scăzuţi de trei ori (o dată
pentru fiecare dintre numerele 6, 14 şi 21). Aşadar am scăzut prea mult şi
trebuie să adunăm din nou numerele divizibile cu 42, care sunt două.
Răspunsul final este aşadar 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ă determinăm câte numere sunt divizibile cu cel puţin unul dintre
numerele dintr-un şir dat. Dacă N(x) este o funcţie egală cu numărul
multiplilor lui x din intervalul dat, atunci putem scrie răspunsul la problema
precedentă în felul următor:

𝑆 = 𝑁 2 + 𝑁 3 + 𝑁 7 − 𝑁 < 2,3 > − 𝑁 < 2,7 > − 𝑁 < 3,7 > +


+𝑁(< 2,3,7 >)

Unde S este numărul 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ă determinăm cardinalul reuniunii mai
multor mulţimi. Dacă avem n mulţimi notate A1 , A2 , ..., An atunci:
150
Algoritmi matematici

𝐴𝑖 =
𝑖=1
𝑛

= 𝐴𝑖 − 𝐴𝑖1 ⋂𝐴𝑖2 + ⋯ + (−1)𝑛−1 ∙ 𝐴1 ⋂𝐴2 ⋂ … ⋂𝐴𝑛


𝑖=1 1≤𝑖1 <𝑖2 ≤𝑛

De obicei, programele care implementează principiul includerii şi al


excluderii pentru rezolvarea unei probleme au complexitatea cel puţin
O(2n), deoarece este necesară generarea tuturor submulţimilor unui şir.

Exerciţii:
a) Scrieţi un program care rezolvă problema de mai sus pentru
cazul general.
b) Scrieţi un program care citeşte n mulţimi de numere întregi dintr-
un fişier şi calculează cardinalul reuniunii lor folosind principiul
includerii şi al excluderii.
c) Scrieţi un program care citeşte n mulţimi de numere întregi dintr-
un fişier şi calculează cardinalul reuniunii lor fără 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 nişte probleme pe care să nu ştim să le
rezolvăm pentru că nu am mai văzut niciodată asemenea cerinţe şi nu
suntem familiari cu un anumit tip de gândire care se cere pentru a ajunge la
o soluţie corectă şi eficientă. Alte ori este posibil să nu cunoaştem anumite
noţiuni sau formule, sau să cunoaştem rezolvarea matematică a problemei,
dar să nu ştim care este cea mai bună metodă de implementare. Această
secţiune încearcă să înlăture aceste neajunsuri, în măsura în care acest lucru
este posibil, prezentând anumite formule matematice şi tehnici de
programare des întâlnite ş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 aceeaşi formulă ca la cmmdc, dar uneori
există posibilitatea folosirii unui algoritm mai simplu şi anume:
se înmulţeşte cel mai mic număr din şir cu 0, 1, 2, ..., până când
rezultatul se împarte fără rest la toate celelalte numere.
4. Folosirea operaţiilor be biţi pentru a optimiza memoria folosită şi
timpul de execuţie:
a. 2k = 1 << k
b. X∙2k = 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 stânga 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 𝑛=0
𝑓 𝑛 = 1 𝑛=1
𝑓 𝑛−2 +𝑓 𝑛−1 𝑛>1
Atunci:
𝑛 𝑓(𝑛 + 1) 𝑓(𝑛)
1 1
=
1 0 𝑓(𝑛) 𝑓(𝑛 − 1)

Similar se pot rezolva şi alte recurenţe de genul acesta, rezolvând


ecuaţia:

𝑓(𝑛 + 2) 𝑎 𝑏 𝑓(𝑛 + 1)
= ∙
𝑓(𝑛 + 1) 𝑐 𝑑 𝑓(𝑛)

152
Algoritmi matematici

6. Pentru ca metoda de mai sus să fie eficientă, este necesar să


folosim exponenţiere logaritmică. Pentru acest lucru putem lucra
direct cu matrici şi cu o funcţie de înmulţire a matricilor, sau
putem scrie o clasă care să gestioneze lucrul cu matrici. Vom
introduce lucrul cu clase în secţiunea următoare. Deocamdată
prezentăm doar o secvenţă de cod ce înmulţeşte două matrici
pătratice (X şi Y) de ordin N date ca parametru într-o a treia
matrice T, iniţializată 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 ∙ … ∙ 𝑝𝑘 𝑘 , 𝑝𝑖 𝑛𝑢𝑚ă𝑟 𝑝𝑟𝑖𝑚 ∀ 1 ≤ 𝑖 ≤ 𝑘

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 secvenţei următoare:

void F(T param) { ... }


int main() { T x; F(x); return 0; } // se transmite o copie a
// obiectului x

La apelul funcţiei F din main, funcţiei 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 funcţii
recursive sau cu un număr mare de astfel de apeluri. Putem
elimina problema copierii folosind parametri de tip referinţă.
Pentru a elimina riscul modificării valorii acestora (fiind de tip
referinţă, s-ar modifica obiectul iniţial, lucru care poate fi
nedorit), vom folosi parametri constanţi:

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. Numărul zerourilor terminale ale lui n!:


𝑛 𝑛 𝑛
𝑍 𝑛 = + 2 + 3 +⋯
5 5 5

10. Dacă avem:


𝑒 𝑒 𝑒
𝑁 = 𝑝1 1 ∙ 𝑝2 2 ∙ … ∙ 𝑝𝑘 𝑘 , 𝑝𝑖 𝑛𝑢𝑚ă𝑟 𝑝𝑟𝑖𝑚 ∀ 1 ≤ 𝑖 ≤ 𝑘

atunci:

𝑘 𝑒 +1
𝑝𝑖 𝑖 − 1
𝑠 𝑁 = , 𝑢𝑛𝑑𝑒 𝑠 𝑥 = 𝑠𝑢𝑚𝑎 𝑑𝑖𝑣𝑖𝑧𝑜𝑟𝑖𝑙𝑜𝑟 𝑙𝑢𝑖 𝑥
𝑝𝑖 − 1
𝑖=1

4.11. Operaţii cu numere mari


Uneori tipurile de date oferite de limbajul de programare C++ nu
sunt suficiente pentru cerinţele 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ă implementăm propriul nostru tip de date care suportă
operaţiile necesare (de obicei adunare, scădere, înmulţire, câtul impărţirii şi
modulo).
Pentru a putea refolosi implementările în mai multe probleme, este
folositor să scriem o clasă (numită evenntual BigInt) pentru care să
supraîncărcăm operatorii de care avem nevoie. În acest fel, algoritmii clasici
de rezolvare rămân neschimbaţi, cu excepţia tipurilor de date folosite de
aceştia. Acest lucru va rămâne ca un exerciţiu pentru cititor. Vom prezenta
doar implementarea procedurală a algoritmilor de gestiune a numerelor
mari.

154
Algoritmi matematici

a) Reprezentarea unui număr mare

Vom reprezenta un număr mare cu ajutorul unui vector de întregi.


Primul element al vectorului (cel cu indicele 0) va reprezenta numărul de
cifre din vector, iar restul elementelor vor reprezenta cifrele numărului, dar
în ordine inversă. Adică, ultima cifră a numărului va avea indicele 1,
penultima va avea indicele 2 etc. Acest lucru este făcut pentru a putea
efectua mai uşor operaţiile care au ca efect creşterea numărului de cifre.
De exemplu, numărul 290145 este reprezentat prin următorul vector:

0 1 2 3 4 5 6
6 5 4 1 0 9 2

Secvenţa de cod care transformă un număr mic (reprezentabil pe


tipul de date int) într-un număr mare (reţinut în vectorul X) este următoarea:

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


învăţat în clasele primare. Considerăm numerele scrise unul sub altul,
aliniate la dreapta (la stânga în cazul reprezentării noastre). Vom completa
numărul cu mai puţine cifre cu zerouri pentru ca numerele să aibă acelaşi
număr de cifre. Numerele fiind reprezentate ca vectori şi cifrele fiind în
ordine inversă, putem parcurge efectiv vectorii şi aduna cifrele de pe aceeaşi
poziţie, ţinând cont de algoritmul clasic de adunare (cifra curentă a primului
număr + cifra curentă a celui de-al doilea număr + transportul).

De exemplu, să considerăm 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, modificările care
trebuiesc aduse algoritmului sunt minime.

i 0 1 2 3 4 5 i 0 1 2 3 4 5
A 5 9 9 6 2 1 B 5 9 8 2 4 9

155
Capitolul 4

La primul pas i = 1, transport = 0. Efectuăm


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, efectuăm aceleaşi operaţii, obţinând


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 până ce se ajunge la pasul i = 5, la


sfârşitul căruia vom avea următorul vector:

i 0 1 2 3 4 5 6
C 0 8 8 9 6 0
transport = 1

Deoarece transport = 1 trebuie să mai efectuăm un pas (i = 6), şi


anume să punem transportul pe ultima poziţie a vectorului. Mai mult, mai
este necesar să completăm numărul de cifre al rezultatului. Acest număr de
cifre va fi întotdeauna i, în cazul acesta 6.

i 0 1 2 3 4 5 6
C 6 8 8 9 6 0 1

Rezultatul adunării este aşadar numărul de 6 cifre 106988.

Secvenţa de cod care efectuează adunarea este următoarea:

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;
}

Atenţie: am presupus că vectorii A şi B sunt iniţializaţi în întregime


cu 0!

c) Scăderea a două numere mari

Presupunem că vrem să efecutăm diferenţa A – B şi A ≥ B.


Algoritmul este tot cel clasic: vom efectua scăderi cifră cu cifră,
împrumutând o unitate din cifra anterioară dacă acest lucru este necesar.
Deoarece A ≥ B nu avem probleme scăderea celei mai semnificative cifre.
Presupunem că rezultatul scăderii se reţine în vectorul A.
De exemplu, dacă avem de efectuat diferenţa 131 – 99 procedăm în
felul următor:

i 0 1 2 3 i 0 1 2 3
A 3 1 3 1 B 2 9 9 0

Iniţial imprumut = 0. Efectuăm A[1] = A[1] – B[1] – imprumut,


adică A[1] = -8. Verificăm dacă A[1] < 0, ceea ce este adevărat, deci
adunăm 10 la A[1] şi imprumut devine 1. Rezultă:

i 0 1 2 3
A 3 2 3 1

Efectuăm A[2] = A[2] – B[2] – imprumut, adică A[2] = -7.


A[2] < 0, deci adunăm 10 iar imprumut rămâne 1:

157
Capitolul 4

i 0 1 2 3
A 3 2 3 1

Efectuăm A[3] = A[3] – B[3] – imprumut, adică A[3] = 0. A[3] nu


este mai mic decât 0, aşa că imprumut devine 0:

i 0 1 2 3
A 3 2 3 0

Rezultatul final s-ar interpreta în felul următor: 131 – 99 = 032, ceea


ce nu are sens, deoarece un număr nu poate începe cu cifra 0. Aşadar, cât
timp prima cifră este 0, va trebui să scădem numărul de cifre. Rezultatul
corect este:
i 0 1 2 3
A 2 2 3 0

Scăderea a două numere poate părea puţin contraintuitivă datorită


modului în care reţinem numerele. Practic nu efectuăm împrumutul la pasul
curent, ci la pasul curent ţinem cont de împrumutul efectuat anterior (dacă a
existat), scazându-l la pasul curent, şi marcând faptul că ne-am împrumutat
dacă rezultatul scăderii devine negativ.
Funcţia care efectuează diferenţa a două numere mari este
următoarea:

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 încât 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 încât 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 număr are mai multe cifre ca celălalt, acel număr
este mai mare. Dacă ambele numere au acelaşi număr de cifre, atunci se
compară numerele cifră cu cifră, începând de la cea mai semnificativă cifră.
Aceaste comparaţii fie vor determina care număr este mai mare, fie vor
determina că numerele sunt egale.

Funcţia 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) Înmulţirea unui număr mare cu un număr mic


Putem avea nevoie să înmuţim un număr mare cu un număr mic, de
exemplu atunci când vrem să calculăm puteri mai ale unor numere sau
factorialele unor numere. Acest lucru se face în mod natural: se înmulţeşte

159
Capitolul 4

fiecare cifră a numărului mare cu numărul mic, se adună transportul la


rezultat (care iniţial este 0), se păstrează restul împărţirii la 10, iar noul
transport devine rezultatul împărţit la 10.

De exemplu, dacă vrem să înmulţim numărul mare A = 3173 cu


numărul mic B = 13, vom proceda în felul următor:

i 0 1 2 3 4
A 4 3 7 1 3
transport = 0

La pasul i = 1 efectuăm 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 până la pasul i = 4, când vectorul A va arăta


în felul următor:

i 0 1 2 3 4
A 4 9 4 2 1
transport = 4

Cât timp mai există transport, cifrele acestuia trebuie adăugate


numărului A. Rezultatul final va fi aşadar:

i 0 1 2 3 4 5
A 5 9 4 2 1 4

Adică 3173 ∙ 13 = 41 249.

O funcţie care înmulţeşte un număr mare cu un număr mic poate fi


implementată în felul următor:

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) Înmulţirea a două numere mari

Algoritmul de înmulţire a două numere mari este puţin mai complex


decât algoritmii prezentaţi până acuma. O primă idee ar fi să aplicăm
algoritmul clasic de înmulţire învăţat în clasele primare: se scriu numerele
unul sub altul, se înmulţeşte fiecare cifră a celui de-al doilea cu primul
număr iar rezultatele se scriu unul sub altul, fiecare deplasat cu o poziţie în
plus spre stânga, după care se adună rezultatele cifră cu cifră. De exemplu,
pentru a înmulţi 1213 cu 413 procedăm în felul următor:

1213 ∙
413
––––––– (*)
3639
1213
4852
––––––– (+)
500969

Acest algoritm ar putea fi implementat folosind algoritmii de


adunare a două numere mari şi de înmulţire a unui număr mare cu un număr
mic, dar această abordare ar fi ineficientă şi greu de implementat, aşa că
vom încerca să obţinem ceva mai eficient şi totodată mai uşor de
implementat.

161
Capitolul 4

Algoritmul propus este următorul:


 Se înmulţeşte fiecare cifră i a primului număr cu fiecare cifră j a
celui de-al doilea număr, iar rezultatul se adună la cifra i + j – 1 a
rezultatului (care este iniţial nulă)
 La sfârşit se face corectarea rezultatului (care este evident greşit
î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 obţinem 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ă afirmaţie), deci vom declara un vector de lungime
7, iniţializat cu 0, care va reţine rezultatul:

i 0 1 2 3 4 5 6 7
A 4 3 1 2 1 0 0 0
B 3 3 1 4 0 0 0 0
Rez 6 0 0 0 0 0 0 0

Se adună rezultatul înmulţirii primei cifre a primului număr (A) cu


fiecare cifră a celui de-al doilea număr în poziţia corespunzătoare sumei
poziţiilor celor două cifre (aşa cum sunt reţinute î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

Efectuăm corectarea rezultatului: Rez[1] rămâne 9, iar transportul


devine 0. Rez[2] rămâne 6, iar transportul rămâne 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ă verificăm dacă transportul este diferit de 0,


caz în care trebuie să creştem numărul de cifre şi să mai adăugăm o cifră
egală cu transportul. Pe acest exemplu nu este însă cazul.

Mai trebuie să demonstăm corectitudinea acestei metode. În primul


rând, produsul a două numere X şi Y, având P respectiv Q cifre este egal fie
cu P + Q fie cu P + Q – 1. Acest lucru reiese din următoarele relaţii:

10𝑃−1 ≤ 𝑋 < 10𝑃


10𝑄−1 ≤ 𝑌 < 10𝑄

163
Capitolul 4

Dacă înmulţim cele două relaţii obţinem:

10𝑃+𝑄−2 ≤ 𝑋 ∙ 𝑌 < 10𝑃+𝑄

De unde rezultă afirmaţia anterioară.

Având demonstrat numărul de cifre, corectitudinea acestei metode


este uşor de dedus: la fiecare pas se adună rezultatul înmulţirii cifrelor
curente în poziţia corespunzătoare a vectorului rezultat. Exact acelaşi lucru
se întamplă şi în forma clasică a algoritmului, atâta doar că forma clasică
este mai uşor de efectuat pentru om (deoarece nu trebuie să ţinem cont de
poziţii şi nici de corectarea rezultatului), iar această formă este mai simplu
de implementat pentru un programator, deoarece nu trebuie reţinute ş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) Câtul împărţirii unui număr mare la un număr mic

Se procedează în modul clasic, construindu-se rezultatul cifră cu


cifră. De exemplu, dacă vrem să împărţim numărul mare 62117 la numărul
mic 13, procedăm în felul următor:

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


deîmpărţitului au fost coborâte. Zerourile din faţa 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 împărţirii unui număr mare la un număr mic

Algoritmul de determinare a restului este uşor de dedus dacă ţinem


cont de modul în care sunt reprezentate numerele în baza 10. De exemplu,
numărul 3672 se poate scrie în baza 10 în felul următor: 3672 = 3∙103 +
6∙102 + 7∙101 + 2∙100 = (((3∙10 + 6) ∙10) + 7)∙10 + 2. Putem aşadar să

165
Capitolul 4

parcurgem numărul mare cifră cu cifră şi să construim treptat restul ţinând


cont de proprietăţile operaţiei 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;
}

Operaţiile prezentate până acum reprezintă operaţiile matematice de


bază.
Există metode mult mai eficiente decât cele prezentate, dar acestea
depăşesc scopul acestei lucrări. Dacă cititorul consideră că are nevoie de o
librărie mai avansată, recomandăm librăria de numere mari GMP sau
extinderea operaţiilor prezentate până acum.
Reamintim exerciţiul de a implementa operaţiile prezentate (eventual
şi altele) într-o clasă sau structură. Acest lucru va uşura refolosirea codului
de fiecare dată când veţi avea nevoie de acesta.

166
Algoritmi backtracking

5. Algoritmi
backtracking
Am prezentat până acum descrierea generală a tehnicii de
programare numită backtracking, împreună cu nişte probleme elementare
care se rezolvă cu ajutorul acestei tehnici. Problemele prezentate anterior nu
au însă nicio aplicabilitate intrinsecă, acestea aparând 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 săriturii calului ....................................................................... 173
5.3. Generarea submulţimilor ...................................................................... 175
5.4. Problema reginelor ................................................................................ 177
5.5. Generarea partiţiilor unei mulţimi ........................................................ 180

168
Algoritmi backtracking

5.1. Problema labirintului


Se dă un labirint reprezentat cu ajutorul unei matrici pătratice A de
ordin N a căror valori pot fi doar 0 şi 1. Fiecare element al matricii
reprezintă o încăpere 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 posibilităţilor de a ajunge din încăperea
(1, 1) în încăperea (N, N) respectând condiţiile din enunţ.
Datele de intrare se citesc din fişierul labirint.in, iar modalităţile
găsite se scriu în fişierul labirint.out în felul următor: fiecare linie a
fişierului conţine, în ordine, câte o pereche i j care descrie o cameră a
traseului curent. Când se trece la un nou traseu, se lasă o linie liberă.

Exemplu:

labirint.in labirint.out
2 11
00 12
00 22

11
21
22

Deoarece ni se cere să găsim toate posibilităţile de „ieşire 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 reţinem toate elementele matricii până la pasul curent. La sfârşit, adică
atunci când am ajuns pe elementul (N, N) afişăm conţinuturile stivei.

Detaliile algoritmului sunt destul de evidente: vom folosi o funcţie


care acceptă ca paramtri pasul la care ne aflăm, coordonatele (lin, col) a
camerei în care ne aflăm, matricea şi stiva folosită. Primul lucru pe care îl
facem în această funcţie este să reţinem perechea (lin, col) în stivă. Apoi,
verificăm dacă ne aflăm pe elementul final, caz în care afişăm conţinuturile
stivei şi ieşim din funcţie. În caz contrar, apelăm funcţia recursiv pentru toţi
vecinii valizi. În pseudocod algoritmul este următorul:

169
Capitolul 5

fie back(k, lin, col, N, A, st) funcţia la care am făcut referire mai sus.
Această funcţie poate fi implementată astfel:
 Reţine (lin, col) în st[k]
 Dacă (lin, col) == (N, N) afişează conţinuturile 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 rând trebuie să explicăm câteva lucruri care pot părea


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ă
decât 1 sau mai mare decât N.

Pentru a parcurge mai uşor vecinii unui element, putem folosi


vectori de direcţie. Vectorii de direcţie sunt nişte vectori dx şi dy cu valori
alese în aşa fel încât dacă adunăm dx[0] la lin şi dy[0] la col să obţinem
primul vecin (n_lin, n_col). Dacă adunăm dx[1] respectiv dy[1] vom obţine
al doilea vecin etc. (ordinea obţinerii vecinilor nu are importanţă atâta timp
cât nu se obţine acelaşi vecin de mai multe ori). Deoarece avem 4 vecini,
vectorul va avea patru elemente. Se poate observa uşor că:
(n_lin, n_col) ∈ {(lin + 1, col); (lin, col + 1); (lin – 1, col); (lin, col – 1)}
aşa 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ă marcăm 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 căutăm încă un drum, adică trebuie marcate cu 0.

Pentru exemplul dat algoritmul funcţionează în felul următor: prima


dată se adaugă (1, 1) în stivă. Pentru fiecare vecin valid al său, adică pentru
(1, 2) şi (2, 1), efectuăm A[1][1] = true şi apelăm funcţia recursiv, prima
dată pentru (1, 2). Înainte de apelul recursiv stiva şi matricea arată în felul
următor:
170
Algoritmi backtracking

st A
true false
(1, 1)
false false

La apelul recursiv pentru (1, 2), adăugăm această pereche în stivă,


marcăm elementul curent ca fiind vizitat şi apelăm funcţia recursiv pentru
singurul vecin valid al lui (1, 2), adică (2, 2). Înainte de apel avem
următoarea configuraţie:

st A
(1, 2) true true
(1, 1) false false

La apelul recursiv pentru (2, 2) vom ajunge la următoarea


configuraţie, care ne va da primul traseu:

st A
(2, 2) true true
(1, 2)
(1, 1) false false

Deoarece (2, 2) reprezintă sfârşitul traseului, acesta nu se mai


setează pe true. La revenire din recursivitate se vor redeschide (seta pe
false) celelalte camere şi se va găsi celălalt traseu.

Prezentăm în continuare implementarea algoritmului de rezolvare a


problemei labirintului.

#include <fstream> void citire(int &N,


bool A[maxn][maxn])
using namespace std; {
ifstream in("labirint.in");
const int maxn = 100;
const int dx[] = {1, 0, -1, 0}; in >> N;
const int dy[] = {0, 1, 0, -1}; for ( int i = 1; i <= N; ++i )
for ( int j = 1; j <= N; ++j )
struct stiva in >> A[i][j];
{
int lin, col; in.close();
}; }

171
Capitolul 5

bool valid(int lin, int col, int N, int main()


bool A[maxn][maxn]) {
{ int N;
if ( lin > 0 && lin <= N && bool A[maxn][maxn];
col > 0 && col <= N ) stiva st[maxn*maxn];
if ( A[lin][col] == false ) citire(N, A);
return true;
ofstream out("labirint.out");
return false; back(1, 1, 1, N, A, st, out);
}
out.close();
void back(int k, int lin, int col, int N, return 0;
bool A[maxn][maxn], stiva st[], }
ofstream &out)
{
st[k].lin = lin;
st[k].col = col;

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;
}
}
}

Exerciţiu: modificaţi programul dat în aşa fel încât în cadrul funcţiei


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 săriturii calului


Considerăm o tablă de şah (matrice pătratică) de dimensiune N. În
poziţia (1, 1) se află un cal. Ne interesează toate posibilităţile de a parcurge
toate elementele matricii exact o singură dată respectând modul de deplasare
al unui cal pe tabla de şah.
Fişierul de intrare cal.in conţine doar numărul N. Fişierul de ieşire
cal.out va conţine numărul traseelor posibile.

Exemplu:

cal.in cal.out
5 304

Rezolvarea problemei este identică din punct de vedere structural cu


rezolvarea problemei anterioare. Diferă doar conţinutul vectorilor de direcţie
şi condiţia de oprire. Pentru a construi vectorii de direcţie vom folosi
următoarea figură,

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

Aşadar avem dx = {-1, -2, -2, -1, 1, 2, 2, 1} şi


dy = {-2, -1, 1, 2, 2, 1,-1,-2}

Condiţia de oprire este clară: când am parcurs N 2 elemente am găsit


un traseu şi putem să-l contorizăm şi să trecem la un alt traseu.
Va trebui să folosim şi aici o matrice booleană care reţine dacă un
element a fost sau nu vizitat la un moment dat.

173
Capitolul 5

Trebuie menţionat 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
său este următorul:
 Fie C poziţia curentă a calului. Iniţial C = (1, 1)
 Se marchează poziţia C cu 1
 Pentru i de la 2 până la N2 execută
o Deplasează calul într-o poziţie validă (x, y) astfel încât
(x, y) să permită un număr minim de deplasări ulterioare.
o C = (x, y).
o Se marchează poziţia C cu i.
 Numerele din matrice reprezintă, în ordine, elementele traseului.

Implementarea algoritmului Warnsdorff este lăsată pe seama


cititorului. Prezentăm aici doar implementarea cu ajutorul metodei
backtracking. Citirea şi afişarea sunt lăsate 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 numeşte şi


backtracking în plan.

174
Algoritmi backtracking

5.3. Generarea submulţimilor


Se dă un număr natural N. Ne interesează generarea tuturor
submulţimilor nevide ale mulţimii {1, 2, 3, ..., N – 1, N}.
Numărul N se citeşte din fişierul sub.in, iar submulţimile se afişează
în fişierul sub.out, câte 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ă numărul
i face parte din submulţimea curentă şi 0 în caz contrar. La fiecare pas k
vom depune în stivă valoarea 0, după care vom trece la pasul următor. La
revenire din recursivitate vom depune în stivă valoarea 1, după care vom
efectua încă un apel recursiv. Când am ajuns la pasul k > N, afişăm
numerele de ordine a poziţiilor pe care se găseşte 1 în stivă. Dacă există cel
puţin o poziţie pe care se găseşte 1, trecem la următoarea linie la sfârşit, în
caz contrar fiind vorba de mulţimea vidă. În pseudocod algoritmul este
următorul: fie back(k, N, st) funcţia care rezolvă problema:
 Dacă k > N execută
o Pentru fiecare i de la 1 la N execută
 Dacă st[i] == 1 afişează i
o Dacă s-a afişat cel puţin un număr, 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 fără a folosi metoda backtracking.


Deoarece numărul submulţimilor care ne interesează este 2N – 1, putem fi
siguri că nu vom avea nevoie de submulţimile unei mulţimi cu mai mult de

175
Capitolul 5

32 de elemente pentru niciun scop practic (de fapt 32 este chiar o


supraestimare).

Dacă analizăm algoritmul de mai sus, observăm că valorile stivei pot


fi interpretate ca un număr în baza 2. Putem aşadar să folosim reprezentarea
numerelor în baza 2 pentru generarea submulţimilor astfel: începem cu
numărul 1, care în baza doi se reprezintă astfel: 000...01 (unde 0 apare de
N – 1 ori). Această reprezentare semnifică faptul că avem o submulţime
formată fie din numărul N, fie din numărul 1, depinde cum interpretăm
ordinea biţilor. În continuare vom interpreta biţii ca reprezentând, de la
stânga la dreapta, numerele N, N – 1, ..., 3, 2, 1. Pentru a genera următoarea
submulţime, tot ce trebuie să facem este să adunăm 1 pentru a obţine
reprezentarea 000...10, reprezentând submulţimea {2}. Mai adunăm 1 şi
obţinem reprezentarea binară 000...11, reprezentând submulţimea {1, 2}. Se
procedează în acest mod până ajungem la reprezentarea 111...11 (N de 1),
care reprezintă submulţimea finală, adică {1, 2, 3, ..., N}.

Observaţie: deseori se confundă metoda backtracking cu metodele


exhaustive de rezolvare a unei probleme. Doar pentru că încercăm toate
posibilităţile 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). Deşi algoritmul care
foloseşte operaţii pe biţi are aceeaşi complexitate ca algoritmul
backtracking, cel pe biţi nu revine niciodată la un pas precedent pentru a
schimba o alegere făcută, deci nu este un algoritm de tip backtracking!

Prezentăm doar funcţiile relevante, programele complete se


consideră uşor de realizat, urmând un tipar care deja ar trebui să fie
cunoscut.

176
Algoritmi backtracking

folosind backtracking folosind operaţii pe biţi


void back(int k, int N, bool st[], void submultimi_biti(int N, ofstream &out)
ofstream &out) {
{ // reamintim ca 1 << N este
if ( k > N ) // egal cu 2 la puterea N
{ int nr = 1 << N;
// am grija sa nu afisez
// multimea vida for ( int i = 1; i < nr; ++i )
bool afisat = 0; {
for ( int i = 1; i <= N; ++i ) // pentru fiecare bit al lui i
if ( st[i] ) for ( int j = 0; j < N; ++j )
{ if ( i & (1 << j) ) // daca e 1
afisat = 1; out << j + 1 << ' '; // afisare
out << i << ' ';
} 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


Considerăm o tablă de şah de dimensiune N. Ne interesează toate
posibilităţile de a plasa N regine pe tablă astfel încât oricum am alege două
regine, acestea să nu se atace reciproc. Două regine se atacă reciproc dacă se
află pe aceeaşi linie, coloană sau diagonală.
Numărul N se citeşte din fişierul regine.in, iar numărul de
posibilităţi se afişează în fişierul regine.out.

Exemplu:
regine.in regine.out
8 92

177
Capitolul 5

O soluţie este:

Fig. 5.4.1. – O soluţie a problemei reginelor

Problema poate fi abordată folosind backtracking în plan. Această


rezolvare este similară cu cele prezentate până acum, dar mai greu de
implementat şi mai puţin eficientă deoarece trebuie să construim o funcţie
de validare mai complexă. Vom aborda puţin diferit această problemă şi
anume în felul următor: vom folosi un vector lin, unde lin[i] = linia pe care
se află regina de pe coloana i. Aşadar, o soluţie este caracterizată de o
permutare a primelor N numere naturale. Pentru exemplu de mai sus, soluţia
prezentată este caracterizată prin vectorul lin = {6, 4, 7, 1, 8, 2, 5, 3}
Din cauza modului în care am definit vectorul şi deoarece lucrăm cu
numere distincte în cadrul permutărilor, nu mai este necesar să verificăm
dacă două regine se află pe aceeaşi linie sau coloană, fiind suficient să
verificăm dacă două regine se află pe aceeaşi diagonală. Dacă avem o regină
în poziţia (x, y) şi o altă regină în pozţia (p, q), atunci cele două regine se
află pe aceeaşi diagonala dacă şi numai dacă |p – x| = |q – y|. Astfel, pentru
a testa dacă introducerea unui nou număr în permutare strică sau nu
validitatea soluţiei parţiale curente, în momentul în care încercăm depunerea
unui număr i pe poziţia k în stivă trebuie să verificăm dacă există sau nu o
poziţie j < k astfel încât k – j = |i – st[j]|. Dacă da, atunci nu putem depune
acel număr în acea poziţie (ar rezulta două regine care se atacă reciproc).
Dacă nu există nicio astfel de poziţie, atunci se depune numărul i în stivă
(bineînţeles, se verifică şi condiţia necesară proprietăţii de permutare: i să nu
fi fost depus deja în stivă).
Când am ajuns la pasul k > N ştim că în stivă se află o soluţie 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;
}
}

Menţionăm că şi această problemă admite o rezolvare liniară dacă ne


interesează doar o soluţie. Lăsăm găsirea acesteia pe seama cititorului.

Exerciţii:
a) Implementaţi un program care foloseşte backtracking în plan
pentru rezolvarea problemei.
b) Comparaţi timpul de execuţie al celor doi algoritmi. Încercaţi să
găsiţi optimizări.

179
Capitolul 5

5.5. Generarea partiţiilor unei mulţimi


Se numeşte partiţie a unei mulţimi A o mulţime P formată din
submulţimi distincte ale lui A care îndeplineşte condiţiile:

1. 𝑃=𝐴
2. 𝑋 ∩ 𝑌 = ∅, ∀𝑋, 𝑌 ∈ 𝑃 ş𝑖 𝑋 ≠ 𝑌

Dându-se un număr natural N, ne propunem să scriem un program


care generează toate partiţiile mulţimii {1, 2, 3, ..., N}.
Numărul N se citeşte din fişierul partitii.in, iar partiţiile găsite se
afişează în fişierul partitii.out, fiecare partiţie pe o singură linie, cu
mulţimile partiţiei între acolade, separate între ele prin spaţiu şi elementele
unei mulţimi separate prin virgulă şi spaţiu, aşa 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 partiţie. Fiecare element
i al stivei va reprezenta numărul de ordine al mulţimii din care face parte
elementul i în cadrul partiţiei curente. De exemplu, codificarea următoare:
st = {1, 1, 2} reprezintă partiţia {1, 2} {3}, codificarea st = {1, 2, 2}
reprezintă partiţia {1} {2, 3} etc.
O primă idee ar fi să generăm pe rând N posibilităţi pentru fiecare
poziţie, dar putem găsi uşor un contraexemplu la această abordare:
codificările {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 acelaşi lucru. Avem aşadar nevoie fie de o funcţie de validare fie de o
metoda de a genera configuraţii care produce numai configuraţii valide.

180
Algoritmi backtracking

Vom încerca să generăm partiţii astfel încât primul element al unei


configuraţii valide să fie întotdeauna 1. Cu alte cuvinte, numărul 1 va face
întotdeauna parte din prima mulţime a unei partiţii. Numărul 2 se va afla
întotdeauna fie în prima mulţime a unei partiţii, fie în a doua. În cazul
general, numărul i va face parte întotdeauna dintr-o mulţime dintre primele i
mulţimi ale unei partiţii. Acest lucru este corect, deoarece oricum am genera
partiţii putem observa că un element i va face parte din i mulţimi 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 aşadar o metodă de a genera partiţii care va
genera numai partiţii valide, deci nu avem nevoie de o funcţie de validare.
Menţionăm că există o metodă eficientă de a număra câte partiţii
există. Aceasta va fi prezentată în capitolul dedicat programării 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 când
trecem la pasul k + 1, noul maxim va fi maximul dintre max şi st[k].

Prezentăm doar funcţia de generare a partiţiilor. Restul programului


este asemănător cu programele prezentate anterior. Observaţi cât cod s-a
scris numai pentru afişarea î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 încadraţi într-o anume categorie fără a
defini nişte categorii fie foarte restrictive, fie foarte vagi. Aceştia au, de
obicei, aplicabilitate în nişte probleme practice foarte specifice. În cele ce
urmează vom prezenta câţiva astfel de algoritmi, care considerăm că îşi
merită totuşi propria secţiune, datorită eleganţei acestora şi datorită
aplicaţiilor teoretice care pot fi găsite pentru aceştia.

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 câte ori şirul S2 apare ca subsecvenţă în şirul S1 .
Reamintim că prin subsecvenţa [st, dr] a unui şir S înţelegem secvenţa de
caractere S[st] S[st+1] S[st+2] ... S[dr-1] S[dr].
Datele de intrare se citesc din fişierul kmp.in. Primul şir pe prima
linie, iar al doilea şir pe cea de-a doua linie. Valoarea cerută se va afişa în
fişierul kmp.out.

Exemplu:

kmp.in kmp.out
abbbbbabaabbbaab 1
abbbaab

O primă idee de rezolvare are complexitatea O(N∙M) şi funcţionează


destul de intuitiv:
 contor = 0
 Pentru fiecare i de la 1 la N – M + 1 execută
o găsit = true
o Pentru fiecare j de la 1 la M execută
 Dacă S1 [i + j – 1] != S2[j] execută
 găsit = false
 Se opreşte iterarea lui j
o Dacă găsit == true execută
 contor = contor + 1
 Returnează contor

Practic, pentru fiecare poziţie i a şirului S1 verificăm dacă


subsecvenţa S1 [i, i + M – 1] este egală cu şirul S2. Dacă da, am găsit o
potrivire, adică o apariţe a şirului S2 ca subsecvenţă în şirul S1 , potrivire pe
care o numărăm.
Acest algoritm conţine o optimizare importantă în practică: dacă
găsim un caracter în S1 care nu se potriveşte cu caracterul curent din S2, nu
mai are rost să continuăm ciclul iterativ interior, deoarece este clar că nu
vom găsi o potrivire începând cu poziţia i curentă. Se mai pot face şi alte
optimizări asemănătoare, dar aceastea sunt prea puţin intuitive pentru a
putea fi descoperite cu uşurinţă, aşa că le vom prezenta detaliat în cadrul
algoritmului care le înglobează.

185
Capitolul 6

Metoda clasică prezentată până acum poate fi vizualizată în felul


următor, unde cu roşu 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. Când se potrivesc
toate caracterele şirului S2 avem o soluţie:

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

Următorul pas îl putem vizualiza ca o deplasare a lui S2 spre dreapta:

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

Procedăm la fel până când ajungem în final la deplasarea următoare:

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 uşor de observat de ce această metodă are complexitatea


O(N∙M) pe cel mai defavorabil caz. Neajunsul acestei metode este că nu ne
folosim de informaţiile furnizate de către comparaţiile efectuate până la un
anumit pas pentru a deduce într-un mod inteligent care sunt acele poziţii
(deplasări) care sigur nu vor furniza o potrivire.
Algoritmii eficienţi de rezolvare a problemei reţin astfel de
informaţii ş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 funcţiei prefix.
Funcţia prefix va conţine informaţii despre modul în care şirul căutat se
potriveşte cu deplasări ale sale spre dreapta. Aceste informaţii 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ă, aşa că îl vom considera un algoritm


total distinct. În cadrul algoritmului naiv, primul pas conduce la următoarele
operaţii:
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 uităm atent la acest tabel, obervăm că primul caracter al


şablonului (şirul S1) se potriveşte cu primul caracter (p) al textului căutat, al
doilea caracter al şablonului se potriveşte cu al doilea caracter (p + 1) al
textului căutat ş.a.m.d. până la al 5-lea caracter al şablonului, care se
potriveşte cu caracterul p + 3 al textului căutat. Cu alte cuvinte, avem
potrivite q = 4 caractere ale textului căutat. Ştiind acest lucru, putem
determina următoarea poziţie de la care putem avea o potrivire. Spunem că
p se numeşte poziţia de început a unei potenţiale potriviri (care poate se
va dovedi ca fiind potrivire sau nu). Se observă uşor că, dacă deplasăm şirul
căutat la poziţia p‟ = p + 1, primul caracter al textului căutat nu se va potrivi
cu al doilea caracter al şablonului, deoarece ştim că acesta trebuie potrivit cu
al doilea caracter al textului căutat. Deplasarea la p‟ = p + 4 în schimb va
conduce la o nouă potenţială 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 căutat S2[1, q] se potriveşte


cu secvenţa şirului şablon S1 [p, p + q – 1], trebuie să ştim care este cea mai
mică deplasare p‟ > p astfel încât 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ă poziţie, după care se procedează similar până când
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 deplasările avem nevoie de calculul funcţiei


prefix (π), unde π[i] = lungimea celui mai lung prefix al şirului S2 , care
este prefix al secvenţei S2[1, i].
Mai mult, vom avea π:{1, 2, ..., |S 2 |} → {0, 1, ..., |S2 | – 1}.
Funcţia π se calculează folosind valorile deja calculate. Evident,
π[1] = 0. Vom considera o variabilă k = 0. Parcurgem şirul căutat de la
stânga la dreapta, începând 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 secvenţa 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ă
verificăm egalitatea dintre S2[k + 1] şi S2[i], deoarece toate caracterele până
la k reprezintă un prefix de lungime maximă care este sufix al subsecvenţei
formate din primele i – 1 caracterele ale textului căutat, deci trebuie doar să
verificăm dacă putem extinde acest prefix. Aşadar, 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 funcţiei π pentru
a determina un prefix care poate reprezenta, extins cu un caracter, un sufix
de lungime maximă a secvenţei curente. Acest lucru îl vom face atribuindu-i
lui k valoarea π[k] atâta timp cât k > 0 şi S2[k + 1] != S2[i]. Pentru exemplul
de mai sus, funcţia prefix este următoarea:

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 apriţiilor textului căutat în textul şablon. Metoda este foarte
similară cu metoda folosită pentru a calcula funcţia prefix.
Vom parcurge cu i textul şablon de la stânga la dreapta. Vom
considera k = 0. Dacă S1[i] este egal cu S2[k + 1], îl incrementăm pe k.
Dacă cele două caractere sunt diferite însă, trebuie să vedem care este
următoarea poziţie la care putem avea o potrivire, atribuindu-i lui k valoarea
π[k] atâta timp cât k > 0 şi S2[k + 1] ≠ S1[i]. Dacă la un pas k devine egal
cu |S2 |, am găsit o potrivire pe care trebuie să o numărăm.

188
Algoritmi generali

#include <fstream> void KMP_potrivire(char S1[], char S2[],


#include <cstring> int pi[])
{
using namespace std; KMP_prefix(S2, pi);
const int maxn = 100001; ofstream out("kmp.out");
int lgS2 = strlen(S2 + 1);
void KMP_prefix(char S2[], int pi[])
{ int nr = 0, k = 0;
pi[1] = 0; for ( int i = 1; S1[i]; ++i )
int k = 0; {
while ( k > 0 &&
for ( int i = 2; S2[i]; ++i ) S2[k + 1] != S1[i] )
{ k = pi[k];
while ( k > 0 &&
S2[k + 1] != S2[i] ) if ( S2[k + 1] == S1[i] )
k = pi[k]; ++k;

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


++k; ++nr;
}
pi[i] = k; out << nr;
} out.close();
} }

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 indexaţi de la 0 este


chiar mai uşor decât în cazul altor structuri de date, deci recomandăm
cititorilor să reimplementeze algoritmul folosind numărătoarea de la 0.

Exerciţiu: folosiţi tipul de date string pentru implementarea


algoritmului, folosind indexarea atât de la 1 cât şi de la 0.
189
Capitolul 6

6.2. Evaluarea expresiilor matematice


Considerăm o expresie matematică formată din operatorii celor patru
operaţii matematice elementare (adunare, scădere, înmulţire şi împărţire),
paranteze şi cifre. Considerăm că expresia este validă din punct de vedere
matematic, în sensul că este scrisă corect şi nu există împărţiri la 0.
Priorităţile operatorilor sunt cele obişnuite. Ne propunem să scriem un
program care să evalueze astfel de expresii.
Fişierul expr.in conţine o expresie matematică. În fişierul expr.out
se va afişa un număr raţional reprezentând 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 puţin) două moduri: folosind un


algoritm recursiv sau folosind forma poloneză postfixată a expresiei date.
Vom prezenta mai întâi algoritmul recursiv. Acesta presupune
existenţa unei funcţii pentru fiecare nivel de prioritate al operatorilor.
Pentru a evidenţia mai bine modul de funcţionare al algoritmului, să
considerăm următorul exemplu: 2 + 3 * 2. Aşa cum bine ştim, această
expresie are valoarea 8. Putem argumenta acest rezultat în felul următor:
citim expresia de la stânga la dreapta. Reţinem valoarea 2. Când dăm de
semnul +, ştim că acesta are cea mai mică prioritate, deci dacă numărul de
după el este urmat de un operator cu prioritate mai mare, trebuie să aplicăm
acel operator numărului de după +, iar rezultatul acelei operaţii să îl adunăm
la 2. Dacă numărul de după operatorul de adunare are prioritate mai mică
sau egală cu adunarea, atunci putem aduna numărul de după plus la 2 fără
nicio problemă. În cazul acesta, avem înmulţire 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 recursivităţii
indirecte pentru rezolvarea problemei. Astfel, vom avea:
1. O funcţie numită plus_min, responsabilă de efectuarea
operaţiilor asociate operatorilor de prioritate minimă, adică de
adunare şi scădere.
2. O funcţie numită inm_imp, responsabilă de efectuarea
operaţiilor asociate operatorilor de prioritate imediat superioară,

190
Algoritmi generali

adică de înmulţire şi împărţire. Această funcţie va fi apelată de


către funcţia anterioară, din motivul explicat mai sus.
3. Pentru a putea schimba priorităţile naturale ale operatorilor,
avem la dispoziţie paranteze. Putem considera o subexpresie
încadrată între paranteze ca fiind la rândul ei o expresie, pentru
evaluarea cărei vom porni un nou ciclu recursiv prin apelarea
funcţiei plus_minus. Vom avea aşadar o funcţie paran, care va
fi apelată de funcţia inm_imp şi care va verifica dacă pe poziţia
curentă se găseşte o paranteză deschisă: dacă da, se sare peste
aceasta, se evaluează paranteza apelând funcţia plus_min, se
sare peste paranteza închisă şi se returnează rezultatul evaluării
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 următoarea poziţie.

Practic, ne putem imagina algoritmul ca o succesiune de întrebări de


genul: la pasul curent, dacă ne aflăm pe un operator, putem aplica acest
operator operanzilor asociaţi lui, sau trebuie să verificăm existenţa unor
paranteze şi a operatorilor de prioritate mai mare?
Să exemplificăm algoritmul pe exemplul dat. Avem expresia
7*2/3+6-(2+1).

Parcurgem expresia de la stânga la dreapta. Îl reţinem pe 7 la primul


pas. La al doilea pas ne aflăm pe operatorul *, care se află pe cel mai înalt
nivel de prioritate. Deoarece dupa * nu există paranteză, aplicăm operatorul
operanzilor asociaţi şi obţinem 14. Procedăm la fel pentru /, obţinând
14 / 3 = 4.66. Procedăm la fel şi pentru +, obţinând 10.66. Ajungem pe
operatorul –, care este urmat de o paranteză, pe care va trebui să o evaluăm
separat. Evaluarea parantezei ne dă 3, care se scade din 10.66, obţinând
rezultatul final 7.66.

O primă metodă (clasică) de implementare a acestei metode este


următoarea:

191
Capitolul 6

#include <fstream> double plus_min(char expr[], int &k)


{
const int maxn = 1001; double ret = inm_imp(expr, k);
using namespace std;
while ( expr[k] == '+' ||
// avem nevoie de prototipul functiei expr[k] == '-' )
// plus_min pentru a putea folosi if ( expr[k++] == '+' )
// recursivitatea indirecta ret += inm_imp(expr, k);
double plus_min(char [], int &); else
ret -= inm_imp(expr, k);
double paran(char expr[], int &k)
{ return ret;
if ( expr[k] == '(' ) }
{
++k; // sar peste '(' int main()
double ret = plus_min(expr, k); {
++k; // sar peste ')' char expr[maxn];

return ret; ifstream in("expr.in");


} in >> expr;
// returnez operandul in.close();
return expr[k++] - '0';
} int k = 0; // pozitia curenta

double inm_imp(char expr[], int &k) ofstream out("expr.out");


{ out << plus_min(expr, k);
double ret = paran(expr, k); out.close();

while ( expr[k] == '*' || return 0;


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

return ret;
}

Putem implementa aceeaşi metodă scriind mai puţin cod şi evitând


recursivitatea indirectă. Vom împărţi operatorii pe niveluri de prioritate: + şi
– pe nivelul 0, * şi / pe nivelul 1. Parantezele vor fi considerate caz
particular. Astfel, putem folosi o singură funcţie recursivă în loc de trei:

192
Algoritmi generali

#include <fstream> int main()


{
using namespace std; char expr[maxn];
const int maxn = 1001;
const char oper[2][3] = {"+-", "*/"}; ifstream in("expr.in");
in >> expr;
double operatie(double a, double b, char op) in.close();
{
switch ( op ) int k = 0;
{
case '+': return a + b; ofstream out("expr.out");
case '-': return a - b; out << eval(expr, 0, k);
case '*': return a * b; out.close();
case '/': return a / b;
} 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;
}

Aşa 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 aşa fel încât să putem evalua expresia rezultată
printr-o simplă parcurgere a sa de la stânga la dreapta, fără a mai fi nevoiţi
să ţinem cont de paranteze şi de priorităţile operatorilor (care nu mai există
în cadrul formei poloneze). În cadrul formei poloneze postfixate, un
operator este precedat de operanzii asociaţi acestuia (care pot fi la rândul lor
subexpresii).
De exemplu, dacă avem expresia 2 + 3 * 2, forma sa poloneză va fi
3 2 * 2 +, care se va evalua de la stânga la dreapta foarte uşor: pentru
fiecare operator întâlnit, aplicăm operatorul respectiv celor doi operanzi din
urma sa (întotdeauna vor fi doi operanzi în urmă) şi înlocuim operatorul şi
operanzii respectivi cu rezultatul operaţiei. Pentru exemplul dat, după
întâlnirea primului operator vom rămâne cu 6 2 +, iar după întâlnirea
ultimului operator vom rămâne cu 8, care reprezintă rezultatul final. Putem
implementa aceste operaţii 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 următorul
algoritm, propus de Edsger Dijkstra:
 Pentru fiecare i de la 1 până la |S| execută
o Dacă S[i] este un operand, se adaugă în vectorul soluţie
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 vârful stivei şi scriu în vectorul fpol, până la
întâlnirea unei paranteze deschise în st, paranteză care se
scoate din stivă, dar nu se scrie în fpol.
o Dacă S[i] este operator execută
 Cât timp în vârful stivei se află un operator de
prioritate mai mare sau egală decât S[i], se
scoate acest operator din stvă şi se trece în fpol.
 Se scot din stivă toate elementele rămase ş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 conţine şi funcţii şi operatori cu proprietăţi diferite de ale operatorilor
elementari, cum ar fi factorialul (operator unar) şi operatorul de ridicare la
putere (care trebuie evaluat de la dreapta spre stânga: 2^1^2, unde prin a^b
194
Algoritmi generali

2
înţelegem ab, este de fapt egal cu 21 = 2, nu cu (21 )2 = 4). Aceste
subtilităţi, precum şi implementarea suportului pentru funcţii, sunt intuitive
şi uşor de implementat în varianta recursivă, dar mai dificil de implementat
în varianta itertivă.
Prezentăm în continuare funcţiile relevante pentru varianta iterativă.

int prio(char); int prio(char oper)


{
void forma_pol(char expr[], char fpol[]) if ( oper == '-' || oper == '+' )
{ return 0;
int k = 0, p = 0; else if ( oper == '(' )
char st[maxn]; return -1;

for ( int i = 0; expr[i]; ++i ) return 1;


if ( expr[i] >= '0' && expr[i] <= '9' ) }
fpol[p++] = expr[i];
else if ( expr[i] == '(' ) double eval(char expr[])
st[k++] = expr[i]; {
else if ( expr[i] == ')' ) char fpol[maxn];
{ forma_pol(expr, fpol);
while ( k - 1 >= 0 )
if ( st[k - 1] != '(' ) // la sfarsit va contine
fpol[p++] = st[--k]; // rezultatul final
else break; double st[maxn];
--k; int p = 0;
} for ( int k = 0; fpol[k]; ++k )
else // am neaparat un operator {
{ if ( fpol[k] >= '0' &&
while ( k - 1 >= 0 ) fpol[k] <= '9' )
if ( prio(st[k - 1]) >= st[p++] = fpol[k] - '0';
prio(expr[i]) ) else
fpol[p++] = st[--k]; {
else st[p - 2] = operatie(st[p - 2],
break; st[p - 1],
fpol[k]);
st[k++] = expr[i]; --p;
} }
}
while ( k - 1 >= 0 )
fpol[p++] = st[--k]; return st[0];
fpol[p] = '\0'; }
}

195
Capitolul 6

Exerciţii:
a) Modificaţi variantele recursive astfel încât să construiască într-un
vector dat ca parametru forma poloneză postfixată a expresiei
evaluate.
b) Aceeaşi cerinţă pentru forma poloneză prefixată. Forma poloneză
prefixată îşi are toţi operatorii urmaţi de operanzii asociaţi. De
exemplu, 2 + 3 * 2 ≡ + * 3 2 2.
c) Folosiţi o singură stivă atât pentru formarea formei poloneze cât
şi pentru evaluarea acesteia, în cadrul algoritmului iterativ.
d) Găsiţi un algoritm iterativ care construieşte forma poloneză
prefixată a unei expresii.
e) Implementaţi funcţiile sin şi cos atât în variantele recursive cât şi
în varianta iterativă prezentată.
f) Consideraţi existenţa parantezelor drepte în cadrul expresiei date.
Modificaţi algoritmii daţi astfel încât acestea să fie tratate ca
însemnând ridicarea la pătrat 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 dispoziţia programatorilor C++ mai multe
structuri de date generice, lucru care scuteşte programatorii de timpul şi
efortul implementării acestor structuri de la zero. În aceeaşi bibliotecă se
regăsesc şi diferiţi algoritmi care pot reduce timpul necesar rezolvării unei
probleme.
Avantajul folosii bibliotecii S.T.L. constă, în primul rând, în
reducerea timpului necesar implementării unui algoritm. În al doilea rând,
aceste containere au fost implementate de o echipă de profesionişti de-a
lungul unei perioade lungi de timp şi testate foarte riguros, deci putem fi
siguri de corectitudinea şi eficienţa acestora.
În cele ce urmează vom prezenta pe scurt principalele containere şi
algoritmi din S.T.L. şi modul de folosire a acestora în nişte situaţii concrete.

Multe dintre metodele acestor containere sunt comune, deci


cunoaşterea tuturor acestor structuri nu este un lucru greu de realizat.
Recomandăm cititorului familiarizarea cu acestea, întrucât vor fi folosit mai
des în capitolele ce urmează.

Atenţie: toate containerele prezentate de acum încolo sunt, practic,


variabile. Când sunt transmise funcţiilor trebuie transmise prin referinţă ca
să poate suferi modificări. Chiar dacă nu vrem să sufere modificări, este
preferabilă transmiterea prin referinţă constantă, pentru se a evita copierea
lor, lucru care poate scădea drastic performanţa programelor, mai ales în
cazul funcţiilor recursive.

197
Capitolul 7

CUPRINS

7.1. Containere secvenţiale .......................................................................... 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 secvenţiale


Containerele secvenţiale reprezintă colecţii liniare de elemente de
acelaşi tip. Acestea permit acces secvenţial 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 colecţie de elemente de acelaşi tip stocate în locaţii
consecutive de memorie. Vectorii rezolvă două mari probleme şi surse de
erori pe care o au tablourile obişnuite şi anume:
- programatorul trebuie să se asigure că are întotdeauna stocată
undeva dimensiunea fiecărui tablou.
- programatorul trebuie să se asigure că tablourile declarate au
dimensiuni suficient de mari.

Pentru a folosi un vector trebuie inclus fişierul antet <vector> şi


folosit spaţiul de nume std. Pentru a declara un vector se foloseşte sintaxa:

vector<T> aniPari;

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

Pentru a adăuga elemente în vector se foloseşte metoda push_back.


Exemplul următor declară un vector aniPari în care adaugă toţi anii pari
mai mari decât 2000 şi mai mici decât 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 foloseşte


notaţia clasică de la tablouri. Pentru a determina numărul de elemente din
vector se foloseşte metoda size. Secvenţa de mai jos afişează elementele
vectorului aniPari.

for ( int i = 0; i < aniPari.size(); ++i )


cout << aniPari[i] << endl;

199
Capitolul 7

Atenţie: în cazul vectorilor, numerotarea începe de la 0, şi este bine


să nu schimbăm forţat acest lucru, deoarece pot apărea 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 următor parcurge acelaşi 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 sfârşitul vectorului în timp constant


folosind metoda pop_back. Secvenţa următoare şterge anul 2012 din
aniPari:

aniPari.pop_back();

Pentru a insera un element la o anumită poziţie în vector putem


folosi metoda insert. Aceasta primeşte ca argumente un iterator, care
reprezintă poziţia de inserare, şi elementul care trebuie inserat. Secvenţa
următoare 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 operaţie liniară, aşa că nu trebuie abuzată dacă


performanţa este importantă.

Vectorii sunt implementaţii ca tablouri alocate dinamic. Aceste


tablouri sunt la început de o dimensiune mică, iar pe măsură ce se inserează
elemente acestea sunt realocate dacă este cazul. Aceste realocări pot fi
costisitoare dacă folosim des metoda push_back. Dacă ştim în prealabil de
câte elemente vom avea nevoie, putem rezerva spaţiul necesar folosind
metoda reserve:

aniPari.reserve(92); // rezerva spatiu pentru 92 de ani pari

200
Introducere în S.T.L.

Când declarăm un vector, putem să-l iniţializăm 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 iniţializa un vector şi cu un tablou clasic a cărui 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 poziţii identice egale) putem folosi pur şi simplu operatorul ==.
Acesta se poate aplica şi altor containere.

Asupra vectorilor putem apela funcţia sort din <algorithm> pentru a


sorta elementele acestuia:

sort(numere.begin(), numere.end());

Vectorii sunt foarte folositori atunci când nu vrem să gestionăm


manual alocarea memoriei şi numărul de elemente. Datorită gestiunii interne
a memoriei, vectorii sunt uneori mai puţin eficienţi decât tablourile clasice,
aşa că trebuie folosiţi cu grijă.

b) Containerul deque

Un deque (Double-Ended Queue) este similar cu un vector,


diferenţele 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 locaţii 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 adăuga,


respectiv şterge, elemente de la sfârşitul unui deque. Următoarea secvenţă
adaugă numere la sfârşitul containerului:

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


minusPlus.push_back(i);

Pentru a adăuga elemente la începutul unui deque se foloseşte


metoda push_front, iar pentru a şterge elemente de la începutul acestuia se
foloseşte metoda pop_front. Secvenţa următoare ş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 afişa conţinutul unui deque fie folosind operatorul clasic [ ],


fie cu ajutorul iteratorilor. Prezentăm parcurgerea cu ajutorul iteratorilor:

deque<int>::iterator it;
for ( it = minusPlus.begin(); it != minusPlus.end(); ++it )
cout << *it << " ";

Se va afişa următoarul şir de numere, după execuţia tuturor


secvenţelor de cod prezentate:

-1 -2 -3 -4 -5 -6 -7 -8 -9 2 3 4 5 6 7 8

Un deque este mai eficient decât un vector atunci când avem mai
multe operaţii de inserare, deoarece nu au loc realocări de memorie. Deque-
urile au însă o implementare internă mai complexă, care poate să le facă mai
ineficiente în unele situaţii.
Un deque nu trebuie folosit decât dacă avem nevoie să ştergem şi să
adăugăm elemente în ambele capete ale unei structuri liniare, situaţie care
apare în unii algoritmi.

Deque-urile suportă la rândul lor restul operaţiilor prezentate la


vectori.

202
Introducere în S.T.L.

c) Containerul list

list este un container care are la bază o listă dublu înlănţuită. Acest
lucru înseamnă că fiecare element are o locaţie de memorie imprevizibilă şi
câte un pointer la elementul precedent şi următor 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 când efectuăm multe inserări, mutări şi ştergeri de elemente din listă.

Pentru a declara o listă trebuie inclus fişierul antet <list>. Sintaxa de


declarare ar trebui să fie deja uşor de intuit:

list<int> nrPrime;

Un dezavantaj important al listelor este că nu suportă accesarea


elementelor după poziţie, adică nu au implementat operatorul [ ]. Asta
înseamnă că accesarea unui element al listei necesită parcurgerea tuturor
elementelor care îl preced, deci este o operaţie liniară.

Listele implementează metodele push_back, push_front, pop_back


şi pop_front, care au aceeaşi funcţionalitate 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. Operaţia de


inserare este constantă, căutarea poziţiei de inserare este liniară. În cazul
vectorilor, atât căutarea poziţiei cât şi operaţia de inserare în sine erau
liniare. Secvenţa de mai jos inserează numărul 3 după numărul 2:

list<int>::iterator it = nrPrime.begin();
while ( it != nrPrime.end() && *it != 5 )
++it;
nrPrime.insert(it, 3);

203
Capitolul 7

Pentru a afişa conţinutul unei liste este obligatoriu să folosim


iteratori, deoarece listele nu suportă accesul aleator la elemente.

Pentru a şterge un element se foloseşte metoda erase, care primeşte


ca argument un iterator către elementul care trebuie şters. Secvenţa 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 funcţia remove.
Secvenţa de mai jos şterge numărul 15 dintr-o listă de numere prime, dacă
acesta există. Dacă nu există, nu se întâmplă nimic.

nrPrime.remove(15);

Putem şterge elemente dintr-o listă dacă acestea îndeplinesc o


condiţie cu ajutorul metodei remove_if. Această metodă primeşte ca
parametru o funcţie care returnează bool şi accepta ca parametru un obiect
de tipul celor reţinute în liste. Funcţia va fi apelată pentru toate elementele
listei, iar cele pentru care funcţia returnează true vor fi şterse. Secvenţa 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ă funcţionează corect doar pe liste care sunt sortate. Din
fiecare grup de elemente egale va rămâne doar primul element. Secvenţa de
cod următoare 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 afişează 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
uşurează 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 ieşit. Stivele sunt folosite des în
viaţa 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 vârful stivei.
În aceste situaţii au prioritate obiectele care au fost depuse mai târziu
în stivă.

205
Capitolul 7

Intrare în stivă Ieşire 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


următoarea:

stack<string> studenti;

Pentru a adăuga un element în stivă se foloseşte metoda push.


Observaţi că, deoarece stiva permite adăugarea de elemente doar în vârful
său, numele metodei nu mai este calificat cu informaţii 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 vârful stivei se foloseşte metoda top.


În orice moment se poate accesa doar elementul din vârful stivei. Pentru a
putea fi accesate alte elemente, trebuie eliminat mai întâi elementul din vârf.

// Georgescu
cout << "Primul care isi va sti nota este: " << studenti.top();

Pentru a elimina elementul din vârful stivei se foloseşte 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 afişa:


Georgescu
Popescu
Ionescu

Până în acest moment implementam stivele cu ajutorul tablourilor.


Containerul stack ne scuteşte de necesitatea gestionării indicilor şi a
dimensiunii, reducând posibilitatea apariţiei erorilor. Recomandăm
rescrierea programelor prezentate până 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 ieşit. O astfel de coadă se
întâlneşte de exemplu la magazinele aglomerate. Fiecare cumpărător stă şi
îşi aşteaptă rândul la casă după ce şi-a terminat cumpărăturile. Primul care a
terminat este şi primul care va plăti şi va putea pleca acasă.
În aceste situaţii au prioritate obiectele care au intrat mai devreme în
coadă.

Intrare în coadă Ieşire din coadă

Ultimul intrat ... Primul intrat


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

Pentru a declara o coadă trebuie inclus fişierul antet <queue>.


Sintaxa de declarare este următoarea:

queue<string> cumparatori;

O coadă permite adăugarea elementelor într-o parte şi ştergerea lor


din cealaltă parte. Prin convenţie, adăugările se fac la început şi ştergerile la
sfârşit.
Pentru a adăuga un element în coadă se foloseşte 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


foloseşte metoda front, iar pentru a accesa ultimul element al cozii (ultimul
intrat în coadă) se foloseşte metoda back. De exemplu:

// afiseaza Vlad George


cout << cumparatori.front() << " " << cumparatori.back();

Pentru a scoate un element din coadă se foloseşte metoda pop.


Secvenţa de mai jos scoate din coadă primul element adăugat:

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.
Până acum am implementat cozile tot cu ajutorul tablourilor.
Recomandăm rescrierea programelor respective folosind containerul queue.

c) Containerul priority_queue

priority_queue este o coadă de priorităţi care are la bază un heap.


Cozile de priorităţi suportă interogarea valorii maxime din acestea (maxime
după o anumită relaţie 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 priorităţi trebuie inclus fişierul antet


<queue>. Sintaxa de declarare este:

priority_queue<int> note;

Operaţiile permise sunt exact cele de la stive: push, top şi pop.


Implicit este folosit operatorul > pentru prioritizarea elementelor. De
exemplu, codul de mai jos afişează 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 supraîncarcă operatorul (), operator care
primeşte doi parametri şi returnează true dacă primul parametru are o
prioritate mai mică decât al doilea şi false în caz contrar. Această clasă se
foloseşte în declararea obiectului de tip priority_queue. Se va schimba
puţin declararea containerului.
Exemplul de mai jos prioritizează elementele după restul împărţirii
la numărul 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 afişa: 100 30 97 80. Numerele cu resturi mai mari la împărţirea


cu 17 au prioritate mai mare.

Deoarece declararea unui astfel de obiect este greoaie (tipul


obiectului are un nume foarte mare), se foloseşte de obicei un typedef dacă
ştim că vom avea mai multe astfel de declaraţii.
Exemplul următor poate fi rescris astfel:
...
typedef priority_queue<int, vector<int>, cmp> myQueue;
myQueue note;
...

209
Capitolul 7

Acest container uşurează foarte mult implementarea algoritmului


Heapsort. Recomandăm 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 afişa numărul de telefon
al unei persoane prin numele persoanei respective.
Un element al unui container asociativ este caracterizat printr-o
cheie şi valoare. Valoarea este obţinută cu ajutorul cheii.

a) Containerele set şi multiset

Seturile şi multiseturile sunt clase care implementează arbori binari


de căutare. Un set admite doar elemente unice, iar un multiset admite şi
elemente care se repetă.
Seturile permit inserarea, ştergerea şi găsirea elementelor în timp
logaritmic. Elementele unui set sunt menţinute întotdeauna ordonate după o
relaţie de ordine. Relaţia de ordine implicită este cea indusă de operatorul <,
dar putem defini propriile relaţii.

Pentru a declara seturi şi multiseturi trebuie inclus fişierul antet


<set>. Sintaxa de declarare este următoarea:

set<int> nrUnice;
multiset<int> nrMultiple;

Pentru a adăuga elemente într-un set se foloseşte metoda insert.


Aceasta primeşte ca parametru valoare pe care vrem să o inserăm în set.
În cazul seturilor, metoda insert întoarce o pereche a cărei prim
element este un iterator către valoarea nou inserată şi a cărei al doilea
element este o valoare booleană care specifică dacă valoarea a existat deja în
set.
În cazul multiseturilor, insert întoarce doar un iterator către valoarea
nou inserată.
Exemplul următor 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;
}

Inserările 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 foloseşte


metoda find. Aceasta primeşte ca parametru valoarea căutată. Dacă această
valoare există în set atunci se returnează un iterator către aceasta. Dacă
valoarea nu există se returnează un iterator către set::end (respectiv
multiset::end), adică un iterator care indică sfârşitul 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 foloseşte metoda erase. Aceasta


primeşte ca parametru fie un iterator către elementul care trebuie şters, fie
valoarea acestuia. În cazul în care parametrul este valoarea care trebuie
ştearsă, funcţia returnează numărul de elemente care au acea valoare şi care
au fost şterse (relevant doar în cazul multiseturilor, în cazul seturilor este
întotdeauna 1).
Exemplul următor evidenţiază funcţiile 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 primeşte ca argumente doi


iteratori şi şterge toate elementele dintre cei doi iteratori.

Metoda count returnează numărul de elemente care au o valoarea


dată ca parametru. Această metodă este folositoare mai mult în cazul
multiseturilor, în cazul seturilor returnând 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


făceam şi la alte structuri de date. Iterarea se face în timp liniar şi în ordine
crescătoare a elementelor, relativ la relaţia 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 către cea mai mare
valoare din set mai mică sau egală decât parametrul, iar cea din
upper_bound returnează cea mai mică valoare din set strict mai mare decât
parametrul. Acestea sunt comune atât seturilor cât şi multiseturilor.
Exemplul următor evidenţiază 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 << " ";

Funcţionalitatea este identică pentru multiseturi.

Am afirmat la început că putem defini propria relaţie de ordine care


să fie folosită în cadrul seturilor. Acest lucru se face aproape la fel ca la
priority_queue.
Exemplul următor prezintă un set ordonat după restul împărţirii
elementelor sale la 17. Asta înseamnă că vor exista maxim 17 elemente în
set, câte un element pentru fiecare rest. Dacă vrem să poată exista mai multe
elemente cu acelaşi 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 primeşte niciun parametru. Acest lucru este folositor
atunci când vrem să refolosim setul pentru alte lucruri. Metoda clear se
regăseşte şi la restul containerelor.

b) Containerele map şi multimap

map şi multimap sunt containere asociative care reţin 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ă numărul de
telefon al acelei persoane (tip de date int sau tot string).

Pentru a putea folosi aceste containere trebuie inclus fişierul antet


<map>. Un exemplu de declarare este următorul:

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ă accesăm 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, operaţiile


de inserare şi de interogare se execută în timp logaritmic relativ la numărul
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


aceeaşi 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 către containerul map, dar folosite mai rar datorită
existenţei operatorului [ ].
Metoda insert primeşte ca parametru elementul pe care vrem să-l
inserăm. Reamintim că un element este o pereche (pair) formată din cheie
şi valoare. Valoarea returnată de funcţie este, în cazul containerului map, o
pereche pair<iterator, bool> unde primul element este un iterator către
valoarea inserată şi al doilea o valoare booleană care indică dacă elementul a
fost inserat sau exista deja în colecţie. În cazul containerului multimap, se
returnează doar un iterator.
Metoda find returnează un iterator către elementul care are cheia
dată ca parametru. Exemplul următor evidenţiază 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 foloseşte


metoda erase. Această metodă este similară cu cea de la seturi. Există trei
versiuni: una care primeşte ca parametru un iterator către elementul pe care
vrem să-l ştergem şi care nu returnează nimic, una care primeşte ca
parametru o cheie, şterge toate elementele cu acea cheie şi returnează
numărul de elemente şterse şi una care primeşte ca parametru doi iteratori şi
şterge toate valorile dintre cei doi iteratori.
Exemplul următor 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 număra câte elemente au aceeaşi cheie (folositor în cazul unui


multimap) folosind metoda count, care primeşte ca parametru o cheie. De
exemplu:

cout << agendaMulti.count("John Doe") << endl;

Până acum am iterat elementele cu o anumită cheie într-un mod


destul de bizar: am continuat iterarea atâta timp cât iteratorul nu a ajuns la
sfârşitul colecţiei şi cât timp cheia elementului indicat de iterator este egală
cu cheie elementului care ne interesează. Dacă am verifica numai să nu
depăşim sfârşitul colecţiei am risca afişarea 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ă primeşte
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 sfârşitul colecţiei.
Exemplul următor prezintă un scurt program care afişează 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 execuţie, care este întotdeauna logaritmic pentru operaţiile de
căutare, inserare şi ştergere. Acest lucru poate reprezenta un dezavantaj în
faţa tabelelor de dispersie, sau un avantaj dacă dorim să evităm cel mai rău
caz al tabelelor de dispersie, caz în care operaţiile de căutare şi ştergere
devin liniare. De obicei, map şi multimap sunt mai puţin eficiente decât un
trie implementat manual.

c) Containerul bitset

Containerul bitset ne permite să lucrăm cu biţi, lucru care poate


reduce semnificativ memoria folosită de un program care nu are nevoie
decât de un tablou a cărui elemente poate lua doar două valori: adevărat (1)
şi fals (0). Aşadar, fiecare element ocupă un singur bit, spre deosebire de
tipurile bool sau char care ocupă opt biţi.

Pentru a folosi un set de biţi trebuie inclus fişierul antet <bitset>.


Sintaxa de declarare este puţin diferită faţă de sintaxa containerelor
prezentate până acum, în sensul că între parantezele unghiulare nu se mai
trece tipul datelor din container, ci numărul de biţi pe care vrem să-l avem la
dispoziţie. De exemplu:

bitset<2011> aniBisecti;

Constructorul implicit setează toţi biţii setului pe 0 la declarare.


Pentru a accesa şi seta un anumit bit se foloseşte operatorul [ ].
Secvenţa de mai jos setează pe 1 toţi biţii corespunzători 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 biţilor pe valoarea 1,


respectiv pe valoarea 0. Acestea nu necesită niciun parametru. Se pot
transmite însă parametri pentru poziţie, în caz că nu vrem să afectăm
întreaga colecţie, dar este de preferat operatorul de acces în acest caz.
Metoda flip se comportă similar: dacă nu este dat niciun parametru,
toţi biţii din colecţie sunt scazuţi pe rând din 1, adică 1 devine 0 şi 0 devine
1. Se poate transmite un parametru pentru poziţie.

218
Introducere în S.T.L.

Putem lucru cu valoarea binară reţinută într-un set de biţi folosind


metodele to_ulong şi to_string. Acestea transformă biţii dintr-un set de biţi
într-o valoare numerică fără 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

Atenţie: dacă valoarea dintr-un set de biţi este prea mare pentru a fi
reprezentabilă pe un întreg unsigned long, va apărea o eroare!

Alte metode importante sunt count, care numără câţi biţi au valoarea
1, any, care returnează true dacă există un bit cu valoarea 1 şi false altfel şi
metoda none care returnează true dacă toţi biţii au valoarea 0 şi false în caz
contrar.

Constructorul unui set de biţi ne permite să iniţializăm un astfel de


set cu ajutorul unui întreg sau a unui string. Obţinem astfel o metodă foarte
simplă şi directă de a converti orice număr în baza doi:

bitset<32> numar(2010);

// afiseaza 00000000000000000000011111011010
cout << numar.to_string() << endl;

Containerul bitset permite folosirea tuturor operatorilor pe biţi: >>,


<<, |, &, ^, ~. Aceştia 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 aşadar un container care ne permite să reţinem numere foarte


mari în baza doi şi să lucrăm cu ele ca şi când ar fi numere obişnuite, lucru
care poate reduce foarte mult resursele consumate de un program şi timpul
alocat implementării.

7.4. Algoritmi S.T.L.


Biblioteca S.T.L. pune la dispoziţia programatorilor unii algoritmi
care sunt consideraţi folositori în rezolvarea unui număr mare de probleme.
Vom prezenta în continuare numai câţiva dintre algoritmii disponibili, pe
care îi considerăm imediat folositori în rezolvarea de probleme.
Pentru a folosi aceşte algoritmi trebuie inclus fişierul antet
<algorithm>.

a) Algoritmul for_each

Practic, for_each este o funcţie cu ajutorul căreia putem aplica o altă


funcţie asupra unor anumite elemente (identificate prin doi iteratori) ale unui
container. Funcţia aplicată trebuie să accepte un singur parametru de tipul
elementelor din containerul asupra căruia se va aplica. Dacă funcţia
returnează ceva, valoarea returnată va fi ignorată.
Exemplul următor afişează 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ă aplicăm funcţia 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. funcţionează atât asupra tablourilor


clasice cât şi a propriilor colecţii.

b) Algoritmii find şi find_if

Funcţia find primeşte ca argumente doi iteratori (pointeri) şi o


valoare căutată. Aceasta returnează un iterator (sau pointer) către elementul
căutat, dacă acesta există. De exemplu:

int numere[4] = {1, 5, 3, 2};


int *p = find(numere, numere + 4, 3);
cout << *p << endl; // afiseaza 3

Funcţia find_if este similară, doar că ultimul parametru este o


funcţie. Se returnează un pointer către primul element din colecţie pentru
care funcţia 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ă


numărul de elemente care sunt egale cu o valoare dată respectiv pentru care
o funcţie 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ă secvenţe aparţinând a două containere


distincte (doi vectori de exemplu) şi returnează true dacă cele două secvenţe
sunt egale şi false în caz contrar.
Funcţia acceptă fie trei parametri fie patru: prim1 – un iterator către
primul element din prima secvenţă, ultim1 – un iterator către primul
element care nu va fi inclus în comparaţie, prim2, un iterator către primul
element din a doua secvenţă şi un parametru opţional predicat care
reprezintă o funcţie cu două argumente conform căreia se va testa egalitatea.
Informal, se va compara secvenţa [prim1, ultim1) cu [prim2, prim2 +
ultim1 – prim1).
Exemplul următor 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

Funcţia unique şterge toate repetiţiile unui element dintr-o colecţie


sortată. Aceasta primeşte doi parametri care reprezintă graniţele în care se
va aplica funcţia (iteratori sau pointeri). Se mai poate transmite un
parametru opţional: o funcţie cu doi parametri care determină dacă două

222
Introducere în S.T.L.

elemente sunt egale, funcţie similară cu cea de la funcţia equal. Funcţia


returnează un iterator către noul sfârşit al colecţiei.

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

Funcţia copy copiază o colecţie în alta. Primii doi parametri sunt


iteratori care definesc prima secvenţă, iar al treilea parametru este un iterator
către poziţia î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

Funcţia reverse oglindeşte o secvenţă dată prin doi iteratori. Aceasta


nu returnează numic. Exemplul următor 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
Funcţia rotate primeşte trei parametri: prim, mij, ult şi roteşte
secvenţa [prim, ult) în aşa fel încât 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 colecţii. 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 funcţii acţionează doar asupra unor colecţii sortate.


Funcţiile lower_bound şi upper_bound sunt variante globale ale
metodelor de la containere. lower_bound returnează un iterator către primul
element mai mare sau egal cu un element dat, iar upper_bound returnează
un iterator către primul element strict mai mare decât 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

Funcţia binary_search foloseşte căutarea binară pentru a determina


în timp logaritmic dacă un element aparţine unei colecţii 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 către cel mai mic, respectiv cel mai mare
element al unei colecţii. 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 funcţie care determină


minimul a două elemente.

Funcţiile min respectiv max fac acelaşi lucru pentru non-colecţii


(întregi de exemplu). Astfel putem să nu mai scriem propriile funcţii de
determinare a minimului sau maximului a două valori.
225
Capitolul 7

l) Algoritmii next_permutation şi prev_permutation

Determină următoarea, respectiv anterioara, permutare în ordine


lexicografică pe baza valorilor dintr-un container dat. Implicit, funcţiile
folosesc operatorul < pentru comparare, dar pot accepta o funcţie care să
compare două elemente. Acestea returnează true dacă există o permutare
următoare şi false în caz contrar. Dacă funcţia returnează false, mai întâi
elementele colecţiei se resetează, devinind fie prima permutare
lexicografică, fie ultima.
Secvenţa următoare afişează toate permutările primelor 5 numere
naturale nenule: mai întâi crscător, iar apoi descrescător 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 înţelege algoritmii genetici, în primul rând trebuie să
înţelegem şi să cuantificăm modelul evoluţiei naturale (darwiniste). Modelul
evolutiv presupune existenţa unui habitat (a unui spaţiu de evoluţie)
guvernat de legi locale (condiţiile de mediu) în care speciile (populaţiile
reprezentate de indivizi) se supun următorului mecanism:
1. Pe baza selecţiei, un număr restrâns de indivizi din populaţia
iniţială vor constitui populaţia intermediară de părinţi (algoritmul
de selecţie trebuie să respecte paradigma conform căreia un
individ mai bine adaptat să aibe şanse mai mari de supravieţuire).
2. Din indivizii selectaţi ca şi părinţi, pe baza operatorilor genetici
(mutaţie, încrucişare, ...), se va reconstitui o nouă populaţie.

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


1970), vom înlocui în primul rând spaţiul de evoluţie (habitatul) cu
problema dată, indivizii din populaţie cu posibile soluţii la problema în
cauză şi urmărind mecanismul evolutiv, ne vom aştepta ca după un timp să
găsim cele mai bune (optime) soluţii.

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 găsirii unei expresii ............................................................... 236
8.3. Rezolvarea sistemelor de ecuaţii .......................................................... 241

228
Algoritmi genetici

8.1. Descrierea algoritmilor genetici

a) Căutarea în spaţiul soluţiilor

În acest paragraf vom aprofunda modul de construcţie a unui


algoritm genetic, setările şi variantele acestuia, modulele folosite în
rezolvarea de probleme şi câteva 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 căutare sau optimizare
a soluţilor. Ca o analogie cu modelul evoluţionist, vom numi în continuare
soluţiile, cromozomi, iar spaţiul tuturor soluţiilor îl vom nota cu Crom.

În primul rând avem nevoie de o funcţie sau un criteriu de selecţie,


care va arăta cât de adaptat este un cromozom soluţie, sau dacă cromozomul
curent este chiar o soluţie acceptabilă, asociind acestora, de regulă, o valoare
reală. Această funcţie se numeşte funcţie de adecvare (en. fitness function)
şi o vom nota cu fadec şi avem:

𝑓𝑎𝑑𝑒𝑐 : 𝐶𝑟𝑜𝑚 → 𝑹

În conceptul algoritmilor genetici trebuie acceptat că orice element


din spaţiul soluţiilor este o posibilă soluţie, doar că funcţia de adecvare
stabileşte dacă această soluţie este acceptabilă sau nu. Să luăm de exemplu
ecuaţia (cu caracter demonstrativ): 3𝑥 − 6 = 0. Spaţiul soluţiilor este R şi
x = 17 este o soluţie, însă, evident, nu este cea mai bună, dar este mai bună
decât x = 46, de exemplu. Continuând căutarea în spatiul soluţiilor vom găsi
la un moment dat x = 2, această soluţie fiind cea mai bună.
În general, vom accepta o eroare (𝜀), iar

𝑓𝑎𝑑𝑒𝑐 𝑐𝑟𝑜𝑚 < 𝜀

va fi una din condiţiile de oprire a algoritmului.

Graficul din figura 8.1.1. a fost obţinut reprezentând valoarea


funcţiei de adecvare pentru fiecare cromozom în parte pentru o problemă
oarecare P. Funcţia de adecvare este în aşa fel construită încât:

𝑓𝑎𝑑𝑒𝑐 𝑐1 = 0
229
Capitolul 8

Fig. 8.1.1. – Căutarea în spaţiul soluţiilor

înseamnă că c1 este soluţia cea mai bună (optimul global), iar pentru

𝑓𝑎𝑑𝑒𝑐 𝑥1 < 𝑓𝑎𝑑𝑒𝑐 𝑥2 ,

x1 este soluţie mai bună (mai acceptabilă) decât x2.

În acest caz spunem că minimizăm funcţia obiectiv.

Există două concepte fundamentale de căutare în spaţiul soluţiilor:


1. explorarea spaţiului soluţiilor: căutarea complet aleatorie în
spaţiul soluţiilor atâta timp cât nu se găseşte o valoare
acceptabilă (în cazul nostru, atâta timp luăm câte o valoare
aleatorie pe axa reprezentată de spaţiul soluţiilor, până când
aceasta este în unul din intervalele de soluţii acceptabile).
2. exploatarea unor potenţiale soluţii: reprezentată în general de
metodele de coborâre (gradient, Newton) care vor minimiza
succesiv graficul funcţiei de adecvare. Exploatarea unor
potenţiale soluţii se referă la condiţiile iniţiale asociate metodei
de coborâre. În exemplul nostru, o metodă de coborâre pornită
din punctul A va ajunge la optimul local, dar pentru datele de
intrare stabilite în punctul B nu va găsi nicio soluţie (Fig. 8.1.2.)

Pentru a avea posibilitatea ajungerii la o soluţie globală, este


necesară stabilirea unui punct de pornire a unei metode de coborâre în
intervalul I.

230
Algoritmi genetici

Fig. 8.1.2.

Acest mod se poate realiza cel mai eficient prin procesarea mai
multor posibile soluţii simultan, cu diferite puncte de plecare aleatoare.

Conceptul asociat cu îmbinarea celor două metode de căutare în


spaţiul soluţiilor se numeşte 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 rând, pentru a construi un model general al unui algoritm


genetic, trebuie să luăm în calcul timpul de evoluţie (notat în continuare
tEvol). Considerăm valoarea iniţială tEvol = 0, ce corespunde cu pasul de
initializare a populaţiei. Apoi, la fiecare etapă de selecţie – generare, acest
timp de evoluţie îl incrementăm.
1. tEvol = 0
2. Se iniţializează o populaţie iniţială de soluţii (cromozomi)
a. Se verifică dacă prin metoda aleatoare de iniţalizare a
populaţiei nu s-a obţinut o soluţie acceptabilă, caz în care
se incheie algoritmul.
3. Pe baza funcţiei de adecvare se selectează cele mai optime soluţii
(se formează populaţia de soluţii-părinţi).
4. Pe baza operatorilor genetici se generează soluţiile-copii din
populaţia intermediară.
5. tEvol++
231
Capitolul 8

6. Se verifică condiţiile de oprire în funcţie de tEvol = tEvolMAX


sau dacă s-a găsit o soluţie acceptabilă
a. Dacă da algoritmul se încheie
b. Altfel se revine la pasul 3

Paşii 3 şi 4 reprezintă nucleul algoritmului genetic.

c) Selecţia
Există mai multe tipuri de selecţie, toate acestea având scopul ca
implementarea capacităţii de supravieţuire a unei soluţii să fie proporţională
cu valoarea funcţiei de adecvare, aici fiind de fapt implementată paradigma
evoluţiei darwiniste survival of the fittest. Una dintre cele mai simple
metode de selecţie este selecţia bazată pe ordonare (ierarhie), în care se
ordonează populaţia de soluţii astfel încât adaptarea lor să fie
descrescătoare, după care se selectează primii n indivizi doriţi.

Metoda cea mai naturală de selecţie este metoda de selecţie Monte


Carlo (proporţională). Această metodă presupune construirea unei rulete,
fiecare individ din populaţie fiind reprezentat sub forma unui sector de cerc
proporţional cu o pondere. Pentru a avea sens din punct de vedere evolutiv,
ponderea trebuie să fie cu atât mai mare cu cât adecvarea individului soluţie
este mai bună. În figura 8.1.3. avem o populaţie 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 selecţie Monte Carlo are ca date de


intrare o matrice formată din simboluri şi ponderi asociate. De exemplu să
cuantificăm aruncarea unui zar: simbolurile sunt feţele notate cu 1, 2, ..., 6,

232
Algoritmi genetici

reprezentate de numărul de puncte, iar ponderile (de apariţie a unei feţe) ar


trebui să fie identice (1). Astfel avem matricea de intrare:

1 2 3 4 5 6
1 1 1 1 1 1

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


referinţă şi învârtim ruleta. (Fig. 8.1.4.) Simbolul extras este acela a cărui
sector de cerc asociat se opreşte în dreptul puctului de referinţă. Se păstrează
astfel modelul natural al unui zar perfect (fiecare simbol are aceeaşi
probabilitate de apariţie):

Fig. 8.1.4. – Ruleta Monte Carlo pentru un zar perfect

Algoritmul de selecţie Monte Carlo poate fi exprimat astfel:


 Fie 𝑆 = 𝑛−1𝑖=0 𝑝𝑖 suma ponderilor
 se generează un număr aleator t între 0 şi S – 1
 se parcurg ponderile şi atâta timp cât 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 selecţie, pe lângă cele două amintite


anterior. Avantajele, dezavantajele, modul lor de implementare şi
particularităţile acestora le vom trata într-un manual dedicat inteligenţei
artificiale.

d) Operatorii genetici

Pentru a continua construcţia unui algoritm genetic funcţional, avem


nevoie de o modalitate de generare a soluţiilor-copii din populaţia de
soluţii-părinţi. Aceasta se realizează prin operatorii genetici. Există doi
operatori genetici fundamentali: mutaţia (notată în continuare opM),
respectiv încrucişarea (opI). Avem:

𝑜𝑝𝑀 ∶ 𝐶𝑟𝑜𝑚 → 𝐶𝑟𝑜𝑚


şi
𝑜𝑝𝐼 ∶ 𝐶𝑟𝑜𝑚 × 𝐶𝑟𝑜𝑚 → 𝐶𝑟𝑜𝑚

Se observă că mutaţia este un operator unar şi acţionează prin


schimbarea uneia sau a mai multor valori din cromozomul părinte: în forma
cea mai simplă fie 𝑋 = (𝑥0 , 𝑥1 , … , 𝑥𝑛−1 ) un cromozom cu valorile
𝑥0 , 𝑥1 , … , 𝑥𝑛−1. Atunci un operator de mutaţie ar genera o valoare aleatoare t
între 0 şi n – 1, iar valoarea corespunzătoare lui t ar fi schimbată cu o altă
valoare.

𝑜𝑝𝑀 𝑋 𝑡, 𝑣 → 𝑥0 , 𝑥1 , … , 𝑥𝑡−1 , 𝒗, 𝑥𝑡+1 , … , 𝑥𝑛−1

Să considerăm cromozomul 1110001010011111, în codificarea


binară. pentru a obţine diversitatea populaţiei 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 mutaţie au ca scop diversificarea


populaţiei, în efect contrar cu selecţia.

Operatorul de încrucişare este un operator binar şi are ca scop


schimbarea valorilor existente între cromzomii părinţi. În forma cea mai
simplă (numită încrucişarea cu un punct de tăietură) se generează un

234
Algoritmi genetici

număr aleator t, acesta reprezentând punctul în care se rup cei doi


cromozomi şi se recombină.

𝑋 = 𝑥0 , 𝑥1 , … , 𝑥𝑛−1
𝑜𝑝𝐼 𝑡 → 𝑥0, 𝑥1 , … , 𝑥𝑡−1 , 𝑦𝑡 , 𝑦𝑡 +1 … , 𝑦𝑛 −1
𝑌 = 𝑦0 , 𝑦1 , … , 𝑦𝑛 −1

sau
𝑦0 , 𝑦1 , … , 𝑦𝑡 −1 , 𝑥𝑡 , 𝑥𝑡+1 … , 𝑥𝑛−1

Forma cea mai întâlnită a operatorului de încrucişare este aceea în


care se generează un şir de numere aleatoare (t i), care vor reprezenta
punctele de tăietură:

𝑋 = 𝑥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


𝑜𝑝𝐼 5,8,16 → (1010101110000010 010)
11101 011 10111101 010

Operatorii genetici de mutaţie şi încrucişare sunt necesari într-un


algoritm genetic pentru a asigura procesul evolutiv. Pe lângă aceşti operatori
se pot construi şi alţii, în funcţie de cerinţele problemei.
Există şi posibilitatea căutării unor soluţii pentru care lungimea
cromozomilor să fie variabilă, sau informaţia reţinută de aceştia să fie
supusă anumitor restricţii, caz în care trebuie adaptaţi şi operatorii genetici
în consecinţă.

Algoritmii genetici sunt foarte eficienţi atunci când dorim soluţii


apropiate de un optim global într-un timp scurt, dar dacă dorim optime
globale atunci aceştia pot fi mai puţin eficienţi decât alte abordări.

Prezentăm în continuare două probleme care se pot rezolva în mod


natural cu ajutorul algoritmilor genetici: o problemă în care se cere o
expresie a cărei rezultat să fie un număr dat şi o problemă în care se cere
rezolvarea unui sistem de ecuaţii. Sperăm ca rezolvările prezentate în cadrul
acestora să vă ajute să înţelegeţi atât logica din spatele algoritmilor genetici,
cât şi modul de implementare al acestora.
235
Capitolul 8

8.2. Problema găsirii unei expresii


Se dau N – 1 operatori matematici O1, O2 , ..., ON – 1 din mulţimea
{+, -, *}, având semnificaţia lor obişnuită şi un număr S.
Scrieţi un program care găseşte un şir de N numere naturale
X1, X2, ..., X N din intervalul [1, N], astfel încât expresia formată prin
alăturarea numerelor găsite cu operatorii daţi (adică X1O1X2O2 ... ON-1XN )
să dea, modulo 16 381, rezultatul S.
Datele de intrare se citesc din fişierul expresie.in, iar soluţia se scrie
în fişierul expresie.out. Fişierul de intrare conţine pe prima linie numerele
N şi S, separate printr-un spaţiu, iar pe a doua linie N-1 operatori matemtici
din mulţimea specificată în enunţ. În fişierul de ieşire se afişează, separate
printr-un spaţiu, pe prima linie, elementele şirului X.

Exemplu:

expresie.in expresie.out
4 18 4412
**+

Explicaţie: 4 * 4 * 1 + 2 = 18. 18 mod 16 381 = 18. Pot exista şi alte


soluţii.

O primă idee de rezolvare este să folosim metoda backtracking.


Trebuie să generăm toate posibilităţile de a completa N poziţii cu numere
din intervalul [1, N]. Acest lucru se poate face cu un algoritm similar cu cel
al generării permutărilor unei mulţimi, 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 poziţii care
trebuie completate, avem N posibilităţi de completare (N resurse şi N
poziţii).
Complexitatea este foarte mare, iar algoritmul este ineficient şi în
practică pentru valori mari ale lui N. Există diverse optimizări care pot fi
făcute, dar nici acestea nu vor mări cu mult viteza algoritmului.

O altă idee este să generăm aleator numere până când găsim o


expresie care dă rezultatul S. În practică, nici această metodă nu
funcţionează pentru valori mari ale lui N.

236
Algoritmi genetici

Gândiţi-vă ce se întâmplă dacă generăm un şir de numere a cărui


rezultat este foarte aproape de S. Asta înseamnă că şirul respectiv ar putea fi
o soluţie validă, cu mici modificări. În cazul algoritmului anterior însă, acest
şir se va pierde la pasul următor, generându-se alt şir, care va avea mai
multe şanse să fie mai îndepărtat de soluţie decât mai apropiat ca şirul
curent
Ideea din spatele algoritmilor genetici este să reţinem mai multe
astfel de şiruri (o populaţie sau ecosistem) generate aleator, pe care să le
sortăm după o funcţie de adecvare (funcţie de fitness) care ia valori tot mai
apropiate de 0 pentru şiruri (indivizi sau cromozomi) care tind spre o
soluţie. Când am găsit un şir pentru care funcţia de adecvare ia exact
valoarea 0, am găsit o soluţie.
Algoritmul nu se opreşte însă la sortarea unor şiruri generate aleator.
Vom genera un anumit număr de şiruri o singură dată, după care vom aplica
anumiţi operatori genetici asupra lor. Aceşti operatori asigură faptul că
informaţia dintr-o generaţie nu se va pierde în generaţiile următoare. O
generaţie este o stare a populaţiei la un moment dat.
Se pune problema alegerii indivizilor asupra cărora vom aplica
operatorii genetici şi alegerii indivizilor a căror informaţie dorim să o
păstrăm şi în generaţia următoare. Evident, dacă un individ a fost foarte
aproape de soluţie într-o generaţie, acesta va merita păstrat aşa cum e şi în
generaţia viitoare. Vom menţine o listă cu elite pentru fiecare generaţie,
elite care vor trece nemodificate în generaţia următoare. Operatorii genetici
se vor aplica asupra elitelor, combinând calităţile acestora în speranţa
obţinerii unor soluţii din ce în ce mai bune.

Operatorii genetici se aplică, fiecare, cu o anumită probabilitate, în


funcţie de necesitatea aplicării lor.
Operatorii cei mai des întâlniţi sunt operatorii de recombinare şi de
mutaţie. Operatorul de recombinare combină informaţia reţinută de doi
cromozomi A şi B ce fac parte din elite într-un singur cromozom ce va face
parte din generaţia următoare. Modul de desfăşurare al operaţiei 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
următoarele gene identice cu genele de după poziţia P a cromozomului B
Pentru problema de faţă, lucrând pe exemplul dat, recombinarea s-ar putea
face astfel:

237
Capitolul 8

Cromozom Informaţie
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 mutaţie modifică aleator valoarea unei gene alese tot


aleator. În cazul problemei de faţă, operatorul de mutaţie trebuie să fie
implementat în aşa fel încât să nu modifice valoarea unui operator.
Exemplu:

Înainte de mutaţie După mutaţie


4 * 3 * 1 + 2 4 * 4 * 1 + 2

Funcţie de adecvare este, în acest caz, foarte simplu de construit.


Aceasta va calcula, pentru fiecare cromozom, diferenţa în modul dintre
suma S şi valoarea expresiei reţinute de cromozomul curent.

Astfel, algoritmul de rezolvare este următorul:


 Iniţializează aleator maxpop cromozomi / indivizi.
 Execută:
o Crează o nouă populaţie aplicând operatorii de
recombinare şi de mutaţie (fiecare cu probabilităţi
prestabilite).
o Sortează indivizii crescător după funcţia de adecvare.
 Cât timp valoarea funcţiei de adecvare pentru primul cromozom
este diferită de 0.
 Afişează operanzii primului cromozom.

Pentru mai multe detalii despre funcţia de evaluare a unei expresii,


vedeţi capitolul Algoritmi generali.

#include <fstream>
#include <algorithm>
#include <cstdlib>

using namespace std;

238
Algoritmi genetici

const int maxN = 1001; // <EVALUARE>


const int maxpop = 400; int paran(int &k, int cr, info
const int maxlg = 2*maxN + 1; A[])
const int maxeli = 50; {
const int prob_recomb = return A[cr].P[k++];
(int)((double)0.80 * RAND_MAX); }
const int prob_mutatie =
(int)((double)0.95 * RAND_MAX); int inm(int &k, int cr, info A[])
const int mod = 16381; {
int ret = paran(k, cr, A);
struct info
{ while ( A[cr].P[k] == -3 )
int P[maxlg], fitness; {
}; ++k;

void citire_init(int &N, int &S, ret *= paran(k, cr, A);


info A[]) ret %= mod;
{ }
ifstream in("expresie.in");
in >> N >> S; return ret;
char x; }
int ops[maxN];
for ( int i = 1; i < N; ++i ) int eval(int &k, int cr, info A[])
{ {
in >> x; int ret = inm(k, cr, A);
if ( x == '-' ) ops[i] = -1;
else if ( x == '+' ) ops[i] = -2; while (A[cr].P[k] == -1 ||
else ops[i] = -3; A[cr].P[k] == -2)
} {
ops[N] = -100; if ( A[cr].P[k++] == -1 )
in.close(); ret -= inm(k, cr, A);
for ( int i = 1; i < maxpop; ++i ) else
{ ret += inm(k, cr, A);
for ( int j=1, k=1; j < 2*N;
j += 2, ++k ) ret %= mod;
{ while ( ret < 0 )
A[i].P[j] = 1 + rand() % N; ret += mod;
A[i].P[j+1] = ops[k]; }
}
} return ret;
} }
// </EVALUARE>

239
Capitolul 8

void calc_fitness(int N, int S, info A[]) bool operator<(const info &x,


{ const info &y)
for ( int cr = 1; cr < maxpop; ++cr ) {
{ return x.fitness < y.fitness;
int k = 1; }
A[cr].fitness = abs(eval(k, cr, A) - S);
} void start(int N, int S, info A[])
sort(A+1, A+maxpop); {
} do
{
void noua_gen(int N, info A[]) noua_gen(N, A);
{ calc_fitness(N, S, A);
for ( int i = maxeli + 1; i < maxpop; ++i ) } while ( A[1].fitness != 0 );
{
if ( rand() < prob_recomb ) // recombinare ofstream out("expresie.out");
{ for ( int i = 1; i < 2*N;
int i1, i2; i += 2 )
do {
{ out << A[1].P[i] << " ";
i1 = 1 + rand() % maxeli; }
i2 = 1 + rand() % maxeli; out << endl;
} while ( i1 == i2 ); out.close();
int poz; }
do
{
poz = 1 + (rand() % (2*N - 1)); int main()
} while ( poz % 2 == 0 ); {
for ( int j = 1; j < poz; j += 2 ) int N, S;
A[i].P[j] = A[i1].P[j]; info *A = new info[maxpop];
for ( int j = poz; j < 2*N; j += 2 )
A[i].P[j] = A[i2].P[j]; srand((unsigned)time(0));
} citire_init(N, S, A);
start(N, S, A);
if ( rand() < prob_mutatie ) // mutatie
{ delete[] A;
int poz; return 0;
do }
{
poz = 1 + (rand() % (2*N - 1));
} while ( poz % 2 == 0 );
A[i].P[poz] = 1 + (rand() % N);
}
}
}

240
Algoritmi genetici

Exerciţii:
a) Comparaţi performanţa algoritmului cu performanţa celorlalţi doi
algoritmi menţionaţi.
b) Cum afectează constantele de la începutul programului timpul de
execuţie ş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 ecuaţii


Se dă un sistem cu M ecuaţii şi N necunoscute. Considerăm ca
necunoscutele se notează cu A1, A2, ..., AN, iar o soluţie validă este o
permutare a mulţimii {1, 2, ..., N} care verifică fiecare ecuaţie.
Datele de intrare se găsesc în fişierul sistem.in, iar soluţia se scrie în
fişierul sistem.out. Fişierul de intrare are următoarea structură: pe prima
linie N şi M, iar pe următoarele M linii câte o ecuaţie în care operanzii sunt
despărţiţi de operatori prin câte un spaţiu, aşa cum se poate vedea în
exemplu.
Se presupune că sistemul are întotdeauna cel puţin o soluţie şi că, in
cazul unei operaţii de împărţire, se reţine doar partea întreagă a rezultatului.
În ecuaţii nu apar paranteze.

Exemplu:

sistem.in sistem.out
32 312
A1 + A2 - A3 = 2
A1 * A2 / A3 = 1

Explicaţie:
3+1–2=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 foloseşte algoritmul de generare a tuturor permutărilor unei
mulţimi. Folosind algoritmul respectiv, putem verifica, pentru fiecare
permutare P, rezultatul fiecărei expresii date, în care înlocuim fiecare
necunoscută Ai cu numărul Pi (1 ≤ i ≤ N). Dacă am găsit o permutare care

241
Capitolul 8

verifică toate ecuaţiile date, am găsit o soluţie a sistemului şi putem opri


căutarea.

Această metodă are avantajul de a fi relativ uşor de implementat şi


de a găsi rapid o soluţie pentru un sistem oarecare. Alt avantaj este
posibilitatea găsirii tuturor soluţiilor unui sistem.
Dezavantajele acestei metode constau în eficienţă. Complexitatea
asimptotică va fi întotdeauna O(N!) deoarece trebuie să generăm toate
permutările. Totuşi, există optimizări care pot face ca algoritmul să ruleze
foarte rapid în practică. Câteva astfel de optimizări sunt:
 Sortarea ecuaţiilor după numărul de necunoscute care apar în
acestea şi rezolvarea ecuaţiilor cu număr mai mic de variabile
mai întâi.
 Verificarea ecuaţiilor înainte de generarea unei permutări întregi,
fapt ce ne poate ajuta să respingem o permutare mai devreme.
 Diverse optimizări legate de modul de generare al permutărilor.
Aceste optimizări nu garantează însă întotdeauna o îmbunătăţire ş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 constrângeri
(ecuaţiile sistemului), putem începe cu un număr prestabilit (populaţia) de
permutări generate aleator (indivizi), pe care vom aplica apoi anumiţi
operatori genetici şi pe care le vom sorta după o funcţie de adecvare.
Procedeul se repetă până când se ajunge la o soluţie.
Corectitudinea şi eficienţa acestei metode stă aşadar în alegerea
operatorilor genetici şi a funcţiei de adecvare (fitness).
Propunem următoarele două funcţii de adecvare:
1. Prima funcţie, F1 , calculează, pentru fiecare individ, numărul de
ecuaţii ale sistemului pe care permutarea le verifică. Evident, am
găsit o soluţie atunci când există un individ X pentru care
F1(X) = M.
2. A doua funcţie, F2, calculează, pentru fiecare individ,
𝑀

𝑓 𝑖 −𝑔 𝑖
𝑖=1
unde f(i) este rezultatul evaluării 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ă numărul din dreapta egalului expresiei i. Am găsit o soluţie
atunci când există un individ X pentru care F2(X) = 0.
242
Algoritmi genetici

Ambele funcţii de adecvare se comportă similar din punct de vedere


al timpului de execuţie. Acelaşi lucru nu poate fi spus însă şi despre
operatorii genetici.
Primul lucru care trebuie observat este că nu putem păstra modelul
clasic al algoritmilor genetici, deoarece nu putem folosi nici operatorul de
recombinare (în caz contrar am genera permutări invalide, cu elemente care
se repetă), nici operatorul clasic de mutaţie (din acelaşi motiv).
O primă idee ar fi să folosim un operator de inversare: alegem
aleator două poziţii x1 şi x2 , cu 1 ≤ x1 < x2 ≤ N şi inversăm secvenţa cuprinsă
între x1 şi x2. Acest lucru încalcă însă ideea principală din spatele
algoritmilor genetici: păstrarea unor trăsaturi ale elitelor din generaţia
curentă pentru a îmbunătăţi generaţiile următoare. Folosind operatorul de
inversare, se pierde informaţia din generaţia curentă.
Propunem următorul operator genetic, similar cu operatorul de
recombinare: se alege un individ oarecare din elitele generaţiei precedente,
din care se copiază primele x gene în cromozomul curent. Următoarele gene
se completează aleator, având grijă să nu avem două gene (o genă
reprezintă, practic, un element al permutării) identice. Astfel, generaţiile
următoare au şanse mai mari să fie mai aproape de rezolvarea problemei
decât generaţia curentă, iar îmbunătăţirea timpului de execuţie este evidentă
pentru un volum mai mare al datelor de intrare.
Putem folosi şi operatorul de mutaţie, dar şi acesta trebuie modificat
pentru necesităţile problemei. Mutaţia nu va mai avea loc asupra unei
singure gene, ci asupra a două gene. Vom alege două gene pe care le vom
interschimba, păstrând astfel proprietatea de permutare.

Structura de bază a algoritmilor genetici rămâne la fel. În consecinţă,


prezentăm doar acele funcţii care suferă modificări.

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]);
}
}
}

Precizăm că implementarea aceasta foloseşte funcţia de adecvare


descrisă anterior ca F2.
Funcţia eval() evaluează expresia numărul i, înlocuind necunoscutele
cu valorile date de cromozomul cr. Aceasta a fost descrisă în cadrul
capitolului Algoritmi generali şi în cadrul problemei precedente.

Exerciţiu:
Implementaţi în întregime un program care rezolvă problema,
folosind, pe rând, ambii operatori genetici menţionaţi, precum şi ambele
funcţii de adecvare descrise. Comparaţi, pe mai multe date de intrare,
performanţele acestora.

244
Algoritmi de programare dinamică

9. Algoritmi de
programare
dinamică
Am prezentat într-un capitol anterior noţiunile de bază ale metodei
programării dinamice. În acelaşi capitol am prezentat câteva probleme
elementare rezolvate, urmând în acest capitol să prezentăm mai multe
aplicaţii, atât clasice cât şi mai avansate, ale programării dinamice. Tot aici
vom face tranziţia de la implementările mai apropiate de limbajul C folosite
până acum la implementări C++ care profită mai mult de avantajele oferite
de limbajul C++, cum ar fi librăria S.T.L.

245
Capitolul 9

CUPRINS

9.1. Problema labirintului – algoritmul lui Lee ............................................ 247


9.2. Problema subsecvenţei de sumă maximă ............................................ 258
9.3. Problema subşirului crescător maximal ............................................... 262
9.4. Problema celui mai lung subşir comun ................................................. 269
9.5. Problema înmulţirii optime a matricelor .............................................. 273
9.6. Problema rucsacului 1............................................................................ 276
9.7. Problema rucsacului 2............................................................................ 279
9.8. Problema plăţii unei sume 1.................................................................. 280
9.9. Problema plăţii unei sume 2.................................................................. 283
9.10. Numărarea partiţiilor unui număr ...................................................... 284
9.11. Distanţa Levenshtein ........................................................................... 286
9.12. Determinarea strategiei optime într-un joc ....................................... 289
9.13. Problema R.M.Q. (Range Minimum Query) ....................................... 292
9.14. Numărarea parantezărilor booleane .................................................. 296
9.15. Concluzii ................................................................................................ 300

246
Algoritmi de programare dinamică

9.1. Problema labirintului – algoritmul lui Lee


Am prezentat problema labirintului în cadrul secţiunii despre
backtracking. Similar, se dă o matrice pătratică de dimensiune N, cu valori
de 0 sau de 1, codificând 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 poziţia (1, 1) la poziţia (N, N), mergând doar prin camere
deschise şi doar la stânga, dreapta, în jos sau în sus. Nu se poate trece de
două ori prin acelaşi loc. Lungimea unui drum este dată de numărul de paşi
necesari parcurgerii drumului.
Vom citi datele de intrare din fişierul lee.in, iar în fişierul de ieşire
lee.out vom afişa pe prima linia lungimea drumului minim, iar pe
următoarele linii coordonatele care descriu un drum de lungime minimă.

Exemplu:

lee.in lee.out
4 6
0111 11
0100 21
0000 31
1110 32
33
34
44

Rezolvarea prin metoda backtracking de la problema în care se


cereau toate ieşirile din labirint se poate aplica şi la această variantă a
problemei. Trebuie doar să generăm toate drumurile, iar apoi să-l alegem pe
cel de lungime minimă. Această rezolvare nu este însă eficientă, deoarece
are la bază o căutare exhaustivă.
Problema se poate rezolva eficient în timp O(N2) folosind
algoritmul lui Lee (care este de fapt o parcurgere în lăţime, pentru cei
familiarizaţi cu noţiuni de teoria grafurilor). Algoritmul poate fi privit ca un
algoritm de programare dinamică. Pentru a evidenţia acest lucru, să
presupunem că vrem să aflăm lungimea drumului minim de poziţia (1, 1) a
matricii până la poziţia (p, q). Deoarece dintr-o poziţie (x, y) ne putem
deplasa în poziţiile învecinate cu (x, y) la nord, sud, este sau vest, potem
scrie următoarea 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ă distanţa minimă de la (1, 1) la (x, y). Pentru indici
invalizi sau care reprezintă un zid, distanţa minimă va fi infinit.

Există mai multe metode de implementare a acestui algoritm. Fie A


matricea dată. Prima metodă reprezintă implementarea relaţiei de recurenţă
exact aşa cum este dată, cu ajutorul unei funcţii recursive. Vom folosi pentru
această metodă o matrice D cu semnificaţia anterioară şi o funcţie recursivă
Lee(A, N, x, y, D) care va construi această matrice. Vom iniţializa D[1][1]
cu 0, iar restul matricei cu infinit, semnificând faptul că acele valori nu au
fost calculate încă.
Funcţia Lee poate fi implementată astfel:
 Pentru fiecare vecin (newx, newy) al lui (x, y) execută
o Dacă (newx, newy) este o poziţie 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 iniţial lee(A, N, 1, 1, D), matricea D va fi calculată


conform definiţiei sale, deci D[N][N] va conţine distanţa minimă.
Deşi această metodă este cel mai uşor de implementat, nu este cea
mai eficientă, deoarece funcţia lee poate fi apelată de mai multe ori pentru
aceeaşi poziţie. Pentru a evidenţia acest lucru vom prezenta modul de
execuţie al funcţiei de mai sus pe exemplul dat. Vom considera că vectorii
de direcţie (acest concept a fost definit la secţiunea dedicată metodei
backtracking) sunt:
dx[] = {1, 0, -1, 0};
dy[] = {0, 1, 0, -1};

Iniţial avem:
A D
0111 0∞∞∞
0100 ∞∞∞∞
0000 ∞∞∞∞
1110 ∞∞∞∞

Primul vecin al poziţiei (1, 1), conform vectorilor de direcţie folosiţi,


este (2, 1). Această poziţie este validă, nu conţine un zid şi ∞ > 0 + 1. Se
observă că şi al doilea pas va conduce la poziţia validă (3, 1). Aşadar, după
primii doi paşi avem:

248
Algoritmi de programare dinamică

A D
0111 0∞∞∞
0100 1∞∞∞
0000 2∞∞∞
1110 ∞∞∞∞

Primul vecin al poziţiei (3, 1), este (4, 1), poziţia invalidă deoarece
conţine un zid. Al doilea vecin este poziţia (3, 2), poziţie validă. Funcţia se
autoapelează pentru această poziţie şi obţinem:

A D
0111 0∞∞∞
0100 1∞∞∞
0000 23∞∞
1110 ∞∞∞∞

Din poziţia (3, 2) prima dată se încearcă vecinul (4, 2), care este însă
un zid. Se va merge în continuare la stânga încă doi paşi, până obţinem:

A D
0111 0∞∞∞
0100 1∞∞∞
0000 23 4 5
1110 ∞∞∞∞

Din poziţia (3, 4) funcţia se va apela prima dată pentru poziţia (4, 4),
obţinându-se următoarea configuraţie:

A D
0111 0∞∞∞
0100 1∞∞∞
0000 2 3 4 5
1110 ∞∞∞6

Deoarece niciun vecin al poziţiei (4, 4) nu este valid pentru a se


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

249
Capitolul 9

A D
0111 0∞∞∞
0100 1∞∞6
0000 2 3 4 5
1110 ∞∞∞6

Singurul apel recursiv valid din poziţia (2, 4) este pentru poziţia (2,
3), obţinându-se:

A D
0111 0∞∞∞
0100 1 ∞ 7 6
0000 2 3 4 5
1110 ∞∞∞ 6

Se revine din recursivitate până la poziţia (3, 3), de unde, când se


verifică vecinul (2, 3) se vor îndeplini toate condiţiile necesare efectuării
unui apel recursiv, deoarece 7 > 4 + 1 = 5 şi poziţia (2, 3) este validă şi
conţine o cameră deschisă. Forma finală a matricei D va fi:

A D
0111 0∞∞∞
0100 1 ∞ 5 6
0000 2 3 4 5
1110 ∞∞∞ 6

Aşadar, am actualizat de două ori valoarea D[2][3]. Vom încerca să


găsim un algoritm care actualizează fiecare valoare o singură dată, dar vom
prezenta mai întâi 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) void init(int D[maxn][maxn],


{ int N)
ifstream in("lee.in"); {
in >> N; for ( int i = 1; i <= N; ++i )
for ( int i = 1; i <= N; ++i ) for ( int j = 1; j <= N; ++j )
for ( int j = 1; j <= N; ++j ) D[i][j] = inf;
in >> A[i][j]; D[1][1] = 0;
}
in.close();
} int main()
{
bool valid(int N, int x, int y) int N;
{ bool A[maxn][maxn];
return x >= 1 && y >= 1 && int D[maxn][maxn];
x <= N && y <= N;
} citire(A, N);
init(D, N);
void Lee(bool A[maxn][maxn], int N, Lee(A, N, 1, 1, D);
int x, int y, int D[maxn][maxn])
{ ofstream out("lee.out");
for ( int i = 0; i < 4; ++i ) out << D[N][N] << '\n';
{ out.close();
int newx = x + dx[i];
int newy = y + dy[i]; 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);
}
}
}

Precizăm că am omis intenţionat funcţia care determină coordonatele


ce descriu un drum de lungime minimă. Această funcţie va fi prezentată la
sfârşit.
Am afirmat la începutul acestei secţiuni că algoritmul lui Lee este de
fapt o parcurgere în lăţime. Acei cititori care cunosc parcurgerea în lăţime şi
cea în adâncime probabil au observat că prima metodă este de fapt o
parcurgere în adâncime, deoarece se merge în aceeaşi direcţie până când se

251
Capitolul 9

întâlneşte un obstacol şi abia apoi se revine la un pas anterior sau se schimbă


direcţia. Parcurgerile grafurilor vor fi prezentate într-un alt capitol, aşa că nu
vom detalia aici parcurgerea în lăţime. Ideea de bază este să verificăm la
fiecare pas toţi vecinii poziţiei curente, iar apoi toţi vecinii acestora şi aşa
mai departe, până când se parcurgere întreaga matrice. Datorită faptului că
vom parcurge matricea uniform, fiecare element va fi analizat o singură
dată.
Ideea de bază rămâne aceeaşi, 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. Notaţiile
rămân aceleaşi, iar Q reprezintă coada F.I.F.O. folosită, p reprezintă poziţia
primului element din coada, iar u poziţia ultimului element din coadă:
 p=u=1
 Q[p] = (1, 1)
 Cât 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 poziţie 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 acelaşi timp, deci nu va exista posibilitatea
completării unei părţi a matricei D cu valori care vor trebui ulterior
corectate, aşa cum a fost cazul în implementarea iniţială. Pentru a evidenţia
acest lucru vom prezenta modul de execuţie al algoritmului pe exemplul dat.

Iniţial avem:

A D
0111 0∞∞∞
0100 ∞∞∞∞
0000 ∞∞∞∞
1110 ∞∞∞∞
p, u
Q: (1, 1)

252
Algoritmi de programare dinamică

Se extrage primul element din coada Q şi anume (1, 1). Se


actualizează toţi vecinii acestuia care se supun condiţiilor de mai sus.
Singurul vecin valid este (2, 1), care se actualizează, iar poziţia (2, 1) se
introduce în coadă. Avem configuraţia:

A D
0111 0∞∞∞
0100 1∞∞∞
0000 ∞∞∞∞
1110 ∞∞∞∞
p, u
Q: (1, 1) (2,1)

La următorul pas se extrage Q[p], adică (2, 1). Singurul vecin valid
este (3, 1), care se actualizează şi se introduce în coadă:

A D
0111 0∞∞∞
0100 1∞∞∞
0000 2∞∞∞
1110 ∞∞∞∞
p, u
Q: (1, 1) (2,1) (3,1)

Similar, singurul vecin valid al poziţiei Q[p] = (3, 1) este (3, 2), care
se va introduce în coadă şi se va actualiza. La următorul pas se va extrage
(3, 2) din coadă şi se va introduce singurul vecin valid al acestei poziţii,
(3, 3):

A D
0111 0∞∞∞
0100 1∞∞∞
0000 2 34 ∞
1110 ∞∞∞∞
p, u
Q: (1, 1) (2,1) (3,1) (3, 2) (3, 3)

Se extrage (3, 3) din Q. Poziţia (3, 3) are doi vecini valizi: (3, 4) şi
(2, 3), care se actualizează şi se introduc amândoi în coadă:

253
Capitolul 9

A D
0111 0∞∞∞
0100 1∞5 ∞
0000 2 34 5
1110 ∞∞∞∞
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 poziţiile (4, 4) şi (2, 4)


şi le va introduce în coadă. Se observă că după acest pas matricea este deja
completată corect.

A D
0111 0∞∞∞
0100 1∞5 6
0000 2 34 5
1110 ∞∞∞6
p u
Q: (1, 1) (2,1) (3,1) (3, 2) (3, 3) (3, 4) (2, 3) (4, 4) (2, 4)

Dacă ne interesează doar poziţia (N, N), putem returna D[N][N]


imediat ce această valoare a fost calculată. Dacă ne interesează întreaga
matrice D, algoritmul trebuie continuat până când p devine mai mare decât
u.
Datorită faptului că am introdus fiecare poziţie a matricei (care nu
reprezintă un zid) în coadă exact o singură dată şi pentru că am evitat
recursivitatea, timpul de execuţie al acestei implementări este cu mult mai
bun decât cel al implementării recursive.

Pentru a determina coordonatele care alcătuiesc traseul vom folosi o


funcţie recursivă drum(x, y). Observăm că dacă D[x][y] == k (k > 0,
k != ∞) atunci poziţia 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ă condiţie este chiar condiţia de
ieşire din recursivitate. Aşadar funcţia drum(x, y) poate fi scrisă astfel:
 Dacă D[x][y] == 0 afişează (1, 1) şi opreşte execuţia
 Caută un singur vecin (p, q) al lui (x, y) pentru care
D[p][q] == D[x][y] – 1
 Apel recursiv drum(p, q)
 Afişează (x, y)

254
Algoritmi de programare dinamică

În C++ această funcţie poate fi implementată în felul următor.


Funcţia funcţionează atât pentru implementarea deja prezentată, cât şi pentru
implementările 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';


}

Funcţia determină un singur traseu, iar apelul iniţial este


drum(D, N, N, N, out) pentru cerinţa problemei prezentate.
Vom prezenta în continuare două variante de funcţii Lee care
implementează ultimul algoritm descris. Avem mai multe posibilităţi de a
implementa o coadă. Prima şi cea mai evidentă posibilitate este să folosim
un vector cu N2 perechi de numere întregi (deoarece fiecare poziţie poate fi
introdusă cel mult o singură dată în coadă) şi să reţinem poziţia primului
element al cozii în variabila p şi poziţia ultimului element în variabila u,
exact aşa cum se poate vedea în evidenţierea exemplului dat. Pentru această
implementare avem nevoie de o structură care grupează două variabile
întregi:

struct pereche { int x, y; };

Noua funcţie lee poate fi implementată în felul următor:


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 iniţial devine Lee(A, N, D).


Putem scrie un cod mai compact şi mai natural limbajului C++
folosind utilităţile puse la dispoziţie de către biblioteca S.T.L. şi anume
containerele pair şi queue, care pun la dispoziţie programatorului ceea ce
noi a trebuie să implementăm singuri în codul anterior: posibilitatea de a
reţine 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 aşa fel încât 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
incrementăm o limită inferioră pentru poziţia primului element.
Nu vom prezenta pe larg aici aceste două containere întrucât au fost
prezentate în cadrul capitolului Introducere în S.T.L. Precizăm doar că
pentru folosirea lor trebuie incluse fişierele 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
}
}
}
}

Recomandăm cititorilor să se familiarizeze cât mai bine cu biblioteca


S.T.L., mai ales pentru capitolele ce vor urma, deoarece facilităţile oferite de
aceasta sunt de multe ori foarte folositoare şi conduc la implementări mai
uşoare sau mai eficiente. De aceea, de fiecare dată când acest lucru este
posibil şi preferabil, următoarele implementări vor fi prezentate exclusiv
folosind facilităţile S.T.L.

Exerciţii:
a) Considerăm că o persoană porneşte din (1, 1) şi alta din (N, N).
Cele două persoane se mişcă exact în acelaşi timp. Scrieţi un
program care determină coordonatele spre care acestea ar trebui
să se îndrepte pentru a se întâlni cât mai rapid.
b) Daţi un exemplu pe care soluţia recursivă efectuează cu mult mai
mulţi paşi decât e necesar.
c) Modificaţi funcţia de afişare a drumului astfel încât să afişeze
toate drumurile minime existente.

257
Capitolul 9

9.2. Problema subsecvenţei de sumă maximă


Considerăm un număr natural N şi un vector A cu N elemente
numere întregi. O subsecvenţă [st, dr] a vectorului A reprezintă secvenţa de
elemente A[st], A[st + 1], ..., A[dr]. Suma unei subsecvenţe reprezintă
suma tuturor elementelor acelei subsecvenţe. Se cere determinarea unei
subsecvenţe de sumă maximă.
Datele de intrare se citesc din fişierul subsecv.in, iar suma maximă
se va afişa în fişierul subsecv.out.

Exemplu:

subsecv.in subsecv.out
10 11
-6 1 -3 4 5 -1 3 -8 -9 1

Vom prezenta trei metode de rezolvare, începând de la o metodă


trivială şi sfârşind cu metoda optimă de rezolvare, care constă într-o singură
parcurgere a vectorului.
În implementările oferite ca model vom prezenta doar o funcţie
subsecvi(A, N) care primeşte ca parametri vectorul A respectiv dimensiunea
acestuia şi returnează suma maximă a unei subsecvenţe. Considerăm citirea
şi afişarea ca fiind cunoscute.

Prima metodă constă în verificarea tuturor subsecvenţelor 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 decât maximul curent (iniţializat la început cu o valoare foarte
mică: –infinit), actualizăm maximul curent. Complexitatea acestei metode
este O(N3), deoarece există O(N2) subsecvenţe şi fiecare dintre acestea
trebuie parcursă pentru a-i afla suma.

Putem implementa această metodă în felul următor:

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ă eliminăm parcurgerea
prin care facem suma subsecvenţei [st, dr], sau, altfel spus, vom încerca să
calculăm suma fiecărei subsecvenţe pe măsură ce acestea sunt generate şi nu
pentru fiecare în parte printr-o parcurgere. Să presupunem că ştim care este
suma temp a unei subsecvenţe [st, dr]. Atunci suma subsecvenţei
[st, dr + 1] va fi temp + A[dr + 1]. Vom iniţializa aşadar 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


număr de elemente de până la ordinul miilor, spre deosebire de prima
implementare care este aplicabilă doar pentru un număr de elemente de
ordinul sutelor. Putem obţine însă o rezolvare şi mai eficientă, care
funcţionează rapid pe vectori cu sute de mii sau chiar milioane de elemente.

Cea de-a treia metodă foloseşte paradigma programării dinamice


pentru a obţine o rezolvare în O(N). Fie S[i] = suma maximă a unei
subsecvenţe care se termină cu elementul i. Să presupunem că, pentru un
anume 1 ≤ k < N, cunoaştem valoarea lui S[k]. Ne interesează să-l obţinem
pe S[k + 1] din S[k]. Observăm că avem două posibilităţi:
1. Adăugăm elementul k + 1 la sfârşitul subsecvenţei de sumă
maximă care se termină cu elementul k, obţinând o subsecvenţă
de sumă A[k + 1] + S[k].
2. Ignorăm subsecvenţa de sumă maximă care se termină cu
elementul k şi considerăm subsecvenţa formată doar din
elementul k + 1, aceasta având suma A[k + 1].

Evident vom alege maximul sumelor aferente celor două cazuri.


Aşadar, obţinem următoarea formulă de recurenţă:
S[k + 1] = max(A[k + 1] + S[k], A[k + 1]).
Singura iniţializare care trebuie făcută este S[1] = A[1]. Răspunsul
problemei este dat de valoarea maximă din S.
În implementarea prezentată am folosit un vector S pentru
implementarea recurenţei. Deoarece pentru calculul lui S[k + 1] avem
nevoie doar de S[k], în loc de vectorul S putem folosi doar nişte variabile.
Această ultimă optimizare este lăsată ca exerciţiu pentru cititor.
Să evidenţiem modul de execuţie al algoritmului pe exemplul dat.
Iniţial 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] luăm maximul dintre S[1] + A[2] şi A[2].


S[1] + A[2] = -5, iar A[2] = 1. Maximul este aşadar 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ă uşor că subsecvenţa de sumă maximă care se termină pe


poziţia 2 are suma 1 (cealaltă posibilitate fiind doar subsecvenţa [1, 2] care
are suma -5), deci se respectă definiţia lui S.
La sfârşit, vectorul S este următorul. Se poate verifica, folosind
eventual implementările 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;
}

Exerciţii:
a) Modificaţi implementările date pentru a afişa şi poziţiile de
început şi de sfârşit a unei subsecvenţe de sumă maximă.
b) Se cere o subsecvenţă de produs maxim, iar numerele sunt reale.
Rezolvaţi problema atât pentru numere strict pozitive cât ş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 subşirului crescător maximal


Considerăm un vector A cu N elemente numere întregi. Un subşir a
lui A este o secvenţă de elemente nu neapărat consecutive ale lui A, dar a
căror ordine relativă în A este păstrată. Un subşir crescător a lui A este un
subşir a lui A a cărui elemente sunt ordonate crescător. Un subşir crescător
maximal este un subşir crescător la care nu se mai pot adăuga elemente fără
a strica proprietatea de subşir crescător. Se cere determinarea celui mai lung
subşir crescător maximal al vectorului A.
Datele de intrare se citesc din fişierul subsir.in. În fişierul
subsir.out se va afişa pe prima linie lungimea lg a celui mai lung subşir
crescător maximal găsit, iar pe următoarea linie se vor afişa valorile (în
număr de lg) care constituie un astfel de subşir. Se poate afişa orice soluţie
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(N∙log N). Vom prezenta ambele
rezolvări.

Rezolvarea prin programare dinamică presupune găsirea unei


formule de recurenţă care fie va furniza direct răspunsul problemei, fie va fi
doar un pas intermediar în rezolvarea problemei. În acest caz, putem găsi o
formulă de recurenţă pentru Lg care va conduce direct la calcularea acestei
valori. Raţionamentul este unul similar cu cel de la problema anterioară. Fie
L[i] = lungimea celui mai lung subşir crescător maximal care se termină
pe poziţia i. Iniţial vom considera L[i] = 1 pentru fiecare 1 ≤ i ≤ N. Evident,
L[1] va rămâne întotdeauna 1, deoarece singurul subşir al unui vector cu un
singur element este însuşi acel vector.
Să presupunem acum că avem calculate valorile L[1], L[2], ..., L[k]
pentru un k < N. Ne propunem să calculăm L[k + 1]. Folosind definiţia lui
L, ne propunem aşadar să calculăm lungimea celui mai lung subşir crescător
maximal care se termină pe poziţia k + 1, ştiind lungimile celor mai lungi
subşiruri crescătoare maximal care se termină pe poziţiile 1, 2, ..., k. Ştiind
aceste valori, este evident că pentru a maximiza lungimea subşirului care se

262
Algoritmi de programare dinamică

termină pe poziţia k + 1 trebuie adăugat A[k + 1] unui subşir maximal care


se termină pe o poziţie j < k + 1, pentru care L[j] are valoarea maximă şi
pentru care A[j] < A[k + 1], deoarece subşirul trebuie să fie crescător.
Aşadar obţinem recurenţa:
L[1] = 1
L[i] = 1 + max{L[j] | A[j] < A[i]} sau 1 dacă mulţimea respectivă e
vidă, unde 1 ≤ j < i.
Timpul O(N2) rezultă din faptul că pentru fiecare i trebuie să
determinăm minimul subsecvenţei [1, i – 1], rezultând un număr pătratic de
operaţii. Valoarea lg este dată de valoarea maximă din vectorul L.

Pentru determinarea valorilor care fac parte din subşirul crescător


maximal vom folosi un vector P unde P[i] = poziţia 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 evidenţia în continuare modul de execuţie al algoritmului pe


exemplul dat. Iniţial avem:

i 1 2 3 4 5 6 7 8 9 10
A[i] 6 3 8 9 1 2 10 4 -1 11
L[i] 1 1 1 1 1 1 1 1 1 1
P[i] 0 0 0 0 0 0 0 0 0 0

La pasul i = 2 căutăm poziţia max a celui mai mare element din


subsecvenţa [1, 1] a vectorului L pentru care A[max] < A[2]. Nu se găseşte
nicio astfel de poziţie, aşa că totul rămâne neschimbat.
La pasul i = 3 căutăm acelaşi max din subsecvenţa [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 poziţii respectând condiţiile
impuse. Vom alege max = 1. Aşadar, L[3] devine L[max]+1 = L[1]+1 = 2,
iar P[3] devine max, adică 1. Am marcat cu roşu actualizările:

i 1 2 3 4 5 6 7 8 9 10
A[i] 6 3 8 9 1 2 10 4 -1 11
L[i] 1 1 2 1 1 1 1 1 1 1
P[i] 0 0 1 0 0 0 0 0 0 0

263
Capitolul 9

Se procedează în acest fel până la completarea vectorilor L şi P.


Forma lor finală este prezentată mai jos. Este uşor de verificat corectitudinea
calculării acestor vectori conform definiţiei lor.

i 1 2 3 4 5 6 7 8 9 10
A[i] 6 3 8 9 1 2 10 4 -1 11
L[i] 1 1 2 3 1 2 4 3 1 5
P[i] 0 0 1 3 0 5 4 6 0 7

Am marcat mai sus coloanele care identifică o soluţie optimă. Vom


explica în continuare cum putem folosi vectorul P pentru a obţine valorile
soluţiei optime. Fie sol poziţia celui mai mare element din vectorul L. În
acest caz, sol = 10. Este clar că ultima valoare din subşirul crescător
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ă poziţia
penultimei valori a subşirului crescător maximal găsit. Atunci A[ P[10] ]
reprezintă penultima valoare a soluţiei. Mergând în continuare cu acest
raţionament, A[ P[ P[10] ] ] va reprezenta antepenultima valoare şi aşa mai
departe pând când ajungem la o valoare k pentru care P[k] = 0. Când acest
lucru se întâmplă, am găsit prima valoare a subşirului soluţie.
Vom folosi aşadar o funcţie recursivă care va reconstitui soluţia
folosind vectorul P. Acest vector se numeşte vector de predecesori, iar
ideea folosită în construcţia sa poate fi aplicată la orice problemă de
programare dinamică la care se cere afişarea unor obiecte care constituie un
optim cerut. Prezentăm î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[]) int main()
{ {
for ( int i = 1; i <= N; ++i ) int N;
L[i] = 1, P[i] = 0; int A[maxn], L[maxn], P[maxn];

int sol = 1; citire(A, N);


for ( int i = 2; i <= N; ++i )
{ ofstream out("subsir.out");
for ( int j = 1; j < i; ++j )
if ( L[j]+1 > L[i] && A[j] < A[i] ) int sol = cmlscm(A, N, L, P);
{ out << L[sol] << '\n';
L[i] = L[j] + 1; reconst(sol, P, A, out);
P[i] = j;
} out.close();
return 0;
if ( L[i] > L[sol] ) }
sol = i;
}

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 indexaţi 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 execuţie
O(N∙log N) care nu presupune decât noţiuni algoritmice elementare.

Vom considera A ca fiind vectorul citit şi vom folosi încă doi vectori
L şi P, dar care nu vor avea aceeaşi semnificaţie ca până acum.
Mai întâi iniţializăm vectorul L cu valoarea infinit. Aplicăm apoi
următorul 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 decât A[i]. (1)
o Fie k poziţia peste care a fost suprascris A[i]. P[i] ia
valoarea k.
 Lungimea vectorului L (făcând abstracţie de poziţiile marcate cu
infinit) reprezintă lungimea celui mai lung subşir crescător
maximal.
 Fie lg lungimea vectorului L. Pentru a reconstitui soluţia, se
caută în vectorul P poziţia ultimei apariţii a valorii lg. Fie această
poziţie klg . Se caută apoi ultima apariţie a valorii lg – 1, dar care
apare înainte de poziţia klg . Aceasta va fi aşadar pe o poziţie
klg – 1 < klg. Se procedează similiar pentru valorile lg – 2, lg – 3,
..., 2, 1. Soluţia va fi dată de subşirul: A[k1], A[k2], ..., A[klg ].
Putem implementa reconstituirea tot recursiv.

La pasul (1), dacă A[i] este mai mare decât toate elementele diferite
de infinit din L, atunci A[i] se va suprascrie peste cea mai din stânga
valoare egală cu infinit. Putem implementa acest pas eficient în timp O(log
N) folosind o căutare binară.

Să prezentăm modul de execuţie al algoritmului pe exemplul dat.


Iniţial avem:

i 1 2 3 4 5 6 7 8 9 10
A[i] 6 3 8 9 1 2 10 4 -1 11
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 decât 6. Singura posibilitate este să
suprascriem elementul A[1] peste primul inf. În P[1] vom reţine 1:

i 1 2 3 4 5 6 7 8 9 10
A[i] 6 3 8 9 1 2 10 4 -1 11
L[i] 6 inf inf inf inf inf inf inf inf inf
P[i] 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 1 2 3 4 5 6 7 8 9 10
A[i] 6 3 8 9 1 2 10 4 -1 11
L[i] 3 inf inf inf inf inf inf inf inf inf
P[i] 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 3 4 5 6 7 8 9 10
A[i] 6 3 8 9 1 2 10 4 -1 11
L[i] -1 2 4 10 11 inf inf inf inf inf
P[i] 1 1 2 3 1 2 4 3 1 5

Lungimea lg este aşadar 5, deoarece există 5 elemente diferite de inf


în L. Soluţia este dată de A[2], A[3], A[4], A[7] şi A[10].

Prezentăm î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[]) int cmlscm(int A[], int N, int L[],
{ int P[])
while ( st < dr ) {
{ int lg = 0;
int m = (st + dr) / 2; for ( int i = 1; i <= N; ++i )
if ( L[m] < val ) {
st = m + 1; L[i] = inf;
else
dr = m; int k = cbin(1, lg + 1, A[i], L);
}
// creste lungimea celui mai lung
return st; // subsir?
} if ( L[k] == inf )
++lg;
void reconst(int N, int A[], int P[],
int val, ofstream &out) L[k] = A[i];
{ P[i] = k;
for ( int i = N; i; --i ) }
if ( P[i] == val )
{ return lg;
reconst(i - 1, A, P, val - 1, out); }
out << A[i] << ' ';
int main()
break; {
} 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;
}

Deşi acest algoritm este mai eficient, spre deosebire de metoda


clasică, nu poate fi adaptat la toate variaţiunile problemei.

Exerciţiu: scrieţi un program care determină cel mai scurt subşir


crescător maximal şi altul care determină numărul de subşiruri crescătoare
maximal.
268
Algoritmi de programare dinamică

9.4. Problema celui mai lung subşir comun


Se dau două şiruri de caractere A şi B, formate din litere mici ale
alfabetului englez. Se cere găsirea unui şir de caractere C de lungime
maximă care este subşir atât a lui A cât şi a lui B.
Şirurile A şi B se citesc din fişierul sircom.in, fiecare pe câte o linie.
În fişierul sircom.out se va afişa pe prima linie lungimea celui mai lung
subşir comun, iar pe a doua linie şirul găsit.

Exemplu:

sircom.in sircom.out
gatcbccgaatabbat 10
gcbcataabbaggaacba gcbcatabba

Rezolvare
Pentru a rezolva problema vom încerca să găsim o formulă de
recurenţă pentru calculul lungimii celui mai lung subşir comun. Fie L[i][j] =
lungimea celui mai lung subşir comun al secvenţelor 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 aceeaşi 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 adăuga caracterul
A[p + 1] celui mai lung subşir comun al secvenţelor A[1, p] şi
B[1, q], obţinând, pentru secvenţele A[1, p + 1] şi B[1, q + 1] un
subşir comun de lungime maximă care este mai lung cu un
caracter. Aşadar, L[p + 1][q + 1] = L[p][q] + 1.
2. Dacă A[p + 1] != B[q + 1], atunci nu putem extinde niciun
subşir de lungime maximă calculat anterior şi va trebui să salvăm
în L[p + 1][q + 1] lungimea celui mai lung subşir de lungime
maximă calculat până acuma. Această valoare este dată de
maximul dintre L[p][q + 1] şi L[p + 1][q].

Timpul de execuţie al acestei metode este O(N∙M), unde N este


lungimea primului şir, iar M este lungimea celui de-al doilea şir. Memoria
folosită de algoritm este tot O(N∙M), deoarece matricea L are N linii şi M
coloane. Putem reduce memoria folosită la O(N + M), dar sacrificăm astfel
posibilitatea reconstituirii soluţiei. Vom descrie însă şi această metodă.

269
Capitolul 9

Pentru a reconstitui soluţia vom proceda similar cu metoda de


reconstituire a unui drum a cărui lungime minimă a fost calculată cu
algoritmul lui Lee. Vom folosi o funcţie recursivă reconst(x, y) care va
afişa un subşir comun de lungime maximă. În primul rând, condiţia de ieşire
din recursivitate va fi dacă x < 1 sau y < 1. Dacă nu, verificăm dacă
A[x] == B[y], iar dacă da, apelăm recursiv reconst(x – 1, y – 1) şi afişăm
A[x]. Dacă A[x] != B[y] atunci apelăm 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 reţinerea şirurilor de caractere. Indicii şirurilor de caractere încep de
la 0, aşa că trebuie să fim atenţi la cum comparăm caracterele individuale,
deoarece indicii matricei L încep de la 1. Singura iniţializare care trebuie
făcută este completarea liniei şi coloanei 0 a matricei L cu valoarea 0,
pentru a evita cazurile particulare ale recurenţei descrise.

Prezentăm în continuare implementarea algoritmului de rezolvare.

#include <fstream> int cmlsc(string &A, string &B,


#include <string> int L[maxn][maxn])
{
using namespace std; for ( int i = 0; i <= A.length(); ++i )
const int maxn = 101; L[i][0] = 0;
for ( int i = 0; i <= B.length(); ++i )
void citire(string &A, string &B) L[0][i] = 0;
{
ifstream in("sircom.in"); for ( int i = 1; i <= A.length(); ++i )
in >> A >> B; for ( int j = 1; j <= B.length(); ++j )
in.close(); 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 decât 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]).
Aşadar, 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 celălalt vector, L2, va reprezenta chiar linia curentă. Noua
formă a formulei de recurenţă este:

𝐿1 𝑗 − 1 + 1 𝑑𝑎𝑐ă 𝐴 𝑖 = 𝐵[𝑗]
𝐿2 𝑗 =
max⁡
(𝐿1 𝑗 , 𝐿2 𝑗 − 1 ) 𝑎𝑙𝑡𝑓𝑒𝑙

După fiecare calculare completă a lui L2, înainte de începerea unei


noi iteraţii vectorul L2 va trebui copiat în L1, pentru ca valorile calculate la
pasul curent să poate fi folosite la pasul următor. La sfârşitul algoritmului,
cei doi vectori vor avea acelaşi conţinut, deci răspunsul problemei va fi dat
fie de L1[lungime(B)] fie de L2[lungime(B)].
Prezentăm doar modificările 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 soluţii, acest lucru fiind
imposibil deoarece algoritmul păstrează doar valorile finale ale recurenţei,
nu şi pe cele iniţiale.
272
Algoritmi de programare dinamică

Exerciţii:
a) Afişaţi întreaga matrice L pentru a înţelege mai bine formula de
recurenţă.
b) Afişaţi toate subşirurile 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) Scrieţi o implementare care foloseşte vectori clasici de caractere
în loc de tipul string.
e) Scrieţi un program care afişează acel subşir 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 înmulţirii 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 găsirea numărului minim de înmulţiri scalare
necesare pentru calcularea produsului celor N matrice.
Fişierul de intrare inmopt.in conţine pe prima linie numărul N al
matricelor, iar pe linia următoare N + 1 valori ce reprezintă vectorul de
dimensiuni. Numărul minim de înmulţiri scalare se va afişa în fişierul
inmopt.out.

Exemplu:

inmopt.in inmopt.out
3 64
4325

Explicaţie: notăm cele trei matrice cu A, B şi C. Numărul minim de


înmulţiri scalare necesare se obţine înmulţind matricele astfel: (A∙B)∙C.
Dacă am folosi parantezarea A∙(B∙C), am efectua 90 de înmulţiri.

Precizăm în primul rând că înmulţirea matricelor este asociativă,


deci putem să parantezăm înmulţirea matricelor în orice mod valid fără a
afecta rezultatul final.
În al doilea rând, numărul de înmulţiri scalare necesare pentru a
înmulţi două matrice de dimensiuni (x, y) şi (y, z) este egal cu x∙y∙z.

273
Capitolul 9

Vom încerca să exprimăm recursiv problema. Notăm matricele date


cu A1, A2, ..., AN. Să presupunem că ştim un k între 1 şi N astfel încât
parantezarea (A1∙A2∙...∙Ak)∙(Ak+1∙...∙AN) să fie o parte a soluţiei optime.
Atunci, pentru a obţine soluţia optimă, trebuie să ştim cum putem paranteza
optim înmulţirile A1 ∙A2∙...∙Ak şi Ak+1∙...∙AN.
Pentru a putea exprima matematic acest lucru, fie M[i][j] = numărul
minim de înmulţiri scalare necesare înmulţirii secvenţei de matrice
[i, j]. Dacă ştim calcula matricea M, atunci răspunsul problemei va fi
M[1][N].

Se disting următoarele cazuri:


1. Dacă avem o singură matrice, atunci nu trebuie efectuată nicio
înmulţire scalară. Acesta este cazul de bază al recurenţei, deci
M[i][i] = 0 pentru orice 1 ≤ i ≤ N.
2. Pentru aflarea numărului minim de înmulţiri scalare necesare
pentru înmulţirea unei secvenţe de matrice [i, j], 1 ≤ i < j ≤ N
este necesar să ştim poziţia i ≤ k < j în care vom „împărţi”
secvenţa [i, j] în două secvenţe parantezate separat [i, k] şi
[k + 1, j]. Dimensiunea matricei rezultate din înmulţirea
matricelor din secvenţa [i, k] va fi dată de (D[i – 1], D[k]), iar
dimensiunea celei rezultate din înmulţirea matricelor din
secvenţa [k + 1, j] va fi dată de (D[k], D[j]). Avem aşadar:
M[i][j] = min(M[i][k] + M[k + 1][j] + D[i – 1]∙D[k]∙D[j]).
i≤k<j

Timpul de execuţie al algoritmului este O(N3), deoarece pentru


fiecare dintre cele O(N2) secvenţe efectuăm o parcurgere în timp O(N)
pentru a găsi k care verifică minimul de mai sus. Memoria folosită este
O(N2), deoarece folosim o matrice pătratică 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) int main()


{ {
ifstream in("inmopt.in"); int N, D[maxn];

in >> N; citire(D, N);


for ( int i = 0; i <= N; ++i )
in >> D[i]; ofstream out("inmopt.out");
out << rezolvare(D, N);
in.close(); out.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];
}

Exerciţii:
a) De ce i trebuie să pornească de la N – 1 şi nu de la 1? Ce se
întâmplă dacă i merge de la 1 la N?
b) Concepeţi o modalitate de a reconstitui soluţia. Pentru exemplul
dat, o reconstituire a soluţiei ar putea fi (A1*A2)*A3.

275
Capitolul 9

9.6. Problema rucsacului 1


Considerăm N obiecte caracterizate prin două mărimi: greutate (în
kg) şi valoare. Considerăm un rucscac de capacitate C kg. Ne interesează
alegerea unei submulţimi de obiecte a căror greutate totală să fie cel mult C
şi a căror valoare să fie maximă.
Datele de intrare se citesc din fişierul rucsac1.in: pe prima linie
valorile N şi C, iar pe următoarele N linii câte două valori Gi Vi
reprezentând greutatea respectiv valoarea obiectului i. În fişierul de ieşire
rucsac1.out se va afişa pe prima linie valoarea maximă a obiectelor alese,
iar pe următoarea linie se vor afişa indicii obiectelor alese, în orice ordine.
Considerăm că există întotdeauna soluţie.

Exemplu:

rucsac1.in rucsac1.out
4 13 22
10 9 32
4 10
5 12
13 20

Putem fi tentaţi să abordăm problema printr-o rezolvare de tip


greedy. Următoarea strategie nu este corectă: se sortează obiectele
descrescător după valoare şi se aleg cele mai valoroase obiecte a căror
greutate nu depăşeşte C. În cazul exemplului de mai sus, s-ar putea alege
doar obiectul 4, obţinându-se profitul 20. Rezolvarea corectă şi eficientă a
problemei se face prin metoda programării dinamice.

Fie F[i] = valoarea maximă care se poate obţine dacă nu avem


voie să depăşim greutatea i. Dacă putem calcula vectorul F, răspunsul
problemei va fi F[C]. Pentru a găsi o formulă de recurenţă pentru F[i], să
vedem mai întâi care sunt cazurile de bază. Este clar că dacă nu alegem
niciun obiect, atunci profitul nostru va fi 0, deci vom iniţializa F cu 0.
Se presupunem că citim un obiect, adică două valori Gi Vi. Vom
încerca să actualizăm vectorul F folosind obiectul citit. Pentru acest lucru,
vom itera un j de la C la Gi şi vom aplica următoarea 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 decât F[i], înseamnă că putem obţine o soluţie
mai bună adăugând obiectul i obiectelor cu greutatea j – Gi, a căror valoare
este F[j – Gi].
Este important să iterăm variabila j de la C la Gi şi nu invers
deoarece, în caz contrar, am putea ajunge în situaţia de a folosi un obiect de
mai multe ori: să presupunem că pentru a calcula un F[k] se foloseşte
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. Iterându-l
pe j de la C la Gi ne asigurăm că fiecare obiect va fi folosit o singură dată în
calculul lui F.
Complexitatea algoritmului este O(N∙C), 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 decât ar sugera acest rezultat, deoarece nu se
parcurge aproape niciodată întreg vectorul F. Memoria suplimentară este
O(C).
Pentru a putea reconstitui soluţia, vom folosi un vector P unde
P[i] = ultimul element care a intrat în calculul valorii F[i]. Pentru a afla
soluţia, vom proceda similar cu celelalte probleme, atâta doar că nu mai
avem nevoie de o funcţie recursivă, deoarece de data aceasta nu ne
interesează ordinea de afişare şi că va trebui să pornim de la suma greutăţile
obiectelor alese de către algoritm şi nu de la C.

Prezentăm î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 main()


int F[], int P[]) {
{ int N, C;
for ( int i = 0; i <= C; ++i ) obiect A[maxn];
F[i] = P[i] = 0;
citire(A, N, C);
for ( int i = 1; i <= N; ++i )
for ( int j = C; j >= A[i].G; --j ) int F[maxc], P[maxc];
if ( F[j] < F[j - A[i].G]+A[i].V ) ofstream out("rucsac1.out");
{
F[j] = F[j - A[i].G] + A[i].V; rezolvare(A, N, C, F, P);
P[j] = i; out << F[C] << '\n';
} reconst(A, F, P, C, out);
}
out.close();
void reconst(obiect A[], int F[], int P[],
int C, ofstream &out) return 0;
{ }
int max = F[C];
while ( F[C] == max )
--C;
++C;

while ( P[C] )
{
out << P[C] << ' ';

C -= A[ P[C] ].G;
}
}

Exerciţii:
a) Afişaţi indicii obiectelor crescător.
b) Afişaţi vectorii F şi P după fiecare actualizare a lor.
c) Daţi exemplu de un set de date de intrare pentru care algoritmul
execută un număr maxim de operaţii.

278
Algoritmi de programare dinamică

9.7. Problema rucsacului 2


Aceeaşi cerinţa şi format al datelor de intrare / ieşire ca la problema
anterioară, atâta doar că de data aceasta dispunem de un număr 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 câte ori dorim, aşa
că j va merge de la Gi la C pentru fiecare obiect i.
Noua funcţie 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;
}
}

Menţionăm 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 număr întreg pozitiv
de ori.

279
Capitolul 9

Exerciţii:
a) Rezolvaţi o variantă a problemei în care fiecare obiect i poate fi
folosit de cel mult Nri ori.
b) Rezolvaţi o variantă a problemei în care obiectele pot avea
greutăţi negative.
c) Implementaţi un algoritm greedy pentru rezolvarea celor două
probleme. Cât de mare poate ajunge să fie diferenţa dintre soluţia
optimă şi soluţia dată de algoritmul greedy?
d) Implementaţi un algoritm genetic pentru rezolvarea celor două
probleme. Cât de aproape de soluţia optimă este acesta?
Comparaţi rezultatele algoritmului genetic cu rezultatele
algoritmului greedy.

9.8. Problema plăţii 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 subşir al celor N
numere astfel încât suma elementelor din subşir să fie egală cu S. Fiecare
număr din şir poate fi folosit o singură dată.
Datele de intrare se citesc din fişierul plata1.in, a cărui format se
poate deduce din exemplul de mai jos. În fişierul de ieşire plata1.out se vor
afişa indicii numerelor alese, în orice ordine. Se garantează existenţa unei
soluţii.

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 reprezentând obiecte a căror valoare este 0, fiind deci
caracterizate de o singură mărime: greutatea. Se cere de data aceasta
umplerea completă a „rucsacului”, a cărui capacitate este S. Pentru acest
lucru vom folosi un vector boolean F unde F[i] = true dacă putem obţine
suma i şi false în caz contrar. Iniţial, F[0] = true, deoarece suma 0 o
putem obţine întotdeauna, neselectând niciun număr.
Relaţia de recurenţă este similară cu cea de la problema rucsacului 1.
La fiecare citirea unui număr 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 biţi. Folosind

280
Algoritmi de programare dinamică

acest operator, ne asigurăm că F[j] nu va lua niciodată valoarea false dacă


pâna acuma a fost true. Pentru a reconstitui soluţia, vom folosi un vector P
cu semnificaţia obişnuită.
Complexitatea algoritmului este O(N∙S).

Problema admite o rezolvare randomizată care se dovedeşte a fi


foarte eficientă pe majoritatea datelor de intrare şi anume:
 Fie sel un vector care reţine, la fiecare pas, indicii numerelor care
se adună pentru a încerca să obţinem suma S. Fie nesel un
vector care conţine indicii numerelor care nu se adună. Fie
stmp = 0. Fie A vectorul care reţine numerele date.
 Pentru fiecare număr, se decide aleator în care vector va fi plasat
şi se actualizează, dacă este necesar, stmp.
 Cât 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.
 Afişează vectorul sel.

Complexitatea algoritmului este greu de calculat, deoarece numărul


de operaţii depinde în totalitate de nişte 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ă.

Prezentăm doar funcţiile relevante fiecărei metode. În ambele


implementări, A este vectorul dat.

281
Capitolul 9

void dinamica(int A[], int N, int S) void random(int A[], int N, int S)
{ {
bool F[maxn]; int sel[maxn], nesel[maxn];
int P[maxn]; // sel[0], nesel[0] sunt numarul de
for ( int i = 0; i <= S; ++i ) // elemente
F[i] = P[i] = 0; // 0 == false sel[0] = sel[0] = 0;
int stmp = 0;
F[0] = true; srand((unsigned)time(0));
for ( int i = 1; i <= N; ++i ) for ( int i = 1; i <= N; ++i )
for ( int j = S; j >= A[i]; --j ) if ( rand() % 2 )
{ sel[ ++sel[0] ] = i, stmp += A[i];
F[j] |= F[j - A[i]]; else
nesel[ ++nesel[0] ] = i;
if ( F[j - A[i]] && !P[j] )
P[j] = i; while ( stmp != S )
} if ( stmp > S )
{
ofstream out("plata1.out"); int poz = 1 + (rand() % sel[0]);
while ( S ) stmp -= A[ sel[poz] ];
{ nesel[ ++nesel[0] ] = sel[poz];
out << P[S] << ' '; sel[poz] = sel[ sel[0]-- ];
S -= A[ P[S] ]; }
} else
out.close(); {
} 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();
}

Exerciţii:
a) Concepeţi un algoritm care afişează soluţia cu număr minim de
numere.
b) Cum se pot afişa toate soluţiile?
c) Ce se întâmplă dacă pot exista numere mai mari ca S? Dar dacă
există şi numere negative?
d) Încercaţi să găsiţi date de intrare pe care algoritmul randomizat
să ruleze mult timp.

282
Algoritmi de programare dinamică

9.9. Problema plăţii unei sume 2


Aceeaşi cerinţă ca la problema anterioară, doar că de data aceasta
putem alege un număr de câte ori dorim pentru a forma suma S.

Exemplu:

plata2.in plata2.out
7 23 11233
8 3 2 5 7 3 10

Explicaţie: 8 + 8 + 3 + 2 + 2 = 23. O altă soluţie este 3 + 3 + 3 + 3 +


3 + 8.

Trebuie făcută exact aceeaşi modificare pe care am făcut-o pentru a


rezolva a doua problemă a rucsacului: se schimbă ordinea de parcurgere a
sumelor. Astfel, un număr 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 modificăm


pentru această variantă a problemei, deoarece ar trebui să alegem aleator şi
de câte ori este folosit un anumit număr.
Variantele randomizate de rezolvare a problemei presupun folosirea
unui algoritm genetic, pentru a evolua de exemplu un vector F unde
F[i] = de câte ori trebuie să folosim numărul A[i] pentru a ne apropia
de suma S. Funcţia de fitness va fi dată de diferenţa în modul dintre suma
codificată de către un cromozom şi suma cerută S. Lăsăm implementarea
unui astfel de algoritm ca exerciţiu pentru cititor.

Exerciţii:
a) Scrieţi o implementare recursivă pentru ultimele patru probleme
prezentate. Folosiţi tehnica memoizării.

283
Capitolul 9

b) Se consideră N numere. Scrieţi un program care adună sau scade


fiecare număr astfel încât să se obţină o sumă S. Consideraţi N şi
S de ordinul sutelor de mii.
c) În soluţiile de la ultimele două probleme, vectorul F este un
vector boolean. Asta înseamnă că putem optimiza memoria
folosită folosind operaţii pe biţi. Scrieţi un program care face
acest lucru.
d) Scrieţi un program care determină în câte moduri se poate obţine
suma S.
e) Încercaţi adaptarea algoritmului randomizat pentru această
variantă a problemei. Cum se compară acesta cu un algoritm
genetic?

9.10. Numărarea partiţiilor unui număr


Se dă un număr natural N. Să se determine numărul partiţiilor lui N.
O partiţie a unui număr natural este o modalitate de a scrie numărul N ca
sumă de numere naturale nenule.
Fişierele de intrare şi de ieşire sunt nrpart.in respectiv nrpart.out.

Exemplu:

nrpart.in nrpart.out
7 15
100 190569292

Explicaţie:
7=7
1+6=7
1+1+5=7
...

Am rezolvat o problemă asemănătoare în capitolul despre


backtracking. Acolo se cereau însă şi partiţiile efective, neavând altă soluţie
decât să le generăm pe toate. Deoarece aici se cere numai numărul partiţiilor
unui întreg, vom folosi programarea dinamică pentru a afla acest număr.
Fie nrpart(N, K) o funcţie care returnează numărul partiţiilor lui N
în care nu apar termeni mai mici decât K. Putem distinge următoarele
situaţii:

284
Algoritmi de programare dinamică

1. Numărăm doar partiţiile pantru care cel mai mic număr folosit
este K, acestea fiind în număr de nrpart(N – K, K), deoarece,
dacă adăugăm numărul K fiecărei partiţii a numărului N – K
(care nu va conţine termeni mai mici decât K) atunci obţinem
partiţii a numărului N.
2. Numărăm doar partiţiile lui N care conţin termeni strict mai mari
decât K. Acestea vor fi în număr de nrpart(N, K + 1), deoarece
o partiţie cu termeni de valoare cel puţin K care nu conţine
termeni de valoare K trebuie să aibă toţi termenii cel puţin
K + 1.

Se poate observa că cele două situaţii în care am împărţit problema


sunt disjuncte, deci nu vor număra niciodată aceeaşi partiţie. Mai mult,
reuniunea (suma) lor ne va da numărul de partiţii ale numărului N.
Aşadar, formula de recurenţă este următoarea:
nrpart(N, K) = 0 pentru K > N, deoarece nu putem avea partiţii cu
termeni mai mari decât N.
nrpart(N, K) = 1 pentru K == N deoarece avem o singură partiţie cu
termeni egali cu N, dată chiar de numărul N.
nrpart(N, K) = nrpart(N – K, K) + nrpart(N, K + 1) din motivele
prezentate mai sus. Răspunsul problemei va fi dat de nrpart(N, 1).

Aţi observat probabil că de data aceasta am exprimat recurenţa


printr-o funcţie şi nu printr-un vector sau o matrice la fel cum am făcut până
acum. Vom implementa de data aceasta recurenţa în mod direct, printr-o
funcţie recursivă, dar vom folosi tehnica memoizării pentru a obţine o
soluţie eficientă. Reamintim că memoizarea constă în folosirea unei tabele
de valori (în acest caz o matrice) în care se reţin rezultatele calculate de
către funcţie. La intrarea într-un apel recursiv se verifică mai întâi dacă
rezultatul pentru parametrii actuali ai funcţiei 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 abordări este că unele recurenţe pot fi
implementate scriind mai puţin cod.

Prezentăm în continuare implementarea acestei rezolvări.

285
Capitolul 9

#include <fstream> int main()


using namespace std; {
const int maxn = 101; int N, memo[maxn][maxn];

int nrpart(int N, int K, for ( int i = 1; i < maxn; ++i )


int memo[maxn][maxn]) for ( int j = 1; j < maxn; ++j )
{ memo[i][j] = -1;
if ( K > N ) return 0;
if ( N == K ) return 1; ifstream in("nrpart.in");
in >> N;
if ( memo[N][K] != -1 ) in.close();
return memo[N][K];
ofstream out("nrpart.out");
memo[N][K] = nrpart(N - K, K, memo) + out << nrpart(N, 1, memo);
nrpart(N, K + 1, memo); out.close();

return memo[N][K]; return 0;


} }

Pentru cei interesaţi, numărul de partiţii ale unei mulţimi de N


elemente este dat de numărul lui Bell, care poate fi calculat printr-o
formulă de recurenţă care foloseşte combinări. Lăsăm această formulă ca
temă de cercetare pentru cititor.

Exerciţii:
a) Scrieţi un program care afişează numărul de partiţii ale lui N
formate doar din numere prime.
b) Care este complexitatea algoritmului de numărare a partiţiilor?
c) Scrieţi un program care foloseşte o implementare iterativă a
formulei de recurenţă.

9.11. Distanţa Levenshtein


Se dau două şiruri de caractere A şi B, formate din litere mici ale
alfabetului englez. Asupra şirului A putem face următoarele trei operaţii:
1. Inserăm un caracter.
2. Ştergem un caracter.
3. Înlocuim un caracter cu orice alt caracter din alfabetul folosit.
Se cere determinarea numărului minim de operaţii necesare
transformării şirului de caractere A în şirul de caractere B.
Cele două şiruri de caractere se citesc din fişierul lev.in, iar numărul
minim de operaţii se va afişa în fişierul lev.out.

286
Algoritmi de programare dinamică

Exemplu:

lev.in lev.out
afara 3
afacere

Explicaţie: se inserează caracterele c şi e după afa şi se înlocuieşte


ultimul a cu e.

Problema cere determinarea distanţei Levenshtein dintre cele două


şiruri de caractere. Aceasta este o distanţa de editare, adică un metric
folosit pentru măsurarea gradului de asemănare a două şiruri.
Algoritmul de rezolvare este similar cu algoritmul de găsire a celui
mai lung subşir comun. De fapt, cel mai lung subşir comun poate fi privit ca
o distanţă de editare în care operaţiile permise sunt doar inserarea unui
caracter şi ştergerea unui caracter.
Pentru a rezolva această problemă, vom construi o matrice D unde
D[i][j] = numărul minim de operaţii necesare transformării secvenţei
A[1, i] în secvenţa B[1, j]. Răspunsul problemei va fi evident
D[lungime(A)][lungime(B)].
Vom trata mai întâi cazurile de bază, presupunând că indicii şirurilor
încep de la 1:
1. Pentru a transforma o secvenţă A[1, i] în secvenţa nulă B[0, 0]
trebuie evident să ştergem toate caracterele din secvenţa A[1, i],
deci D[i][0] = i pentru i de la 0 la lungime(A).
2. Pentru a transforma secvenţa A[0, 0] într-o secvenţă B[1, i]
trebuie să adăugăm i caractere secvenţei 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 nişte poziţii p şi q valid alese. Putem atunci calcula
D[p + 1][q + 1] considerând următoarele cazuri:
1. A[p + 1] == B[q + 1], caz în care putem face abstracţie de
caracterele A[p + 1] şi B[q + 1], fiind suficient să transformăm
A[1, p] în B[1, q], lucru pe care îl putem face cu D[p][q]
operaţii.
2. Altfel, fie transformăm A[1, p] în B[1, q + 1] după care ştergem
caracterul A[p + 1], fie transformăm A[1, p + 1] în B[1, q] după
care inserăm caracterul B[q + 1], fie transformăm A[1, p] în
B[1, q] după care înlocuim A[p + 1] cu B[q + 1].

287
Capitolul 9

Aşadar:
D[p + 1][q + 1] = 1 + minim(D[p][q],D[p + 1][q],D[p][q + 1]).

Implementarea prezentată foloseşte tipul de date string, în care


caracterele sunt numerotate de la 0. Rezolvarea însă nu suferă nicio
modificare majoră, fiind necesar doar să scădem 1 când accesăm un
caracter. Prezentăm doar funcţia relevantă, restul programului fiind aproape
identic cu cel prezentat în cadrul problemei celui mai lung subşir 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 păstra doar două


linii ale matricei D, deoarece pentru a calcula un rând al matricei nu folosim
decât valori de pe aceeaşi linie sau de pe linia anterioară. Mai mult, la
această problemă este puţin probabil să avem nevoie de reconstituirea
soluţiei.
Am precizat la început că distanţa Levenshtein este o distanţă de
editare dintre două şiruri. Pentru cei interesaţi prezentăm succint şi alte
distanţe de editare:
 Distanţa Hamming, care se aplică asupra a două şiruri A şi B de
lungime egală şi este egală cu numărul de poziţii i pentru care
A[i] != B[i].
 Distanţa Damerau – Levenshtein, care adăugă operaţia de
interschimbare setului de operaţii permise de distanţa
Levenshtein.
 Distanţa 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ă

Exerciţii:
a) Complexitatea algoritmului de calcul a distanţei Levenshtein este
O(N∙M), 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 număr relativ mic de operaţii k. Cum ne
poate ajuta această informaţie?
b) Considerăm existenţa unor costuri pentru fiecare operaţie precum
şi pentru caracterele asupra cărora se efectuează operaţii. Scrieţi
un program care rezolvă această variantă a problemei.
c) Scrieţi un program care afişează noul şir A pentru fiecare
operaţie efectuată.
d) Scrieţi un program care determină numărul minim de caractere
care trebuie inserate într-un şir pentru a-l transforma într-un
palindrom.

9.12. Determinarea strategiei optime într-un joc


Considerăm un şir de 2∙N numere întregi şi două persoane A şi B
care joacă alternativ următorul joc: fiecare persoană, începând 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
obţine persoana A dacă ambele persoane joacă optim.
Prima linie a fişierului de intrare joc.in conţine pe prima linie
numărul N, iar pe următoarea linie 2∙N numere întregi reprezentând şirul pe
care se joacă. În fişierul joc.out se va afişa suma maximă pe care o poate
obţine persoana A, în condiţiile în care ambii jucători joacă optim.

Exemplu:

joc.in joc.out
3 115
4 5 6 3 2 1
9 100 6 8 4 7

Explicaţie: am marcat cu roşu numerele alese de persoana A şi cu


albastru numerele alese de persoana B. Exponenţii reprezintă ordinea în
care s-au ales numerele.

În primul rând precizăm că problema nu poate fi rezolvată printr-un


algoritm de tip greedy care alege la fiecare pas cel mai mare număr.
Exemplul de mai sus pune în evidenţă acest lucru.

289
Capitolul 9

Pentru a rezolva problema vom considera numerele date ca fiind


reţinute în vectorul V şi vom folosi o matrice S cu semnificaţia
S[i][j] = suma maximă care poate fi obţinută de primul jucător dacă
luăm în considerare doar secvenţa de numere V[i, j]. Vom iniţializa
pentru fiecare i de la 1 la 2∙N pe S[i][i] cu V[i] şi pe S[i][i + 1] cu
max(V[i], V[i + 1]). Apoi distingem următoarele cazuri pentru a calcula
S[i][j], cu j > i + 1:
1. Alegem numărul V[i]. La pasul următor, oponentul va putea
alege între V[i + 1] şi V[j], aducându-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 obţine o sumă cât mai mică.
Aşadar, dacă alegem numărul V[i], atunci obţinem câştigul
V[i] + min(S[i + 2][j], S[i + 1][j – 1]) = C1.
2. Alegem numărul V[j]. Dintr-un raţionament identic cu cel de mai
sus rezultă că obţinem câştigul
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 recurenţei pentru problema înmulţirii optime a
unui şir de matrice. Vom începe cu i de la 2∙N – 2 şi cu j de la i + 2,
asigurându-ne în acest mod că nu vom folosi valori ale matricei necalculate
încă.
Iniţializarea valorilor S[i][i + 1] este necesară deoarece la fiecare pas
fie vom scădea 2 din j fie vom aduna 2 la i, această iniţializare având rolul
de a evita unele cazuri particulare care pot apărea 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;
}

Exerciţii:
a) Modificaţi algoritmul astfel încât să afişeze fiecare număr ales
împreună cu jucătorul care a ales acel număr.
b) Rezolvaţi problema considerând că se pot alege k numere
consecutive dintr-un capăt al şirului.
c) Reduceţi memoria folosită de algoritm la O(N).
d) Modificaţi implementarea prezentată astfel încât să afişeze care
jucător câştigă jocul.
e) Rezolvaţi problema considerând 3 jucători şi 3∙N numere.

291
Capitolul 9

9.13. Problema R.M.Q. (Range Minimum Query)


Se dă un şir A de N numere întregi. Ne interesează răspunsul la T
întrebări de genul: „dându-se x şi y care este cel mai mic număr din secvenţa
A[x, y]?”.
Prima linie a fişierului de intrare rmq.in va conţine N şi T,
următoarea linie va conţine elementele şirului A, iar următoarele T lini vor
conţine numerele x y cu semnificaţia din enunţ. În fişierul rmq.out se vor
afişa T linii, fiecare conţinând răspunsul la intrebarea corespunzătoare.

Exemplu:

rmq.inrmq.out
93 1
819344526 2
13 9
58
33

O soluţie în care parcurgem fiecare secvenţă dată şi determinăm


minimul se dovedeşte a fi foarte ineficientă, având complexitatea O(N∙T) în
cel mai rău caz.
Problema prezentată este cunoscută sub numele de problema Range
Minimum Query (traducere: problema interogărilor de minim pe
intervale). Această problemă poate fi rezolvată în timp O(N∙log N + T) şi
folosind memorie O(N∙log N) calculând o matrice pe care o vom folosi apoi
pentru a răspunde la fiecare întrebare în timp O(1).

Fie M[i][j] = cel mai mic număr din subsecvenţa A[j, j + 2i – 1].
Altfel spus, M[i][j] reprezintă cel mai mic număr din subsecvenţa care
începe pe poziţia j şi are lungimea 2i. Vom prezenta mai întâi modul de
construcţie al acestei matrici iar apoi algoritmul prin care vom răspunde la
întrebări.
Pentru i = 0 obţinem subsecvenţe de forma A[j, j], aşadar 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 încât 0 < i ≤ log2[N] vom calcula vectorul M[i] astfel:
pentru fiecare j ≥ 1 astfel încât să aibă loc j + 2i – 1 ≤ N vom efectua operaţia
M[i][j] = min(M[i – 1][j], M[i – 1][j + 2i – 1]).
Să demonstrăm că acest mod de calculare este corect:

292
Algoritmi de programare dinamică

1. În primul rând trebuie să demonstrăm 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 condiţia ca j + 2i – 1 ≤ N.
2. Mai trebuie demonstrat că luând minimul dintre M[i – 1][j] şi
M[i – 1][j + 2i – 1] pentru a calcula M[i][j] acoperim exact
subsecvenţa A[j, j + 2i – 1]. Din definiţia matricei M ştim că
M[i – 1][j] este minimul subsecvenţei notate A[j, j + 2i – 1 – 1]
respectiv că M[i – 1][j + 2i – 1] este minimul subsecvenţei
A[j + 2i – 1, j + 2i – 1 + 2i – 1 – 1] adică minimum subsecvenţei
A[j + 2i – 1, j + 2i – 1]. Rezultă de aici că alegând minimul
acestor două valori, calculăm M[i][j] corect.

Pentru a determina răspunsul pentru o subsecvenţă A[x, y], fie


L = log2[y – x + 1], adică L este partea întreagă a logaritmului în baza doi
din lungimea subsecvenţei. Răspunsul pentru secvenţa dată este aşadar
min(M[L][x], M[L][y – 2L + 1]).
Vom demonstra în continuare doar că alegând minimul dintre aceste
două valori luăm în considerare exact numerele din subsecvenţa A[x, y].
Vom presupune prin absurd că prin concatenarea subsecvenţelor
A[x, x + 2L – 1] şi A[y – 2L + 1, y] fie luăm în considerare numere care nu
fac parte din A[x, y] fie nu luăm în considerare toate numerele din A[x, y]:
1. Ca să luăm î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, situaţii imposibile.
2. Pentru a nu considera toate numerele din A[x, y] trebuie să aibă
loc inegalitatea următoare:
x + 2L – 1 < y – 2L => 2L + 1 < y – x + 1 => L + 1 < L, imposibil.

Aşadar, se iau în considerare toate numerele din A[x, y] şi numai


aceste numere.

Figura următoare 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 căror
lungime este jumătate din lungimea dreptunghiului curent. Culorile sunt
folosite pentru a putea identifica mai bine relaţiile dintre valori.

293
Capitolul 9

i\j 1 2 3 4 5 6 7 8 9
0 8 1 9 3 4 4 5 2 6
1
1
3
3
1
4
4
2
2
1
1
3
2
3
2
2
1
3
1
Fig. 9.14.1. – Modul de execuţie al algoritmului R.M.Q.

Se poate observa din acest tabel că oricum am alege o secvenţă


[x, y], vom putea găsi întotdeauna două dreptunghiuri care să acopere
complet secvenţa respectivă. Aşadar, tabelul poate servi ca o ilustraţie
intuitivă a modului de funcţionare al algoritmului şi a corectitudinii acestuia.
Matricea M va fi o matrice cu log2[N] linii şi N coloane, rezultând
astfel complexităţile menţionate la început. Ideea folosită în cadrul acestui
algoritm, de a folosi puteri ale lui 2 în cadrul recurenţei, se dovedeşte a fi
folositoare în multe probleme de programare dinamică.

Implementarea prezentată foloseşte operaţii pe biţi pentru a calcula


eficient puterile lui 2. Recomandăm cititorului să se familiarizeze cu aceste
operaţii pentru o mai bună înţelegere 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, putând să citim numerele date direct în matricea
M. Acest lucru reduce şi memoria folosită şi timpul necesar iniţializării.

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

Exerciţii:
a) Modificaţi algoritmul astfel încât să afişeze poziţia numărului
minim în şirul dat.
b) Modificaţi algoritmul astfel încât să afişeze cel mai mare element
din şir, precum şi poziţia acestuia.
c) Extindeţi algoritmul pentru găsirea celui mai mic sau celui mai
mare element dintr-un dreptunghi al unei matrice.
d) Ce se întâmplă dacă interschimbăm cele două dimensiuni ale
matricei? Comparaţii timpii de execuţie a celor două variante de
implementare şi încercaţi să explicaţi eventualele diferenţe.

9.14. Numărarea parantezărilor booleane


Fişierul paran.in conţine pe prima linie un număr natural N. Pe a
doua linie se află N valori booleane (0 sau 1), iar a treia linie conţine N – 1
valori din mulţimea {-1, -2, -3}, reprezentând operatorii and, or, respectiv
xor. Aceştia au priorităţi egale şi se consideră dispuşi între operanzii daţi.
Se cere numărul de parantezări valide existente astfel încât expresia
rezultată să aibă rezultatul true.

Exemplu:

paran.in paran.out
3 1
100
-2 -1

Explicaţie: expresia dată este T or F and F. Există două parantezări


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 până acum,


aceasta este o problemă de numărare, nu de determinare a unui optim.
Rezolvarea acestei probleme este similară cu problema numărării partiţiilor
unui număr: va trebui să găsim subprobleme a căror rezultat să îl putem
aduna pentru a obţine rezultatul unei probleme mai mari.
Este evident că abordările backtracking ies din discuţie, întrucât am
fi astfel limitaţi la expresii de lungime nu mai mare de ~10.

296
Algoritmi de programare dinamică

Să punem la punct modul în care vom reţine datele. Vom considera


valorile 1 / 0 date ca fiind nişte simboluri şi le vom reţine într-un vector
boolean S. Operatorii daţi îi vom reţine într-un vector de numere întregi op,
cu semnificaţie 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] = câte posibilităţi există de a paranteza expresia


formată din simbolurile [i, j] astfel încât rezultatul acesteia să fie true.
Mai mult, vom calcula în paralel şi F[i][j] = câte posibilităţi există de a
paranteza expresia formată din simbolurile [i, j] astfel încât rezultatul
acesteia să fie false. Aceste două matrici se vor calcula similar cu modul de
calcul al matricii de la problema înmulţirii optime a matricelor. T şi F vor
depinde una de cealaltă, dar dependeţele nu vor fi circulare, deci le vom
putea calcula pe amândouă î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 stăpânit: inexistenţa unui şablon de rezolvare a
problemelor. De multe ori avem nevoie de tehnici pe care nu le-am mai
întâlnit, sau de combinarea mai multor idei de rezolvare a altor probleme.

În primul rând ne punem problema cazurilor de bază: T[i][i] şi


F[i][i] ar trebui să fie evident cum se calculează, aşa că nu vom insista
asupra acestui aspect.
Să presupunem acum că vrem să aflăm numărul de parantezări
valide şi adevărate (a căror rezultat este true) ale unei subexpresii formate
din simbolurile [i, j]. Mai mult, presupunem că ştim care este numărul de
parantezări valide şi adevărate (şi false) ale subexpresiilor [i, k] şi [k + 1, j],
pentru orice 1 ≤ i ≤ k < j ≤ N. Dacă ştim să calculăm T[i][j] şi F[i][j] pe
baza acestor informaţii, atunci putem aplica acelaşi procedeu pentru toate
elementele de deasupra diagonalei principale, iar în final răspunsul
problemei va fi dat de T[1][N].
Avem trei cazuri pentru un k fixat:
1. Dacă op[k] = -1 (and), atunci adunăm la T[i][j] valoarea
T[i][k] * T[k + 1][j], deoarece avem nevoie ca ambele
subexpresii să fie adevărate, deci putem combina orice
parantezare adevărată a acestora.
2. Dacă op[k] = -2 (or), atunci este de ajuns ca doar una dintre
subexpresii să fie adevărată. Vom aduna aşadar 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
parantezărilor cele false, rămânând cele adevărate.
297
Capitolul 9

3. Dacă op[k] = -3 (xor), atunci cele două subexpresii trebuie să


aibă valori diferite (una adevărată, iar cealaltă falsă). Adunăm
aşadar 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 aşadar recurenţele:


𝑗 −1 𝑇 𝑖 [𝑘] ∙ 𝑇[𝑘 + 1][𝑗] 𝑜𝑝 𝑘 = −1
𝑇𝑖 𝑗 = 𝐴 𝑖 𝑘 ∙ 𝐴 𝑘 + 1 𝑗 − 𝐹 𝑖 [𝑘] ∙ 𝐹[𝑘 + 1][𝑗] 𝑜𝑝 𝑘 = −2
𝑘=𝑖 𝑇 𝑖 𝑘 ∙ 𝐹 𝑘 + 1 𝑗 + 𝐹 𝑖 [𝑘] ∙ 𝑇[𝑘 + 1][𝑗] 𝑜𝑝 𝑘 = −3

şi
𝑗 −1 𝐴 𝑖 𝑘 ∙ 𝐴 𝑘 + 1 𝑗 − 𝑇 𝑖 [𝑘] ∙ 𝑇[𝑘 + 1][𝑗] 𝑜𝑝 𝑘 = −1
𝐹𝑖 𝑗 = 𝐹 𝑖 𝑘 ∙ 𝐹 𝑘 + 1 [𝑗] 𝑜𝑝 𝑘 = −2
𝑘=𝑖 𝐹 𝑖 𝑘 ∙ 𝐹 𝑘 + 1 𝑗 + 𝑇 𝑖 [𝑘] ∙ 𝑇[𝑘 + 1][𝑗] 𝑜𝑝 𝑘 = −3

Menţionăm că suma T[1][N] + F[1][N] este al N-lea număr


Catalan. Acestea reprezintă, printre altele, numărul de parantezări valide
formate din N paranteze.

Avem aşadar un algoritm cu timpul de execuţie O(N3), a cărui


implementare nu ar trebui să fie o noutate.

#include <fstream> int main()


#include <iostream> {
// op[i] = operatorul dintre
using namespace std; // simbolul i si i + 1
const int maxn = 101; int N, op[maxn];
int rezolvare(int, bool[], int[]); bool S[maxn];

void citire(int &N, bool S[], int op[]) citire(N, S, op);


{
ifstream in("paran.in"); ofstream out("paran.out");
in >> N; 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 căror soluţii folosesc
programarea dinamică. Metoda programării dinamice este o metodă foarte
utilă pentru rezolvarea problemelor de informatică, dar este şi metoda cea
mai grea de stăpânit, întrucât problemele care se rezolvă printr-un algoritm
de programare dinamică pot fi foarte variate, deci este nevoie de experienţă
pentru a putea găsi anumite recurenţe..
Propunem aşadar spre rezolvare următoarele probleme:

1. Dându-se N şi K scrieţi un program care determină câte numere


de N cifre cu suma cifrelor K există. Analog pentru produs.
2. Scrieţi un program care răspunde eficient la mai multe interogări
privind suma unor dreptunghiuri ale unei matrice.
3. Dându-se un şir de numere naturale A scrieţi un program care
determină numărul minim de numere din şir a căror sumă dă
restul R la împărţirea la K.
4. Scrieţi un program care determină câte drumuri există într-un
caroiaj cu obstacole de la poziţia (1, 1) la poziţia (N, N), drumuri
care pot conţine cel mult K paşi şi care pot parcurge de mai
multe ori orice poziţie (drumuri neelementare).
5. Scrieţi un program care determină câte drumuri elementare există
într-un caroiaj fără obstacole de la poziţia (1, 1) la poziţia (N, N),
ştiind că dintr-o poziţie oarecare ne putem deplasa doar în jos sau
la dreapta.
6. Scrieţi un program care determină numărul şirurilor de lungime
N formate cu caracterele a, b, c şi d cu proprietatea că a nu poate
fi lângă b şi c nu poate fi lângă d. Problema admite o rezolvare
clasică şi una eficientă.
7. Scrieţi un program care determină cel mai lung drum într-un graf
orientat aciclic.
8. Scrieţi un program care află cel mai mic număr cu K divizori.
9. Scrieţi un program care citeşte o matrice binară şi determină
dreptunghiul de arie maximă care conţine numai valoarea 1.
10. Scrieţi un program care determină subsecvenţa de sumă maximă
a unui şir circular (după ultimul element urmează primul
element)
11. Scrieţi un program care determină subsecvenţa de sumă maximă
de lungime cel puţin L.

300
Algoritmi de geometrie computaţională

10. Algoritmi de
geometrie
computaţională
Toate problemele de informatică au la bază probleme matematice,
informatica fiind de fapt o ramură a matematicii aplicate. Până acum am
prezentat probleme şi algoritmi care au o legături 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 numeşte
geometrie computaţională. Această ramură a informaticii are aplicaţii
practice importante în programe de grafică (aplicaţii CAD, aplicaţii 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 către algoritm). Vom folosi pur şi simplu variabile de tip
double, iar operaţiile cu acestea le vom efectua în mod obişnuit.
Considerăm că acest lucru este suficient pentru nişte implementări didactice,
accentul fiind pus pe validitatea teoretică a algoritmilor şi pe uşurinţa de
înţelegere a acestora.

Scopul acestui capitol nu este de a prezenta pe larg algoritmii de


geometrie computaţională, deoarece acest lucru ar necesita un spaţiu mult
prea larg şi nu face obiectul acestei lucrări. Capitolul acesta are ca scop doar
introducerea unor noţiuni şi algoritmi de bază, cu ajutorul cărora cititorul să
poată căuta şi înţelege lucruru mai avansate.

301
Capitolul 10

CUPRINS

10.1. Convenţii de implementare ................................................................. 303


10.2. Reprezentarea punctului şi a dreptei ................................................. 304
10.3. Panta şi ecuaţia unei drepte................................................................ 305
10.4. Intersecţia a două drepte .................................................................... 306
10.5. Intersecţia a două segmente ............................................................... 308
10.6. Calculul ariei unui poligon ................................................................... 311
10.7. Determinarea înfăşurătorii convexe (convex hull) ............................ 313

302
Algoritmi de geometrie computaţională

10.1. Convenţii de implementare


O problemă importantă şi o sursă însemnată de erori în informatică o
constituie lucrul cu numere raţionale şi iraţionale. Deoarece numerele
iraţionale sunt formate dintr-un număr infinit de cifre, este imposibil ca
acestea să fie reprezentate cu exactitate într-un program. Datorită acestui
fapt se folosesc aproximări, adică numere raţionale ale căror valoare este
relativ apropiată de numărul iraţional 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 număr raţional format din primele şase
cifre ale sale. Aceeaşi probleme sa pune şi în cazul numerelor raţionale
atunci când acestea au prea multe cifre pentru a putea fi stocate în întregime
într-un tip de date elementar.
Mai mult, este posibil ca efectuând multe operaţii cu numere
raţionale, rezultatul să nu fie cel aşteptat, 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ă conştientizarea celor
afirmate anterior pentru a evita, pe cât posibil, lucrul cu numere în virgulă
mobilă, sau pentru a ţine cont de eventualele erori numerice în stabilirea
corectitudinii unui algoritm.
De exemplu, consideraţi următoarea secvenţă de cod:

float t = 0.0;
for ( int i = 0; i < 20; ++i ) t += 0.1;
t *= 10000000;
cout << (int)t;

Matematic, valoarea afişată pe ecran ar trebui să fie 20 000 000,


valoare reprezentabilă pe patru octeţi (dimensiunea unui float). Valoarea
afişată 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
aproximări generează erori iniţial nesemnificative, dar care, prin operaţii
aritmetice repetate, pot ajunge să denatureze rezultatul unui algoritm.
Dacă înlocuim float cu double în secvenţa de mai sus, valoarea
afişată va fi corectă. Acest lucru se datorează faptului că double este
reprezentat pe opt octeţi şi poate reprezenta exact mai multe valori decât un
float, deci erorilor generate sunt mai mici. Pot apărea însă aceleaşi erori şi
pentru un double, dacă lucrăm cu numere mai mari sau efectuăm mai multe
operaţii. Erorile numerice sunt aşadar o problemă greu de rezolvat, care
uneori nici nu se poate rezolva în totalitate, scopul fiind păstrarea 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 întâi să
introducem un model de reprezentare a formelor geometrice elementare:
punctul şi dreapta. Deoarece vom lucra exclusiv în spaţiul bidimensional, o
asemenea reprezentare nu este greu de găsit. Fiecare punct este determinat
de o pereche (x, y), unde x (abscisa) şi y (ordonata) sunt numere raţionale.
Aşadar, putem folosi următoarea 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 posibilităţi, în funcţie


de ce problemă vrem să rezolvăm. O metodă des întâlnită este reprezentarea
printr-un triplet de numere raţionale (a, b, c) format din coeficienţii din
ecuaţia dreptei ax + by + c = 0. De exemplu, dreapta d din figura
următoare are ecuaţia 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


aparţine dreptei d.
O dreaptă poate fi reprezentată într-un program prin următoarea
structură:

304
Algoritmi de geometrie computaţională

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ă

Având structuri pentru reprezentarea punctelor şi a dreptelor putem


începe să vorbim despre algoritmi care lucrează ce acestea. Este importantă
înţelegerea acestor structuri şi a corespondeţei acestora cu realitatea
matematică, întrucât acestea vor sta la baza tuturor algoritmilor din acest
capitol.

10.3. Panta şi ecuaţia unei drepte


Pentru a putea lucra cu drepte este important să ştim să calculăm
pante şi ecuaţii, deoarece aceste două atribute se regăsesc în foarte mulţi
algoritmi de geometrie computaţională.
Reamintim că panta unei drepte d este un număr raţional egal cu
tangenta unghiului pozitiv dintre axa Ox şi dreapta d.

Fig. 10.3.1. – Vizualizarea pantei unei drepte

Aşadar, 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 alăturată unghiului, deducem că 𝐦𝐝 = . Pentru
𝚫𝐱
𝟐–𝟎
dreapta din figura de mai sus, 𝐦𝐝 = = 0.5.
𝟒–𝟎
În general, dacă ştim că două puncte (x1 , y1) şi (x2, y2) aparţin dreptei
d, atunci panta dreptei va fi

305
Capitolul 10

𝑦2 – 𝑦1
𝑚𝑑 = .
𝑥2 – 𝑥1

Ştiind panta unei drepte d, putem să îi aflăm şi ecuaţia cu ajutorul


formulei:
d: y – y0 = m(x – x0)

Aşadar, putem să calculăm ecuaţia unei drepte ştiind un punct (x0,


y0) care aparţine acesteia şi panta sa. Dacă ştim două puncte care aparţin
dreptei putem din nou să aflăm ecuaţia dreptei calculându-i mai întâi panta,
iar apoi ecuaţia cu ajutorul formulei de mai sus.
Putem să aflăm panta unei drepte căreia îi ştim doar ecuaţia în felul
următor: ecuaţia o avem sub forma d: ax + by + c = 0. Rescriem ecuaţia în
a c
felul următor: d: y = − ∙ x − . Observăm că ecuaţia unei drepte poate fi
b b
calculată cu ajutorul pantei şi a unui punct care aparţine dreptei în felul
următor: d: y = mx –mx0 + y0. Comparând cele două forme ale ecuaţiei
𝐚
rezultă că panta unei drepte d: ax + by + c = 0 este egală cu − .
𝐛

Două proprietăţi importante ale pantei unei drepte sunt date de


condiţiile 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 acelaşi unghi faţă de Ox, iar două drepte cu acelaşi 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. Intersecţia a două drepte


Până acum am prezentat mai mult noţiuni teoretice. În această
secţiune ne propunem să scriem o funcţie care să determine dacă două
drepte, date prin ecuaţiile lor, se intersectează sau nu. În caz afirmativ, ne
propunem să aflăm punctul de intersecţie al acestora.
Din punct de vedere geometric, două drepte se intersectează dacă au
cel puţin un punct în comun. Practic însă, dacă două drepte au mai mult de

306
Algoritmi de geometrie computaţională

un punct în comun atunci ele pot fi considerate ca fiind aceeaşi dreapta


(ecuaţiile lor vor coincide), aşa că nu vom considera şi acest caz, pentru a
păstra programul cât mai scurt. Mai mult, vom considera că dreptele date nu
sunt nici orizontale nici verticale, aceste tipuri de drepte introducând din nou
cazuri particulare.
Aşadar, ne propunem să determinăm dacă două drepte se
intersectează. Deoarece o dreaptă are lungime infinită, oricare două drepte
care nu sunt paralele se intersectează, aşa că este suficient să verificăm dacă
pantele sunt sau nu egale pentru a determina dacă dreptele date se
intersectează sau nu.
Pentru a determina punctul de intersecţie trebuie să rezolvăm
următorul sistem:

𝑑1 : 𝑎1 𝑥 + 𝑏1 𝑦 + 𝑐1 = 0
𝑑2 : 𝑎2 𝑥 + 𝑏2 𝑦 + 𝑐2 = 0

Sistemul se poate rezolva prin metoda substituţiei. Îl scriem pe x în


funcţie de y în prima ecuaţie, după care îl înlocuim în a doua. Se procedează
la fel şi cu y şi se ajunge în final la următorul rezultat:
𝑏2 𝑐1 − 𝑏1 𝑐2
𝑥=
𝑎2 𝑏1 − 𝑎1 𝑏2

𝑎2 𝑐1 − 𝑎1 𝑐2
𝑦=
𝑎1 𝑏2 − 𝑎2 𝑏1

Deoarece nu vom aplica aceste formule decât dacă ştim sigur că


dreptele se intersectează, adică pantele lor nu sunt egale, nu există riscul ca
unul dintre numitori să fie 0.

Prezentăm în continuare implementarea unei funcţii care determină


dacă două drepte se intersectează. În caz afirmativ, punctul de intersecţie
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. Intersecţia a două segmente


Este simplu să răspundem la întrebarea se intersectează aceste două
drepte date?, deoarece oricare două drepte neparalele se intersectează sigur,
întrucât 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 noţiunea de ordine


a trei puncte. Vom spune că trei puncte A, B, C se află în ordine
trigonometrică (se mai foloseşte ş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 stângă 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 computaţională

Având aceste definiţii, condiţia de intersecţie a două segmente nu


este greu de observat: folosind notaţiile din figura de mai sus, putem afirma
că două segmente se intersectează dacă şi numai dacă A şi B nu se află în
aceeaşi parte a dreptei CD, iar C şi D nu se află în aceeaşi parte a dreptei
AB. În continuare vom exprima formal aceste condiţii.
Fie o funcţie Orientare(A, B, C) care returnează -1 dacă cele trei
puncte date ca parametri sunt dispuse în ordine trigonometrică, 0 dacă
acestea sunt coliniare (observaţie: în implementarea prezentată nu vom
trata acest caz) şi 1 dacă acestea sunt dispuse în ordine invers
trigonometrică. Pentru a putea implementa această funcţie vom folosi
formula pantei în inecuaţia din definiţie 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 uşor


de scris funcţia ajutătoare 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ă funcţie va juca un rol important în simplificarea şi


optimizarea multor algoritmi de geometrie computaţională.
Acum, pentru ca un segment [AB] să intersecteze un segment [CD],
sunt necesare următoarele condiţii:
 Orientare(A, B, C) diferit de Orientare(A, B, D). Această
condiţie ne asigură că punctele C şi D nu sunt de aceeaşi parte a
dreptei AB.
 Orientare(C, D, A) diferit de Orientare(C, D, B). Această
condiţie ne asigură că punctele A şi B nu sunt de aceeaşi parte a
dreptei CD.

Dacă aceste două condiţie sunt îndeplinite, atunci ştim sigur că cele
două segmente se intersectează. Ne-am putea pune acum problema
determinării punctului de intersecţie 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ă, aşa că
putem pur şi simplu să aplicăm algoritmul de determinare a punctului de
intersecţie din cazul dreptelor, algoritm prezentat anterior.

Implementarea algoritmului prezentat nu este dificilă. În primul rând


vom folosi următoarea structură pentru a memora un segment:

struct Segment
{
Punct A, B;
Segment(Punct P1, Punct P2) : A(P1), B(P2) {}
Segment() {}
};

Având această structură implementarea algoritmului de intersecţie


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 computaţională

10.6. Calculul ariei unui poligon


Pentru poligoane precum triunghiul, pătratul, pentagonul etc. se
cunosc multe metode cu ajutorul cărora 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 rămâne în interiorul poligonului (sau, altfel
spus, măsura fiecărui 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 următoarea problemă: dându-se N puncte


X1, X2, ..., XN care reprezintă vârfurile unui poligon oarecare (dar a cărui
muchii nu se intersectează decât în extremităţi), dispuse în ordine
trigonometrică sau invers trigonometrică, să se calculeze aria poligonului
respectiv.
Putem rezolva această problemă în timp O(N) cu ajutorul
determinanţilor. Aria unui poligon P este:

1 𝑋1 𝑋2 𝑋 𝑋3 𝑋 𝑋1
𝐴 𝑃 = + 2 + ⋯+ 𝑁
2 𝑌1 𝑌2 𝑌2 𝑌3 𝑌𝑁 𝑌1

Modul de calcul poate fi vizualizat în felul următor:


– – – – –
X1 X2 X3 ... XN X1

Y1 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ă calculăm modulul acestei funcţii.
Funcţie Arie(P, N), unde P este un vector de puncte care reprezintă
un poligon, iar N numărul punctelor din vector va returna aria poligonului
definit de punctele din P. Această funcţie 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 obţine valoarea i + 1 pentru


orice i < N şi valoarea 1 atunci cand i == N. Astfel evităm tratarea ultimului
determinant ca şi un caz particular. Folosirea operatorului modulo poate să
încetinească însă un algoritm care apelează des această funcţie, aşa că în
unele cazuri este preferabilă adunarea ultimului determinant la sfârşit.
Această funcţie va funcţiona corect pentru orice poligon (convex sau
concav), mai puţin pentru poligoanele care au laturi ce se taie reciproc, cum
ar fi de exemplu următorul 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 reţine punctele şi o variabilă
întreagă N care ne va da numărul punctelor. Codul este următorul:

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 computaţională

O problemă înrudită este următoarea: dându-se un poligon a cărui


vârfuri au coordonate numere întregi, să se determine numărul de puncte cu
coordonate numere întregi care se află în interiorul poligonului, respectiv
numărul 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ă numărul de puncte din interiorul poligonului şi l


numărul de puncte de pe graniţa poligonului.
Pentru a putea rezolva problema dată trebuie să calculăm i şi l.
Formula nu ne ajută foarte mult atâta timp cât avem două necunoscute şi o
singură ecuaţie, dar există o metodă simplă de a calcula valoarea l. Numărul
de puncte laticiale (puncte de coordonate întregi) de pe un segment [AB]
este:

𝑙[𝐴𝐵] = 𝑐𝑚𝑚𝑑𝑐 𝐴. 𝑥 − 𝐵. 𝑥 , 𝐴. 𝑦 − 𝐵. 𝑦 + 1

Având această formulă putem calcula punctele de pe fiecare latură a


poligonului în timp ce calculăm aria, după care i poate fi aflat uşor:

𝑙
𝑖= 𝐴 𝑃 − +1
2

10.7. Determinarea înfăşurătorii convexe (convex hull)


Dându-se N > 2 puncte în plan, notate P1, P2, ..., PN , să se determine
poligonul convex de arie minimă care are în interiorul său sau pe muchiile
sale toate cele N puncte. Se consideră că oricare trei puncte sunt necoliniare.
Să se afişeze punctele care reprezintă vârfurile poligonului
determinat, într-o ordine oarecare.
Problema cere determinarea înfăşurătorii convexe a unui set de
puncte. Figura de mai jos prezintă înfăşurătoarea convexă a 13 puncte:

313
Capitolul 10

Fig. 10.7.1. – Înfăşurătoarea convexă a unui set de puncte oarecare

Vom prezenta în continuare trei algoritmi de rezolvare: un algoritm


naiv cu timpul de execuţie O(N3), unul cu timpul de execuţie O(Nh), unde
h este numărul de vârfuri ale înfăşurătorii convexe şi un algoritm de
complexitate O(Nlog N), care este eficient pentru orice set de puncte dat.

Algoritmul naiv se bazează pe observaţia că un segment [Px Py]


reprezintă o latură a înfăşurătorii convexe dacă şi numai dacă toate punctele
Pk, k ≠ x, k ≠ y se află de aceeaşi parte a dreptei PxPy.
Dacă segmentul [PxPy] reprezintă o latură a înfăşurătorii convexe,
atunci evident că punctele Px şi Py reprezintă vârfuri 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ă
 afişează y
o dacă y == x + 1 execută: afişează x
 punctele afişate reprezintă vârfurile înfăşurătorii convexe a
setului de puncte dat.

Dezavantajele acestui algoritm sunt în primul rând numărul mare de


operaţii efectuate şi în al doilea rând faptul că vârfurile înfăşurătorii convexe
sunt afişate într-o ordine imprevizibilă, lucru care nu poate fi remediat uşor.
În practică acest algoritm nu se foloseşte, fiind preferaţi algoritmi
mai eficienţi, de genul celor prezentaţi în continuare.
Algoritmul de complexitate O(Nh), unde h este numărul de vârfuri
al înfăşurătorii convexe se numeşte algoritmul lui Jarvis şi este o
optimizare a algoritmului naiv prezentat anterior.

314
Algoritmi de geometrie computaţională

Vom începe prin a selecta un punct care ştim sigur că reprezintă un


vârf al înfăşurătorii convexe. Un astfel de punct este punctul cel mai din
stânga (cu abscisa minimă), iar în caz de egalitate cel mai de sus (cu
ordonata maximă).
Pentru a înţelege mai bine algoritmul lui Jarvis, vom face o analogie
cu problema determinării celui mai mic număr dintr-o secvenţă. Cel mai mic
număr dintr-o secvenţă este acel număr pentru care nu există niciun alt
număr mai mic decât el. Un algoritm naiv de determinare a acestui număr
este următorul:
 pentru fiecare număr x din secvenţă execută
o minim = adevărat
o pentru fiecare număr y != x din secvenţă execută
 dacă y < x execută
 minim = fals
o dacă minim == adevărat execută
 raportează x ca fiind cel mai mic număr din
secvenţă

Acest algoritm este evident aberant şi nu serveşte absolut niciun scop


practic. Acesta seamănă însă cu algoritmul naiv prezentat anterior pentru
determinarea înfăşurătorii convexe, care caută toate muchiile care au toate
punctele din setul dat într-o singură parte. Algoritmul naiv poate fi optimizat
printr-un raţionament 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 număr din secvenţă

Deşi 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 următoarea: se consideră minim ca fiind,
la fiecare pas i, cel mai mic element dintre primele i ale secvenţei. Iniţial
minim este secv[1] (primul element al secvenţei secv). Acest lucru este
corect deoarece minimul oricărei secvenţe de un singur element este chiar
acel element. La fiecare pas i > 1, verificăm dacă secv[i] > minim. Dacă da,
atunci este necesar să atribuim lui minim valoarea secv[i] pentru a menţine
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
menţine. La sfârşit, datorită invariantului ales şi a respectării acestuia pe tot
315
Capitolul 10

parcursul algoritmului, minim va conţine cel mai mic element din secvenţa
secv.
O analiză intuitivă a algoritmului este simplă: de fiecare dată când
dăm de un element mai mic decât cel presupus a fi minimul global, revizuim
presupunerea făcută, considerând acest nou element ca fiind minimul global.
După parcurgerea tuturor elementelor, vom avea evident adevăratul minim
global.
Algoritmul lui Jarvis are un raţionament aproape identic. Fie P1
punctul ales care face sigur parte din înfăşurătoarea convexă. Vom
presupune că segmentul [P1PnewPct] este o muchie a înfăşurătorii convexe.
Iniţial vom considera newPct = 2. Parcurgem acum toate celelalte puncte
date. Dacă există un punct Pq, astfel încât Orientare(P1, Pq, PnewPct) > 0
(sau mai mic ca zero, nu are importanţă atâta timp cât suntem consistenţi în
alegere) atunci segmentul [P1Pq] are mai multe şanse să aibă restul
punctelor într-o singură parte decât segmentul [P1PnewPct]. Acest lucru se va
clarifica imediat. Vom seta newPct = q şi vom continua algoritmul, căutând
(fără a reporni căutarea!) un alt q astfel încât Orientare(P 1, Pq, PnewPct) > 0
(atenţie, newPct este acuma egal cu vechiul q).
La finalul acestui pas, PnewPct va face sigur parte din înfăşurătoarea
convexă. Mai mult, segmentul [P1PnewPct] va avea toate punctele date într-o
singură parte.
Se reia algoritmul de la următorul punct de pe înfăşurătoarea
convexă, adică PnewPct. Acesta va juca acum rolul lui P1. Se continuă până
când se ajunge din nou la punctul de început. Deoarece se execută O(N) paşi
pentru fiecare punct de pe înfăşurătoarea convexă deducem că timpul de
execuţie a întregului algoritm este O(Nh).

Funcţia Jarvis(P) care determină vârfurile înfăşurătorii convexe a


setului de puncte P poate fi implementată astfel:
 adaugă în CH cel mai din stânga 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 înfăşurătoarea convexă a setului P.

316
Algoritmi de geometrie computaţională

Figura de mai jos prezintă modul de selectare a următorului punct de


pe înfăşurătoare. Liniile albastre unesc startPct cu nextPct, iar liniile verzi
unesc startPct cu q. Se poate observa uşor corectitudinea algoritmului din
figură.

Fig. 10.7.2. – Modul de execuţie al algoritmului lui Jarvis

Se poate observa cum la fiecare pas albastrul ia locul verdelui de la


pasul anterior, până când toate punctele ajung să fie într-o singură parte a
dreptei selectate în final. Algoritmul continuă în acest fel până când se
completează întreg poligonul.
Prezentăm în continuare un program complet C++ care citeşte N
puncte şi afişează cele h vârfuri ale înfăşurătorii convexe a setului de puncte
dat folosind algoritmul lui Jarvis. Acest algoritm este folositor atunci când
numărul de puncte de pe înfăşurătoare este mic (pentru puncte generate
aleator acest lucru este adevărat).

#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 computaţională

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;
}

Exerciţii:
a) În ce ordine se afişează punctele de pe înfăşurătoare?
b) Cum se poate modifica algoritmul astfel încât punctele de pe
înfăşurătoare să fie afişate în altă ordine?
c) Daţi exemplu de un set de puncte pe care algoritmul efectuează
un număr nefavorabil de paşi.
319
Capitolul 10

Algoritmul de complexitate O(Nlog N) este algoritmul lui


Graham (sau scanarea Graham). Acesta este un algoritm foarte eficient pe
orice set de puncte, presupunând doar o sortare şi o parcurgere liniară a
punctelor.
Funcţia Graham(P) care determină punctele ce reprezintă vârfuri ale
înfăşurătorii convexe a setului de puncte P poate fi scrisă în pseudocod în
felul următor:
 Interschimbă P[1] cu un punct care sigur este vârf al
înfăşurătorii.
 Sortează P[2, N] crescător după panta dreptei formată de fiecare
dintre aceste puncte cu punctul P[1].
 P[0] = P[N]
 nrH = 2, va reprezenta numărul de vârfuri ale înfăşurătorii.
 Pentru fiecare i de la 3 la N execută
o Cât 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ă mulţimea punctelor care sunt vârfurii ale
înfăşurătorii convexe a setului de puncte P.

Se poate observa că algoritmul foloseşte o stivă în care construieşte


soluţia (practic se foloseşte 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 păstrat de algoritm este ca toate punctele să aibă semnul
pozitiv, aşa că vor fi scoase din stivă punctele care nu îndeplinesc această
condiţie. La sfârşitul algoritmului, în stivă vor rămâne 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 înfăşurătoare.
Deoarece fiecare punct intră în stivă o singură dată şi iese din stivă
cel mult o dată, deducem că asupra fiecărui punct se efectuează O(N)
operaţii de cost constant. Complexitatea algoritmului este deci O(Nlog N)
datorită sortării.

Algoritmul începe cu cel mai din stânga punct şi construieşte


muchiile în sensul acelor de ceasornic. Muchiile albastre din figura
următoare reprezintă muchii alese la un moment dat de către algoritm, dar
care au fost determinate apoi ca fiind greşit alese, forţând algoritmul să

320
Algoritmi de geometrie computaţională

revină asupra deciziei făcute. Am putea spune aşadar că scanarea Graham


este un algoritm de tip backtracking, deoarece revine asupra deciziilor
făcute atunci când este cazul.

Fig. 10.7.3. – Modul de execuţie al scanării Graham

Prezentăm în continuare implementarea algoritmului. Aceasta este


puţin 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 înlănţuite

11. Liste
înlănţuite
Acest capitol prezintă noţiunile elementare despre liste înlănţuite
(simplu înlănţuite, dublu înlănţuite şi circulare). Vor fi prezentate noţiuni
teoretice şi detalii de implementare.
Listele înlănţuite 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ă
inserări mai eficiente, mai ales la sfârşitul şi începutul acestora (acestea se
pot face în timp constant.
Aşadar, alegerea dintre liste şi tablouri trebuie făcută în funcţie de
natura problemei pe care vrem să o rezolvăm. Vom prezenta în acest capitol
şi alte avantaje şi dezavantaje.

Deşi listele înlănţuite există deja implementate în cadrul librăriei


S.T.L. (containerul list), considerăm că este important pentru orice
programator să cunoscă modul de implementare manual al acestora, întrucât
implementarea manuală oferă mai mult control asupra acestora. Acest
control va fi necesar în capitolele următoare, 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 înlănţuite
care stau la baza acestora.

Recomandăm aşadar parcurgerea şi înţelegerea acestui capitol


înainte de a trece mai departe la capitolele următoare.

323
Capitolul 11

CUPRINS

11.1. Noţiuni introductive ............................................................................. 325


11.2. Tipul abstract de date listă simplu înlănţuită..................................... 327
11.3. Aplicaţii ale listelor înlănţuite ............................................................. 339
11.4. Tipul abstract de date listă dublu înlănţuită ...................................... 343
11.5. Dancing Links ........................................................................................ 354

324
Liste înlănţuite

11.1. Noţiuni introductive


Din punct de vedere al structurilor de date înlănţuite vom introduce
următoarea clasificare: vectori statici, vectori dinamici şi liste. Vectorul
static este caracterizat prin numărul fix de elemente şi operaţiile de adăugare
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 până


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 întâi 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 atâta memorie cât avem nevoie. În cazul în care
se doreşte adăugarea sau ştergerea unui element prin realocarea memoriei,
codul devine puţin mai complex.
Un avantaj major al folosirii vectorilor, atât statici cât şi dinamici,
este acela că, prin definiţie, vectorul este indexat, fapt ce permite
vizualizarea sau modificarea elementului i prin poziţionarea pe elementul
respectiv prin operatorul [ ]: V[i]. Acest acces direct prin indexare este
pierdut la următorul tip de date înlănţuit: lista. La nivel teoretic, prin
pierderea indexului, în cazul listei, fiecărui element ce conţine informaţie
(element numit acum nod) i se mai asociază o variabilă de tip pointer care
va reţine 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


următoarea structură de tip nod, creeându-se astfel o structură de date
înlănţuită la care se face referire prin adresa primului nod, respectiv se
încheie atunci când elementul curent pointează spre elementul NULL, de
unde şi reprezentarea uzuală:

Fig. 11.1.1. – Reprezentarea uzuală a unei liste înlănţuite

Acest tip de date permite o utilizare mult mai eficientă a memoriei la


nivel de modificări structurale (adăugare, stergere) a informaţiei, dar (în
cazul definiţiei clasice) pierde avantajul indexării, deci pentru a vizualiza
sau modifica elementul i dintr-o listă este necesară parcurgerea de la primul
element până la elementul i din adresă în adresă.
Mecanismele de utilizare a tipurilor de date înlănţuite (în acest
model de definiţie) relativ la operaţiile de bază (adăugare – Add, ştergere –
Del, vizualizare – View şi modificare – Mod) se pot sintetiza în figura
următoare:

Fig. 11.1.2. – Operaţiile de bază a tipurilor de date înlănţuite

326
Liste înlănţuite

Cu alte cuvinte, dacă este necesară folosirea în program a unui tip de


date înlănţuit, fără necesitatea modificărilor structurale de adăugare sau
ştergere, se recomandă utilizarea vectorilor statici. Dacă se cer modificări
structurale minime şi se pune accentul pe modificări şi vizualizări ale
elementelor (satistici, ordonări...), 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 înlănţuit bazat pe modificări
structurale, atunci este necesară şi utilă folosirea unei liste.

11.2. Tipul abstract de date listă simplu înlănţuită


În continuare vom construi, pe baza unei structuri nod ce conţine o
informaţie virtuală T info; şi respectiv pointerul spre structura nod, funcţiile
aferente ale unui model general al unei liste numită şi T.A.D. – lista (Tipul
Abstract de Date) din care vom deriva mai târziu o serie de obiecte familiare
(stiva, coada, lista simplu înlănţuită, lista circulară) acestor structuri de date.
În prima parte a construirii T.A.D. – listă este necesară stabilirea
funcţiilor de bază ce trebuie explicitate, în cazul acesta adăugarea unui nod
în listă (add), ştergerea unui nod (del), modificarea informaţiei unui nod
(mod) şi vizualizarea informaţiei unui nod (view). În cazul adăugării şi
ştergerii, construcţia şi implementarea funcţiilor se poate face prin părţi,
mecanismele în cazul adăugării sau ştergerii la început (beg), la mijloc
(mid), sau la sfârşit (end) fiind uşor diferite între ele.

a) add_beg (adăugarea unui nod la începutul listei)


Să presupunem construcţia listei pe următoarea structură:

struct nod
{
int info;
nod * link_;
}

Pentru fiecare procedură care necesită adăugare vom construi un nou


element cu numele New.

Pentru a analiza mecanismul de adăugare la început să urmărim


figura următoare:

327
Capitolul 11

Fig. 11.2.1. – Adăugarea unui nou element la începutul listei

Vom construi o funcţie care primeşte 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 informaţia acestuia:


cin >> New->info;

Şi aşa cum se vede în figura anterioară, pointerul elementului New


pointează spre lista veche, obţinând astfel o adăugare 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;

Acelaşi mecanism se foloseşte şi dacă add_beg se construieşte 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 înlănţuite

b) add_end (adăugarea unui nod la sfârşitul listei)

În cazul adăugării unui element la sfârşitul listei este necesar să


găsim 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. – Adăugarea unui nod la sfârşitul unei liste înlănţuite

Pentru a găsi adresa ultimului element în cazul unei liste este


necesară parcurgerea nodurilor listei pas cu pas, printr-o metodă repetitivă,
până când se îndeplineşte condiţia ce caracterizează ultimul nod:
Temp->link_ == NULL.

Fig. 11.2.3. – Găsirea adresei ultimului element dintr-o listă înlănţuită

În cazul nostru, datorită capacităţii de utilizare a instrucţiunii for în


C++, modalitatea de a ajunge la ultimul element se poate scrie într-o singură
instrucţiune:
for ( nod *Temp = Old; Temp->link_ != NULL; Temp=Temp>link_);

Având aceste date putem scrie codul funcţiei add_end:

329
Capitolul 11

nod *add_end(nod *Old)


{
// construcţia nodului de adăugat
nod *New = new nod;
New->link_ = NULL;
cin >> New->info;

// găsirea adresei ultimului nod


nod *Temp;
for ( Temp = Old; Temp->link_ != NULL; Temp = Temp->link_ );

// legătura
Temp->link_ = New;

// valoarea de returnat
return Old;
}

Precondiţii: este necesar să amintim că această funcţie nu este


completă deoarece într-un anume caz aceasta va genera erori:

nod *LISTA = NULL; nod *LISTA = NULL;


LISTA = add_end(LISTA); LISTA = add_beg(LISTA);
eroare! LISTA = add_end(LISTA);
fără 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 preîntâmpina prin
introducerea primului element prin add_beg (...), sau completarea funcţiei
add_end după cum urmează:

nod *add_end(nod *Old)


{
if ( Old == NULL )
Old = add_beg(Old); // add_beg(Old) in cazul procedurii
else
{
//construcţia nodului de adăugat
nod *New = new nod;
New->link_ = NULL;
cin >> New->info;

330
Liste înlănţuite

//găsirea adresei ultimului nod


nod *Temp;
for ( Temp = Old; Temp->link_ != NULL; Temp = Temp->link_ );

//legătura
Temp->link_ = New;

//valoarea de returnat
return Old;
}
}

c) add_mid (adăugarea unui nod în interiorul listei)


Un proces mai complex este acela prin care se adăugă un element în
interiorul unei liste. Pentru adăugarea în interior este necesară în primul rând
o cheie care ne arată care element unde trebuie introdus. În funcţie de
anumite probleme cheia poate fi o poziţie (să se introducă după poziţia k un
element...), caz în care cheia face referire la structura listei, sau o valoare
(să se construiască o listă cu informaţia numere întregi ordonată în timpul
construcţiei), caz în care cheia face referire la conţinutul listei.
Pentru a introduce un nou nod de valoare oarecare astfel încât lista să
rămână sortată, se parcurge lista până când valoarea nodului care trebuie
adăugat este mai mică sau egală cu următorul element (evident, pot exista şi
alte criterii de inserare, acesta este doar un exemplu), adică:
New->info <= Temp->link_->info

În cazul figurii următoare variabila nod *Temp este variabila care


parcurge lista în funcţie de cheie (aici nodul nou trebuie introdus între
nodurile 2 şi 3).

Se creează nodul nou:


nod *New = new nod;
cin >> New->info;

Legătura acestuia trebuie să fie către nodul 3, la adresa căruia


ajungem prin Temp->link_.
New->link_ = Temp->link_; (1)

Refacerea legăturii iniţiale dintre nodurile 2 şi 3 astfel încât 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 înlănţuite

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 greşeală des întâlnită în implementarea inserării

Observaţie: de multe ori căutarea după cheie poate să nu dea


rezultate, caz în care este necesară şi condiţia de oprire pentru când
pointerul Temp ajunge la ultimul element.
Vom construi funcţia add_mid în cazul problemei în care se cere
crearea unei liste înlănţuite ordonate cu informaţie de tip întreg completând
şi precondiţiile necesare rulării corecte (vezi add_end).
Presupunem că de la tastatură se citesc N numere întregi. Se cere
crearea unei liste înlănţuite care să conţină numerele în ordine crescătoare.
Conţinutul liste se va afişa pe ecran.
Prezentăm doar funcţia add_mid. Programul principal presupune
declararea unei liste LISTA, iniţializată 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 înlănţuite

Pentru funcţionarea corectă a funcţiei în orice caz, avem nevoie de


tratarea unor cazuri particulare: în primul rând, dacă lista nu are niciun nod,
se adaugă pur şi simplu nodul citit în listă. În al doilea rând, observăm că
parcurgerea menţionată mai sus nu funcţionează corect dacă primul nod al
listei are deja o valoare mai mare decât a nodului care trebuie adăugat,
deoarece verificarea începe abia de la al doilea nod. De aceea, vom pune o
condiţie ca dacă primul nod are o valoare mai mare decât a noului nod, noul
nod va fi adăugat la începutul listei. Restul condiţiilor 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 următoare, astfel:

Fig. 11.2.6. – Ştergerea primului nod al unei liste înlănţuite

nod *del_beg(nod *Old) void del_beg(nod *&Old) // procedura


{ {
return Old->link_; Old = Old->link_;
} }

Codul anterior, deşi funcţional în anumite cazuri, necesită două


îmbunătăţiri: tratarea listei în cazul în care nu există Old->link_ şi eliberarea
memoriei folosite de nodul peste care s-a sărit. Pentru prima parte avem:

nod *del_beg(nod * Old)


{
if ( Old != NULL ) return Old->link_;
else return Old;
}

Pentru eliberarea memoriei se construieşte un pointer ToDel care va


pointa la valoarea care trebuie ştearsă, după care memoria alocată acestuia
va fi ştearsă prin instrucţiunea delete:

Fig. 11.2.7. – Ştergerea efectivă (din memorie) a unui nod


334
Liste înlănţuite

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 sfârşitul listei)

În cazul acestei ştergeri trebuie să ne poziţionăm cu o variabilă de tip


pointer Temp pe penultima poziţie din listă, iar apoi prin instrucţiunea
Temp->link_ = NULL vom sări peste ultimul element.
Dacă dorim să completăm 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 înlănţuite

Vom ţine cont şi de precondiţiile date de existenţa penultimului


element: Temp->link_->link_ != NULL;

Dacă lista are un singur element sau este NULL, atunci returnăm
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 inserării 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 cărei noduri conţin ca
informaţie un număr întreg şi trebuie să construim cheia (key un număr
întreg) astfel încât să ştergem nodul a cărui informaţie este egală cu valoarea
dată prin cheie:
nod *del_mid (nod *Old, int key) {...}

În prima variantă (neţinând cont de precondiţii şi restricţii) trebuie să


parcurgem lista cu o variabilă de tip pointer Temp astfel încât să ne
poziţionăm pe elementul imediat anterior celui a cărui valoare este egală cu
variabila key:
for ( nod *Temp = Old; Temp->link_ ; Temp = Temp->link_) { ... }

336
Liste înlănţuite

Vom folosi condiţia Temp->link_->info == key pentru a verifica


dacă următorul element este cel care trebuie şters. Dacă da, acest element se
va şterge în felul următor: 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 eliberăm memoria lui ToDel prin


delete ToDel; (3)

Fig. 11.2.9. – Ştergerea unui nod din interiorul unei liste înlănţuite

În acest caz avem tratată condiţia Temp->link_->info == key, dar


dacă primul element este chiar cel care trebuie şters? ]n implementarea
iniţială 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 (cât timp Temp != NULL) şi la fiecare pas se
vizualizează (afişează 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 înlănţuit: lista dublu
înlănţuită, despre care vom vorbi în mai târziu tot în cadrul acestui capitol,
dar mai întăi să rezolvăm problema parcurgerii inverse.

Putem parcurge lista în mod normal, iar valorile elementelor să le


reţinem într-un vector pe măsură ce acestea sunt parcurse. Parcurgerea
inversă a listei este dată de afişarea în ordine inversă a elementelor acestui
vector. Pentru a nu se utiliza un vector, putem implementa recursiv o funcţie
de afişare, care afişează elementele la revinirea din recursivitate, având
acelaşi efect, dar scriind mai puţin cod:

void view_rev(nod *Old)


{
if ( Old == NULL )
{
cout << endl;
return;
}

view_rev(Old->link_);
cout << Old->info << ' ';
}

Exerciţiu: când se va trece la linie nouă în cadrul programului de


mai sus? Scrieţi o funcţie care face trecerea la linie nouă după afişarea
tuturor elementelor.

338
Liste înlănţuite

11.3. Aplicaţii ale listelor înlănţuite


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 înlănţuite este mai eficientă, deoarece
acestea ne permit ştergerea din memorie a elementelor de care nu mai avem
nevoie.
Vom prezenta în continuare câteva astfel de obiecte. Acestea se
regăsesc şi în librăria S.T.L.

a) Stiva

Stiva este un tip particular de listă în care adăugarea şi ştergerea


nodurilor se realizează într-un singur capăt (numit uzual vârf). În cazul
definiţiei noastre, tipul de date stivă permite fie (add_beg şi del_beg), fie
(add_end şi del_end), creând 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 căreia să se poată verifica dacă o stivă
este sau nu plină (sau goală).

Fig. 11.3.1. – Modul de funcţionare al unei stive

b) Coada

Pentru acest tip particular de listă, adăugarea se realizează la un


capăt în schimb ce ştergerea unui nod se realizează la capătul opus. În cazul
definiţiei noastre, tipul coadă va permite sau (add_beg şi del_end) sau
(add_end şi del_beg), creându-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 funcţii cu ajutorul căreia să putem verifica dacă


structura este plină sau nu.

Fig. 11.3.2. – Modul de funcţionare al unei cozi

c) Lista simplu înlănţuită


Trebuie să amintim aici că, în urma definirii tipurilor de date
înlănţuite stivă şi coadă prin folosirea anumitor module de adăugare sau de
ştergere dintr-o listă, nerestricţionarea utilizării acestor module determină
tipul de date numit listă simplu înlănţuită.

d) Lista circulară simplu înlănţuită

Se derivează din listă, prin adăugarea condiţiei prin care ultimul nod
nu va pointa la NULL, ci va pointa la începutul listei. Această condiţie
restricţionează adăugarea şi ştergerea unor elemente la funcţiile add_mid şi
del_mid, deoarece lista nu mai are început şi sfârşit.

Fig. 11.3.3. – O listă circulară simplu înlănţuită

Nu putem vorbi de lista circulară simplu înlănţuită fără să tratăm


problema cavalerilor mesei rotunde:
Din fişierul cavaleri.in se citeşte un număr natural N reprezentând
numărul de cavaleri aşezaţi la masa rotundă. Regele Arthur vrea să aleagă
un cavaler pe care să-l trimită într-o misiune foarte importantă. Pentru

340
Liste înlănţuite

aceasta, el va începe o numărătoare începând de la cavalerul cu numărul de


ordine 1. Iniţial, el numără până la 1, oprindu-se pe cavalerul imediat
următor, adică 2, care este eliminat (sigur nu va fi trimis în misiune şi se
ridică de la masă). După aceea, el numără până la 2, oprindu-se pe al doilea
cavaler după cel eliminat anterior, acesta fiind cel cu numărul de ordine 4,
care este şi el eliminat. După aceea numără până la 3, oprindu-se pe al
treilea cavaler după ultimul eliminat. Regele se opreşte atunci când mai
rămâne un singur cavaler neeliminat, cavaler care va fi trimis în misiune.
Fişierul de ieşire cavaleri.out va conţine, în ordinea eliminării lor,
numerele de ordine ale cavalerilor eliminaţi.

Exemplu:
cavaleri.in cavaleri.out
6 24135

Problema poate fi rezolvată folosind o listă circulară în felul


următor: vom ţine o variabilă nr care va reprezenta numărul de cavaleri
eliminaţi deja. Când nr = N – 1, algoritmul se încheie. Eliminarea efectivă
este simplu de realizat: ne vom deplasa la fiecare pas de atâtea ori de cât
este necesar pentru a găsi următorul 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 deplasările în mod natural.

#include <fstream> nod *add_end(nod *Old, int val)


{
using namespace std; if ( Old == NULL )
Old = add_beg(Old, val);
struct nod else
{ {
int info; nod *New = new nod;
nod *link_; New->link_ = NULL;
}; New->info = val;

nod *add_beg(nod *Old, int val) nod *Temp;


{ for ( Temp = Old;
nod *New = new nod; Temp->link_ != NULL;
New->info = val; Temp = Temp->link_ );
New->link_ = Old; Temp->link_ = New;
}
return New; return Old;
} }

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 înlănţuite

11.4. Tipul abstract de date listă dublu înlănţuită


Pe parcursul acestui subcapitol vom construi şi explicita tipul
abstract de date listă dublu înlănţuită (T.A.D. listă D.Î.). În primul rând,
prin listă dublu înlănţuită înţelegem un tip de date a cărui elemente sunt
structuri numite noduri ce conţin informaţie şi doi pointeri la tipul nod ce
vor pointa unul spre adresa nodului următor (listă simplu înlănţuită), iar
celălalt spre adresa nodului anterior, eliminând astfel dezavantajul
parcurgerii inverse întâlnit la listele simplu înlănţuite.

Fig. 11.4.1. – O listă dublu înlănţuită, reprezentată în două moduri

struct nod
{
T info;

nod *link_;
nod *_link; // pointer catre nodul anterior
};

Fig. 11.4.2. – Pointerii existenţi în cadrul unei liste dublu înlănţuite

Diferenţa dintre cele două modele prezentate în figura 11.4.1. constă


în modul de folosire a listei dublu înlănţuite.

343
Capitolul 11

În primul caz (clasic), se face referire la lista dublu înlănţuită


printr-un singur pointer la primul nod:
nod *LISTA;

Iar apoi, pentru a ajunge la adresa ultimului element de pe direcţia


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 înlănţuite

În al doilea caz, lista dublu înlănţuită este reprezentată de o altă


structură care are doi pointeri la tipul de date nod:

struct Lista
{
nod *adrp;
nod *adru;
};

Aceştia au rolul de a reţine atât adresa primului element căt ş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 funcţiile de adăugare, ştergere şi


vizualizare pentru listele dublu înlănţuite.

344
Liste înlănţuite

a) add_beg (adăugarea unui nod la începutul listei)

Presupunem informaţia de tip int.

struct nod
{
int info;

nod *link_;
nod *_link;
};

Adăugarea, fiind o modificare structurală, presupune un nou nod


(New) care se va lega de lista Old.
Din figura următoare rezultă că vom avea nevoie de
New->link_ = Old; (1)
Old->_link = New; (2)

Fig. 11.4.4. – Adăugarea unui nod la începutul unei liste dublu înlănţuite

Să nu uităm că adresa primului element se schimbă, deci va trebui să


returnăm 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


neexistând Old->_link; fapt ce poate genera erori. Modul tratare al acestui
caz este foarte simplu: se înlocuieşte în funcţia anterioară linia
Old->_link = New;
cu:
if ( Old != NULL ) Old->_link = New;

Ceea ce este suficient.

b) add_end (adăugarea unui nod la sfărşitul listei)

Pentru acest tip de adăugare vom avea nevoie de un pointer Temp,


pe care îl vom pointa spre ultimul element, pe direcţia link_, astfel încât să
putem scrie:
Temp->link_ = New; (1)
New->_link = Temp; (2)

Fig. 11.4.5. – Adăugarea unui nod la sfârşitul unei liste dublu înlănţuite

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 înlănţuite

Mai trebuie verificată condiţia de existenţă a lui Temp->link_: dacă


lista Old este NULL, atunci adăugarea la sfârşit 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 (adăugarea unui nod în interiorul listei)


În cazul acestui tip de adăugare vom proceda la fel ca la adăugarea
în interiorul unei liste simplu înlănţuite: avem nevoie de un parametru care
ne spune unde trebuie introdus noul nod (o poziţie sau o valoare înaintea
căreia se va efectua adăugarea). Este necesar ca pointerul Temp să ajungă
înaintea elementului căutat.
Va trebui să avem grijă la restabilirea proprietăţii de listă (atribuirea
pointerilor). Acest lucru se poate realiza comform figurii următoare:

Fig. 11.4.6. – Adăugarea unui nod în interiorul unei liste dublu înlănţuite

Implementarea, respectiv condiţiile de existenţă a acestui mod de


adăugare, se pot urmări în aplicaţia 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 legătura pointerului _link cu nodul anterior


Old -> _link = NULL (3)

Fig. 11.4.7. – Ştergerea primului nod al unei liste dublu înlănţuite

Urmând instrucţiunea de eliberare a memoriei alocată pointerului


ToDel: delete ToDel;

e) del_ end (ştergerea unui nod de la sfârşitul 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. – Poziţionarea pointerilor pentru ştergerea ultimului nod

348
Liste înlănţuite

După care:

Fig. 11.4.9. – Ştergerea efectivă a ultimului nod

Temp -> link_ = NULL; (3)


delete ToDel ; (4)

f) del_ mid (ştergerea unui nod din interiorul listei)

Asemănătoare cu ştergerea de la sfârşitul 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. – Poziţionarea pointerilor pentru ştergerea nodului 3

Urmează saltul ponterului Temp->link_ peste nodul care se doreşte


şters, astfel:
Temp -> link_ = Temp-> link_ -> link_; (3)

349
Capitolul 11

Fig. 11.4.11. – Stabilirea legăturii de la nodul 2 către nodul 4

Avem până în acest moment stabilită legătura de la nodul 2 către


nodul 4. Mai trebuie să stabilim şi legătura de la nodul 4 către nodul 2,
deoarece avem de a face cu o listă dublu înlănţuită:
Temp -> link_ ->_link = Temp (4)

Fig. 11.4.12. – Stabilirea legăturii inverse, de la nodul 4 către nodul 2

Tot ce mai avem de făcut în final este delete ToDel;

Un model complet al ştergerii în lista dublă înlănţuită se poate


urmări în implementarea următoarei 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
înlănţuită formată din cele N numere citite, astfel încât aceasta să fie
ordonată crescător. Să se afişeze lista, iar apoi să se şteargă din ea toate cele
M numere (dacă acestea există). Să se afişeze lista după fiecare ştergere.

Pentru rezolvarea acestei probleme prezentăm un program complet,


program care incorporează toate funcţiile prezentate în acest subcapitol,
inclusiv cazurile particulare ale acestora. Am folosit comentarii pentru a
clarifica anumite lucruri.
Recomandăm cititorilor să reimplementeze de la zero rezolvarea
acestei probleme, pentru o mai bună înţelegere a listelor.

350
Liste înlănţuite

#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 înlănţuite

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) int main()


{ {
for ( ; L; L = L->link_ ) // atentie la initializarea cu NULL!
cout << L->info << ' '; nod *LISTA = NULL;
cout << endl;
} 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 înlănţuite este
aceea că, dacă ne permitem să nu eliberăm memoria alocată unui nod şters,
acesta poate fi adăugat înapoi în listă într-un mod foarte uşor, efectuând un
număr constant de operaţii cu pointeri. Această metodă a fost popularizată
de Donald Knuth 1 şi poartă numele de Dancing Links (Legături Dansante)
sau DLX.
Să presupunem o listă circulară dublu înlănţuită cu cel puţin un nod
şi un pointer X către un nod din această listă. Atunci, secvenţa de operaţii:
X->_link->link_ = X->link_;
X->link_->_link = X->_link;

Va avea ca efect scoaterea nodului X din listă, iar secvenţa:


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ă apelăm recursiv
funcţia, iar la revenire din recursivitate să reintroducem nodul i în listă. În
acest fel, la fiecare pas al recursivităţii se va parcurge o listă cu (cel puţin)
un element mai mică, îmbunătăţindu-se timpul de execuţie.
Mai mult, se simplifică astfel codul. Problema anterioară, de
exemplu, ar putea fi rezolvată mai uşor prin acest tip de ştergere. Singurul
dezavantaj este că nodurile nu vor fi eliberate efectiv din memorie.

Recomandăm cititorilor implementarea unor algoritmi backtracking


folosind această tehnică. Algoritmii care se pretează acestei abordări sunt
cei de generare a permutărilor, a aranjamentelor etc.

1
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 relaţiilor existente între elementele unui set suport. Elementele
din setul suport se numesc noduri, iar relaţiile existente între acestea se
numesc muchii sau arce (termen folosit uneori în cazul grafurilor orientate).

În cele ce urmează ne propunem să prezentăm în detaliu principalele


structuri de date folosite în teoria grafurilor şi algoritmii cei mai des folosiţi
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 decât 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
numeroşi. Temele abordate în acest capitol sunt însă suficiente pentru ca
cititorul să poată, o dată cu înţelegerea acestora, aborda de unul singur
aproape orice problemă din acest domeniu.

Vom prezenta, acolo unde este cazul, mai mulţi algoritmi de


rezolvare a unei probleme, sau mai multe posibilităţi de implementare a unui
anumit algoritm. Considerăm că lucrările 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 mulţi factori, iar alegerea soluţiei folosite trebuie să ţină
cont de avantajele şi dezavantajele unei metode relativ la situaţia în care ne
aflăm.

Toate funcţiile şi structurile de date folosite în acest capitol au fost


prezentate în cadrul secţiunii Introducere în S.T.L., secţiune pe care
cititorul ar trebui să o parcurgă înainte de a începe aceste capitol.

355
Capitolul 12

CUPRINS

12.1. Noţiuni teoretice .................................................................................. 357


12.2. Reprezentarea grafurilor în memorie ................................................. 360
12.3. Probleme introductive ......................................................................... 364
12.4. Parcurgerea în adâncime ..................................................................... 369
12.5. Parcurgerea în lăţime........................................................................... 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. Reţele de transport ............................................................................ 423
12.12. Arbore parţial de cost minim ............................................................ 438
12.13. Concluzii .............................................................................................. 445

356
Teoria grafurilor

12.1. Noţiuni teoretice


În cele ce urmează vom prezenta termeni, definiţii, teoreme şi
formule importante pentru înţelegerea temelor care vor urma a fi abordate.
Nu este necesară însuşirea întregului volum de informaţii prezentat în
această secţiune înainte de a merge mai departe; cititorul poate oricând să
revină aici când întâlneşte ceva necunoscut şi care nu este explicat în locul
întâlnit.
În această secţiune vor fi doar enunţate anumite definiţii şi formule.
Eventualele demonstraţii şi exemple vor fi prezentate în momentul aplicării
acestora.

1. Un graf G este o pereche (V, E), unde V reprezintă mulţimea


nodurilor grafului (en. Vertices) şi E reprezintă mulţimea
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 numeşte orientat dacă mulţimea E este ordonată şi
neorientat în caz contrar. O muchie a unui graf orientat se mai
numeşte şi arc şi are se desenează ca o săgeată dinspre nodul
sursă spre nodul destinaţie. Grafurile orientate se mai numesc şi
digrafuri.
3. Un graf se numeşte ponderat dacă fiecare muchie are asociat un
cost sau o lungime.
4. O muchie (i, j) se numeşte 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 numeşte vecin al lui j şi invers. În cazul
grafurilor neorientate, i şi j se mai numesc extremităţi ale
muchiei (i, j), iar în cazul grafurilor orientate i se numeşte sursă,
iar j se numeşte destinaţie.
6. Gradul unui nod i este egal cu numărul muchiilor incidente la i.
7. În cazul grafurilor orientate, gradul interior al nodului i este
egal cu numărul de muchii care îl au ca destinaţie pe i, iar gradul
exterior al lui i este egal cu numărul de muchii care îl au ca
sursă pe i.
8. Un nod cu gradul 0 se numeşte nod izolat.
9. O muchie de la un nod la el însuşi se numeşte 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 fără bucle care are muchie între
oricare două noduri. Numărul de muchii al unui graf neorientat
complet este egal cu:

𝑉 ∙ ( 𝑉 − 1)
2

Numărul de muchii al unui graf orientat complet este egal cu:

𝑉 ∙ ( 𝑉 − 1)
.
Numărul de grafuri neorientate cu N noduri este dat de formula:
𝑁∙(𝑁−1)
2 2

13. Un graf se numeşte planar dacă poate fi desenat în aşa fel încât
muchiile sale să nu se intersecteze.
14. Un graf se numeşte nul dacă are 0 noduri.
15. Un graf se numeşte infinit dacă are un număr infinit de noduri.
16. Un subgraf G‟ = (V‟, E‟) al unui graf G = (V, E) este un graf
obţinut din G prin eliminarea unor noduri şi a muchiilor
incidente acestora. Aşadar, V‟ ⊆ V şi E‟ ⊆ E.
17. Un graf parţial G‟ = (V, E‟) al unui graf G = (V, E) este un graf
obţinut din G prin eliminarea unor muchii. Aşadar, 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 numărul de muchii existente
în drum. Costul unui drum este egal cu suma costurilor asociate
fiecărei muchii din drum.
20. Un graf G = (V, E) se numeşte bipartit dacă nodurile sale pot fi
partiţionate în două mulţimi X şi Y astfel încât V = X ∪ Y, X ∩
Y = ∅ şi oricare muchie a lui G are o extremitate în X şi cealaltă
extremitate în Y.
21. Se numeşte ciclu (sau ciclu elementar) un drum în care ultimul
nod coincide cu primul.
22. Un graf care nu conţine cicluri se numeşte aciclic.
23. Un graf neorientat G se numeşte 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 numeşte componentă conexă a grafului neorientat G un


subgraf conex de ordin maxim a lui G.
25. Un graf orientat G se numeşte 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 numeşte 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 fiecărui 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 pădure este un graf a cărui componente conexe sunt arbori.
31. Se numeşte nivelul k al unui arbore o mulţime formată din toate
nodurile aflate la distanţa k faţă de rădăcină.
32. Înălţimea unui arbore este dată de numărul de niveluri existente
în arbore.
33. Se numeşte 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
decât al lui i. Nodul i se numeşte fiu al lui j.
34. Se numeşte strămoş al unui nod i dintr-un arbore acel nod j care
se află pe un nivel mai mic decât cel al lui i. Nodul i se va numi
descendent al nodului j.
35. Se numeşte rădăcină a unui arbore nodul care nu are predecesor.
36. Se numeşte nod terminal (sau frunză) acel nod al unui arbore
care nu are fii.
37. Spunem că un graf se numeşte rar dacă numărul de muchii este
relativ mic (graful este mai aproape de un arbore decât de un graf
complet) şi dens dacă numărul 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 reţine un
graf într-un program C++. Există mai multe metode de a face acest lucru,
fiecare având anumite avantaje şi dezavantaje. Alegerea celei mai bune
reprezentări 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 pătratică 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 înlănţuite
unde G[i] reprezintă o listă cu toate nodurile adiacente nodului i.

De exemplu, pentru graful următor:

Fig. 12.2.1. – Un graf neorientat oarecare

Avem următoarele reprezentări:

Matrice de adiacenţă Liste de adiacenţă


1 2 3 4 5 6 G[1] = {2, 4}
1 0 1 0 1 0 0 G[2] = {1, 5}
2 1 0 0 0 1 1 G[3] = {6}
3 0 0 0 0 0 1 G[4] = {1}
4 1 0 0 0 0 0 G[5] = {2, 6}
5 0 1 0 0 0 1 G[6] = {5, 2, 3}
6 0 1 1 0 1 0

După cum se poate vedea din acest exemplu, matricele de adiacenţă


au dezavantajul de consuma mult mai multă memorie decât 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ă conţine 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 când 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 determinării adiacenţei a două noduri. Pentru a verifica dacă două
noduri i şi j sunt adiacente folosind matricea de adiacenţă, este suficient să
verificăm valoarea elementului A[i][j]. Pentru a verifica acelaşi lucru
folosind liste, trebuie să parcurgem întreaga listă asociată nodului i sau
nodului j, lucru care, în cel mai rău caz, se efectuează în timp O(N).
Pe grafuri rare, majoritatea algoritmilor care lucrează cu grafuri se
execută mult mai rapid dacă grafurile sunt reţinute 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 taţi în cazul arborilor. Dacă vrem să reprezentăm un
arbore, îl putem reprezentă reţinând pentru fiecare nod al său care este tatăl
acestuia, folosind un vector T, unde T[i] = j se citeşte j este tatăl lui i.
T[i] = 0 dacă i este rădăcina arborelui. De exemplu, următorul arbore:

Fig. 12.2.2. – Un arbore neorientat oarecare

Se poate reprezenta cu ajutorul următorului vector de taţi:


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 taţi are anumite avantaje şi
aplicaţii care vor fi discutate mai târziu.

361
Capitolul 12

O altă metodă de reprezentare a grafurilor este folosirea listelor de


muchii. O listă de muchii este un vector simplu care reţine muchiile
grafului. Această poate fi implementată construind o structură care reţine
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 reţine costul muchiei
respective, respectiv o valoare care semnifică inexistenţa muchiei; listele de
adiacenţă mai reţin o valoare împreună cu fiecare nod, care reprezintă costul
de la nodul asociat listei curente la nodul aflat la poziţia respectivă etc.
În continuare vom prezenta nişte modele de implementare a
metodelor discutate de reprezentare a grafurilor. Implementările propuse fac
uz de liste înlănţuite şi de containerul S.T.L. vector. Este recomandat ca
cititorul să fie familiarizat cu listele înlănţuite şi cu noţiunile de bază S.T.L.
În restul capitolului, implementarea listelor de adiacenţă se va face
folosind exclusiv containerul vector, fiind cel puţin la fel de eficient, mai
uşor de folosit şi mai flexibil.

a) Declaraţii

Pentru a folosi liste înlănţuite avem nevoie de o structură care ne


permite folosirea acestora. Pentru a folosi vectori, tot ce trebuie să facem
este să includem fişierul antet <vector> şi să declarăm un vector S.T.L. de
vectori clasici.

Declaraţie liste înlănţuite Declaraţie S.T.L. vector


struct graf #include <vector>
{
int nod; using namespace std;
graf *link_; // G[i] este vector S.T.L.
}; vector<int> G[maxn];
graf *G[maxn]; // vector clasic de liste inalntuite

b) Iniţializări

În cazul listelor înlănţuite, este necesar să iniţializăm fiecare listă cu


valoarea NULL înainte de aplicarea altor operaţii. 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) Adăugarea unei muchii

În cazul listelor înlănţuite, adăugarea unei muchii se face în felul


următor: dacă dorim să adăugăm muchia (x, y), îl vom adăuga pe y la
începutul listei asociate lui x. Ordinea nodurilor dintr-o listă nu are
importanţă, iar alegerea de a adăuga la început nodul este pentru a evita
parcurgerea întregii liste, lucru necesar dacă vrem să adăugăm nodul la
sfârşit.
În cazul vectorilor, adăugarea se face la sfârşit folosind funcţia
push_back(). Adăugarea la sfârşit în cadrul unui vector se realizează tot în
timp O(1) (complexitate amortizată, deoarece pot apărea realocări 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 înlănţuite 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 funcţie separată pentru adăugare.

d) Parcurgerea unei liste de adiacenţă

Dacă vrem să afişăm listele de adiacenţă, trebuie avut grijă în cazul


listelor să nu le şi stricăm, adică să nu mutăm pointerul link_. Pentru acest
lucru vom introduce o variabilă auxiliară cu ajutorul căreia vom parcurge
listele. În exemplu, N reprezintă numărul de noduri ale grafului.

Folosind liste înlănţuite Folosind S.T.L. vector


for ( int i = 1; i <= N; ++i ) for ( int i = 1; i <= N; ++i )
{ {
cout << i << ": "; cout << i << ": ";
for ( graf *tmp = G[i]; tmp; for ( int j = 0;
tmp = tmp->link_ ) j < G[i].size(); ++j )
cout << tmp->nod << ' '; cout << G[i][j] << ' ';
cout << endl; cout << endl;
} }

363
Capitolul 12

12.3. Probleme introductive


Pentru a familiariza cititorul cu noţiunile de până acum, prezentăm
câteva probleme elementare gata rezolvate, folosind atât reprezentarea prin
matricea de adiacenţă cât şi reprezentarea prin liste de adiacenţă
implementate ca vectori. Pentru problemele ce urmează, datele de intrare se
citesc din fişierul graf.in şi se afişează în fişierul graf.out.

a) Determinarea gradelor tuturor nodurilor

Se dă matricea de adiacenţă a unui graf neorientat cu N noduri. Să se


determine gradul fiecărui nod. Pe prima linie a fişierului de intrare se
găseşte numărul N, iar pe următoarele linii matricea de adiacenţă a grafului.
Linia i (1 ≤ i ≤ N) a fişierului de ieşire va conţine gradul fiecărui nod. Pentru
implementarea cu vectori, considerăm că se dă N, numărul de muchii M şi
lista acestora!
Rezolvarea problemei este imediată în cazul ambelor modalităţi de
reprezentare. Gradul unui nod este egal cu numărul 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 numărul 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> #include <fstream>
using namespace std; #include <vector>
const int maxn = 101; 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 identificării tipului unui graf


Se dă un graf orientat cu N noduri şi M muchii, de data asta prin lista
de muchii în cazul ambelor implementări. Să se determine dacă graful poate
fi considerat neorientat. Afişaţi 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ă reţinem graful prin liste de adiacenţă, pentru fiecare nod j din
lista nodului i, trebuie să verificăm dacă şi nodul i se găseşte în lista nodului
j.

365
Capitolul 12

Cu matrice de adiacenţă Cu vectori


#include <fstream> #include <fstream>
#include <vector>
using namespace std;
using namespace std;
const int maxn = 101;
const int maxn = 101;
int main()
{ bool cauta(const vector<int> &L,
int N, M, x, y; int nod)
bool G[maxn][maxn]; {
for ( int i = 0; i < L.size(); ++i )
ifstream in("graf.in"); if ( L[i] == nod )
in >> N >> M; 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; ifstream in("graf.in");


for ( int i = 1; i <= N; ++i ) in >> N >> M;
for ( int j = 1; j <= N; ++j ) for ( int i = 1; i <= M; ++i )
if ( G[i][j] && !G[j][i] ) {
neor = 0; in >> x >> y;
G[x].push_back(y);
ofstream out("graf.out"); }
out << neor; in.close();
out.close();
bool neor = 1;
return 0; 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;
}

366
Teoria grafurilor

c) Problema identificării frunzelor unui arbore

Se dă un arbore reprezentat prin vectorul de taţi. Să se determine


nodurile care sunt frunze. Prima linie a fişierului de intrare conţine numărul
de noduri N, iar a doua linie conţine elementele vectorului de taţi.

Pentru a rezolva această problemă vom porni de la definiţia


vectorului de taţi: valoarea fiecărui element i al vectorului de taţi reprezintă
tatăl 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 tatăl niciunui nod. Aşadar, problema
se reduce la a determina care numere naturale de la 1 la N ce nu apar în
vectorul de taţi.
De exemplu, pentru arborele:

Fig. 12.3.2. – Un arbore neorientat oarecare

Avem vectorul de taţi 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, obţinând
complexitatea O(N2), fie folosim un vector boolean de caracterizare V, unde
V[i] = true dacă numărul i se află în vectorul de taţi şi false în caz contrar.
Indicii elementelor care au valoarea 0 reprezintă nodurile terminale.
Complexitatea acestei metode este O(N), atât ca timp cât şi ca memorie.

Prezentăm 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 noţiunile de bază a grafurilor, propunem următoarele
probleme. Cititorul este încurajat să exploreze, pe cât posibil, mai mult de o
singură metodă de rezolvare.

a) Se dă un graf oarecare. Să se determine dacă acesta conţine


bucle.
b) Se dă un graf neorientat. Să se determine dacă acesta este
complet. Algoritmul se schimbă sau nu pentru grafuri orientate?
c) Scrieţi un program care construieşte o matrice de adiacenţă din
liste de adiacenţă şi invers.
d) Scrieţi un program care construieşte un vector de taţi dintr-o
matrice de adiacenţă, iar apoi din liste de adiacenţă.
e) Scrieţi un program care implementează o matrice de adiacenţă
folosind operaţii pe biţi.

368
Teoria grafurilor

f) Se dă un graf neorientat şi o secvenţă de noduri. Să se determine


dacă secvenţa reprezintă un drum elementar, un drum
neelementar, un ciclu elementar, un ciclu neelementar sau
niciuna dintre aceste variante.
g) Care este numărul maxim de componente conexe a unui graf cu
2010 noduri şi 100 de muchii?
h) Scrieţi un program care implementează listele de adiacenţă
folosind vectori clasici şi alocare dinamică.
i) Scrieţi un program care generează graful transpus al unui graf
orientat dat prin listă de muchii.
j) Rezolvaţi problemele propuse folosind containerul S.T.L. list.
k) Scrieţi un program care determină numărul de arbori existenţi
într-o pădure reprezentată prin vector de taţi.

12.4. Parcurgerea în adâncime


Parcurgerea grafurilor este o temă foarte importantă în teoria
grafurilor, aceste parcurgeri stând la baza celor mai mulţi algoritmi care
lucrează cu grafuri. Parcurgerea în adâncime (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ă direcţie atunci când 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 stânga sunt alese
înaintea celor din dreapta:

Fig. 12.4.1. – Ordinea de procesare a nodurilor în cadrul


parcurgerii în adâncime
369
Capitolul 12

Algoritmul în pseudocod foloseşte o funcţie 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. Funcţia poate fi
implementată astfel:
 Dacă V[nod] == true se iese din funcţie
 V[nod] = true
 Prelucrează nodul nod
 Pentru fiecare vecin i al nodului nod, apelează recursiv
DFS(G, i, V)

În cazul arborilor reprezentaţi ca grafuri orientate nu este necesar


vectorul V, deoarece nu există posibilitatea să vizităm un nod deja vizitat,
pentru că nu avem muchie de la un nod la tatăl său. În cazul grafurilor
orientate avem însă nevoie de un vector care să ne spună dacă am vizitat
deja un anumit nod.
Parcurgerea în adâncime este un algoritm fundamental în teoria
grafurilor, întrucât stă la baza altor algoritmi şi structuri de date mai
avansate. Este un algoritm uşor de implementat care poate fi folosit pentru a
rezolva elegant o multitudine de probleme. De exemplu, folosind
parcurgerea în adâncime putem determina distanţa dintre două noduri,
înălţimea arborelui, nivelul la care se află un nod, dacă există sau nu cicluri,
ce cicluri există şi altele.

Timpul de execuţie al parcurgerii în adâncime pe un graf cu N


noduri şi M muchii este O(N + M) în cazul reprezentării prin liste de
adiacenţă şi O(N2) în cazul reprezentării prin matrice de adiacenţă.

În continuare vom prezenta o implementare recursivă a parcurgerii


în adâncime, câteva particularizări a acestei parcurgeri, două probleme
rezolvate şi o implementare iterativă.

Programul următor citeşte din fişierul dfs.in un graf dat prin lista de
muchii şi afişează în fişierul dfs.out nodurile grafului în ordinea în care au
fost parcurse de către algoritm, pornind de la nodul 1.

370
Teoria grafurilor

Exemplu:

dfs.in dfs.out
45 1324
13
23
31
34
14

Există mai multe răspunsuri corecte, în funcţie de ordinea nodurilor


în listele de adiacenţă.

#include <fstream> int main()


#include <vector> {
using namespace std; int N, M;
const int maxn = 101; bool V[maxn];
vector<int> G[maxn];
void citire(vector<int> G[], int &N, int &M)
{ citire(G, N, M);
ifstream in("dfs.in"); for ( int i = 1; i <= N; ++i )
in >> N >> M; V[i] = false;
int x, y;
for ( int i = 1; i <= M; ++i ) ofstream out("dfs.out");
{ DFS(G, 1, V, out);
in >> x >> y; out.close();
G[x].push_back(y);
G[y].push_back(x); return 0;
} }
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
Capitolul 12

În cazul arborilor există două tipuri de parcurgeri în adâncime:


1. Parcurgerea în preordine, care este dată de algoritmul discutat şi
prezentat anterior. Această parcurgere este cea mai naturală,
prelucrând 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ă
aşadar principiul prelucrează nodul curent după prelucrarea
tuturor subarborilor. Dacă se dă un arbore şi se cere parcurgerea
sa în postordine, se poate folosi funcţia următoare:

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 stâng şi înainte de
apelul recursiv pentru subarborele drept. O astfel de implementare este
lăsată ca exerciţiu pentru cititor.
Aceste trei parcurgeri au aplicaţii în algoritmica arborilor de expresii
şi în determinarea componentelor tare conexe a unui graf.

Cele două desene de mai jos reprezintă ordinea prelucrării nodurilor


în cadrul parcurgerii unui arbore binar în postordine (stânga), respectiv în
ordine (dreapta).

372
Teoria grafurilor

Fig. 12.4.2. – Parcurgerea în postordine şi în ordine

a) Determinarea proprietăţii de graf aciclic

Se dă un graf neorientat conex prin lista de muchii. Să se determine


dacă graful conţine cicluri. În caz afirmativ se va afişa 1, iar în caz negativ
0. Fişierele asociate problemei sunt dfs.in şi dfs.out.
O primă idee de rezolvare ar fi să folosim parcurgerea în adâncime
aşa cum a fost aceasta prezentată anterior, adăugând condiţia că dacă ne
aflăm pe un nod deja marcat ca vizitat, am găsit un ciclu. Această soluţie ar
fi însă eronată, deoarece, graful fiind neorientat, am ajunge să considerăm
orice muchie (x, y) ca fiind un ciclu de lungime doi. Un ciclu trebuie să
conţină însă cel puţin trei muchii.
Pentru a evita situaţia în care o muchie neorientată este considerată
ciclu, vom introduce încă un parametru pred funcţiei 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
efectuăm apeluri recursive pentru un nod care este predecesorul nodului
curent. De exemplu, dacă ne aflăm î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, când vrem să
efectuăm un apel recursiv, verificăm mai întâi dacă nodul pentru care vrem
să apelămrecursiv nu este predecesorul nodului curent. Dacă nu este,
verificăm dacă acest nod are deja un predecesor: dacă da, am găsit un ciclu,
iar dacă nu salvăm predecesorul şi efectuăm apelul recursiv.
Desenele de mai jos reprezintă un posibil mod de funcţionare al
algoritmului pe un graf oarecare. Am marcat cu roşu nodurile vizitate în
cadrul parcurgerii şi cu albastru predecesorii nodurilor.

373
Capitolul 12

Fig. 12.4.3. – Modul de execuţie 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;
}

Exerciţii:
a) Modificaţi algoritmul în aşa fel încât să şi afişeze un ciclu în
cazul existenţei unuia.
b) Cum s-ar putea rezolva problema pentru grafuri orientate?
c) Scrieţi un program care afişează 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 distanţa maximă dintre două noduri. Distanţa maximă dintre două
noduri se mai numeşte şi diametrul arborelui.

Exemplu:

dfs.in dfs.out
5 3
12
23
24
45

Explicaţie: distanţa 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
rădăcină, aşa că vom aplica următorul algoritm:
 Se determină distanţele de la rădăcină (vom considera rădăcina
ca fiind nodul cu numărul 1) la toate celelalte noduri ale
arborelui. Acest lucru se poate face adăugând un parametru dist
funcţiei DFS, care iniţial este 0 şi care creşte cu 1 la fiecare apel
recursiv. Vom folosi un vector de distanţe D care va reţine aceste
distanţe.
 Se determină acel nod X pentru care D[X] este maxim.
 Se determină distanţele de la X la toate celelalte noduri ale
arborelui. Distanţa maximă determinată la acest pas reprezintă
soluţia problemei.

Pentru exemplul de mai sus, avem distanţa maximă de la rădăcină la


celelalte noduri dată de distanţa de la nodul 1 la nodul 5. Răspunsul
problemei va fi dat de distanţa maximă de la nodul 5 la celelalte noduri.
Acest maxim are loc pentru distanţa 3 dintre dintre nodul 5 şi nodul 3 sau 1.
În figura următoare, distanţele calculate apar în albastru:

376
Teoria grafurilor

Fig 12.4.4. – Modul de execuţie al algoritmul de determinare a


diametrului unui arbore

Poate părea inutilă şi complicată această abordare. Mulţi


programatori se gândesc că pot pur şi simplu să determine distanţa de la
rădăcină la toate nodurile, iar apoi să facă suma celor mai mari două
distanţe. Exemplul dat este un contraexemplu la această abordare. Altă
abordare eronată este considerarea răspunsului ca fiind distanţa maximă de
la rădăcină la celelalte noduri. Găsirea unui contraexemplu pentru această
idee este lăsată cititorului.

Prezentăm î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) int main()


{ {
for ( int i = 1; i <= N; ++i ) int N;
D[i] = -1; int D[maxn];
} vector<int> G[maxn];

void DFS(vector<int> G[], int nod, int dist, citire(G, N);


int D[]) init(D, N);
{
// putem folosi D ca sa vedem daca DFS(G, 1, 0, D);
// un nod a mai fost sau nu vizitat
if ( D[nod] != -1 ) int X = 1;
return; for ( int i = 2; i <= N; ++i )
D[nod] = dist; 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;
}

Exerciţii:
a) Rezolvaţi aceeaşi problemă pe un graf ponderat (fiecare muchie
are asociat un anumit cost).
b) Modificaţi programul în aşa fel încât să afişeze şi nodurile
drumului.

c) Implementare iterativă

Pot exista cazuri în care, din considerente de timp de execuţie sau de


limitări ale stivei, nu ne permitem să folosim parcurgerea în adâncime
implementată recursiv. Putem însă implementa iterativ această parcurgere
simulând stiva folosind un vector clasic. Implementarea este dificilă, mai

378
Teoria grafurilor

ales dacă folosim vectori pentru reţinerea listelor de adiacenţă sau liste
înlănţuite pe care nu dorim să le distrugem în timpul prelucrării. Funcţia
prezentată este doar orientativă; cititorul este sfătuit să o studieze şi să o
îmbunătăţească. În practică, astfel de implementări sunt foarte rar întâlnite
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;
}
}

Exerciţii:
a) Folosiţi parcurgerea în adâncime pentru a determina
componentele conexe ale unui graf.
b) Implementaţi varianta iterativă a parcurgerii în adâncime
folosind liste înlănţuite.

379
Capitolul 12

c) Implementaţi varianta iterativă folosind liste înlănţuite


implementate manual. Codul scris se reduce cu mult. Explicaţi
de ce.
d) Implementaţi parcurgerea DF pe un graf reprezentat prin matrice
de adiacenţă.
e) Scrieţi variante iterative ale parcurgerii în adâncime care parcurg
un arbore binar în preordine, ordine şi postordine.
f) Scrieţi un program care determină, pentru mai multe perechi de
noduri, dacă acestea se află în aceeaşi componentă conexă.
g) Aceeaşi cerinţă ca la f), dar pentru aceeaşi componentă tare
conexă.

12.5. Parcurgerea în lăţime


Parcurgerea în lăţime (en. breadth-first search – BFS) este o
parcurgere a grafurilor care stă la baza algoritmilor de determinare a
distanţelor minime în grafuri. Putem extinde noţiunea de nivel de la arbori
la grafuri oarecare. Astfel, vom defini nivelul k al unui graf ca fiind
mulţimea tuturor nodurilor aflate la distanţa k faţă de un nod fixat (sau nod
sursă), care va fi considerat de acum în colo nodul 1. Parcurgerea în lăţime
presupune prelucrarea nodului 1, după aia prelucrearea tuturor vecinilor
acestuia, după aia prelucrarea vecinilor acestora ş.a.m.d. până când se
parcurge tot graful sau se găseşte un nod anume. Altfel spus, se parcurg
toate nodurilor de pe un anumit nivel înainte de a trece la următorul 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 lăţime

380
Teoria grafurilor

Acest desen ascunde o proprietate importantă a parcurgerii în lăţime:


parcurgerea în lăţime determină distanţa minimă de la nodul sursă la toate
celelalte noduri accesibile ale grafului. Trebuie menţionat totuşi că acest
lucru are loc numai atunci când toate muchiile au acelaşi cost (adică graful
nu este ponderat).
Timpul de execuţie al parcurgerii în lăţime pentru un graf cu N
noduri şi M muchii este O(N + M) în cazul în care graful este reţinut prin
liste de adiacenţă şi O(N2) în cazul în care graful este reţinut prin matricea
de adiacenţă.
Cititorii care au parcurs secţiunea dedicată programării dinamice
sunt deja familiari cu acest algoritm: acesta nu este altceva decât o
generalizare a ceea ce atunci am numit algoritmul lui Lee. Algoritmul lui
Lee este folosit pentru a determina distanţe 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 stânga, dreapta,
sus, jos şi eventual pe diagonale. Din punct de vedere conceptual, putem
transforma uşor matricea într-un graf neorientat în care un nod este
caracterizat prin linia şi coloana pe care s-a situat în matrice. Aşadar,
algoritmul lui Lee este de fapt o parcurgere în lăţime.
Deoarece am prezentat deja algoritmul, nu vom insista foarte mult
asupra modului de funcţionare ş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). Recomandăm citirea secţiunii
Introducere în S.T.L. cititorilor nefamiliarizaţi cu acest container.
Vom prezenta două aplicaţii ale parcurgerii în lăţime:
a) Determinarea proprietăţii de graf bipartit.
b) Sortarea topologică

În primul rând, prezentăm o funcţie care doar afişează nodurile unui


graf, într-o ordine dată de parcurgerea sa în lăţime, în fişierul bfs.out.
Programul întreg are aceeaşi 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 lăţime este preferabilă parcurgerii în


adâncime, datorită faptului că nu folosim apeluri recursive. Mai mult, dacă
ne interesează găsirea unui nod, parcurgerea BFS poate fi, de cele mai multe
ori, oprită mai devreme decât parcurgerea în adâncime, 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 adevărat
pentru parcurgerea în adâncime.
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 făcută în funcţie de problema pe care dorim
să o rezolvăm şi de resursele disponibile.

382
Teoria grafurilor

a) Determinarea proprietăţii de graf bipartit

Un graf este bipartit dacă putem partiţiona nodurile acestuia în două


mulţimi disjuncte X şi Y în aşa fel încât orice muchie a grafului să aibă una
dintre extremităţi î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 aşa fel încât extremităţile fiecărei
muchii să fie colorate diferit. În cazul grafurilor bipartite este evident că
putem colora nodurile grafului cu două culori distincte în aşa fel încât
extremităţile oricărei muchii să fie colorate diferit. Fiecare culoare va
determina câte o partiţie a grafului.
Pentru a determina dacă un graf este sau nu bipartit putem să
încercăm să colorăm graful cu două culori distincte: dacă reuşim, 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 săi cu culoarea 2, vecinii
acestora iarăşi cu culoarea 1 etc. Dacă la un anumit pas ar trebui să
schimbăm 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
lăţime. Vom seta primul nod pe culoarea 1, iar apoi vom aplica parcurgerea
în lăţime pornind din primul nod. Pentru fiecare nod nod extras din coadă,
vom încerca să atribuim vecinilor săi culoarea 3 – cul[nod] (astfel, în caz că
avem cul[nod] == 1, vecinii vor primi culoarea 2 şi invers).
Desenele de mai jos reprezintă execuţia 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 aceeaşi culoare, deci acesta
este tot bipartit, iar al treilea este un graf care nu e bipartit, nodul colorate în

383
Capitolul 12

verde reprezentând nodul pe care algoritmul a încercat să-l coloreze cu două


culori distincte. Fiecare nod are asociat un număr care reprezintă o posibilă
ordine de parcurgere.

Fig. 12.5.3. – Determinarea proprietăţii de graf bipartit


pe mai multe grafuri

Prezentăm doar funcţia 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;
}

Exerciţii:
a) Implementaţi algoritmul prezentat folosind parcurgerea în
adâncime.
b) Considerăm funcţiile f definite pe mulţimea numerelor naturale
cu valori tot în mulţimea numerelor naturale. Daţi exemple de
funcţii pentru care graful format din muchiile (x, f(x)) este
bipartit şi de funcţii pentru care acelaşi graf nu este bipartit, când
x parcurge pe rând numerele naturale.

385
Capitolul 12

b) Sortarea topologică

Considerăm o mulţime de activităţi {A1, A2, ..., AN} şi o mulţime de


relaţii (Ap, Aq) care semnifică faptul că activitatea Ap trebuie desfăşurată
neapărat înaintea activităţii Aq. Se cere găsirea unei ordini de desfăşurare a
activităţilor.

Rezolvarea problemei presupune modelarea acesteia ca o problemă


de grafuri. Astfel, fiecare activitatea va reprezenta un nod şi fiecare relaţie
(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ă activităţi
care depind reciproc una de cealaltă, deci nu ar exista soluţie). Rezolvarea
problemei se reduce la găsirea unei ordini a nodurilor în care fiecare nod i
apare după apariţia 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 lăţime


în felul următor: iniţial vom calcula gradul interior al fiecărui nod, după care
vom adăuga în coadă toate nodurile care au gradul interior 0 (adică acele
noduri în care nu intră nicio muchie). De fiecare dată când extragem un nod
din coadă, vom scădea cu 1 gradele interioare ale vecinilor acestora şi vom
adăuga în coadă doar acele noduri ale căror grade interioare devin 0. Astfel
ne asigurăm că un nod nu va fi prelucrat decât dacă toate nodurile ce au
muchii înspre el au fost la rândul lor prelucrate. Mai mult, putem detecta în
acest fel şi dacă avem sau nu soluţie, adică dacă graful pe care lucrăm 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 aplicaţii în probleme de planificare a


activităţilor. De exemplu, algoritmul poate fi folositor pentru a stabili
priorităţi şi a stabili sarcini în cadrul unui proiect complex.

Funcţia următoare citeşte un graf orientat din fişierul bfs.in şi


afişează o sortare topologică a sa în fişierul 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 adâncime sau în lăţime pornind de la i şi se etichetează
toate nodurile, inclusiv nodul sursă, cu un număr k, care iniţial este 1. Se
incrementează k şi se trece la următorul nod. La sfârşit, 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ă funcţioneze,


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

Observaţie: Un graf orientat se numeşte tare conex dacă pentru


oricare două noduri x şi y există drum atât de la x la y cât ş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 numeşte


conex sau slab conex.
În rezolvarea problemei vom folosi algoritmul lui Kosaraju, care
porneşte de la următoarea idee pentru a determina componentele tare conexe
ale lui G:
 Se construieşte graful transpus GT .
 Cât 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 atât cu + cât ş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
execuţie de O(N2). Exerciţiu: găsiţi un astfel de caz.
Algoritmul lui Kosaraju este o optimizare a algoritmului
plus-minus, optimizare care face algoritmul să aibă timpul de execuţie de
O(N + M) pe toate cazurile. Această optimizare constă în reţinerea
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 iniţial 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 următor afişează direct componentele tare conexe ale


unui graf citit, fără a efectua propru-zis etichetarea menţionată.

389
Capitolul 12

#include <fstream> void DFS_GT(vector<int> GT[],


#include <vector> int nod,
bool V[],
using namespace std; ofstream &out)
const int maxn = 101; {
if ( !V[nod] )
void citire(vector<int> G[], return;
vector<int> GT[], V[nod] = false;
int &N, int &M)
{ out << nod << ' ';
ifstream in("ctc.in");
in >> N >> M; 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 )
{ int main()
in >> x >> y; {
G[x].push_back(y); int N, M;
GT[y].push_back(x); 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;
vector<int> &postord) for ( int i = 1; i <= N; ++i )
{ DFS_G(G, i, V, postord);
if ( V[nod] )
return; ofstream out("ctc.out");
V[nod] = true; for ( int i = 1; i <= N; ++i )
if ( V[i] )
for ( int i=0; i < G[nod].size(); ++i ) {
DFS_G(G, G[nod][i], V, postord); DFS_GT(GT, i, V, out);
out << '\n';
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 numeşte biconex
dacă acesta nu conţine puncte de articulaţie (numite şi noduri critice). Un
nod se numeşte nod critic dacă ştergerea sa din graf, împreună cu toate
muchiile incidente acestuia, cauzează apariţia 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 aplicaţii în telecomunicaţii. O
reţea de telefonie sau internet este bine să nu aibă niciun nod critic, deoarece
defectarea acestuia poate duce la căderea comunicaţiilor pentru mulţi
utilizatori.
Pentru a rezolva această problemă, vom introduce mai întâi câteva
noţiuni teoretice.

Definiţia 1: Se numeşte arbore parţial al grafului G un graf parţial


al lui G care este arbore. De exemplu, graful parţial marcat cu roşu din
următorul desen este arbore parţial:

Fig. 12.7.1. – Un arbore parţial al unui graf oarecare

Definiţia 2: Se numeşte arbore DFS un arbore parţial obţinut prin


parcurgerea grafului în adâncime. 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 parţial de mai sus este un arbore DFS, care arată în felul
următor (muchiile de întoarcere apar cu albastru):

391
Capitolul 12

Fig 12.7.2. – Un exemplu de arbore DFS

Având construit arborele DFS, putem face următoarea observaţie: un


nod i este critic dacă şi numai dacă există cel puţin un fiu al său de la care
nu se poate ajunge la un strămoş al nodului i (luând în considerare şi
muchiile de întoarcere) fără a trece prin i. În exemplul anterior, astfel de
noduri sunt 2 (de la 4 nu se poate ajunge la 1 fără a trece prin 2), 5 (de la 6
nu se poate ajunge la 2 şi 1 fără a trece prin 5) şi 8.
În cazul rădăcinii arborelui DFS, aceasta este nod critic dacă şi
numai dacă are cel puţin doi fii.
Putem determina aşadar nodurile critice ale unui graf cu o simplă
parcurgere în adâncime, 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 adâncime:
1. D, unde D[nod] = distanţa de la rădăcina arborelui DFS la nodul
nod, fără a lua în considerare muchiile de întoarcere
2. minim, cu minim[nod] = minimul dintre minim[nod] şi D[i],
când din nod vrem să ne deplasăm pe nodul deja vizitat i
folosind o muchie de întoarcere şi minimul dintre minim[nod] şi
minim[i] când din nod vrem să ne deplasăm în nodul nevizitat i.
minim[nod] se iniţializează cu D[nod].

Semnificaţia primului set de valori este evidentă. Cel de-al doilea set
de valori ne ajută să verificăm pentru un nod nod dacă ştergerea lui
păstrează subarborele său conectat de restul grafului (adică dacă nod este
sau nu punct de articulaţie). Dacă ştergerea lui nod păstrează graful conex,
atunci minim[i] trebuie să fie strict mai mic decât D[nod], unde i este un fiu
al lui nod (cu alte cuvinte, există o muchie de întoarcere de la unul dintre
descendenţii lui nod la unul dintre strămoşii săi). Altfel, dacă minim[i] este
mai mare sau egal cu D[nod], nod este nod critic.

392
Teoria grafurilor

Figura următoare prezintă arborele DFS anterior completat cu


valorile D (roşu) şi minim (albastru).

Fig 12.7.3. – Modul de execuţie al algoritmului de determinare a


componentelor biconexe

Implementarea este intuitivă dacă algoritmul a fost înţeles. Trebuie


tratat special nodul 1, care este critic doar dacă are cel puţin doi fii în
arborele DFS (făcând abstracţie de muchiile de întoarcere!).

Prezentăm doar funcţiile relevante. Singura precondiţie necesară este


iniţializarea vectorului D cu -1 înainte de apelarea funcţiei.

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 << ' ';
}

Exerciţii:
a) Problema se poate rezolva şi mai intuitiv, dar mai puţin eficient.
Care ar fi un algoritm naiv de rezolvare?
b) Modificaţi programul prezentat astfel încât să afişeze muchiile
critice, adică acele muchii a căror înlăturare ar deconecta graful.
c) Modificaţi programul prezentat astfel încât să afişeze
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 către matematicianul Leonhard Euler, care a
rezolvat problema podurilor din Königsberg. Această problemă cere
parcurgerea tuturor podurilor din următoarea figură o singură dată
(exerciţiu: este acest lucru posibil?)

394
Teoria grafurilor

Fig. 12.8.1. – Problema podurilor din Königsberg

Pentru a rezolva problema determinării unui drum sau ciclu eulerian,


trebuie să enunţăm câteva condiţii de existenţă a acestora. În primul rând, 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 numeşte
semi-eulerian). Pentru un graf conex G avem următoarele proprietăţi:
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 puţin 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 adâncime 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,
făcută convenabil, ne va furniza fie un ciclu eulerian fie un drum eulerian.
Algoritmul va folosi o funcţie euler(G, nod, st) care va construi în vectorul
st parcurgerea euleriană a grafului. Această funcţie 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 sfârşitul 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 implementării
algoritmului, ştergerea nodului i din lista de adiacenţă a lui nod se va face
atribuind poziţiei lui i valoarea -1. O valoare de -1 va indica faptul că acea
poziţie nu reprezintă un nod existent în lista de adiacenţă curentă.
Atenţie! Deoarece implementarea prezentată lucrează cu grafuri
neorientate, pentru a şterge o muchie (nod, i), trebuie şters atât i din lista lui
nod cât şi nod din lista lui i!

Desenele următoare prezintă modul de execuţie al algoritmului


(ordinea efectuării apelurilor recursive) pe un graf neorientat care admite
doar un drum eulerian. Cu roşu 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 execuţie 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 atâtea apeluri recursive câte muchii există. Complexitatea

397
Capitolul 12

acestui algoritm este aşadar O(M) pentru un graf cu M muchii. În final,


st = {8, 7, 4, 6, 5, 4, 3, 2, 4, 1}. Citit invers, obţinem următorul drum
eulerian: 1 – 4 – 2 – 3 – 4 – 5 – 6 – 4 – 7 – 8.

#include <fstream> int main()


#include <vector> {
using namespace std; int N, M;
const int maxn = 101; vector<int> G[maxn], st;

void citire(vector<int> G[], int &N, int &M) citire(G, N, M);


{
ifstream in("euler.in"); euler(G, 1, st);
in >> N >> M;
int x, y; ofstream out("euler.out");
for ( int i = 1; i <= M; ++i ) for ( int i = st.size() - 1;
{ i >= 0;
in >> x >> y; --i )
G[x].push_back(y); out << st[i] << ' ';
G[y].push_back(x); out.close();
}
in.close(); return 0;
} }

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
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 puţin nodul sursă, care
este identic cu nodul destinaţie (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 noţiunilor şi algoritmilor prezentaţi la
cicluri hamiltoniene şi la grafuri orientate este lăsată ca un exerciţiu pentru
cititor.
Un exemplu de graf semi-hamiltonian este următorul:

Fig 12.9.1. – Un graf semi-hamiltonian

Acest graf admite drumurile hamiltoniene 4 – 1 – 2 – 3 – 5 şi


4 – 1 – 2 – 5 – 3 având ca sursă nodul 4. În general, găsirea unui drum
hamiltonian este o problemă care nu are rezolvări deterministe eficiente. O
soluţie evidentă este generarea tuturor permutărilor P a mulţimii
{1, 2, ..., N} şi verificarea existenţei muchiei (Pi, Pi+1) pentru 1 ≤ i < N.
Există câteva rezultate importante care ne pot ajuta să determinăm
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 existenţei unui ciclu, putem să ştergem o muchie din ciclu şi să
obţinem astfel un drum. Aşadar, următoarele afirmaţii se aplică şi grafurilor
semi-hamiltoniene, care ne interesează:
1. În primul rând, toate grafurile complete sunt hamiltoniene.
2. Teorema lui Dirac (1952): un graf cu N ≥ 3 noduri este
𝑁
hamiltonian dacă fiecare nod al său are cel puţin 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 puţin N.

399
Capitolul 12

4. Teorema Bondy-Chvátal (1972): un graf G cu N noduri este


hamiltonian dacă şi numai dacă cl(G) este hamiltonian. Prin
cl(G) înţelegem închiderea lui G, adică graful obţinut prin
adăugarea de muchii între oricare două noduri neadiacente a lui
G a căror sumă a gradelor este cel puţin N.

Folosind a patra teoremă, putem determina eficient existenţa unui


ciclu hamiltonian, dar găsirea efectivă a unui astfel de ciclu rămâne 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 ataşat un cost (sau o distanţă). Se cere
un drum (uneori un ciclu) hamiltonian de cost minim. Nici această problemă
nu are soluţii deterministe eficiente, dar vom prezenta două abordări
probabiliste care sunt mult mai rapide decât abordările determineste şi care
furnizează răspunsuri foarte apropiate de optimul global.
De exemplu, dacă atribuim costuri muchiilor grafului din exemplul
anterior, avem următorul 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, având ca sursă un nod x şi ca destinaţie un nod
oarecare.

O primă idee de rezolvare constă în parcurgerea tuturor drumurilor


de la x cu ajutorul unei parcurgeri în adâncime. Să analizăm însă
complexitatea acestei metode pe un graf complet. Există N – 1 muchii care
unesc alte noduri cu nodul x, aşadar se vor efectua pe rând 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 rău caz este aşadar
O(N!).
Deoarece complexitatea algoritmului este O(N!) nu ne vom permite
să lucrăm cu grafuri cu mai mult de ~10 noduri. Aşadar, pentru o
implementare mai simplă vom folosi matrici de adiacenţă pentru
reprezentarea grafului. Considerând 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 asemănătoare cu o parcurgere în
adâncime. Va trebui doar să adăugăm câţiva parametri suplimentari funcţiei
de parcurgere. Fie hamilton(G, nod, nr, c, cmin, V, st, sol) o funcţie care
construieşte în sol un drum hamiltonian de cost minim cmin. Variabila nod
reprezintă nodul curent, nr reprezintă numărul de noduri deja parcurse, st
reprezintă drumul curent iar c reprezintă costul drumului curent. Această
funcţie poate fi implementată în felul următor:
 Dacă c > cmin sau V[nod] == true se iese din funcţie
 Adaugă i în st
 Dacă nr == N şi c < cmin execută
o Actualizează sol şi cmin
o Ieşire din funcţie
 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 foloseşte metoda backtracking pentru parcurgerea tuturor


drumurilor. Datorită optimizării facute în prima linie a programului, aceea
de a nu continua pe traseul curent dacă costul acestuia este deja mai mare
decât minimul găsit până acum, este posibil ca pe grafuri generate aleator
algoritmul să funcţioneze mai bine decât ar indica notaţia asimptotică.
Totuşi, în practică, această metodă este ineficientă pentru un număr mare de
noduri (mii, zeci de mii) şi vom încerca să găsim soluţii mai eficiente.
Prezentăm totuşi o implementare a acestei metode.

Datele de intrare se citesc din fişierul hamilton.in, care conţine pe


prima linie numerele N M x, iar pe următoarele M linii o listă de muchii
ponderate care descriu un graf semi-hamiltonian. În fişierul de ieşire
hamilton.out se va afişa costul minim al unui drum hamiltonian şi un drum
care are acest cost.

401
Capitolul 12

#include <fstream> int main()


#include <vector> {
using namespace std; int G[maxn][maxn], N, M, x;
const int maxn = 101;
bool V[maxn];
void citire(int G[maxn][maxn], citire(G, N, M, x);
int &N, int &M, int &x)
{ for ( int i = 1; i <= N; ++i )
ifstream in("hamilton.in"); V[i] = false;
in >> N >> M >> x;
int p, q, c; int st[maxn], sol[maxn];
for ( int i = 1; i <= M; ++i ) int cmin = 1 << 30;
{ hamilton(G, N, x, 1, 0,
in >> p >> q >> c; cmin, V, st, sol);
G[p][q] = G[q][p] = c;
} ofstream out("hamilton.out");
in.close(); out << cmin << '\n';
} for ( int i = 1; i <= N; ++i )
out << sol[i] << ' ';
void hamilton(int G[maxn][maxn], int N, out.close();
int nod, int nr, int c, int &cmin,
bool V[], int st[], int sol[]) return 0;
{ }
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
Teoria grafurilor

Algoritmii folosiţi pentru a rezolva instanţe ale problemei cu un


număr foarte mare de noduri sunt algoritmi probabilişti. Î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 reţine pur şi simplu un vector cu N noduri care va
reprezenta un astfel de drum.
2. Unul sau mai mulţi operatori genetici care se aplică
cromozomilor în cadrul trecerii de la o generaţie la alta.
3. O funcţie de adecvare.

Operatorii genetici care pot fi folosiţi sunt:


 Operatorul de mutaţie: se aleg două poziţii i şi j dintr-un
cromozom şi se interschimbă nodurile reţinute în acele poziţii.
 Operatorul de inversiune: se aleg două poziţii i şi j dintr-un
cromozom şi se înlocuieşte secvenţa [i, j] a acestui cromozom cu
inversul acesteia.

Operaţia de recombinare trebuie tratată puţin diferit, deoarece avem


de-a face cu permutări şi nu putem să concatenăm pur şi simplu două
secvenţe disjuncte a doi cromozomi diferiţi fără a strica validitatea
cromozomului rezultat. Vom aplica aşadar următorul algoritm pentru
recombinare:
 Fie C1 şi C2 cei doi cromozomi din care vrem să obţinem un
cromozom pentru generaţia viitoare.
 Se copiază primele k gene (elemente) din C1 în cromozomul
rezultat, unde k este un număr aleator. Din C2 se copiază toate
genele care nu se află deja în cromozomul rezultat.

Funcţia de adecvare va fi evident costul traseului codificat de către


un cromozom.

Algoritmul aleator este mai uşor de implementat şi presupune


îmbunătăţirea unui drum ales aleator cu ajutorul efectuării unor schimbări
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 cât k este mai mare, cu atât este mai mare
probabilitatea să găsim un optim global)
403
Capitolul 12

o Efectuează două interschimbări aleatore în sol.


o Dacă noul vector sol codifică o soluţie de cost mai mic,
se păstrează interschimbările, altfel se anulează.

Deoarece lucrăm cu permutări şi nu avem garanţia faptului că graful


citit este graf complet, vom adăuga muchii de cost infinit (un număr foarte
mare) grafului până ce acesta devine complet. Probabilitatea ca muchiile de
cost infinit adăugate să facă parte dintr-o soluţie furnizată de oricare
algoritm este foarte mică.
Implementările acestor algoritmi sunt similare cu altele prezentate
deja, aşa că le lăsăm ca exerciţiu pentru cititor.

12.10. Drumuri de cost minim în grafuri ponderate


Am discutat până acum despre drumuri minime în grafuri
neponderate şi despre drumuri de cost minim în cadrul problemei comis
voiajorului. Într-un graf neponderat putem găsi un drum minim dintre două
noduri foarte uşor aplicând o parcurgere în adâncime din nodul sursă.
Problema se complică însă atunci când vrem să găsim un drum minim între
două noduri într-un graf ponderat. Rezolvările eficiente sunt netriviale şi
trebuie ţinut cont de anumite neajunsuri a unor algoritmi.
Aplicaţiile algoritmilor de determinare a drumurilor de cost minim
sunt foarte vaste, atât în alte probleme teoretice cât şi direct în practică. De
exemplu, în scrierea unui program pentru un sistem de navigaţie 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 găsesc 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 secţiuni vor fi prezentaţi următorii 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 găsi distanţa minimă dintre toate perechile de noduri (i, j).
Este un algoritm de programare dinamică. Acesta se aplică asupra matricii
ponderilor prin care se reţine graful. Pentru un graf cu N reţinut în matricea
G algoritmul este următorul:
 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 sfârşitul algoritmului, semnificaţia lui G va fi G[i][j] = costul


unui drum de cost minim de la i la j. Practic, se iniţializează drumurile
minime dintre fiecare două noduri cu costurile muchiei dintre acestea (sau
cu infinit în caz că nu există muchie) şi se încearcă îmbunătăţirea tuturor
drumurilor dintre un nod i şi un nod j trecând 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 reţine 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 obţinem 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
1 0 3 0 1 0 1 0 2 6 1 4
2 3 0 4 1 2 2 2 0 4 1 2
3 0 4 0 0 5 3 6 4 0 5 5
4 1 1 0 0 6 4 1 1 5 0 3
5 0 2 5 6 0 5 4 2 5 3 0

405
Capitolul 12

Unde 0 semnifică inexistenţa 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 câteva 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 cărui cost scade cu fiecare parcurgere. De exemplu, graful următor
conţine un astfel de ciclu:

Fig. 12.10.2. – Un ciclu de cost negativ într-un graf ponderat

Problema găsirii unui ciclu de cost negativ este echivalentă cu


găsirea unui drum de cost mai mic decât 0 de la un nod i la acelaşi nod i.
Deoarece algoritmul încearcă îmbunătăţirea drumului curent dintre oricare
două noduri i şi j, atunci când j = i, dacă există un nod k astfel încât
G[i][k] + G[k][j] < G[i][j] = G[i][i] = 0, atunci există un ciclu de cost
negativ în graf. Aşadar, dacă la sfârşitul algoritmului există o valoare
negativă pe diagonala principală, graful dat are un ciclu de cost negativ.

Programul următor citeşte un graf dat prin matricea ponderilor şi


afişează matricea drumurilor de cost minim:

406
Teoria grafurilor

#include <fstream> void roy_floyd(int G[maxn][maxn], int N)


{
using namespace std; for ( int k = 1; k <= N; ++k )
for ( int i = 1; i <= N; ++i )
const int maxn = 101; for ( int j = 1; j <= N; ++j )
const int inf = 1 << 29; G[i][j]=min(G[i][j],G[i][k]+G[k][j]);
}
void citire(int G[maxn][maxn],
int &N) int main()
{ {
ifstream in("rf.in"); int G[maxn][maxn], N;
in >> N; citire(G, N);
for ( int i = 1; i <= N; ++i ) roy_floyd(G, N);
for ( int j = 1; j <= N; ++j )
{ ofstream out("rf.out");
in >> G[i][j]; for ( int i = 1; i <= N; ++i )
{
if ( i != j && !G[i][j] ) for ( int j = 1; j <= N; ++j )
G[i][j] = inf; out << G[i][j] << ' ';
} out << '\n';
}
in.close(); out.close();
} return 0;
}

Putem fi interesaţi de nodurile care alcătuiesc un drum de cost


minim dintre două noduri x şi y. Pentru a putea reconstitui un drum dintre
două noduri, vom porni de la următoarele observaţii:
1. Deoarece algoritmul caută la fiecare pas un nod intermediar k
pentru a îmbunătăţi distanţa dintre două noduri i şi j rezultă că,
după execuţia algoritmului, există un k astfel încât
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. Aşadar,
vom folosi o funcţie 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
iniţial, deci putem afişa pur şi simplu acea muchie.

O funcţie care afişează pe ecran drumul minim dintre două noduri


poate fi implementată în felul următor:
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';
}

Exerciţii:
a) Ce se întâmplă dacă folosim const int inf = 1 << 30; ?
b) Modificaţi algoritmul astfel încât să determine dacă un graf este
conex (sau tare conex).
c) Memoraţi, pentru fiecare pereche (i, j) din cadrul algoritmului de
calculare a costurilor minime, nodul k intermediar ales. Scrieţi o
funcţie de reconstituire care foloseşte aceste informaţii pentru a
găsi mai eficient drumurile.
d) Modificaţi algoritmul de reconstituire astfel încât să afişeze
nodurile unui drum în loc de muchiile unui drum.

b) Algoritmul lui Dijkstra


Am prezentat anterior un algoritm care determină în timp O(N3)
distanţele minime dintre toate perechile de noduri. În majoritatea
problemelor însă nu ne interesează distanţele minime dintre toate nodurile,
ci distanţele minime de la un singur nod la toate celelalte sau la unul singur.
Această problemă se mai numeşte ş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 înţeles, de implementat şi
care suportă o optimizare foarte importantă. Presupunând că vrem să
calculăm distanţele 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 până atunci şi până la care distanţa minimă calculată deja este
minimă. Se actualizează vecinii i ai lui min şi se continuă algoritmul până
1
Matematician olandez, numele său se citeşte Dai – stra

408
Teoria grafurilor

când au fost alese toate nodurile. Actualizarea se face verificând dacă


distanţa până la min plus costul muchiei de la min la i este mai mică decât
distanţa până la i.
Figurile următoare prezintă modul de execuţie al algoritmului pe un
graf oarecare. Cu roşu apar distanţele minime calculate de către algoritm de
la nodul 1 la toate celelalte noduri împreună cu nodurile şi muchiile deja
parcurse, iar cu verde nodul min şi vecinii săi.

Fig. 12.10.3. – Modul de execuţie al algoritmului lui Dijkstra

409
Capitolul 12

În implementarea clasică folosim un vector D cu semnificaţia


D[i] = distanţa minimă de la nodul 1 la nodul i, un vector V cu semnificaţia
V[i] = true dacă nodul i a fost deja extras ca minim şi false în caz contrar şi
un vector P cu semnificaţia P[i] = ultimul nod care a îmbunătăţit distanţa
până la i. Vectorul P va fi folosit pentru a reconstitui soluţia. Programul
următor citeşte un graf dat prin lista de muchii şi afişează un drum de cost
minim de la nodul 1 la nodul N.

#include <fstream> void Dijkstra(vector<PER> G[], int N,


#include <vector> int D[], int P[])
#include <utility> {
bool V[maxn];
using namespace std; for ( int i = 0; i <= N; ++i )
const int maxn = 101; D[i] = inf, P[i] = 0, V[i] = false;
const int inf = 1 << 29; D[1] = 0;

typedef pair<int, int> PER; for ( int i = 1; i <= N; ++i )


{
void citire(vector<PER> G[], int min = 0;
int &N, int &M)
{ // aflu nodul min cu D[min] minim
ifstream in("dijkstra.in"); for ( int j = 1; j <= N; ++j )
in >> N >> M; if ( !V[j] && D[j] < D[min] )
min = j;
int x, y, c; V[min] = true;
for ( int i = 1; i <= M; ++i )
{ // incerc sa relaxez vecinii lui min
in >> x >> y >> c; vector<PER>::iterator j;
G[x].push_back(make_pair(y,c)); for ( j = G[min].begin();
G[y].push_back(make_pair(x,c)); j != G[min].end(); ++j )
} if (D[min]+j->second<D[j->first])
{
in.close(); D[j->first]=D[min]+j->second;
} P[ j->first ] = min;
}
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 implementării am folosit containerul pair din antetul


<utility> în implementarea listelor de adiacenţă. Acum, o listă de adiacenţă
asociată unui nod x reţine toate nodurile adiacente cu x împreuna cu
costurile muchiilor care le unesc de x. În aşa fel obţinem o implementare
simplă care foloseşte numai uneltele puse la dispoziţie de către 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 obţine un algoritm mult mai performant,
de complexitate O(M∙log N).

Algoritmul de complexitate O(M∙log N) este doar o optimizare a


variantei clasice. Se poate observa că în cadrul implementării precedente
avem de aflat un minim, iar pentru aflarea acestui minim parcurgem toate
cele N noduri, obţinând în felul acesta timpul de execuţie 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 implementări deoarece majoritatea funcţiilor necesare au fost
deja prezentate şi vom prezenta oricum o variantă mai uşor de implementat
a acestei idei. Prezentăm schiţat implementarea manuală a heap-urilor
pentru cititorii interesaţi:
 Se foloseşte un vector H care reprezintă heap-ul şi un vector poz
unde poz[i] = poziţia 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ă distanţele din vectorul


D.
 Cât timp heap-ul este nevid execută
o Se salvează în min valoarea din rădăcina heap-ului (adică
H[1], care reprezintă nodul până la care distanţa de la
nodul sursă este minimă şi care nu a mai fost selectat
până acuma) şi se şterge rădăcina. Rădăcina se şterge
interschimbând H[1] cu H[k], scăzându-l pe k cu 1 şi
aplicând procedura Downheap lui H[1], unde k
reprezintă numărul de elemente din heap.
o Se încearcă relaxarea tuturor vecinilor lui min, ca şi în
cadrul implementării clasice. Dacă un vecin i îşi
îmbunătăţeşte distanţa 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 funcţie
numită Upheap care interschimbă un nod x
transmis ca parametru (practic, se transmite
poziţia lui x în heap) cu tatăl său (tatăl
elementului H[x] este H[x / 2]) atâta timp cât are
loc inegalitatea: D[ H[x] ] < D[ H[ x / 2] ].
Atenţie 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 poziţia nodului nou inserat.

Noul algoritm este mult mai eficient atunci când 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ă
puţin implementarea este faptul că avem nevoie de o modalitate de a ordona
coada de priorităţi după un criteriu dat de noi, deoarece în aceasta vom
reţine etichetele nodurilor, iar ordonarea vrem să se facă după distanţele
minime până 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 relaţii de ordine în funcţie de prima componentă (avem nevoie de
412
Teoria grafurilor

greater<pair> pentru priority_queue). Aşadar, putem declara un


priority_queue care reţine etichetele nodurilor ordonate crescător după
distanţele până la nodurile reţinute! Implementarea funcţiei 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 menţionat că implementarea manuală a heap-urilor este


puţin mai eficientă decât cea cu priority_queue, deoarece în cadrul celei
din urmă se poate insera un nod de mai multe ori în coadă, mai exact de
atâtea ori de câte ori distanţa până la acel nod se actualizează. Acest lucru
este necesar deoarece inserăm 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
implementării manuale a heap-urile deoarece reţinem poziţia nodurilor în
heap, putând astfel actualiza heap-ul după modificarea unei distanţe. Faptul
că un nod poate fi inserat de mai multe ori nu are însă un impact negativ

413
Capitolul 12

semnificativ asupra performanţei algoritmului, deoarece verificăm dacă


perechea extrasă la pasul curent are distanţa care trebuie. De multe ori se
preferă aşadar această implementare.
Două probleme importante în determinarea drumurilor de cost
minim sunt date de existenţa muchiilor de cost negativ şi a ciclurilor de cost
negativ. Deşi 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 următorul graf, orientat de data aceasta:

Fig. 12.10.4. – Un graf orientat cu arce de cost negativ

Dacă modificăm cele două implementări să lucreze cu grafuri


orientate, implementarea clasică găseşte costul minim ca fiind 7 şi drumul
1 – 2 – 3 – 4, iar implementarea cu priority_queue găseşte costul minim 6
şi drumul 1 – 2 – 3 – 4. Este clar aşadar că algoritmul lui Dijkstra, în
varianta clasică, nu funcţionează corect pe grafuri cu muchii de cost negativ.
Să analizăm comportamentul implementării 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. Următorul 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. Următorul nod neales cu distanţa minimă
este 2. Deoarece se verifică inegalitatea D[2] + (-3) < D[3], adică 1 < 2,
D[3] ia valoarea 1. Următorul pas este alegerea nodului 4, care nu
actualizează nicio distanţă. Aşadar, distanţa până la nodul 4 rămâne 7, deşi
distanţa minimă corectă este 6. Chiar dacă în acest caz drumul raportat este
corect, costul acestuia este greşit. Acest lucru se datorează faptului că
distanţa până la nodul 3 a fost actualizată după ce acest nod a fost selectat.
Deoarece un nod este selectat o singură dată, actualizările 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 încât să funcţioneze pe
grafuri cu arce de cost negativ în felul următor: dacă distanţa până la un nod

414
Teoria grafurilor

i se îmbunătăţeşte 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 implementării clasice, este necesară în primul rând
modificarea secvenţei următoare:

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 încât să se execute până când V


conţine numai 1. Această modificare este lăsată ca un exerciţiu pentru
cititor.
În cadrul implementării cu priority_queue nu este necesară nicio
modificare, deoarece un nod se adaugă oricum în heap o dată cu actualizarea
distanţei acestuia, iar algoritmul nu se termină decât atunci când toate
nodurile au fost scoase din heap.
În cazul grafurilor care conţin un ciclu de cost negativ (chiar şi în
cazul grafurilor orientate care conţin arce de cost negativ), algoritmul lui
Dijkstra nu va funcţiona cum trebuie în formele prezentate, intrând într-un
ciclu infinit.
Cum nu are sens să vorbim de distanţe minime în cazul grafurilor cu
cicluri de cost negativ, nu se pune problema calculării distanţelor minime în
astfel de grafuri, ci se pune problema raportării faptului că există un ciclu de
cost negativ. Tehnicile prezentate în cadrul următorului 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 funcţionează în mod
natural şi atunci când există arce de cost negativ în graful pe care se aplică
algoritmul. Mai mult, în cazul existenţei unui ciclu de cost negativ,
Bellman – Ford raportează existenţa unui astfel de ciclu.
În cadrul algoritmului lui Dijkstra alegem la fiecare pas nodul până
la care distanţa minimă calculată deja este cea mai mică şi încercăm să

415
Capitolul 12

relaxăm distanţele minime până la fiecare vecin al nodului selectat.


Algoritmul Bellman – Ford nu se bazează pe selectarea unui minim, ci pe
relaxarea tuturor distanţelor într-o ordine oarecare de N – 1 ori (unde N
este numărul de noduri ale grafului). Aşadar, se parcurg toate muchiile (x, y)
de N – 1 ori şi se verifică dacă putem îmbunătăţi distanţa până la y folosind
muchia (x, y), adică dacă D[x] + C[x][y] < D[y], unde D[i] reprezintă
distanţa minimă până la nodul i, iar C[i][j] reprezintă costul arcului sau
muchiei (i, j).
Parcurgerea tuturor muchiilor de N – 1 ori permite distanţelor
minime să se propage în tot graful, deoarece, în absenţa 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 particularităţi ale problemei (în acest caz lipsa arcelor de
cost negativ), algoritmul Bellman – Ford funcţionează şi pe cazul general.
Figura următoare prezintă modul de execuţie al algoritmului pe un
graf orientat. Pe fiecare desen sunt trecute cu verde iteraţia curentă
(1 ≤ i < N), iar cu roşu muchia curentă şi nodurile incidente acesteia,
împreună cu distanţele minime până la fiecare nod. Deoarece ordinea
parcurgerii muchiilor este aleatoare, pot exista mai multe soluţii.

Fig. 12.10.5. – Modul de execuţie al


algoritmului Bellman - Ford

416
Teoria grafurilor

Există mai multe metode de a implementa algoritmul. Metoda


clasică presupune reţinerea grafului prin liste de muchii. Să presupune că
graful este reţinut î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 destinaţie al muchiei i,
respectiv costul muchiei i. Atunci algoritmul poate fi implementat în felul
următor:
 Pentru fiecare i de la 1 la N – 1 execută
o Pentru fiecare j de la 1 la M (numărul 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 condiţia de mai sus se
verifică, atunci există un ciclu de cost negativ în graf.

La finalul execuţiei, vectorul D va conţine distanţele minime de la


nodul sursă la toate celelalte noduri, iar P va fi vectorul de predecesori.
Trebuie făcute aceleaşi iniţializări ca şi pentru algoritmul lui Dijkstra.
Complexitatea acestui algoritm este O(N∙M).

Există o implementare care, în practică, se dovedeşte a fi mult mai


eficientă decât implementarea corespunzătoare pseudocodului de mai sus.
Această implementare este foarte similară cu o parcurgere în lăţime, singura
deosebire fiind că un nod poate fi introdus în coadă de N – 1 ori. Aşadar,
complexitatea teoretică rămâne O(N∙M), dar algoritmul se comportă mai
bine în practică. Vom prezenta modul de funcţionare al algoritmului pe
exemplul anterior, presupunând 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 roşu nodurile introduse în coadă la pasul curent
şi valorile actualizate. La fiecare pas, se încearcă îmbunătăţirea distanţei
minime până la vecinii nodului extras trecând 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

Pasul 2 Pasul 3
i 1 2 3 4 i 1 2 3 4
Q 1 2 3 Q 1 2 3
D 0 4 2 inf D 0 4 1 inf
P 0 1 1 P 0 1 2
Pasul 4 Pasul 5
i 1 2 3 4 i 1 2 3 4
Q 1 2 3 4 Q 1 2 3 4
D 0 4 1 5 D 0 4 1 5
P 0 1 2 3 P 0 1 2 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).

Observăm că, în cadrul implementării clasice a algoritmului, pe


exemplul acesta se efectuează 16 operaţii, iar în cazul general se vor efectua
întotdeauna exact N∙M operaţii. Folosind o coadă însă, pe exemplul acesta
am efectuat doar 4 extrageri din coadă (timp constant O(1)) şi 4 actualizări,
dintre care 3 au fost însoţite de inserări ale unor noduri în coadă. Aşadar,
putem spune că am efectuat doar 11 operaţii (deşi am putea să considerăm
inserările ca fiind actualizări).
Pentru a putea verifica existenţa ciclurilor de cost negativ, este
necesar să reţinem pentru fiecare nod de câte ori a fost extras din coadă.
Dacă am extras un nod de N ori, atunci există un ciclu de cost negativ.

Folosind această implementare, performanţa 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 funcţionează fără modificări şi pe
grafuri cu arce de cost negativ, iar implementarea este convenabilă, fiind
asemănătoare cu implementarea unei parcurgeri în lăţime.
Mai putem face o optimizare care se dovedeşte 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 următoarea situaţie: să
presupunem că am extras din coadă un nod X şi că nodul P[X], care a
actualizat ultima dată distanţa până la X, se află undeva în coadă. Atunci nu
are rost să actualizăm distanţele până la vecinii lui X, deoarece este clar că
distanţa până la X se va mai actualiza prin P[X]. Implementarea acestei
euristici este lăsată ca exerciţiu pentru cititor.

418
Teoria grafurilor

În literatura de specialitate, algoritmul prezentat apare şi sub


denumirile de algoritmul Bellman – Ford – Moore (mai ales în cadrul
implementării ce foloseşte o coadă) şi algoritmul Bellman – Kalaba.
Deoarece ideea care stă la baza fiecărei implementări este aceeaşi, am ales
folosirea denumirii algoritmului clasic pentru fiecare implementare.

În implementarea următoare am presupus un format al fişierelor


identic cu cel de la algoritmul lui Dijkstra, cerinţele fiind identice şi ele.
Prezentăm aşadar doar funcţia 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 numărare. De data
aceasta nu o să selectăm nici minime şi nici nu o să relaxăm pe rând toate
muchiile, ci vom selecta la fiecare pas cst toate nodurile până la care
distanţa minimă de la nodul sursă este cst şi vom încerca să actualizăm
distanţele până la vecinii acestora.
De exemplu, să considerăm următorul graf:

Fig. 12.10.6. – Un graf orientat oarecare

Vom reţine un vector de N∙maxc cozi (uneori se foloseşte denumirea


de găleţi) notat Q cu semnificaţia Q[cst] = o listă cu nodurile până la care
distanţa de la sursă este cst, unde maxc reprezintă muchia de cost maxim a
grafului (în cazul nostru, N = 4 şi maxc = 5). Prima dată inserăm în Q[0]
nodul 1. Apoi aplicăm următorul algoritm:
 Pentru fiecare cst de la 0 până 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ă
 ... actualizările clasice...
 Se adaugă j în Q[ D[j] ]
 La fel ca până acum, D, P şi C reprezintă vectorul distanţelor
minime, vectorul predecesorilor, respectiv matricea costurilor (în
implementare vom folosi evident liste de adiacenţă).

Tabelul următor reprezintă modul de execuţie al algoritmului pe


graful anterior. Cu albastru apare elementul curent i iar cu roşu 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 găleată la pasul
respectiv. În practică, se trece şi peste găleţile goale.

Tabelul 12.10.7. – Modul de execuţie 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(N∙maxc + M) ca timp şi


O(N∙maxc) ca memorie auxiliară. În practică însă algoritmul rulează foarte
rapid, deoarece multe din cele N∙maxc cozi vor fi goale.

Memoria auxiliară folosită de algoritm poate fi îmbunătăţită


observând că nu este necesar să reţinem N∙maxc cozi, fiind suficiente
maxc + 1. Mai mult, vom reţine un contor cnt care va reprezenta numărul
de elemente din toate cele maxc + 1 cozi şi vom opri algoritmul atunci când
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 execuţie al algoritmului este următorul:

Tabelul 12.10.8. – Modul de execuţie 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 către algoritm este O(maxc).


Algoritmul se bazează pe observaţia că, folosind această metodă, dacă la
pasul curent am analizat un nod din coada Q[k], atunci la sfârşitul pasului
curent cozile Q[k + 1], ..., Q[Cmax], Q[0], ..., Q[k – 1] vor conţine noduri
cu distanţe din ce în ce mai mari, deci se păstrează parcurgerea nodurilor în
ordinea crescătoare a distanţelor minime.

421
Capitolul 12

Faptul că vom opri algoritmul atunci când toate cozile sunt goale va
face algoritmul mult mai rapid în practică, datorită faptului că nu vor exista
aproape niciodată un număr mare de distanţe minime distincte.
Implementarea prezentată conţine toate optimizările discutate. Acest
algoritm este indicat a fi folosit în cazurile în care costurile muchiilor sunt
mici sau distanţele minime se repetă des. Datorită faptului că folosim
indexarea după distanţe, este clar că algoritmul lui Dial nu va funcţiona
corect în cazul existenţei distanţelor negative. În cazul general, rămâne
aşadar preferabil algoritmul Bellman – Ford sau algoritmul lui Dijkstra
implementat cu heap-uri.
Menţionăm că în cadrul implementării prezentate am presupus că
lungimea maximă a unui arc este 1000. Mai mult, am folosit un vector de
1024 de cozi pentru ca operaţia modulo să se poată efectua mai eficient cu
ajutorul operaţiilor pe biţi. Implementarea conţine doar funcţia 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. Reţele de transport


Un alt capitol important în teoria grafurilor îl reprezintă reţelele de
transport. Acestea pot fi folosite pentru a modela o multitudine de situaţii
care apar în diferite domenii, cum ar fi transporturi, telecomunicaţii,
reţelistică etc. Vom considera că o reţea de transport este un graf orientat cu
N noduri şi M arce în care fiecare muchie are asociată o anumită capacitate.
Apare aici noţiunea de flux, care reprezintă o abstractizare pentru cantitatea
de date (în practică, fluxul poate reprezenta de fapt nişte obiecte, sau alte
concepte palpabile) care circulă de la un nod la altul.
Problemele pe care le vom prezenta vor presupune găsirea fluxului
maxim dintre două noduri ale unei reţele. Nodul sursă, considerat de acum
nodul 1, reprezintă nodul de la care începem trimiterea fluxului, iar nodul
destinaţie, 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 puţin în cazul nodurilor 1 şi N. Altfel spus,
trebuie să se respecte legea lui Kirchoff.

Găsirea fluxului maxim înseamnă găsirea cantităţii maxime de flux


care poate ajunge la nodul N.

Graful următor reprezintă o reţea de transport. Am marcat cu


albastru capacitatea arcelor şi cu roşu fluxul trimis pe fiecare arc. Fluxul
maxim în acest graf este 4.

Fig. 12.11.1. – O reţea de transport oarecare

423
Capitolul 12

În cele ce urmează vom prezenta algoritmi de determinare a fluxului


maxim în grafuri cât şi probleme care au la bază aceşti algoritmi. Mai exact,
vom aborda următoarele 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 reţea de transport în complexitatea O(N∙M 2). Acesta
constă în găsirea unor drumuri de ameliorare în graful (reţeaua de
transport) dat şi trimiterea unei cantităţi maxime de flux pe aceste drumuri.
Când nu se mai poate găsi niciun drum de ameliorare, algoritmul garantează
că fluxul trimis deja este maxim posibil.

Pentru a obţine o implementare mai uşoară, vom folosi matrici


pentru reţinerea capacităţilor şi fluxului existent la un moment dat în reţeaua
de transport. Aşadar, matricea C va fi o matrice similară cu matricea
ponderilor, doar că va reţine de data aceasta capacitatea fiecărui 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 reţine graful orientat dat ca un graf neorientat
cu ajutorul listelor de adicenţă.
Găsirea drumurilor de ameliorare se face cu ajutorul mai multor
parcurgeri în lăţime 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 parcurgând doar
arce nesaturate, adică arce (x, y) pentru care are loc inegalitatea
F[x][y] < C[x][y]. Să presupunem că am găsit un astfel de drum şi că ştim
pentru fiecare nod x din drum că P[x] este predecesorul său. Următorul 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. Aşadar, trebuie
să aflăm 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 foloseşte arcele inversate ale
grafului dat. Acest lucru este necesar pentru a putea să ne asigurăm că
reţeaua nu va fi blocată. De exemplu, să considerăm graful de mai jos:

424
Teoria grafurilor

Fig. 12.11.2. – O reţea de transport cu muchii de capacităţi egale

Să presupunem că se alege drumul de ameliorare 1 – 2 – 4 – 6.


Atunci la următorul pas nu vor mai exista alte drumuri de ameliorare,
deoarece nu vom putea ajunge de la nodul 1 la nodul 6 trecând prin muchii
nesaturate. Se poate observa uşor însă că reţeaua 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 obţine răspunsuri corecte indiferent de cum sunt alese
drumurile de ameliorare, este necesar ca atunci când 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 operaţiile:
F[ P[x] ][x] += min;
F[x][ P[x] ] -= min;

Efectuând aceste operaţii ne asigurăm că reţeaua nu se va bloca,


deoarece va fi posibil ca un drum de ameliorare să parcurgă un arc inversat,
având efectul scăderii cantităţii de flux de pe muchia iniţială, deblocându-se
astfel reţeaua în cazurile asemănătoare cu cel de mai sus.
Figura următoare prezintă modul de execuţie al algoritmului pe
graful anterior. Cu albastru apar capacităţile fiecărui arc, cu roşu 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 execuţie al algoritmului Edmonds – Karp

425
Capitolul 12

Aşadar, cu o simplă scădere ne putem asigura că algoritmul va


funcţiona pe orice graf. Prezentăm mai întâi funcţiile relevante unei prime
implementări clasice. Am presupus că graful este dat prin listă de muchii,
reţinut ca graf neorientat in G ca până acum şi că C şi F sunt matricele
menţionate anterior. Funcţia Drum găseşte drumuri de ameliorare, iar
funcţia Flux găseşte 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;
}

Deşi algoritmul este mai rapid în practică decât ar sugera


complexitatea sa asimptotică, acesta nu este foarte eficient pe grafuri cu mii
de noduri şi arce. Putem însă aduce nişte optimizări metodei prezentate şi
anume:
 Dacă am extras nodul N din coadă, nu are rost să continuăm cu
verificarea vecinilor acestuia.
 Putem reduce numărul de parcurgeri în lăţime efectuate
procedând în felul următor: pentru o parcurgere în lăţime, vom
considera toate drumurile găsite de aceasta până la toţi vecinii
nodului N. Evident, va exista cel mult un singur drum pentru
fiecare vecin, deoarece lucrăm 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ă observăm că trimiterea unei

427
Capitolul 12

cantităţi de flux pe un anumit drum poate bloca celelalte


drumuri, aşa că, dacă min este 0, putem scăpa de încă o
parcurgere a N noduri, deoarece nu are rost să trimitem cantitatea
0 de flux.

Prezentăm doar secvenţele 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 găsi o


modalitate de distribuţie a fluxului astfel încât costul total al distribuţiei să
fie minim. Vom considera că avem o reţea de transport în care fiecare arc
are asociat atât o capacitate cât şi un cost per unitate de flux. Ne interesează
să găsim un flux maxim de cost minim în această reţea.
De exemplu, figura următoare reprezintă o reţea de transport
ponderată în care cu albastru apar capacităţile şi cu verde costurile. Fluxul
maxim este 1, iar costul minim este 8.

Fig. 12.11.4. – O reţea de transport ponderată

Problema se poate rezolva în mai multe moduri. Varianta clasică


presupune înlocuirea parcurgerii în lăţime cu algoritmul Bellman – Ford,
care funcţionează şi în cazul existenţei arcelor de cost negativ. Ne
interesează un algoritm care funcţionează şi dacă există arce de cost negativ
deoarece este necesar să considerăm costurile arcelor inversate ca fiind
opusele arcelor date.
După ce am găsit un drum de ameliorare, datorită faptului că acesta a
fost găsit 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
cantităţii min de flux este min∙D[N], unde D reprezintă vectorul distanţelor.
Complexitatea acestui algoritm este O(N2∙M2), dar este din nou o
supraestimare, deoarece algoritmul Bellman – Ford suportă multe optimizări
şi este eficient în practică.
Putem obţine însă complexitatea O(N∙M2∙log N) folosind
algoritmul lui Dijkstra. Avem două posibilităţi. Fie folosim o
implementare a algoritmului care funcţionează şi în cazul existenţei arcelor
de cost negativ, fie transformăm graful dat în aşa fel încât să nu existe arce
de cost negativ.
Pentru a transforma graful, este necesar să rulăm mai întâi algoritmul
Bellman – Ford, care funcţionează şi dacă există arce de cost negativ, pentru

429
Capitolul 12

a calcula vectorul distanţelor D. După ce avem calculat vectorul D, costul


fiecărui arc (x, y), de cost iniţial c, va fi înlocuit cu valoarea
c + D[x] – D[y]. Această valoare este pozitivă, aşa cum vom arăta în
continuare. Să presupunem că c + D[x] – D[y] < 0. Asta ar însemna că
c + D[x] < D[y], contrazicându-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ă conţine doar funcţiile relevante. Am ales
algoritmul lui Dijkstra implementat cu priority_queue. Acest algoritm
funcţionează şi dacă se face transformarea precizată mai sus şi dacă nu. Am
introdus o nouă matrice numită CS care reţine costurile arcelor. Funcţia
Flux returnează costul minim al unui flux maxim.
Restul implementărilor menţionate sunt lăsate ca exerciţiu.

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 aplicaţie importantă a reţelelor de transport o reprezintă
problemele de cuplaj. De exemplu, să presupunem că avem N angajaţi
(numerotaţi 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 angajaţilor astfel încât să fie rezolvate un număr 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


mulţimile de noduri sunt st şi dr, reprezentând angajaţii respectiv sarcinile
existente. De exemplu, graful următor reprezintă un posibil set de date de
intrare. Am marcat cu roşu muchiile unui cuplaj maximal.

Fig. 12.11.5. – Un cuplaj maximal într-un graf bipartit

Cardinalitatea unui cuplaj este dată de numărul 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


prezentaţi anterior. Vom considera că fiecare muchie a grafului bipartit dat
are capacitatea 1. Vom considera toţi angajaţii ca fiind surse şi toate
sarcinile ca fiind destinaţii. Deoarece fluxul maxim în această reţea va satura
un număr maxim de muchii, este clar că fluxul maxim reprezintă
cardinalitatea cuplajului maximal, iar muchiile saturate vor reprezenta
muchiile cuplajului maximal.

Trebuie discutate câteva detalii de implementare. Dacă graful se dă


aşa cum sugerează imaginea de mai sus, adică se dau N, M cu semnificaţia
anterioară şi E care reprezintă numărul de muchii ale grafului, iar apoi E
perechi (x, y) semnificând faptul că angajatul x poate efectua sarcina y,
atunci trebuie efectuate nişte transformări î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ă lucrăm cu mai multe surse şi destinaţii, aşa că vom adăuga
alte două noduri în graf, o supersursă şi o superdestinaţie. Supersursa va fi
conectată de fiecare angajat printr-o muchie de capacitate 1, iar
superdestinaţia 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


superdestinaţia. Aceste noduri sigur nu vor face parte din graful iniţial,
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 aceeaşi
etichetă, vom renumerota nodurile. Astfel, nodurile care reprezintă angajaţii
vor fi numerotate de la 1 la N, iar nodurile care reprezintă sarcinile vor fi
numerotate de la N + 1 la N + M. Practic, când citim o muchie (x, y), vom
adăuga muchia (x, N + y). Problema se reduce aşadar la găsirea fluxului
maxim de la nodul 0 la nodul N + M + 1. Modificarea algoritmilor de flux
maxim prezentaţi pentru a funcţiona pe grafuri neorientate nu prezintă
dificultăţi prea mari, aşa că este lăsată ca exerciţiu pentru cititor.
Graful de mai jos reprezintă transformarea grafului iniţial conform
indicaţiilor anterioare. Fiecare muchie are capacitatea 1, iar muchiile
saturate apar colorate. Evident, din cuplajul maximal fac parte doar muchiile
saturate neincidente cu supersursa sau superdestinaţia.

Fig. 12.11.6. – Un graf bipartit transformat într-o reţea de transport

Doarece cuplajul în graf bipartit este o particularizare a problemei de


flux maxim, există un algoritm mai eficient decât metoda generală şi care
este şi uşor de implementat: algoritmul Hopcroft – Karp. Acesta
determina un cuplaj maximal în timp O(E∙ 𝑵 + 𝑴).
Algoritmul Hopcroft – Karp are la bază parcurgeri în adâncime ale
grafului dat. Nu sunt necesare transformările prezentate. Vom lucra cu
graful iniţial şi vom considera că acesta este orientat, direcţia arcelor fiind
de la mulţimea angajaţilor la mulţimea 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 mulţimea angajaţilor a fost vizitat la pasul curent.

433
Capitolul 12

Vom exemplifica algoritmul pe următorul graf:

Fig. 12.11.7. – Un graf bipartit orientat

Parcurgem în ordine nodurile din mulţimea din stânga, în cazul nostu


din mulţimea angajaţilor. Când dăm peste un nod i necuplat (dr[i] = 0),
apelăm o funcţie Cuplare(i) care încearcă să cupleze acest nod. Se apelează
funcţia pentru nodul i = 1. Se marchează nodul 1 ca fiind vizitat
(V[i] = true) şi se parcurg în ordine toţi vecinii săi v. Primul său 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 funcţia
returnează true.
Se trece la nodul 2. Se marchează ca fiind vizitat şi se verifică
vecinii săi pentru a încerca să găsim un cuplaj pentru nodul 2. Singurul
vecin al nodului i = 2 este nodul v = 1, dar acesta este cuplat cu nodul 1 din
stânga, aşa că nu putem pur şi simplu să îl cuplăm cu nodul 2 din stânga.
Vom apela recursiv aceeaşi funcţie pentru nodul cu care este cuplat nodul 1
din dreapta. Ideea este că, dacă putem găsi un alt nod pe care să-l grupăm cu
nodul 1 din stânga, atunci vom putea cupla nodul 1 din dreapta cu nodul 2
din stânga, cardinalitatea cuplajului crescând. Vom apela aşadar recursiv
funcţia Cuplare având ca parametrul nodul 1 din stânga. La acest pas,
V[1] = true, aşa că funcţia va întoarce false. La revenire din recursivitate se
verifică valoarea întoarsă de funcţie: dacă ar fi true, ar însemna că am putea
realiza cuplajul nodului 2 din stânga cu nodul 1 din dreapta, deoarece fostul
nod cuplat cu nodul 1 din dreapta a fost recuplat. Dar, deoarece valoarea
întoarsă de funcţie este false, acest lucru nu este posibil, cel puţin nu la acest
pas.
Se trece la nodul 3, care se va cupla fără probleme cu nodul 3 din
dreapta. S-a încheiat prima iteraţie a algoritmului, iar cuplajul curent este
următorul:

434
Teoria grafurilor

Fig. 12.11.8. – Rezultatele primei iteraţii a


algoritmului Hopcroft – Karp

Deoarece s-au efectuat cuplaje noi la iteraţia trecută, se resetează


vectorul V şi se reia algoritmul, în speranţa că se va găsi un cuplaj mai bun
de această dată. Nodul 1 este deja cuplat, aşa că nu se va apela funcţia
Cuplare pentru acesta. Nodul 2 nu este cuplat, aşa că se apelează
Cuplare(2). Singurul vecin al lui 2 este 1, care este cuplat cu nodul 1 din
stânga. Apelăm recursiv Cuplare(1) în speranţa că vom găsi un alt cuplaj
pentru nodul 1, eliberând nodul 1 din dreapta pentru a fi cuplat cu nodul 2.
Se găseşte nodul 2 din dreapta în cadrul apelului recursiv, aşa că nodul 1 din
stânga se recuplează cu 2, iar la revenire din recursivitate, deoarece apelul
recursiv a returnat true de această dată, nodul 2 din stânga se cuplează cu 1.
Nodul 3 este deja cuplat, aşa că nu se efectuează niciun apel al
funcţiei de cuplare. Se trece la următoarea iteraţie, care nu va genera noi
cuplaje, aşa că algoritmul se încheie. Cuplajul maximal este dat de toate
perechile (i, dr[i]), pentru i de la 1 la N. Cuplajul maximal este următorul:

Fig. 12.11.9. – Rezultatul final al algoritmului Hopcroft – Karp

Implementarea algoritmului este una intuitivă, necesită mai puţin


cod decât metoda generală şi funcţionează 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> bool Cuplare(vector<int> G[], int i,


#include <vector> int st[], int dr[],
bool V[])
using namespace std; {
if ( V[i] )
const int maxn = 101; return false;
V[i] = true;
void citire(vector<int> G[],
int &N, int &M) vector<int>::iterator v;
{ for ( v = G[i].begin();
int E; v != G[i].end(); ++v )
ifstream in("cuplaj.in"); if ( !st[*v] )
in >> N >> M >> E; {
st[*v] = i;
int x, y; dr[i] = *v;
for ( int i = 1; i <= E; ++i ) return true;
{ }
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) )
in.close(); {
} st[*v] = i;
dr[i] = *v;
return true;
}

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 cărei rezolvare am prezentat-o deja. Pentru cei
interesaţi, un alt algoritm de rezolvare a problemei cuplajului maximal de
cost minim este algoritmul ungar.

437
Capitolul 12

12.12. Arbore parţial de cost minim


Considerăm un graf neorientat, conex şi ponderat G cu N noduri şi
M muchii. Se numeşte arbore parţial al grafului G un arbore care conţine
toate nodurile lui G şi N – 1 muchii din G. Se numeşte arbore parţial de
cost minim (A.P.M.) al lui G un arbore parţial pentru care suma ponderilor
muchiilor sale este minimă.

Găsirea arborelui parţial de cost minim al unui graf are aplicaţii în


diverse probleme. De exemplu, putem dori să eliminăm legături redundante
dintre nodurile unei reţele, păstrând un număr minim de legături care nu
deconectează reţeaua şi al căror cost este minim.
De exemplu, arborele parţial de cost minim al grafului de mai jos
este marcat cu roşu.

Fig. 12.12.1. – Arborele parţial de cost minim al unui graf oarecare

În cele ce urmează vom prezenta doi algoritmi de determinare a unui


arbore parţial de cost minim: algoritmul lui Kruskal şi algoritmul lui
Prim. Aceştia au complexităţi diferite, iar alegerea dintre ei trebuie făcută în
funcţie de natura problemei la care se doreşte aplicarea unui algoritm pentru
determinarea A.P.M..

În implementările oferite, am presupus că lucrăm cu grafuri


ponderate cu N noduri şi M muchii, citite dintr-un fişier prin lista muchiilor.

438
Teoria grafurilor

a) Algoritmul lui Kruskal

Algoritmul lui Kruskal este un algoritm de tip greedy care rezolvă


problema determinării unui A.P.M. în timp O(M∙a(N) + M∙log M). Funcţia
a reprezintă inversa funcţiei Ackermann şi creşte foarte încet, valoarea sa
putând fi considerată o constantă pentru toate valorile practice ale lui N.
Aşadar, complexitatea este foarte apropiată de O(M∙log M). Memoria
folosită de algoritm este O(N + M). Algoritmul în pseudocod este
următorul:
 Se sortează lista E a muchiilor după ponderile acestora
 Pentru fiecare i de la 1 la M execută
o Dacă adăugarea muchiei i în A.P.M. nu duce la formarea
unui ciclu, se adaugă muchia i în A.P.M.
 La finalul algoritmului se obţine un arbore parţial de cost minim.

Se pune problema determinării dacă adaugarea unei muchii în


A.P.M. duce la formarea unui ciclu sau nu. Un arbore parţial poate fi privit
ca o reuniune de subgrafuri ale lui G care sunt la rândul lor arbori. Vom
folosi un vector de taţi T, care ne va ajuta să codificăm aceşti arbori. Iniţial
T[i] = i pentru fiecare i de la 1 la N. Cu alte cuvinte, iniţial fiecare nod este
rădăcina unui arbore format doar din acel nod. Mai mult, este clar că o
muchie (x, y) nu va forma un ciclu decât dacă x şi y fac parte din acelaşi
arbore. Fie Find(x) o funcţie care returnează rădăcina arborelui din care face
parte nodul x. Când analizăm o muchie (x, y), o vom adăuga în A.P.M. dacă
şi numai dacă Find(x) este diferit de Find(y). Funcţia Find(x) poate fi
implementată recursiv astfel:
 Dacă T[x] == x returnează x
 Returnează Find( T[x] )

Mai mult, atunci când adăugăm 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 rădăcina sa, este de ajuns să unim
rădăcinile arborilor lui x şi y. Acest lucru îl vom face cu ajutorul unei funcţii
Merge(x, y) care fie setează T[x] = y fie T[y] = x, adică unul dintre arbori
devine subarbore al celuilalt, formându-se astfel un singur arbore. Când se
adaugă muchia (x, y), trebuie efectuat apelul Merge( Find(x), Find(y) ).

Pentru a obţine însă timpul de execuţie menţionat este nevoie de


două optimizări care se complementează reciproc:

439
Capitolul 12

1. Optimizarea reuniunii după rang. Această optimizare constă


în menţinerea unui vector R cu semnificaţia R[i] = rangul
(înălţimea) arborelui cu rădăcina în i. Acum, când reunim doi
arbori identificaţi prin rădăcinile x şi y, vom subordona arborele
cu înălţime mai mică celui cu înălţime mai mare, iar înălţimea
ambilor arbori va rămâne neschimbată. Dacă înălţimile celor doi
arbori sunt egale, atunci nu contează cum îi subordonăm, dar este
important să incrementăm rangul pentru cel care îşi păstrează
calitatea de arbore, deoarece înălţimea noului arbore va creşte cu
1.
2. Optimizarea comprimării drumurilor. Până acum, în cadrul
apelului Find(x), parcurgem arborele de la nodul x în sus până
când ajungem la rădăcină, trecând 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 rădăcină şi să unim toate nodurile
intermediare (inclusiv pe x) direct de rădăcină. Deşi acest lucru
afectează înălţimea arborilor, nu vom actualiza vectorul R. Cu
această optimizare, la următoarea parcurgere a arborelui, vom
găsi rădăcina unui nod într-un singur pas. Optimizarea aceasta se
poate implementa uşor folosind recursivitate.

Programul următor afişează costul şi muchiile care formează


arborele parţial 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, void Kruskal(muchie E[], int N, int M)


const muchie &y) {
{ int R[maxn], T[maxn], k = 0, cost = 0;
return x.c < y.c;
} // APM va avea intotdeauna N - 1 noduri.
muchie APM[maxn - 1];
int Find(int x, int T[])
{ for ( int i = 1; i <= N; ++i )
if ( T[x] == x ) T[i] = i, R[i] = 0;
return x;
T[x] = Find(T[x], T); sort(E + 1, E + M + 1);
for ( int i = 1; i <= M; ++i )
return T[x]; if ( Find(E[i].x, T) != Find(E[i].y, T) )
} {
cost += E[i].c;
void Merge(int x, int y, APM[++k] = E[i];
int T[], int R[])
{ Merge(Find(E[i].x, T),
if ( R[x] == R[y] ) Find(E[i].y, T), T, R);
{ }
T[y] = x;
++R[x]; ofstream out("kruskal.out");
}
else if ( R[x] < R[y] ) out << cost << '\n';
T[x] = y; for ( int i = 1; i < N; ++i )
else out << APM[i].x << ' ' <<
T[y] = 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 parţial de cost minim. Acesta are complexităţile
O(N2) şi O(M∙log N), în funcţie de implementarea folosită. Vom prezenta
pe larg doar varianta de implementare în O(N2), deoarece aceasta este
preferabilă celorlalţi algoritmi atunci când avem de a face cu un graf dens,
iar varianta în complexitate O(M∙log N) diferă de complexitatea
algoritmului lui Kruskal doar printr-o constantă şi în plus algoritmul lui
Kruskal este mai uşor de implementat.

Pentru a înţelege algoritmul lui Prim, vom presupune situaţia: avem


deja T < N – 1 noduri care fac parte din arborele parţial de cost minim şi
vrem să introducem un nou nod în arbore. Procedând conform paradigmei
algoritmice greedy, la fel ca şi la algoritmul lui Kruskal, vom adăuga î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(N∙M), adică O(N3) pe cel mai defavorabil
caz.

Putem obţine complexitatea O(N2) reţinând pentru fiecare nod care


încă nu face parte din arbore muchia de cost minim care îl leagă de
subarborele curent. Vom folosi aşadar doi vectori D şi vec unde
D[i] = costul muchiei de cost minim care îl leagă pe i de subarborele
existent până 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 iniţializează cu infinit, iar vec se
iniţializează cu 1, semnificând 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 iniţializa D[j] pentru toţi vecinii j ai
nodului 1 cu costul muchiei (1, j).

La fiecare pas, algoritmul găseşte î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 adăugat pe


min în arbore. Mai trebuie actualizate muchiile de cost minim care leagă
restul nodurilor de noul arbore. Evident, singurele noduri care pot cauza
schimbări în vectorii D şi vec sunt vecinii nodului min. Parcurgem aşadar
vecinii nodului min, iar dacă dăm de un vecin j care nu face încă parte din
arbore şi D[j] este mai mare decât costul muchiei (min, j) atunci actualizăm
corespunzător D[j] şi vec[j].
Deoarece la fiecare pas se determină o nouă muchie a arborelui,
avem nevoie de N – 1 paşi. De aici rezultă aşadar complexitatea O(N2).

Implementarea de complexitate O(M∙log 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. Lăsăm această implementare ca
un exerciţiu 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 execuţie 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 roşu muchiile de cost minim care leagă restul
nodurilor i de subarborele curent (vec[i]). Apare îngroşat muchia de cost
minim care va fi aleasă la pasul următor (D[i] minim).

Implementarea propusă reţine graful prin liste de adiacenţă.


Abordarea şi structurile folosite se regăsesc şi în cadrul algoritmilor de
drumuri minime prezentaţi. Am folosit cu această ocazie şi noţiunea de
prototip al unei funcţiei. În aceste cazuri folosirea unui prototip nu are
decât un scop strict didactic şi stilistic, permiţându-ne să scriem
implementarea funcţiei prim după funcţia main.

443
Capitolul 12

Fig. 12.12.2. – Modul de execuţie 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 prim(vector<PER> G[], int N,


int &N, int &M) PER APM[])
{ {
ifstream in("prim.in"); int D[maxn], vec[maxn], k = 0;
in >> N >> M; for ( int i = 1; i <= N; ++i )
D[i] = inf, vec[i] = 1;
int x, y, c; vec[1] = 0;
for ( int i = 1; i <= M; ++i ) vector<PER>::iterator j;
{ for ( j = G[1].begin();
in >> x >> y >> c; j != G[1].end(); ++j )
G[x].push_back(make_pair(y, c)); D[ j->first ] = j->second;
G[y].push_back(make_pair(x, c)); int cost = 0, min = 1;
} for ( int i = 1; i < N; ++i )
{
in.close(); min = 1;
} for ( int j = 2; j <= N; ++j )
if ( vec[j] && D[j] < D[min] )
int main() min = j;
{
int N, M; cost += D[min];
vector<PER> G[maxn]; APM[++k] = make_pair(min,
citire(G, N, M); vec[min]);
vec[min] = 0;
PER APM[maxn - 1]; for ( j = G[min].begin();
j != G[min].end(); ++j )
ofstream out("prim.out"); if ( vec[j->first] &&
out << prim(G, N, APM) << '\n'; D[j->first] > j->second )
for ( int i = 1; i < N ; ++i ) {
out << APM[i].first << ' ' << D[ j->first ] = j->second;
APM[i].second << '\n'; vec[ j->first ] = min;
out.close(); }
}
return 0; return cost;
} }

12.13. Concluzii
Am prezentat în acest capitol noţiunile elementare care stau la baza
algoritmilor de grafuri. Cititorii experimentaţi poate că au observat lipsa
abordării unor teme legate de arbori, cum ar fi determinarea L.C.A. sau cum
ar fi arborii binari de căutare. 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 următoarele probleme:
1. Dacă nu avem de gând sa prelucrăm arborele parţial de cost
minim, putem doar să-l afişăm muchie cu muchie pe măsură ce îl
determinăm. Modificaţi algoritmii prezentaţi în acest scop.
2. Scrieţi programe care compară performanţele algoritmilor
prezentaţi de-a lungul capitolului. Evident, comparaţi doar
algoritmii care rezolvă aceeaşi problemă.
3. Considerăm 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 număr prim la cel de-al doilea printr-un număr minim de
paşi, ştiind că un pas constă din adăugarea, ştergerea sau
modificarea unei cifre.
4. Scrieţi un program care determină numărul de drumuri minime.
5. Scrieţi un program care determină dacă se pot asocia costuri unui
graf astfel încât o matrice a drumurilor dată să fie validă.
6. Se dă un graf neorientat ponderat. Scrieţi un program care
determină valoarea minimă min astfel încât să existe un drum de
la nodul 1 la nodul N care să conţină doar muchii cu ponderi cel
mult egale cu min.
7. Scrieţi un program care generează aleator grafuri conexe cu N
noduri şi M muchii. Analog pentru grafuri bipartite.
8. Scrieţi 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 distanţelor dintre nodurile
drumului cerut şi orice nod blocat să fie cât mai mare.
10. Se dă o matrice pătratică 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 stânga ş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,
fără a se parcurge două muchii de aceeaşi culoare una după alta.

Alte exerciţii ar fi încorporarea algoritmilor prezentaţi într-o clasă de


grafuri şi folosirea clasei list în loc de vector. Avantajul clasei vector este
că putem accesa orice element rapid, pe când folosirea clasei list nu permite
decât parcurgerea secvenţială a elementelor. Comparaţi performanţa
algoritmilor pe grafuri în cazul implementării acestora cu ajutorul fiecărei
dintre cele două clase.
446
Structuri avansate de date

13. Structuri
avansate de
date
Am prezentat până acum o serie de algoritmi fundamentali însoţiţi de
aplicaţii 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 câteva structuri de date care joacă un rol foarte important în
algoritmică, în special în probleme de optimizare.
Practic, acest capitol prezintă metode de a răspunde eficient la
interogări de genul se găseşte un anumit obiect într-o colecţie de obiecte
dată anterior?, întrebări însoţite şi de actualizări de tipul adugă un nou
obiect colecţiei date atnerior. Vom analiza cazurile favorabile, medii şi
defavorabile a mai multor structuri de date şi vom discuta situaţiile în care
fiecare structură este preferabilă celorlalte.
Acest capitol va folosi noţiuni de grafuri, liste, operaţii pe biţi,
recursivitate, tehnici de programare, matematică şi S.T.L., aşa că
recomandăm cu tărie stăpânirea tuturor capitolelor anterioare înainte de
parcurgerea acestui capitol final.

Pe majoritatea structurile de date ce urmează a fi prezentate ne


interesează următoarele trei operaţii de bază şi timpul de execuţie al
acestora:
1. Inserarea unui element (Insert)
2. Ştergerea unui element (Remove)
3. Căutarea 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 indexaţi binar ............................................................................ 474
13.5. Arbori de prefixe (Trie) ........................................................................ 481
13.6. Arbori binari de căutare (Binary Search Trees) .................................. 488
13.7. Arbori binari de căutare căutare echilibraţi ....................................... 504
13.8. Concluzii ................................................................................................ 514

448
Structuri avansate de date

13.1. Skip lists (liste de salt)


Deşi începerea acestui capitol cu o structură de date care oferă
complexităţi optime pentru toate operaţiile de bază poate părea o introducere
prea abruptă în acest capitol, am considerat că această structură de date
foloseşte cele mai simple noţiuni teoretice şi este şi cel mai uşor de
implementat cu o calitate acceptabilă, fiind necesare doar cunoştiinţe despre
liste înlănţuite.
O listă de salt este un ansamblu de mai multe liste înlănţuite sortate,
fiecare listă desemnând un nivel al acestui ansamblu. La nivelul cel mai de
jos (nivelul 0), fiecare element x al listei respective va avea un pointer către
elementul x + 1 (informal spus, x->link_ = x + 1). La următorul nivel
(nivelul 1) vom avea doar o parte a elementelor de la nivelul precedent (de
obicei în jur de 50%), aşadar un element x nu va avea neapărat un pointer
către elementul x + 1, ci către un element mai îndepărtat, cum ar fi x + 2 sau
x + 3. Elementele de la nivelul precedent care se păstrează la nivelul imediat
superior vor fi alese aleator. Aşadar, 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] 4 NULL
L[3] 4 8 NULL
L[2] 1 4 8 NULL
L[1] 1 4 6 8 10 NULL
L[0] 0 1 2 3 4 6 7 8 9 10 NULL
Fig. 13.1.1. – O listă de salt pentru un anumit set de date

a) Căutarea unui element (Search)

Pentru a căuta un element în listă vom începe de la cel mai înalt


nivel, L[4] pe exemplul de mai sus. Verificăm dacă valoarea elementului
către care indică elementul curent (la început, elementul curent este un
header al listei, adică un element care face parte din listă doar pentru a
uşura operaţiile suportate de aceasta) este mai mare (sau este NULL) decât
valoarea căutată: dacă da, atunci se scade nivelul (dar se păstrează poziţia)
până când valoarea indicată de către elementul curent este valoarea căutată.
Dacă în schimb valoarea indicată este mai mică decât cea căutată, păstrăm
nivelul şi avansăm poziţie.

449
Capitolul 13

Figura de mai jos prezintă modul în care se caută valoarea 10:

L[4] 4 NULL
L[3] 4 8 NULL
L[2] 1 4 8 NULL
L[1] 1 4 6 8 10 NULL
L[0] 0 1 2 3 4 6 7 8 9 10 NULL
Fig. 13.1.2. – Modul de căutare a unei valori într-o listă de salt

Chiar şi pe acest exemplu numărul de operaţii efectuate este mai mic


decât ar fi fost dacă am fi folosit o listă înlănţuită clasică. Dacă am avea mai
multe elemente în listă, diferenţa ar fi şi mai evidentă.
Timpul mediu de execuţie al acestei operaţii într-o listă de salt cu N
elemente este O(log N) şi este operaţia cea mai importantă într-o listă de
salt, deoarece restul operaţiilor au la bază acelaşi algoritm.

b) Inserarea unui element (Insert)

Pentru a insera un element în listă trebuie mai întâi să stabilim


numărul 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
1
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 jumătate dintre
nodurile de la nivelul imediat anterior, înălţimea maximă a unei liste cu N
elemente va fi O(log N).
Pentru a stabili nivelul maxim al noului nod, vom genera un număr
aleator pe 32 de biţi, iar nivelul elementului va fi dat de numărul de biţi
consecutivi de valoare 1 de la sfârşitul reprezentării în baza doi a numărului
aleator.
Pentru a insera efectiv noul element vom folosi algoritmul de căutare
împreună cu algoritmul de inserare într-o listă înlănţuită, deoarece, pe
fiecare nivel, noul element va fi inserat înaintea celui mai mic element mai
mare decât acesta.
Va trebui să avem grijă să inserăm 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 găsirea


acestuia pe fiecare nivel şi ştergerea efectivă folosind algoritmul clasic de
ştergere dintr-o listă înlănţuită. Practic, vom efectua operaţia inversă
inserării. Dacă elementul pe care vrem să-l ştergem nu există în listă, nu se
va întâmpla nimic.

d) Detalii de implementare
Deoarece codul complet al tuturor operaţiilor ar fi prea voluminos şi
greu de înţeles la prima vedere, vom prezenta pe rând şi cu explicaţii fiecare
structură şi metodă.
În primul rând vom folosi o structură Node, care va reprezenta un
nod, caracterizat prin informaţia reţinută şi legaturile acestuia.
În al doilea rând vom folosi o structură List, care va conţine
începutul listei, pentru a putea evita nişte cazuri particulare şi a simplifica
implementarea. Aşadar, structurile folosite sunt:

struct Node struct List


{ {
int info; int H; // inaltimea curenta a listei
Node **link_; Node *Header;

Node(int v, int nivele) List()


{ {
info = v; // maxH = 32, suficient pentru
link_ = new Node*[nivele]; // 2^32 elemente, deoarece maxim
// este O(log N)
for ( int i = 0; i < nivele; ++i ) Header = new Node(0, maxH);
link_[i] = NULL; H = 1;
} }
}; };

Deşi funcţia de inserare nu este complicată din punct de vedere


conceptual, implementarea nu este chiar intuitivă. Vom scrie o funcţie
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 generând aleator un număr şi verificând bit cu bit câţi biţi de valoare 1
are la final. O soluţie mai eficientă constă în precalcularea unui tablou

451
Capitolul 13

lookup de 256 de întregi unde lookup[i] = cu câţi biţi de valoare 1 se


termină numărul 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 biţi ai săi de la dreapta (care pot fi aflaţi prin rnd & 255) şi vom
vedea, folosind tabloul lookup, câţi biţi terminali au valoarea 1. Dacă toţi
cei 8 biţi au valoarea 1, atunci vom deplasa numărul rnd la dreapta cu 8
poziţii şi vom relua algoritmul, iar dacă nu, atunci returnăm totalul de până
acum. Astfel se vor efectua maxim 4 operaţii pentru fiecare inserare.
Urmează adăugarea efectivă a noului element, lucru care se face
pornind de la cel mai înalt nivel al listei şi adăugând 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.
Funcţia de inserare poate fi implementată în felul următor:

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

Prezentăm în continuare funcţia de căutare, care este foarte similară


cu a doua parte a funcţiei de inserare. Funcţia 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;
}

Următoarea funcţie este funcţia Remove(v, L) care şterge elementul


cu valoarea v din lista L. Mai exact, funcţia prezentată va şterge un singur
element cu valoarea v din listă, deoarece pot exista mai multe. Funcţia nu va
returna nimic, dar un exerciţiu pentru cititor este să modifice funcţia astfel
încât să returnere true dacă elementul v a fost găsit ş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ă operaţie de care se poate să avem nevoie este afişarea


elementelor listei în ordine sortată. Acest lucru îl putem face afişând
elementele de pe nivelul 0:

for ( Node *tmp = L->Header->link_[0]; tmp; tmp = tmp->link_[0] )


cout << tmp->info << ' ';

e) Analiza experimentală a performanţei

Complexitatea teoretică este O(log N) pe cazul mediu pentru fiecare


operaţie elementară şi evident O(N) pentru afişarea tuturor elementelor.
Tabelul de mai jos prezintă timpii de execuţie obţinuţi de implementările
oferite.
Un tabel similar va exista şi pentru restul structurilor de date
discutate. Toate măsurătorile au fost făcute pe acelaşi calculator, iar
numerele inserate, şterse şi căutate au fost furnizate de către expresia
rand() * rand(), furnizând numere pe 32 de biţi. Eventualele rezultate
returnate de funcţii au fost ignorate.

Tabelul 13.1.3. – Performanţa orientativă a listelor de salt


Număr test Inserări Căutări Ş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) Îmbunătăţiri

Putem transforma listele de salt într-o structură de date deterministă


în modul următor: renunţăm 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

Exerciţii:
a) Implementaţi variantă deterministă a listelor de salt.
b) Implementarea prezentată pune accentul pe simplitate. De
exemplu, ideal ar fi să şi eliberăm memoria aferentă unui nod
după ştergerea acestuia. Ce alte optimizări 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.
Comparaţi sortarea prin liste de salt cu restul algoritmilor de
sortare.
d) Scrieţi un program care citeşte un şir de numere răspunde la mai
multe întrebări de genul care este al k-lea cel mai mic element
din şir? în timp O(log N) pentru fiecare întrebare.
e) Scrieţi o funcţie care returnează poziţia unui element în listă.
f) Scrieţi o funcţie care returnează valoarea elementului de pe o
anumită poziţie.
g) Implementaţi 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 operaţii fundamentale în timp O(1) atât în cazul favorabil cât şi în
cazul mediu. Dezavantajul este că pentru operaţii de căutare şi ştergere
timpul de execuţie este O(N) în cel mai rău caz.
Să presupunem că avem un şir de N numere naturale foarte mari şi
că vrem să răspundem rapid la întrebări de genul numărul x se află în şir? şi
să efectuăm rapid actualizări de genul inserează numărul x în şir şi şterge o
apariţie a numărului x din şir (sau toate apariţiile). Aceeaşi problemă pe
care am rezolvat-o de fapt cu ajutorul listelor de salt.
Ideea din spatele tabelelor de dispersie porneşte de la rezolvarea
problemei prin vectori de caracterizare. Fie H[i] = true dacă numărul i se
află în şir şi false în caz contrar. Având acest vector, putem efectua foarte
rapid toate cele 3 operaţii de bază. Memoria folosită va fi însă O(maxV),
unde maxV este cel mai mare număr care poate apărea în şir. Am spus însă
la început că numerele sunt foarte mari (să presupunem cel mult 10 9), aşa că
această abordare iese din discuţie, deoarece un tablou de întregi de
dimensiunea un miliard ar ocupa aproximativ 4 gB de memorie!
Pentru a folosi mai puţină memorie şi a păstra eficienţa algoritmului,
vom folosi o funcţie de dispersie h, iar H[i] va deveni mulţimea (lista)
tuturor valorilor x pentru care h(x) = i. Vom alege funcţia h în aşa fel
încât să ne permitem memoria necesară, iar valorile din H să fie distribuite

455
Capitolul 13

cât mai uniform. Dimensiunea (numărul de mulţime sau liste) tabloului H va


fi egală cu valoarea maximă care poate fi returnată de funcţia h, iar
reuniunea tuturor mulţimilor va fi chiar şirul dat. Aşadar, memoria folosită
devine O(N + maxh), unde maxh este valoarea maximă care poate fi
returnată de h. Deoarece funcţia 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


funcţia h(x) = x % 5. Atunci tabela de dispersie va arăta în felul următor:

H[0] 5 10 0 NULL
H[1] 31 1 11 NULL
H[2] 132 17 2 7 NULL
H[3] 13 8 NULL
H[4] 4 29 NULL
Fig 13.2.1. – O tabelă de dispersie (hash table)

Se observă că există numere diferite care apar în aceeaşi listă. Când


există două numere (nu neapărat diferite) care, trecute prin funcţie de
dispersie, ajung în acelaşi loc, spunem că avem de a face cu o coliziune. O
tabelă de dispersie eficientă trebuie să aibă cât mai puţine coliziuni, iar
coliziunile existente să fie cât mai variate, adică apariţia lor să fie uniform
distribuită în toate listele.

a) Căutarea unui element (Search)

Pentru a căuta un număr x într-o tabelă de dispersie, mai întâi trecem


acel număr prin funcţia de dispersie aleasă, după care căutăm secvenţial
elementul x în lista H[h(x)]. Dacă am ales o funcţie bună, atunci căutarea
secvenţială nu va parcurge decât un număr foarte mic de elemente.
În cazul defavorabil, în care avem două distincte care se repetă de
multe ori şi care ajung în aceeaşi poziţie, căutarea unuia dintre aceste
numere poate necesita timp O(N). Aşadar, tabelele de dispersie sunt cele
mai folositoare pentru date cât mai variate.

b) Inserarea unui element (Insert)

Inserarea unui element este singura operaţie care necesită


întotdeauna timp O(1), deoarece pentru a insera un număr x îl vom adăuga
456
Structuri avansate de date

la începutul listei H[h(x)], iar inserarea unui element la începutul unei liste
înlănţuite se face în timp constant.

c) Ştergerea unui element (Remove)


Pentru a şterge un număr, se parcurge lista în care se află acesta până
la întâlnirea numărului respectiv, care apoi se şterge efectiv, exact ca într-o
listă înlănţuită oarecare. Se pot şterge toate elementele care au aceeaşi
valoare într-o singură parcurgere, sau se poate opta pentru ştergerea unei
singura instanţe.

d) Detalii de implementare
În primul rând trebuie să stabilim dimensiunea tabelei de dispersie şi
funcţia pe care o vom folosi. Pentru majoritatea problemelor se foloseşte o
funcţie simplă de genul h(x) = x % P, unde P este un număr prim sau
h(x) = x % 2k, pentru a putea calcula mai rapid operaţia modulo folosind
operaţii pe biţi. Vom alege cea de-a doua variantă din motive de eficienţă.
Am putea folosi şi de data aceasta două structuri: una care va reţine
doar un membru val, care va reprezenta numărul reţinut de un anumit
obiect, şi un pointer către următorul element din listă, reprezentând practic o
listă înlănţuită şi o a doua structură care va declara şi iniţializa mai multe
liste înlănţuite, constituind tabela de dispersie. Recomandăm cititorilor să
incorporeze şi funcţiile de gestiune a tabelei într-o clasă, atât pentru această
structură de date cât şi pentru celelalte din acest capitol. În acest fel, veţi
putea refolosi foarte uşor şi intuitiv codul.

struct Node struct Hash


{ {
int val; Node **link_;
Node *link_;
Hash(int size)
Node(int val) {
{ link_ = new Node*[size];
this->val = val; for ( int i = 0; i < size; ++i )
} link_[i] = NULL;
}; }
};

Putem folosi însă clasele list sau vector din S.T.L. pentru a obţine 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 potriveşte 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 încărcătura tabelei.
Vom prezenta implementarea funcţiilor de bază cu ajutorul tipului
list. Lăsăm ca exerciţiu pentru cititor implementarea cu ajutorul tipului
vector sau cu ajutorul unei liste înlănţuite implementate manual.

Prezentăm un program complet care implementează cele trei operaţii


de bază, adaugă 2010 numere aleatoare în tabelă iar apoi afişează rezultatul
a 2010 căutări în tabelă. Programul este explicat prin comentarii.

#include <iostream> void Remove(int x, list<int> H[])


#include <cstdlib> {
#include <ctime> H[ h(x) ].remove(x);
#include <list> }
using namespace std;
const int maxH = 1 << 20; // 2 la 20 int main()
{
int h(int x) // functia de dispersie // 2 la 20 poate fi prea mult pentru
// e h(x) = x % (2 la 20) // un tablou local
{ list<int> *H = new list<int>[maxH];
return x & (maxH - 1);
} srand((unsigned)time(0));

void Insert(int x, list<int> H[]) for ( int i = 1; i <= 2010; ++i )


{ Insert(rand()*rand(), H);
H[ h(x) ].push_front(x);
} for ( int i = 1; i <= 2010; ++i )
cout << Search(rand()*rand(), H)
bool Search(int x, list<int> H[]) << '\n';
{
// pentru cautare se parcurge return 0;
// 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;
}

458
Structuri avansate de date

Practic, folosind clasa list operaţiile de bază devin simple apeluri de


metode ale obiectelor acestei clase. Implementarea este foarte rapidă în
cazul în care se foloseşte o funcţie de dispersie uşoară.

e) Analiza experimentală a performanţei

Pe cazul mediu, fiecare operaţie are complexitatea O(1). Să vedem


cum se comportă tabelele de dispersie pe date (numere pe 32 de biţi)
generate aleator.

Tabelul 13.2.2. – Performanţa orientativă a tabelelor de dispersie


Număr test Inserări Căutări Ş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 uşor că tabelele de dispersie câştigă deplasat în faţa


listelor de salt.

Totuşi, tabelele de dispersie au anumite dezavantaje, fiind o structură


de date foarte specializată. În primul rând, nu putem implementa operaţii de
aflara a minimului, de afişare a elementelor în ordine şi nu putem extinde
uşor structura pentru alte tipuri de date. Aşadar, chiar dacă timpul de
execuţie se dovedeşte a fi foarte bun, nu întotdeauna un tabel de dispersie
este cea mai bună soluţie.

f) Îmbunătăţiri şi aplicaţii
Putem extinde tabelele de disperse pentru numere raţionale şi pentru
şiruri de caractere. O funcţie de dispersie pentru numere reale poate fi
h(x) = [{A∙x}∙P], unde:
5−1
 0 < A < 1, preferându-se (conform lui Knuth) 𝐴 = ≅
2
0.618033989 …
 {x} partea fracţionară a lui x

459
Capitolul 13

 [x] partea întreagă a lui x


 P un număr natural oarecare, de obicei un număr prim sau o
putere a lui 2.

Funcţiile de dispersie pentru numere raţionale au aplicaţii în


probleme de geometrie computaţională (de exemplu pentru căutarea rapidă a
unui punct din plan).
Altă aplicaţie este dată de tablouri asociative. Un tablou asociativ
este un tablou care poate fi indexat prin şiruri de caractere. De exemplu,
dacă vrem să implementăm o agendă telefonică, ar fi convenabil să putem
accesa rapid numărul de telefon al fiecărei persoane din agendă ştiind doar
numele acelei persoane. Astfel, numărul 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
funcţie de dispersie care să asocieze un număr şirului de caractere dat ca
parametru. Evident, o astfel de funcţie va folosi valorile ASCII ale
caracterelor din şir.
O metodă naivă de funcţie de dispersie pentru şiruri de caractere este
să adunăm valorile ASCII a tuturor caracterelor din şir şi să returnăm suma
acestora modulo un număr prim sau putere a lui 2. Această funcţie va genera
însă multe coliziuni. De obicei se foloseşte o funcţie polinomală de genul:
h(C) = (C1∙Pk – 1 + C2∙Pk – 2 + ... + Ck∙P0) % 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 număr mic, în jur de 100. Alegerea
numerelor P şi Q determină calitatea funcţiei.

Având acest model, implementarea unui tablou asociativ simplu


devine trivială. Trebuie doar construită o clasă care supraîncarcă operatorul
[ ], care implementează funcţia de dispersie după modelul de mai sus şi care
construieşte tabela de dispersie după modelul anterior. De data aceasta
listele vor reţine şiruri de caractere, sau string-uri.
Putem folosi proprietăţile operaţiei modulo pentru a calcula eficient
funcţia de dispersie în O(k). Prezentăm 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ă aplicaţie importantă a acestui gen de funcţii 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 funcţie de


dispersie pentru şiruri de caractere construită după modelul prezentat
anterior putem optimiza soluţia naivă astfel încât fie să funcţioneze
întotdeauna în timp O(N), dar să există posibilitatea de apariţie a unor aşa-
numite fals-pozitive, adică returnarea valorii true în cazul în care şirul S2 de
fapt nu este o subsecvenţă a şirului S1, fie să funcţioneze în timp O(N) doar
pe cazul favorabil (şi eventual mediu), dar să rămână O(N∙M) în cel mai rău
caz, evitându-se însă orice fals-pozitive.
Observăm în secvenţa de cod prezentată anterior că ne interesează la
fiecare pas i dacă subsecvenţa S1[i, i + M – 1] este egală cu şirul S2. Acest
lucru îl facem comparând caracter cu caracter cele două şiruri. Putem

461
Capitolul 13

compara însă doar rezultatele funcţiei de dispersie aplicate asupra


acestor două şiruri, transformând astfel cel de-al doilea for într-o simplă
condiţie. Dacă ne interesează să nu obţinem fals-pozitive, atunci în caz că
h(S1[i, i + M – 1]) = h(S2), vom efectua o comparaţie caracter cu caracter
între cele două şiruri pentru a ne asigura că egalitatea este adevărată. În caz
că cele două valori sunt diferite, ştim sigur că cele două şiruri sunt diferite.

Funcţia de dispersie trebuie să poate fi calculată rapid, în O(1),


pentru toate subsecvenţele de lungime M ale lui S1. Pentru acest lucru vom
calcula mai întâi 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 obţine 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+M–1]) – ((S1[i]∙PM – 1) % Q) + Q)∙P +


+ S1[M]) % Q

Practic, aplicând această formulă se elimină termenul care nu mai


face parte din secvenţa curentă, se înmulţesc restul termenilor cu P,
restabilindu-se puterile, şi se adună codul caracterului nou intrat în secvenţă.
Astfel, am obţinut funcţia de dispersie aplicată noii secvenţe î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ă garanţii asupra corectitudinii rezultatului
final, fie nu oferă garanţii asupra timpului de execuţie, care poate degenera
uşor.

Se mai pot face diverse optimizări pentru a ne asigura că vom obţine


cât mai puţine fals-pozitive în cazul în care nu verificăm potrivirile rezultate
din egalitatea valorilor returnate de funcţia de dispersie. Putem de exemplu
să folosim mai multe funcţii de dispersie şi să considerăm o potrivire doar
atunci când toate funcţiile de dispersie indică o potrivire. Pentru şiruri
formate din litere ale alfabetului englez şi două funcţii, probabilitatea de
apariţie a unor fals-pozitive este suficient de mică pentru majoritatea
scopurilor.
462
Structuri avansate de date

Evident, contează şi ce funcţii dispersie alegem. Din păcate, nu


există nicio reţetă de succes pentru găsirea unor funcţii de calitate, care să
nu genereze multe fals-pozitive. Trebuie testate mai multe variante pe cât
mai multe date de intrare şi analizat comportamentului fiecărei funcţii în
parte.

Prezentăm în final o funcţie 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

Exerciţii:
a) Modificaţi funcţia de mai sus astfel încât să afişeze toate poziţiile
de potrivire.
b) Propuneţi algoritmi pentru testarea calităţii unei funcţii de
dispersie, atât pentru numere naturale cât şi pentru şiruri de
caractere.
c) Se dă un şir de N numere naturale aleatoare. Se cere a găsirea a
patru numere din şir a căror sumă este S. Cum se poate rezolva
problema în O(N2) cu ajutorul tabelelor de dispersie?
d) Elaboraţi un test pe care soluţia găsită pentru problema
anterioară să aibă timpul de execuţie 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 interogări şi, eventual, actualizări.
Vom exemplifica arborii de intervale pe o variantă a problemei R.M.Q. care
suportă şi actualizări.
Considerăm un număr N şi un şir A de N elemente numere întregi.
Se dau T triplete de forma (op, x, y) unde:
 op = 1 semnifică operaţia de aflare a minimului din subsecvenţa
A[x, y].
 op = 2 semnifică operaţia A[x] = y.
Pentru fiecare interogare (op == 1) se va afişa 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ă efectuăm ambele operaţii în timp O(log N). Memoria folosită
va fi O(N), ceea ce este o îmbunătăţire faţă de rezolvarea prin programare
dinamică, rezolvare pe care nu o mai putem folosi din cauza actualizărilor.

464
Structuri avansate de date

În primul rând 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ă
subsecvenţa [1, N], nodul 2 subsecvenţa [1, (N+1) / 2], nodul 3 subsecvenţa
[(N + 1) / 2 + 1, N], iar în cazul general fiul stâng al unui nod are asociată
prima jumătate a intervalului părintelui său, iar fiul drept are asociată a doua
jumătate. Frunzele vor fi asociate unor intervale cu capetele egale. Figura
următoare 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 conţine


informaţii despre acesta, informaţii care pot fi calculate pe baza
informaţiilor reţinute în fiii nodului. Pentru această problemă, fiecare nod va
reţine 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 reţin
intervale (secvenţe) de lungime 1, a căror minime sunt chiar acel unic
element al intervalului. Pentru a afla minimul secvenţei asociate unui nod
oarecare, este de ajuns să considerăm minimul celor doi fii ai acestui nod.

Pentru a afla minimul unei secvenţe [x, y] vom considera doar


secvenţele reţinute de arbore care sunt incluse în [x, y]. Răspunsul va fi dat
de cel mai mic minim al acestor secvenţe.
Pentru a afla secvenţele arborelui incluse în [x, y] se foloseşte un
algoritm recursiv care verifică mai întâi dacă intervalul asociat nodului
curent se intersectează (are elemente comune) cu [x, y]:

465
Capitolul 13

 dacă nu, atunci nu are rost să verificăm fiii nodului curent,


deoarece nu au nicio şansă să fie incluşi î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 iniţial 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.

Procedând în acest fel obţinem complexitatea O(log N), datorată


faptului că înălţimea 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 găsit
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 roşu apar minimele fiecărui interval reţinut de arbore, iar cu verde
nodurile parcurse pentru aflarea minimului secvenţei [4, 6]. Îngroşat apar
nodurile a căror intervale sunt incluse în [4, 6], restul nodurilor fiind noduri
intermediare. Cu portocaliu apar nodurile respinse deoarece intervalele
asociate nu intersectează intervalul căutat.

Fig. 13.3.2. – Modul de interogare a unui arbore de intervale

Se poate observa că minimul secvenţei [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 conţin elementul x şi se
continuă parcurgerea doar acelor intervale care îl conţin pe x. Putem face
acest lucru fie terminând apelurile recursive pentru intervalele care nu îl
conţin pe x, fie punând nişte condiţii în aşa fel încât 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, strămoşii acestui nod.
Figura de mai jos prezintă operaţia de actualizare a elementului 5,
acesta luând valoarea 1. Actualizările apar cu verde, la fel şi nodurile a
căror intervale îl conţin pe 5.

Fig. 13.3.3. – Modul de actualizare a unui arbore de intervale

Se pune problema construirii arborelui pentru şirul iniţial de numere.


Avem două posibilităţi:
1. Pentru fiecare element citit actulizăm arborele folosind
procedura de actualizare descrisă anterior. Această metodă este
mai convenabilă, dar mai puţin eficientă, deoarece unele noduri
vor fi parcurse de mai multe ori.
2. Folosim o procedură separată care va construi arborele iniţial.
Aceasta va fi similară cu procedura de actualizare, doar că nu vor
exista condiţii de ieşire prematură din recursivitate în cazul în

467
Capitolul 13

care intervalul curent nu conţine un anumit element, deoarece ne


interesează toate intervalele în această primă fază.

Astfel obţinem o structură de date eficientă care ne ajută să


răspundem la întrebări în timp O(log N) şi să actualizăm 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 aceeaşi idee ca la heap-uri: dacă A este arborele de
intervale, fiii unui nod k vor fi A[2∙k] respectiv A[2∙k + 1]. A va trebui să
fie de dimensiune cel puţin 2∙N – 1 (există N + N / 2 + N / 4 + ... noduri).
Arborele nu este neapărat să fie complet însă, aşa că va trebui să verificăm
dacă apelurile recursive se fac pentru un nod care chiar există în arbore.
Pentru a evita aceste verificări putem declara tabloul A ca fiind de
dimensiune 2P ≥ 2∙N – 1. Practic, vom completa arborele cu nişte
pseudonoduri până când acesta va deveni un arbore binar complet.

#include <fstream> void build(int Arb[], int A[], int nod,


int st, int dr)
using namespace std; {
const int maxN = 101; if ( st == dr )
const int maxArb = 1 << 8; {
const int inf = 1 << 30; Arb[nod] = A[st];
return;
void citire(int &N, int &T, int A[], }
ifstream &in)
{ int m = (st + dr) / 2, fiu = 2*nod;
in >> N >> T;
for ( int i = 1; i <= N; ++i ) build(Arb, A, fiu, st, m);
in >> A[i]; 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 main()


int dr, int x, int y) {
{ int N, T;
if ( st > y || dr < x ) // interval invalid int A[maxN], Arb[maxArb];
return INT_MAX;
ifstream in("RMQ2.in");
if ( x <= st && dr <= y ) // solutie citire(N, T, A, in);
return Arb[nod];
ofstream out("RMQ2.out");
int m = (st + dr) / 2, fiu = 2*nod; build(Arb, A, 1, 1, N);
while ( T-- )
return min(query(Arb, fiu, st, m, x, y), {
query(Arb, fiu+1, m+1, dr, x, y)); int op, x, y;
} in >> op >> x >> y;

void update(int Arb[], int nod, int st, if ( op == 1 )


int dr, int x, int y) out<<query(Arb, 1, 1, N, x, y)
{ << '\n';
if ( st > x || dr < x ) else
return; update(Arb, 1, 1, N, x, y);
}
if ( st == dr )
{ in.close();
Arb[nod] = y; out.close();
return;
} 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 performanţei

De data aceasta operaţiile se schimbă, neexistând clasicele inserări,


ştergeri sau căutări. Vom testa aşadar doar o singură operaţie build
împreună cu mai multe operaţii query şi update. Testele au fost rulate pe
implementarea prezentată anterior, pe date generate aleator şi cu afişarea
scoasă. A fost cronometrată de fiecare dată şi generarea datelor.

469
Capitolul 13

Tabelul 13.3.4. – Performanţa orientativă a arborilor de intervale


Număr 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 eficienţi atât în


teorie cât şi în practică.

d) Aplicaţii în geometria computaţională

Arborii de intervale pot fi folosiţi pentru a rezolva probleme care


conţin interogări şi actualizări care trebuie efectuate foarte rapid. O aplicaţie
importantă a arborilor de intervale este în geometria computaţională. Se dau
N segmente paralele cu axele sistemului de coordonate şi se cere
determinarea numărului total de intersecţii dintre acestea.
Problema se poate rezolva trivial în O(N 2) folosind algoritmul
prezentat în cadrul capitolului de geometrie computaţională. 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 imaginăm o dreaptă verticală care parcurge planul în care se
află segmentele de la stânga la dreapta. Avem următoarele cazuri:
1. Dreapta de baleiere se intersectează cu capătul stâng al unui
segment orizontal, caz în care acest segment este introdus într-o
structură de date care reprezintă stările dreptei de baleiere sau
lista punct-eveniment sau lista stărilor.
2. Dreapta de baleiere se intersectează cu capătul 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 numărul de intersecţii generate de acesta
determinând câte dintre segmentele orizontale prezente în lista
punct-eveniment au ordonata cuprinsă între ordonatele
segmentului vertical curent.

470
Structuri avansate de date

Să observăm în primul rând că introducerea unui segment orizontal


în lista stărilor înseamnă doar introducerea ordonatei acestuia, deoarece
interogările se vor face asupra ordonatelor. Aşadar operaţia 1. reprezintă a
adunarea valorii +1 elementului y, unde y este ordonata segmentului, iar
operaţia 2. reprezintă adunarea valorii -1 elementului y. Operaţia 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 optimizări. Complexitatea finală va fi de
O(N∙log N). Capetele segmentelor vor trebui mai întâi să fie sortate
crescător după abscise, pentru a putea fi parcurse secvenţial în mod eficient.

e) Problema L.C.A. (Lowest Common Ancestor)


Se dă un arbore oarecare cu N noduri. Se cere să se răspundă rapid la
întrebări de genul considerând nodurile x şi y ale arborelui dat, care este
cel mai jos nod din arbore care este strămoş atât pentru x cât şi pentru y?
În figura de mai jos am marcat cu albastru cel mai de jos strămoş
comun al nodurilor marcate cu roşu.

Fig. 13.3.5. – Vizualizarea problemei L.C.A.

Vom arăta î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(N∙log N) şi vom putea răspunde 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 răspunde la o
interogare. Ambele implementări sunt clasice şi au fost deja prezentate, aşa
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
adâncime care adaugă fiecare nod parcurs într-o listă Euler, eventual de mai
multe ori. Mai exact:
 Dacă nodul curent este o frunză, atunci este adăugat parcurgerii.
 Dacă nodul curent are fii, acesta este adăugat la începutul
parcurgerii euleriene a fiilor săi, la sfârşitul acestei parcurgeri şi
între fiecare parcurgere eulerienă a fiilor.

Mai mult, pentru fiecare nod i se va calcula şi H[i] = adâncimea


nodului i în arbore şi Poz[i] = poziţia primei apariţii 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 1 0 1
2 2 1 2
3 1 1 4
4 3 1 12
5 5 2 5
6 3 2 7
7 6 2 13
8 9 2 15
9 6 3 8
10 3 - -
11 1 - -
12 4 - -
13 7 - -
14 4 - -
15 8 - -
16 4 - -
17 1 - -

Având aceşti trei vectori calculaţi putem reduce problema la o


interogare de minim pe interval. Se observă că fiecare nod i este în interiorul
intervalelor formate din două apariţii consecutive a strămoşilor nodului i în
parcurgerea euleriană. De exemplu, nodul 5 se află în intervalul format din
două apariţii consecutive ale lui 3 în parcurgerea euleriană şi din două
apariţii consecutive ale lui 1. Asta înseamnă că nodurile 3 şi 1 sunt strămoşi

472
Structuri avansate de date

ai lui 5. Deci, un anumit nod apare în parcurgerea euleriană atât înainte de


fiii săi cât şi după.
Aşadar, pentru a determina cel mai jos strămoş comun a două noduri
x şi y este de ajuns să determinăm nodul cu adâncimea minimă din
intervalul [ Euler[Poz[x]], Euler[Poz[y]] ]. Pentru nodurile 5 şi 9 din
exemplu, se determină nodul care are adâncimea 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 strămoş
comun al nodurilor 5 şi 9. Prezentăm o funcţie 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 iniţializat k cu 1, H[1] cu 0, iar Euler trebuie să poată


conţine 2∙N – 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 lăsat ca exerciţiu
pentru cititor.

Exerciţii:
a) Scrieţi un program care citeşte un arbore ponderat şi răspunde
eficient la întrebări de genul care este lungimea drumului dintre
nodurile x şi y?
b) Scrieţi un program care citeşte un şir de numere întregi şi
răspunde eficient la întrebări de genul care este subsecvenţa de
sumă maximă dintre poziţiile x şi y? Implementaţi şi actualizări.
c) Scrieţi un program care citeşte un tablou cu N elemente din
mulţimea {0, 1}. Numărul 0 reprezintă faptul că acea poziţie este

473
Capitolul 13

liberă, iar numărul 1 că acea poziţie este ocupată. Programul


trebuie să răspundă eficient la întrebări de genul care este a k-a
poziţie ocupată a tabloului? De exemplu, pentru tabloul 1 | 0 | 0 |
1 | 0 | 1 | 1 | 0 | 1, a 3-a poziţie ocupată este poziţia 6.
d) Problema de mai sus, dar implementaţi şi actualizări.
e) Implementaţi iterativ funcţiile de gestiune a unui arbore de
intervale.
f) Scrieţi un algoritm pentru care o interogare constă în aflarea
sumei unei subsecvenţe a unui şir, iar o actualizare în adunarea
unei valori la un element al şirului.
g) Problema anterioară, doar că acum o actualizare constă în
adunarea unei valori unui întreg interval dat.

13.4. Arbori indexaţi binar


Arborii indexaţi binar reprezintă o altă structură de date cu ajutorul
căreia putem rezolva eficiente probleme cu interogări şi actualizări.
Avantajul acestora asupra arborilor de intervale este că, deşi teoretic sunt cel
mult la fel de eficienţi, în practică sunt mai rapizi datorită naturii lor
nerecursive, algoritmilor simpli de gestiune şi a memoriei folosite.
Dezavantajul este că aceştia sunt mai specializaţi, adică se pot aplica unei
game mai restrânse 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


semnificaţia:
 op = 1 semnifică determinarea şi afişarea sumei:
A[x] + A[x + 1] + ... + A[y].
 op = 2 semnifică adunarea valorii întregi y numărului 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


parţiale. Fie S un vector, S[1] = A[1] şi S[i > 1] = S[i – 1] + A[i]. Aşadar,
S[i] = suma primelor i elemente a şirului A. Pentru a răspunde unei
interogări, este de ajuns să afişăm S[y] – S[x – 1], obţinând astfel suma
cerută în O(1). Pentru a efectua o actualizare însă, trebuie să recalculăm
toate valorile vectorului S, de la poziţia actualizată până la ultimul element.
Aşadar, timpul necesar unei actualizări este O(N). Această soluţie nu este
bună decât dacă numărul de actualizări este foarte mic.
Putem obţine şi timpul O(N) pentru o interogare şi O(1) pentru o
actualizare parcurgând pentru fiecare interogare intervalul asociat acesteia şi
adunând pentru fiecare actualizare valoarea asociată acesteia poziţiei
corespunzătoare.

Folosind arbori indexaţi binar vom obţine timpul O(log N) pentru


ambele operaţii. Acest timp este identic cu cel pe care l-am obţine dacă am
rezolva problema cu ajutorul arborilor de intervale. Aşa cum am mai spus
însă, arborii indexaţi binar nu folosesc recursivitatea, ci, aşa cum vom
vedea, doar operaţii pe biţi şi adunări. Memoria folosită este şi aceasta mai
puţină. Din aceste motive, aceştia vor fi mai eficienţi în practică. Iată
totodată un exemplu care pune în evidenţă natura teoretică a notaţiei
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 decât un vector S unde S[i] = Sumă[i – 2k + 1, i], unde k reprezintă
numărul zerourilor terminale din reprezentarea binară a lui i. Cu alte
cuvinte, S[i] este suma unei subsecvenţe de lungime 2k care se termină pe
poziţia i. Se poate observa că nu este necesar să impunem condiţii speciale
lui i (evident, i nu poate fi mai mare decât N sau mai mic decât 1), deoarece
semnificaţia lui k ne asigură că nu vom scădea prea mult sau prea puţin.

Tabelul următor prezintă arborele indexat binar corespunzător


exemplului dat.

475
Capitolul 13

Tabelul 13.4.1. – Construirea unui arbore indexat binar


i i2 A[i] S[i] Înţeles Explicaţie
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 câteva lucruri din acest tabel: în primul rând


S[i] = A[i] pentru orice i impar. Acest lucru se datorează faptului că bitul cel
mai puţin semnificativ al oricărui număr impar este 1, deci nu există zerouri
terminale. În al doilea rând, orice poziţie putere a lui doi reprezintă, în
arbore, suma tuturor elementelor până la acea poziţie.
Se mai poate observa că putem afla, pe baza tabelului, suma oricărei
subsecvenţe care începe pe prima poziţie. Acest lucru este suficient pentru a
răspunde la o interogare, deoarece putem folosi următoarea 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ă
scăzând din i pe 2k atâta timp cât i este mai mare ca 0, vom obţine la fiecare
pas o poziţie care reprezintă suma unei subsecvenţe disjuncte, dar adiacente
cu subsecvenţa anterioară. Adunând fiecare S[i], vom obţine suma cerută.
Algoritmul de implementare al funcţiei Suma(1, i) este următorul:
 rez = 0
 Cât timp i mai mare decât 0 execută
o rez = rez + S[i]
o i = i – 2k, unde k = numărul 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 răspunsul corect.

Pentru a actualiza arborele indexat binar, algoritmul este aproape


identic. Pentru a implementa funcţia de actualizare Actual(i, v), care adună
elementului i valoarea v, va trebui să creştem valoarea fiecărui element al
arborelui a cărui subsecvenţă asociată îl conţine pe i. Astfel, vom aduna la

476
Structuri avansate de date

S[i] pe v, unde i creşte cu 2k la fiecare pas, atâta timp cât i este mai mic sau
egal cu N.
Algoritmul funcţiei Actual(i, v) este:
 Cât timp i <= N execută
o S[i] = S[i] + v
o i = i + 2k, unde k = numărul zerourilor terminale ale lui i

De exemplu, pentru a aduna o valoare numărului A[4] vom actualiza


valorile S[4] şi S[8].
Putem observa că nu este necesară nici măcar păstrarea vectorului A,
cel puţin pentru această problemă. Spre deosebire de arborii de intervale,
aici nu avem o funcţie dedicată pentru construirea arborelui, aşa că se va
apela pentru fiecare număr citit funcţia de actualizare.

b) Detalii de implementare

O implementare naivă ar parcurge fiecare bit al lui i pentru a


determina valoarea k, sau cel puţin va parcurge biţi atâta timp cât aceştia
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ă operaţie are complexitatea O(log i), aşa că, folosind această
abordare, se va obţine complexitatea totală O(log2 N) pentru fiecare
operaţie. 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 biţilor din stânga bitului de valoare 1 pe valoarea
0, astfel încât restul biţilor sa rămână neschimbaţi. Dacă putem face acest
lucru, atunci vom avea calculată valoarea 2k. Avem nevoie de următoarele
propoziţii:
477
Capitolul 13

 Operaţia i & (i – 1) are ca efect setarea celui mai puţin


semnificativ bit de valoare 1 al lui i pe valoarea 0.
De exemplu:
i 1011000 &
i –1 1010111
–––––––––
i & (i – 1) 1010000

 Operaţia i ^ i are ca efect setarea tuturor biţilor lui i pe valoarea


0.
De exemplu: 10110 ^ 10110 = 00000.
 În plus: 1 ^ 0 = 0 ^ 1 = 1.

Ne interesează o secvenţă de operaţii care să seteze toţi biţii unei


valori pe 0, în afară de cel mai puţin semnificativ bit de valoare 1. Folosind
propoziţiile de mai sus, obţinem formula de calcul 2k = i ^ (i & (i – 1)),
unde k are semnificaţia sa de până acum. De exemplu:

i 1011000 &
i–1 1010111
–––––––––
i & (i – 1) 1010000

i ^ (i & (i – 1)) 0001000

Aşadar, pentru fiecare i din pseudocodurile anterioare, adunarea


respectiv scăderea valorii 2k se face adunând, respectiv scăzând
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 main()


int S[], ifstream &in) {
{ int N, T, A[maxN], S[maxN];
in >> N >> T; ifstream in("sume.in");
// S trebuie initializat cu 0 citire(N, T, A, S, in);
// se poate folosi si memset
for ( int i = 1; i <= N; ++i ) ofstream out("sume.out");
S[i] = 0; while ( T-- )
{
for ( int i = 1; i <= N; ++i ) int op, x, y;
{ in >> op >> x >> y;
in >> A[i];
Actual(N, i, A[i], S); if ( op == 1 )
} out << Suma(y, S) - Suma(x-1, S)
} << '\n';
else
int Suma(int i, int S[]) // query Actual(N, x, y, S);
{ }
int rez = 0; in.close();
for ( ; i > 0; i -= i ^ (i & (i - 1)) ) out.close();
rez += S[i];
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ă decât implementarea arborilor


de intervale. Se poate observa deja de ce am făcut afirmaţiile de la început
referitoare la eficienţa arborilor indexaţi binar. Operaţiile de arborii indexaţi
binar sunt operaţii care se execută foarte rapid pe orice calculator, pe când
operaţiile de gestiune a arborilor de intervale presupun împărţiri,
recursivitate, mai mulţi parametri transmişi funcţiilor şi mai multe condiţii
în cadrul fiecărei funcţii. În plus, memoria folosită este mai mult decât dublă
în cazul arborilor de intervale.
Deşi arborii indexaţi binar nu sunt aplicabili în unele probleme care
se pot rezolva cu ajutorul arborilor de intervale, pentru problemele în care
sunt aplicabili, acesştia sunt mai eficienţi.

479
Capitolul 13

c) Analiza experimentală a performanţei

De data aceasta nu mai avem o funcţie specială dedicată construirii


arborelui, aşa că, pentru a compensa, măsurătorile iau în calcul şi apelurile
funcţiei de actualizare care se fac în timpul citirii datelor.

Tabelul 13.4.2. – Performanţa orientativă a arborilor indexaţi binar


Număr 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

Nişte simple teste demonstrează aşadar cele spuse înainte: arborii


indexaţi binar sunt cu mult mai eficienţi decât arborii de intervale. Pot exista
însă probleme care să nu se poată rezolva uşor (sau deloc) cu ajutorul
arborilor indexaţi binar, necesitând arbori de intervale.

d) Extinderi

Putem extinde ideea prezentată pentru a funcţiona şi în cazul


bidimensional, adică în cazul în care actualizările şi interogările se
efectuează asupra unei matrici. Presupunem că se dă o matrice cu N linii şi
M coloane. O interogare presupune aflarea sumei submatricii cu colţul
stânga-sus în elementul (x, y) şi colţul dreapta-jos în elementul (p, q). O
actualizare presupune adunarea unei valori elementului (x, y). Putem
implementa algoritmi care au timpul de execuţie O(N∙M) pentru una dintre
operaţii şi O(1) pentru cealaltă operaţie. Aceştia sunt similari cu algoritmii
naivi de rezolvare a problemei unidimensionale şi nu vom insista asupra lor.
Pentru a obţine timpul de execuţie O((log N)∙(log M)) pentru fiecare
operaţie este necesar să folosim un arbore de arbori indexaţi binar. Vom
folosi o matrice S, unde S[i][j] semnifică suma submatricii cu colţul
stânga-sus în elementul (i – 2k + 1, j – 2l + 1) şi colţul dreapta-jos în
elementul (i, j), unde k reprezintă numărul de zerouri terminale ale lui i, iar
l numărul 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ă, aşa că o lăsăm pe seama cititorului. Pentru
a afla răspunsul la o interogare, vor trebui făcute mai multe interogări în
arbore (indiciu: găsiţi o metodă de rezolvare, pornind, eventual, de la
rezolvarea naivă în care S[i][j] = suma submatricii cu colţul stânga-sus în
(1, 1) şi dreapta-jos în (i, j)).

Exerciţii:
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) Extindeţi arborii indexaţi binar pentru rezolvarea unei probleme
similare în spaţiul tridimensional.
c) Găsiţi forme echivalente ale expresiei i ^ (i & (i - 1)).

13.5. Arbori de prefixe (Trie)


Am prezentat până acum structuri de date care lucrează în principal
cu numere. Ne propunem în continuare să implementăm un dicţionar, adică
o structură de date cu ajutorul căreia să putem manipula eficient o mulţime
de cuvinte (sau, mai general, şiruri de caractere).

a) Prezentarea generală a structurii de date

Figura următoare 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 proprietăţile


de bază ale acestei structuri de date.
În primul rând, un trie este un arbore.
Nodul rădăcină este un nod special, care nu are afectează conţinutul
structurii de date, ci doar uşurează reprezentarea (şi implementarea) acestei
structuri.
Fiecare frunză are eticheta \0, care semnifică sfârşitul unui cuvânt.
Verificarea existenţei unui cuvânt în dicţionar se face începând
parcurgerea arborelui de la rădăcină şi mergând succesiv pe fiii etichetaţi cu
litera de pe poziţia corespunzătoare a cuvântului căutat. Dacă la un moment
dat am ajuns pe caracterul \0, care semnifică sfârşitul unui cuvânt, atunci
cuvântul căutat există în trie. Dacă am ajuns în situaţia în care nu există
niciun fiu al nodului curent care să fie etichetat cu litera de pe poziţia
curentă a cuvântului căutat, atunci cuvântul căutat nu se află în trie. Operaţia
de căutare se execută în timp O(L), unde L este lungimea cuvântului căutat.
Inserarea unui cuvânt se face în mod similar cu verificarea
existenţei unui cuvânt, şi are acelaşi timp de execuţie. Singura diferenţă este
că, atunci când nodul curent nu are un fiu etichetat cu litera corespunzătoare
poziţiei curente a cuvântului care trebuie inserat, un astfel de fiu este creat.
Se procedează în acest fel până când au fost create noduri (dacă a fost cazul)
pentru toate literele cuvântului. La final, se adaugă un nod cu eticheta \0,
semnificând sfârşitul cuvântului.

În cele ce urmează ne propunem să scriem un program care citeşte


din fişierul trie.in N operaţii de forma op cuv, unde:
 op = 0 înseamnă adăugarea cuvântului cuv în dicţionar.
 op = 1 înseamnă afişarea numărului de apariţii a cuvântului cuv
în dicţionar.
 op = 2 înseamnă ştergerea unei apariţii a cuvântului cuv din
dicţionar. Nu se va afişa nimic. Există posibilitatea ca
argumentul acestei operaţii (cuvântul care trebuie şters) să nu
existe în dicţionar.

Vom explica pe larg modul de funcţionare al fiecărei operaţii,


precum şi implementarea fiecăreia.

b) Detalii de implementare

În primul rând să vedem cum vom reţine acest arbore. Fiind vorba de
un arbore în care fiecare nod poate avea un număr relativ mare de fii

482
Structuri avansate de date

(considerăm că fiecare nod poate avea 26 de fii, câte unul pentru fiecare
literă din alfabet), vom folosi o structură nod cu următoarele câmpuri:
 rasp – folosit doar de nodurile terminale, ne indică numărul
cuvintelor din trie care au acest nod terminal, adică numărul de
apariţii al unui anumit cuvânt.
 nrf – folosit de toate nodurile, ne indică numărul de fii nevizi ai
nodului curent.
 next[26] – folosit de toate nodurile, reprezintă un vector de
pointeri, fiecare indicând un anumit fiu. next[0] va indica fiul
etichetat cu a, next[1] fiul etichetat cu b şi aşa mai departe până
la next[25] care va indica fiul etichetat cu z.

Această structură arată în felul următor î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 informaţiile


reţinute de fiecare nod. Câmpul nrf ne va ajuta să decidem dacă un anumit
nod trebuie şters din memorie sau nu.

În continuare vom prezenta implementarea funcţiilor de gestiune a


arborelui.
Funcţia de inserare, Insert(rad, cuv), inserează cuvântul cuv în
trie-ul cu rădăcina în rad. Acest lucru se face traversând nodurile etichetate
cu caracterul de pe poziţia curentă a cuvântului. De exemplu, la primul apel
al funcţiei se verifică fiul nodului rad etichetat cu caracterul cuv[0]. Dacă
acest fiu nu există, el este creat. Se apelează recursiv funcţia pentru acest
fiu, iar următoarea verificare se va face cu caracterul cuv[1] (practic, se va
incrementa un pointer, deoarece cuv va fi un pointer către char). Se

483
Capitolul 13

procedează în acest fel până când cuv va indica sfârşitul cuvântului, adică \0
(terminatorul de şir). În acest moment se actualizează numărul de apariţii
(câmpul rasp) al acestui nod terminal şi funcţia se termină.

Implementarea este următoarea:

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


}

Funcţia de aflare a numărului de apariţii ale unui cuvânt,


Apar(rad, cuv), funcţionează asemănător. Parcurgem arborele pe drumul
dat de caracterele din şirul cuv. Fie vom ajunge pe un nod terminal
(etichetat cu \0) şi vom afişa valoarea câmpului rasp al acestui nod, fie vom
încerca să accesăm un nod care nu există, caz în care răspunsul va fi 0
(cuvântul nu se află în dicţionar / trie).

Implementarea este următoarea:

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


}

Funcţia de ştergere a unei apariţii a unui cuvânt din dicţionar,


Del(radInit, rad, cuv), funcţionează asemănător, dar trebuie să avem mai
multă grijă la implementare.
În primul rând, trebuie să fim atenţi să nu ştergem rădăcina arborelui
trie, dată de radInit. Chiar dacă se şterg toate cuvintele din trie, acest nod
rădăcină (etichetat, conceptual, cu #) trebuie să rămână pentru a putea
efectua inserări în viitor.
În al doilea rând, observăm că un nod nu poate fi şters efectiv decât
dacă acesta nu mai are fii, adică dacă nrf este 0 pentru nodul respectiv, iar
rasp este la rândul lui 0, deoarece nu vrem să ştergem un nod terminal decât
dacă acesta reprezintă finalul unui cuvânt care nu mai face parte din
dicţionar.
Aşadar, funcţia del va returna o valoare booleană: true dacă am
reuşit să ştergem efectiv nodul curent şi false în caz contrar. Funcţia 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 condiţii 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ă funcţii de gestiune.

Implementarea este următoarea:

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 abordări este că utilizatorul poate să nu


dorească returnarea unei valori booleene de care nici măcar nu se poate
folosi (deoarece aceasta nu ne spune dacă cuvântul care s-a vrut a fi şters a
existat sau nu în trie). O soluţie este să avem o funcţie ajutătoare care
apelează la rândul său funcţia de ştergere efectivă şi care apelează funcţia
apar pentru a verifica dacă argumentul se află sau nu în trie. Această
metodă este folosită mai ales atunci când se lucrează cu clase, unde funcţiile
care vrem să fie ascunse de utilizatorii clasei pot fi făcute uşor private.

Prezentăm în final şi funcţia 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) Aplicaţii

În primul rând, un trie poate fi folosit ca o alternativă la tabelele de


dispersie. În cel mai rău caz, verificarea existenţei unui cuvânt într-un tabel
de dispersie are timpul de execuţie O(N∙L), unde N este numărul de cuvinte,
iar L este lungimea cuvântului căutat. Acest caz are loc atunci când toate
cuvintele ajung pe aceeaşi poziţie în tabel, iar cuvântul căutat ajunge la
sfârşitul listei asociate poziţiei respective. Căutarea unui cuvânt într-un trie
se efectuează întotdeauna în timp O(L). În plus, implementarea unui
dicţionar cu ajutorul tabelelor de dispersie este mai dificilă.
O altă aplicaţie importantă a unui trie este posibilitatea de a sorta
lexicografic cuvintele inserate în acesta într-un mod eficient şi elegant.
Putem efectua această sortare parcurgând arborele în adâncime şi ţinând la
fiecare pas o stivă cu etichetele nodurilor parcurse. Dacă avem grijă să
efectuăm apelurile recursive în mod crescător al etichetelor asociate fiilor
(prima dată pentru fiul etichetat cu „a‟, apoi pentru cel cu „b‟ dacă există
etc.), atunci este suficient să afişăm stiva o dată ajunşi pe un nod terminal şi
vom obţine cuvintele în ordine lexicografică.
Dacă ignorăm costul afişării (o considerăm o operaţie care se
execută în O(1) cu alte cuvinte), atunci complexitatea acestui algoritm de
sortare este O(C), unde C reprezintă numărul total de noduri din trie, adică
suma caracterelor tuturor cuvintelor din trie.

487
Capitolul 13

d) Alte structuri pentru gestiunea şirurilor

Pentru cei interesaţi, următoarele structuri de date sunt foarte


folositoarea în lucrul cu şiruri de caractere. Nu le vom prezenta în această
ediţie, dar le menţionăm pentru a vă putea documenta individual:
 Şiruri de sufixe (suffix arrays)
 Arbori de sufixe (suffix trees)
 Arbori radix (radix trees, PATRICIA trees)

Exerciţii:
a) Scrieţi o funcţie care afişează toate cuvintele dintr-un trie în
ordine lexicografică.
b) Scrieţi o funcţie care primeşte ca argumente rădăcina unui trie şi
un cuvânt cuv şi afişează cel mai lung prefix comun dintre
cuvântul cuv şi orice alt cuvânt din trie.
c) Se dă un vector A cu N numere naturale. Scrieţi un program care
găseşte o subsecvenţă Ai, Ai + 1, ..., Aj, cu 1 ≤ i ≤ j ≤ N astfel
încât valoarea Ai xor Ai + 1 xor ... xor Aj să fie maximă.
Reamintim tabelul de adevăr al operaţiei xor:

x y x xor y
1 0 1
0 1 1
1 1 0
0 0 0

d) Scrieţi un program care implementează un dicţionar cu ajutorul


unui trie şi cu ajutorul unui tabel de dispersie. Comparaţi
performanţele celor două structuri de date. Similar, comparaţi
sortarea unui vector de cuvinte cu ajutorul algoritmilor clasici de
sortare cu sortarea aceluiaşi vector cu ajutorul unui trie.
e) Elaboraţi propria voastră analiză experimentală a performanţei.

13.6. Arbori binari de căutare (Binary Search Trees)

Arborii binari de căutare reprezintă o structură de date utilă în


rezolvarea problemelor de optimizare, suportând următoarele operaţii în
timp O(log N) pe cazul favorabil. Cazul defavorabil al acestor operaţii este
O(N), dar vom prezenta în capitolul următor o structură de date mai
avansată care suportă aceste operaţii î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 rădăcina în
nodul T.
 Insert(x, T) inserează un nod cu valoarea x în arborele binar de
căutare cu rădăcina în nodul T.
 Remove(x, T) şterge nodul cu valoarea x din arborele cu
rădăcina în T.

Un arbore indexat binar cu rădăcina în nodul T se defineşte astfel:


 Subarborele stâng al nodului T este fie nul, fie conţine doar
noduri care au asociate valori mai mici decât valoarea asociată
nodului T.
 Subarborele drept al nodului T este fie nul, fie conţine doar
noduri care au asociate valori mai mari decât sau egale cu
valoarea asociată nodului T.
 Cei doi subarbori ai lui T trebuie să fie la rândul lor arbori binari
de căutare.
De obicei, valorile conţinute de un arbore binar de căutare sunt
distincte, dar majoritatea operaţiilor funcţionează fie exact la fel şi în cazul
existenţei unor valori duplicate, fie necesită doar mici modificări pentru a
funcţiona şi în acest caz. Secvenţele de cod prezentate vor presupune că
arborele binar de căutare pe care lucrează conţine doar valori distincte.

Figura următoare prezintă un arbore binar de căutare pentru şirul


9, 3, 6, 10, 1, 11, 8, 4.

Fig. 13.6.1. – Un arbore binar de căutare oarecare

489
Capitolul 13

Se poate observa uşor că acest arbore respectă toate condiţiile


menţionate mai sus.
În continuare vom prezenta pe rând fiecare operaţie amintită
anterior, iar la sfârşit vom prezenta codul unui program complet care rezolvă
o problemă cu ajutorul unui astfel de arbore.

a) Căutarea unui element (Search)

Funcţia Search(x, T) funcţionează asemănător cu o căutare binară.


Se porneşte de la rădăcina arborelui şi se compară x cu valoarea reţinută de
rădăcină: dacă acestea sunt egale, atunci returnăm true (valoarea x există în
arbore). Dacă x este mai mare decât rădăcina, atunci este clar că nu poate
exista un nod cu valoarea x decât în subarborele drept, deoarece subarborele
stâng conţine doar valori mai mici decât rădăcina, deci mai mici şi decât x.
Dacă x este mai mic decât rădăcina, atunci putem restânge căutarea la
subarborele stâng.

Figura următoare prezintă modul de căutare al valorii 4 în arborele


anterior:

Fig. 13.6.2. – Modul de execuţie al algoritmului de căutare

490
Structuri avansate de date

Funcţia Search(x, T) poate fi descrisă în pseudocod astfel:


 Dacă T este nul execută
o returnează fals
 Dacă T.valoare == x execută
o returnează adevărat.
 Dacă T.valoare < x execută
o returnează Search(x, T.dreapta)
 Altfel
o returnează Search(x, T.stânga)

Timpul de execuţie al acestei funcţii este O(log N), deoarece în


cazuri favorabile arborele este balansat (înălţimea sa este O(log N), unde N
este numărul de noduri) şi la fiecare pas algoritmul merge cu un nivel în jos.

b) Inserarea unui element (Insert)

Pentru a implementa funcţia Insert(x, T), vom proceda într-un mod


similar cu funcţia de căutare. De fapt, singura diferenţă dintre aceste două
funcţii este că, dacă ajungem pe un nod nul (care nu există), nu mai
returnăm fals, ci creăm nodul respectiv, atribuindu-i valoarea x.
Deoarece am presupus că arborele nu va conţine decât valori
distincte, nu mai este necesar nici să verificăm dacă un nod curent are
valoarea x sau nu.
Algoritmul de inserare pentru funcţia Insert(x, T) poate fi descris în
pseudocod astfel:
 Dacă T este nul execută
o T.valoare = x
o ieşire din funcţie
 Dacă T.valoare < x execută
o apelează recursiv Insert(x, T.dreapta)
 Altfel
o apelează recursiv Insert(x, T.stânga)

Complexitatea acestei funcţii este tot O(log N), din exact aceleaşi
motive enunţate pentru funcţia de căutare.

491
Capitolul 13

c) Ştergerea unui element (Remove)

Operaţia de ştergere a unui nod cu o anumită valoare din arbore este


o operaţie puţin mai dificilă, deoarece trebuie să ţinem cont de structura
arborelui, structură care trebuie să se păstreze după ştergerea oricărui nod.
Se pot identifica trei cazuri care apar atunci când 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.

Bineînţeles că trebuie mai întâi să identificăm nodul pe care vrem să-


l ştergem. Această identificare se face exact la fel ca şi în cadrul
algoritmului de căutare, aşa că nu vom insista asupra acestui aspect.
Vom prezenta în continuare modul de gestionare al fiecărui 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ă
identificăm nodul care trebuie şters şi să-l eliminăm din arbore.

Fig. 13.6.3. – Ştergerea unui nod fără 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 căutare. De această dată nu mai putem şterge nodul prin
simple operaţii cu pointeri, ci va trebui să găsim o metodă de a reduce acest
caz la unul dintre cazurile anterioare.
Pentru aceasta, să analizăm 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 cărui
valoare este cea mai mare valoare mai mică decât valoarea
nodului pe care vrem să-l ştergem.
2. Înlocuirea nodului care se vrea a fi şters cu un nod a cărui
valoare este cea mai mică valoare mai mare decât valoarea
nodului pe care vrem să-l ştergem.

Alegând un nod care respectă una dintre cele două condiţii de mai
sus ne asigurăm că nu va exista niciun nod în arbore care să nu respecte

493
Capitolul 13

proprietăţile arborilor binari de căutare. Acest lucru se poate demonstra uşor


prin reducere la absurd.
Ne punem aşadar problema determinării unui nod a cărui 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 funcţie care afişează parcurgerea în ordine a
arborelui cu rădăcina în T. Această funcţie poate fi implementată astfel:
 Dacă T este nenul execută
o apelează recursiv InOrdine(T.stânga)
o afişează T.valoare
o apelează recursiv InOrdine(T.dreapta)

Datorită structurii arborilor binari de căutare, această parcurgere are


prorietatea de a afişa valorile inserate într-un arbore în ordine crescătoare.
De exemplu, pentru arborele binar de căutare dat ca exemplu, funcţia va
afişa: 1 3 4 6 8 9 10 11.
Pornind de la acest algoritm de afişare în ordine crescătoare a
valorilor din arbore, problema iniţială se reduce la a găsi 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ă găsim 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 stâng 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


stânga 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ă decât 9, iar 4 este cea mai mică valoare
din arbore mai mare decât 3.

Pentru a rezolva problema iniţială, adică ştergerea unui nod care are
doi fii, vom înlocui aşadar nodul respectiv fie cu predecesorul său în
parcurgerea în ordine, fie cu succesorul său în această parcurgere, care se
poate determina uşor aşa cum am arătat. Predecesorul sau succesorul cu care
înlocuim nodul pe care vrem să-l ştergem va fi la rândul său şters conform
algoritmilor aferenţi 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ă posibilităţi, este


recomandat să alegem aleator dacă vom înlocui nodul care trebuie şters cu
predecesorul său din parcurgerea în ordine sau cu succesorul său, deoarece
alegând de fiecare dată acelaşi lucru cresc şansele ca arborele să degenereze
într-o listă înlănţuită, lucru care scade foarte mult performanţa acestei
structuri de date, aşa cum vom vedea în continuare.

d) Cazuri defavorabile

Am afirmat la început că operaţiile de căutare, inserare şi ştergere


într-un arbore binar de căutare au complexitatea O(log N) pe cazuri
favorabile. Un caz favorabil este dat de un arbore a cărui înălţime este O(log
N), iar un caz defavorabil de un arbore a cărui înălţime este O(N). Fiecare
operaţie prezentată are proprietatea de a lucra la fiecare pas cu un singur
element dintr-un singur nivel al arborelui, deci dacă arborele are înălţimea
O(log N), atunci şi aceste operaţii se vor executa în aceeaşi complexitate.
Dacă înălţimea arborelui este mai apropiată de N însă, atunci fiecare
operaţie va avea o complexitate liniară.
De exemplu, priviţi cum se construieşte un arbore binar de căutare
pentru valorile 1 2 3 4 5:

496
Structuri avansate de date

Fig. 13.6.8. – Modul de construcţie al unui


arbore binar de căutare dezechilibrat

Aşa cum se poate vedea, fiecare operaţie pe un astfel de arbore va


trebui să parcurgă toate nodurile în cel mai rău caz.

Din aceste caz, arborii binari de căutare nu se folosesc de obicei în


practică, cel puţin nu în forma prezentată aici. În practică se folosesc arbori
echilibraţi de căutare, adică arbori a căror înălţime este întotdeauna
proporţională cu logaritmul numărului de noduri. Am prezentat deja o
structură de date probabilistă care are această proprietate: listele de salt.

e) Detalii de implementare

Vom prezenta pe rând implementarea fiecărei funcţii pentru care am


furnizat până acum doar pseudocod. În primul rând, pentru a reţine un
arbore binar de căutare avem nevoie de o structură nod care va reprezenta
un nod al arborelui. Aceasta va conţine trei câmpuri.

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

Având această structură, operaţiile de inserare şi de căutare nu


prezintă mari probleme la implementare. Funcţia 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 funcţia de căutare rămâne la rândul 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 funcţii cu rădăcina unui arbore binar de căutare
ca parametru va avea ca rezultat afişarea în ordine crescătoare 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 funcţii:


RemoveCazI(T), RemoveCazII(T), RemoveCazIII(T), care vor gestiona
ştergerea nodului T conform fiecărui caz aferent şi o funcţie Remove(T)
care va căuta nodul care trebuie şters şi va decide în care dintre cele trei
cazuri se încadrează acesta, apelând funcţia corespunzătoare de ştergere
efectivă.

void RemoveCazI(nod *&T) { delete T; T = NULL; }

void RemoveCazII(nod *&T) void Remove(int x, nod *&T)


{ {
nod *fiu; // salvam fiul nenul if ( T == NULL )
if ( T->st == NULL ) return;
fiu = T->dr;
else // se foloseste algoritmul de cautare
fiu = T->st; // intr-un arbore pentru a gasi nodul
// care trebuie sters.
// T este inlocuit cu fiul sau nenul if ( T->val == x )
delete T; {
T = fiu; if ( T->st == NULL &&
} T->dr == NULL )
RemoveCazI(T);
void RemoveCazIII(nod *T) else if ( T->st == NULL ||
{ T->dr == NULL )
// vom inlocui nodul T cu RemoveCazII(T);
// predecesorul sau in parcurgerea else
// in ordine, adica cel mai din RemoveCazIII(T);
// dreapta nod al subarborelui stang }
// al lui T. Implementarea prezentata else if ( T->val < x )
// este iterativa. Implementarea Remove(x, T->dr);
// recursiva necesita mai putine else
// operatii cu pointeri. Remove(x, T->st);
}
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ă înlocuieşte întotdeauna,


în cadrul cazului III, nodul care trebuie şters cu predecesorul său din
parcurgerea în ordine. Aşa cum am spus mai devreme însă, acest lucru nu
este indicat deoarece poate contribui la debalansarea arborelui.
Recomandăm cititorilor să implementeze o variantă care alege aleator între
predecesorul şi succesorul nodului pe care vrem să-l ştergem.
Menţionăm că pentru folosirea acestor funcţii, trebuie declarată şi
iniţializată cu NULL o variabilă de tip nod * prin instrucţiunea:
nod *T = NULL;
care poate fi transmisă apoi funcţiilor prezentate.

f) Alţi algoritmi

Am prezentat până acum algoritmii de bază aferenţi acestei structuri


de date. Vom prezenta în continuare două probleme importante care se pot
rezolva cu ajutorul arborilor binari de căutare ş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ă returnăm primul element din cadrul acestei parcurgeri. De fapt,
putem astfel să rezolvăm ambele probleme, doar că timpul de execuţie 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 arăta în continuare că în acest fel se execută un număr de paşi
proporţional cu înălţimea arborelui, adică O(log N) pe cazul mediu. Să
considerăm următorul arbore:
Se poate observa uşor că funcţia de parcurgere în ordine se
autoapelează având ca parametru fiul stând al nodului curent. Acest lucru se
face până când se ajunge pe un nod nul. La revenire din recursivitate se va
afişa valoarea nodului curent. Aşadar, prima valoare afişată de către
algoritmul de parcurgere în ordine este cea mai din stânga valoare a
arborelui, sau, altfel spus, nodul pe care se ajunge pornind din rădăcină şi
mergând la fiecare pas pe fiul stâng al nodului curent, dacă acesta există.

500
Structuri avansate de date

Aşadar, putem afla cea mai mică valoare din arbore în timp mediu
O(log N) cu ajutorul următoarei funcţii:

int Minim(nod *T)


{
while ( T->st != NULL )
T = T->st;

return T->val;
}

Putem afla cea mai mare valoare din arbore aflând care este cel mai
din dreapta nod al arborelui. Acest lucru este corect deoarece algoritmul de
parcurgere în ordine furnizează ultimul rezultat umplând 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ă modificăm puţin


structura arborelui. Vom mai adăuga un câmp numit nr care va reţine,
pentru fiecare nod, numărul de noduri din subarborele stâng al său (luând în
considerare şi nodul în sine). Acest câmp poate fi actualizat cu o simplă
modificare a funcţiei de inserare, modificare lăsată ca exerciţiu pentru
cititor.
Având această informaţie în fiecare nod, algoritmul de rezolvare
constă într-o funcţie 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)

Raţionamentul care ne conduce la acest algoritm este următorul:


dacă ne aflăm la un nod T şi acesta are nr fii în subarborele său stâng (î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 decât k, atunci ştim că al k-lea cel
mai mic element este mai mare decât valoarea lui T şi se află undeva în
subarborele drept al lui T. Putem aşadar să facem abstracţie de subarborele
stâng al lui T şi să reducem problema la găsirea celui x-lea cel mai mic
element din subarborele drept, unde x = k – nr.
Altfel este clar că nr < k, deci elementul căutat se află undeva în
subarborele stâng al nodului curent. Putem aşadar să reducem problema la
găsirea celui de-al k-lea cel mai mic element din subarborele stâng.

De exemplu, să considerăm următorul arbore binar de căutare, în


care am marcat valorile nr pentru fiecare nod:

Fig. 13.6.9. – Un arbore binar de căutare favorabil rezolvării eficiente a


problemei prezentate

Să presupunem că vrem să găsim a 7-a cea mai mică valoare din


arbore. Pornim de la rădăcină. 6 < 7, aşa că vom reduce problema la găsirea
celui mai mic element din subarborele drept al rădăcinii (cel format din
nodurile 10 şi 11). 1 == 1, deci 10 este valoarea căutată.
502
Structuri avansate de date

g) Analiza experimentală a performanţei

Fiecare operaţie testată are complexitatea O(log N) pe cazul mediu.


Cea mai relevantă comparaţie se poate face cu listele de salt. Teoretic, listele
de salt sunt mai puţin 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. – Performanţa orientativă a arborilor binari de căutare


Faţă de
Număr test Inserări Căutări Ştergeri Timp (s)
liste de salt
1 1 000 1 000 1 000 0.020 mai bine
2 10 000 10 000 10 000 0.030 mai bine
3 100 000 0 0 0.065 mai bine
4 100 000 100 000 0 0.103 mai bine
5 100 000 100 000 100 000 0.140 mai bine
6 1 000 000 0 0 0.758 mai bine
7 1 000 000 1 000 000 0 1.600 mai bine
8 1 000 000 1 000 000 1 000 000 2.434 mai bine

După cum se vede, pe testele cu numere aleatoare arborii binari de


căutare sunt mai eficienţi decât listele de salt. La o comparare directă a
acestor două structuri de date pe un test format din 50 000 de inserări a unor
valori distincte de la 0 la 50 000, urmate de 1 000 de căutări ale unor valori
aleatoare, obţinem însă următoarele rezultate:
 Arbori binari de căutare: aproximativ 13 secunde.
 Liste de salt: aproximativ 0.1 secunde.

Mai mult, dacă mărim numărul de valori inserate, implementarea


prezentată pentru arbori binari de căutare poate depăşi dimensiunea stivei,
cauzând o eroare de execuţie, iar o implementare iterativă a tuturor
funcţiilor este mai dificilă. Aşadar, arborii binari de căutare nu sunt rentabili
decât atunci când ştim cât se poate de sigur că datele gestionate nu vor cauza
atingerea cazului defavorabil.

Exerciţii
a) Scrieţi o funcţie care determină al k-lea cel mai mare element
dintr-un arbore binar de căutare.
b) Prezentaţi două abordări pentru ca un arbore binar de căutare să
suporte inserarea mai multor valori identice. Care este mai
avantajoasă?

503
Capitolul 13

c) Scrieţi un program care afişează parcurgerea în preordine şi în


postordine a unui arbore binar de căutare.
d) Scrieţi un program care determină câţi arbori binari de căutare
distincţi din punct de vedere structural există având ca elemente
numere distincte din mulţimea {1, 2, ..., N}. De exemplu, pentru
N = 4 răspunsul este 14, pentru N = 5 este 42, iar pentru N = 6
este 132.
e) Scrieţi un program care determină dacă un arbore binar dat ca
date de intrare este sau nu arbore binar de căutare. Găsiţi un
algoritm eficient.
f) Scrieţi un program care determină numărul de noduri dintr-un
arbore binar de căutare cu valori mai mici decât o valoare dată
(nu este obligatoriu ca valoarea dată să se regăsească în arbore).

13.7. Arbori binari de căutare căutare echilibraţi


Am prezentat în secţiunea anterioară o structură de date care suportă
operaţiile de inserare, căutare şi ştergere a unui element în timp O(log N) în
cazuri favorabile. Am găsit însă foarte uşor exemple în care arborii binari de
căutare degenerează în liste înlănţuite.
Vom prezenta în continuare o structură de date probabilistă care
reprezintă un arbore binar de căutare echilibrat, adică a cărui înălţime 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ă
entităţi: o valoare (sau cheie) şi o prioritate. Valorile nodurilor treap-ului
vor respecta proprietăţile unui arbore binar de căutare, iar priorităţile
nodurilor vor respecta proprietăţile unui heap. Valorile reprezintă datele
inserate de către utilizator, iar priorităţile vor fi nişte numere aleatoare
atribuite fiecăriui nod.
Vom presupune şi aici că oricare două valori din arbore sunt
distincte.

Figura următoare prezintă un treap. Valorile sunt marcate cu roşu,


iar priorităţile cu albastru.

504
Structuri avansate de date

Fig. 13.7.1. – Un treap oarecare

Se poate observa că valorile roşii descriu un arbore binar de căutare,


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 operaţiile de inserare şi stergere pe un treap, ne


punem problema păstrării structurii de heap şi de arbore binar de căutare atât
după execuţia unei operaţii de ştergere cât şi după execuţia unei operaţii de
inserare. Pentru acest lucru vom folosi rotaţii, operaţii care vor sta la baza
algoritmilor de inserare şi de ştergere. Figura de mai jos prezintă cele două
tipuri de rotaţii pe care le vom folosi:

Fig. 13.7.2. – Rotaţiile folosite în cadrul treap-urilor

După cum se poate deduce din figura anterioară, vom efectua o


rotaţie atunci când un nod nu respectă proprietatea de heap. Prin aceste
rotaţii vom păstra proprietatea de arbore binar de căutare şi vom restaura şi
proprietatea de heap.

505
Capitolul 13

Ne vom referi în continuare la arborele din partea stângă, rotaţia spre


stânga explicându-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 decât prioritatea nodului cu valoarea 9 (a). Este clar că
rotind nodul cu valoarea 7 spre stânga se restituie proprietatea de heap a
arborelui, deoare în arborele din dreapta nodul 7 va fi tatăl nodului 9, iar
b > a.
Vom arăta în continuare că o rotaţie spre dreapta păstrează
propritatea de arbore binar de căutare.
În arborele stâng avem următoarele inegalităţi (fiecare identificator
va descrie valoarea rădăcinii subarborelui respectiv):
 A< 7<B
 9<C
 A, 7, B < 9
Combinând aceste relaţii obţinem inegalităţile: A < 7 < B < 9 < C.

În arborele drept avem următoarele inegalităţi:


 A< 7
 B<9<C
 7 < 9, B, C
Combinând aceste relaţii obţinem inegalităţile: A < 7 < B < 9 < C.
Aşadar, deoarece am obţinut acelaşi şir de inegalităţi între noduri
atât înainte cât şi după rotaţie, am demonstrat păstrarea invariantului
arborilor binari de căutare după efectuarea unei rotaţii spre dreapta.
Demonstraţia în sens invers este identică.
Vom prezenta în continuare pseudocod pentru operaţiile necesare în
lucrul cu treap-uri.

a) Echilibrarea arborelui
Echilibrarea arborelui este necesară atunci când 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
rotaţie a acelui nod spre dreapta sau spre stânga, după caz:
 dacă prioritatea fiului stâng al lui T este mai mare decât
prioritatea lui T, atunci se efectuează o rotaţie spre dreapta a
fiului stâng.
 dacă prioritatea fiului drept al lui T este mai mare decât
prioritatea lui T, atunci se efectuează o rotaţie spre stânga a fiului
drept.

506
Structuri avansate de date

Funcţia RotDr(T), care roteşte fiul drept al lui T spre stânga poate fi
scrisă astfel:
 temp = T.dr
 T.dr = temp.st
 temp.st = T
 T = temp

Iar funcţia RotSt(T), care roteşte fiul stâng al lui T spre dreapta
poate fi scrisă astfel:
 temp = T.st
 T.st = temp.dr
 temp.dr = T
 T = temp

În final, funcţia Echilibrare(T), care restabilieşte 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 funcţii este O(1).

b) Căutarea unui element (Search)

Căutarea unui element într-un treap se face exact ca într-un arbore


binar de căutare, deoarece nu trebuie să ţinem cont decât de valori, nu şi de
priorităţi.
Pseudocodul funcţiei Search(x, T) este următorul:
 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.stânga)

507
Capitolul 13

c) Inserarea unui element (Insert)

Inserarea unui element este, la rândul său, o operaţie aproape


identică inserării într-un arbore binar de căutare. O deosebire este că, la
revenire din recursivitate, se va apela funcţia Echilibrare(T) pentru a
restabili, dacă este cazul, proprietatea de heap. Altă deosebire constă în
atribuirea unei priorităţi aleatore elementului înainte ca acesta să fie inserat
în arbore. Pseudocodul funcţiei Insert(x, T) este următorul:
 Dacă T este nul execută
o T.valoare = x
o T.prioritate = număr ales aleator
o ieşire din funcţie
 Dacă T.valoare < x execută
o apelează recursiv Insert(x, T.dreapta)
 Altfel dacă T.valoare > x execută
o apelează recursiv Insert(x, T.stânga)
 apelează Echilibrare(T)

Să urmărim modul de execuţie al funcţiei pe următorul arbore dacă


dorim să inserăm un nod cu valoarea 5 şi prioritatea 19.
În prima fază se găseşte poziţia nodului ignorând priorităţile şi luând
în considerare doar valorile din arbore, după care se echilibrează arborele
folosind rotaţii.

Echilibrarea arborelui prin rotaţii se face la revenirea din


recursivitate, prin apelul funcţiei Echilibrare(T), aşa cum se poate vedea în
pseudocod. Recomandăm cititorului să se familiarizeze cu această funcţie
înainte de a merge mai departe, deoarece acea funcţie şi implicit rotaţiile
prezentate anterior stau la baza gestionării unui treap.

508
Structuri avansate de date

Fig. 13.7.3. – Modul de execuţie al algoritmului


de inserare într-un treap

Complexitatea operaţiei de inserare a unui element în treap este


O(log N), deoarece numărul de operaţii este limitat de înălţimea arborelui.

d) Ştergerea unui element (Remove)

Operaţia de ştergere a unui nod cu o anumită valoare este, practic,


inversa operaţiei de inserare. După ce am identificat nodul care trebuie şters,
vom roti în locul său fiul cu prioritatea cea mai mare. Complexitatea este
O(log N). Pseudocodul funcţiei Remove(x, T) este următorul:
 Dacă T este nul execută
o ieşire din funcţie
 Dacă T.valoare < x execută
o apelează recursiv Remove(x, T.dreapta)
 Altfel dacă T.valoare > x execută
o apelează recursiv Remove(x, T.stânga)
 Altfel execută

509
Capitolul 13

o Dacă T.stânga şi T.dreapta sunt nuli execută


 Şterge T
o Altfel dacă T.stânga e nul sau T.dreapta e nul execută
 Dacă T.stânga 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 asemănătoare cu cea de


la arbori binari de căutare, singura diferenţă fiind că mai avem un câmp 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;
}
};

Atât funcţia de căutare cât şi funcţia de parcurgere în ordine a


arborelui au exact aceeaşi implementare ca la arbori binari de căutare, aşa că
nu vom prezenta din nou aceste implementări.
Menţionăm că înainte de folosirea unui treap trebuie iniţializat
generatorul de numere aleatoare prin includerea fişierelor antet <cstdlib> şi
<ctime> şi executarea instrucţiunii srand((unsigned)time(0));

Funcţia de echilibrare, care asigură păstrarea proprietăţii de heap în


timpul operaţiilor de inserare şi ştergere se poate implementa, împreună cu
funcţiile de rotaţie, astfel:

510
Structuri avansate de date

void RotDr(nod *&T) void Echilibrare(nod *&T)


{ {
nod *temp = T->dr; if ( T->st != NULL && T->st->pr > T->pr )
T->dr = temp->st; RotSt(T);
temp->st = T; else if ( T->dr != NULL && T->dr->pr > T->pr )
T = temp; RotDr(T);
} }

void RotSt(nod *&T)


{
nod *temp = T->st;
T->st = temp->dr;
temp->dr = T;
T = temp;
}

Funcţiile de inserare respectiv de ştergere pot fi implementate


astfel:

void Insert(int x, void Remove(int x, nod *&T)


nod *&T) {
{ if ( T == NULL )
if ( T == NULL ) return;
{
T = new nod(x); if ( T->val < x )
return; Remove(x, T->dr);
} else if ( T->val > x )
Remove(x, T->st);
if ( T->val < x ) else
Insert(x, T->dr); {
else if ( T->val > x ) if ( T->st == NULL && T->dr == NULL )
Insert(x, T->st); {
delete T;
Echilibrare(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);

Remove(x, T);
}
}

511
Capitolul 13

Se poate observa că, spre deosebire de implementarea funcţiei de


inserare din cadrul arborilor binari de căutare, 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 căutare nu este obligatorie impunerea
unicităţii elementelor, dar acest lucru este oricum recomandat. Pentru a
suporta valori duplicate, cea mai bună soluţie este adăugarea unui câmp
nrVal nodurilor, care să indice de câte ori apare valoarea respectivă. Astfel,
algoritmii de gestionare nu necesită decât modificări minime.

Funcţia de parcurgere în ordine a unui treap se poate implementa


exact ca la arbori binari de căutare, deoarece aceasta nu are nevoie decât de
valorile nodurilor, nu şi de priorităţile acestora.

Algoritmii de determinare a minimului şi de determinare a celui de-


al k cel mai mic element sunt, la rândul 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 atenţi să actualizăm această valoare
după fiecare rotaţie efectuată.

f) Analiza experimentală a performanţei

Să vedem cum se comportă treap-urile în comparaţie cu listele de


salt şi arborii binari de căutare. Comparaţiile cu celelalte structuri de date
prezentată nu sunt foarte relevante, întrucât acestea sunt de obicei folosite în
rezolvarea unor probleme diferite.

Tabelul 13.7.4. – Performanţa orientativă a arborilor treap


Faţă de Faţă de
Nr. Inserări Căutări Ştergeri Timp (s)
liste de salt BST
1 1 000 1 000 1 000 0.022 mai bine mai rău
2 10 000 10 000 10 000 0.033 mai bine mai rău
3 100 000 0 0 0.108 mai bine mai rău
4 100 000 100 000 0 0.144 mai bine mai rău
5 100 000 100 000 100 000 0.191 mai bine mai rău
6 1 000 000 0 0 1.669 mai bine mai rău
7 1 000 000 1 000 000 0 2.823 mai bine mai rău
8 1 000 000 1 000 000 1 000 000 3.978 mai bine mai rău

512
Structuri avansate de date

Tabelul prezintă rezultatele testelor de performanţă a structurilor de


date pe date aleatoare, generate cu ajutorul funcţiei rand(). Se poate observa
că pe astfel de date cel mai bine se comportă arborii binari de căutare,
urmaţi de treap-uri, iar apoi de listele de salt.
Supunând treap-urile aceluiaşi test format din inserarea a 50 000 de
valori distincte de la 0 la 50 000, urmate de căutarea a 1 000 de valori
aleatoare, obţinem un rezultat foarte bun: 0.07 secunde, mult mai bine decât
arborii binari de căutare şi mai bine chiar şi decât listele de salt. Aşadar,
treap-ul este cea mai bună alternativă atunci când nu ne permitem cazuri
defavorabile şi dorim totodată o implementare accesibilă.

Mai mult, deoarece înălţimea unui treap este, cu o probabilitate


foarte mare, O(log N), nu există riscul ca implementarea recursivă a
operaţiilor de gestiune să depăşească memoria alocată stivei. De exemplu,
dacă rulăm acelaşi test cu 1 000 000 de inserări a unor valori distincte,
timpul de execuţie este de 0.8 secunde.

Exerciţii:
a) Scrieţi un program care determină numărul de treap-uri distincte
cu N valori de la 1 la N şi cu priorităţi 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.stânga diferă de T2.stânga sau T1.dreapta
diferă de T2.dreapta.
b) Rezolvaţi aceleaşi probleme de la arbori binari de căutare
folosind treap-uri.
c) Scrieţi o funcţie Split care primeşte ca argument un număr întreg
x şi întoarce două treap-uri A şi B astfel încât A să conţină doar
valori mai mici decât x şi B doar valori mai mari decât x.
d) Scrieţi o funcţie Join care primeşte ca argumente două treap-uri
A, B şi o valoare x, cu semnificaţia de mai sus şi uneşte treap-
urile A şi B într-un singur treap.

513
Capitolul 13

13.8. Concluzii
Sperăm că acest ultim capitol, cât şi întreaga lucrare, v-au fost şi vă
vor fi în continuare folositoare în studiul algoritmilor. Cititorii care au
parcurs temeinic materialul pus la dispoziţie în această carte ar trebui să aibă
deja o înţelegere clară a noţiunilor algoritmice elementare şi a metodelor de
rezolvare a problemelor aferente acestui domeniu.

Cititorii care simt că nu şi-au însuşit în totalitate toate temele


abordate nu trebuie să-şi facă griji. Această carte poate fi folosită şi ca o
referinţă asupra algoritmilor şi a implementărilor acestora în limbajul C++.
Mai mult, unele capitole nici nu sunt scrise cu gândul de a putea fi înţelese
într-un timp foarte scurt de către începători – acest lucru ar fi imposibil de
realizat fără a pierde din rigoare.

În încheiere, dorim tuturor cititorilor perseverenţă în studii şi succes


în orice demersuri întreprinse!

Profităm de aceste ultime rânduri pentru a vă aduce la cunoştinţă


publicarea, în viitorul apropiat, a unei cărţi intitulate Tehnici de
programare aplicate, care se va axa exclusiv pe rezolvarea unor
probleme date la concursuri naţionale, olimpiade şi site-uri de evaluare
online.
Sperăm să ne rămâneţi fideli în continuare!

Autorii

514
Bibliografie

BIBLIOGRAFIE
1. Adrian Alexandrescu Programarea modernă în C++. Programare
generică şi modele de proiectare aplicate, Teora, Bucureşti, 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. Béla Bollobás, 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): 464–497, 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. 540–545, 1989.
11. Constantin Popescu, Dan Noje, Ioan Mang, Horea Oros, Programarea
în limbajul C, Editura Universităţii 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, Jürg 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, Addison-


Wesley, 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):225-
231, 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 programării calculatoarelor vol.2, Algoritmi
seminumerici, Editura Teora, Bucureşti, 2000.
35. Knuth D. E. Arta programării calculatoarelor, vol.1, Algoritmi
fundamentali, Editura Teora, Bucureşti, 1999.
36. Knuth D. E. Arta programării calculatoarelor, vol.3, Sortare și căutare,
Editura Teora, Bucureşti, 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): 97-
108, 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 Scorţaru, Arbori indexaţi binar, revista Ginfo nr. 13/1, ianuarie,
2003.
47. Mircea D. Popvici, Mircea I. Popvici C++ Tehnologia orientată spre
obiecte, Aplicaţii Editura Teora, Bucureşti 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 pattern-
matching 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