curs logica computationala.pdf

516
Cuprins iii 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

Upload: dani-olaru

Post on 17-Jul-2016

140 views

Category:

Documents


36 download

TRANSCRIPT

Page 1: Curs Logica Computationala.pdf

Cuprins

iii

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

Page 2: Curs Logica Computationala.pdf

Algoritmică

iv

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

Page 3: Curs Logica Computationala.pdf

Cuprins

v

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

Page 4: Curs Logica Computationala.pdf

Algoritmică

vi

13.7. Arbori binari de căutare căutare echilibraţi ................................. 504

13.8. Concluzii .......................................................................................... 514

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

Page 5: Curs Logica Computationala.pdf

Prefaţă

vii

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

Page 6: Curs Logica Computationala.pdf

Algoritmică

viii

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.

Page 7: Curs Logica Computationala.pdf

Prefaţă

ix

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 a l

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.

Page 8: Curs Logica Computationala.pdf

Algoritmică

x

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ă.

Page 9: Curs Logica Computationala.pdf

Introducere

11

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ă).

Page 10: Curs Logica Computationala.pdf

Capitolul 1

12

CUPRINS

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

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

Page 11: Curs Logica Computationala.pdf

Introducere

13

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.

Page 12: Curs Logica Computationala.pdf

Capitolul 1

14

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

Page 13: Curs Logica Computationala.pdf

Introducere

15

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 ≥ N0, 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

Page 14: Curs Logica Computationala.pdf

Capitolul 1

16

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

0.5∙N2, 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 N2,

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.

Page 15: Curs Logica Computationala.pdf

Introducere

17

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(N

4) sau chiar O(N

2010). 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

Page 16: Curs Logica Computationala.pdf

Capitolul 1

18

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;

Page 17: Curs Logica Computationala.pdf

Introducere

19

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

Page 18: Curs Logica Computationala.pdf

Capitolul 1

20

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

Complexitate Denumirea

complexităţii Exemple de algoritmi

O(N) liniară

Determinarea minimului dintr-un

şir, afişarea unui şir, problema

majorităţii votului.

O(N2) pătratică

Sortări naive, prelucrarea

matricelor, generarea tuturor

subsecvenţelor unui şir.

O(N3) cubică

Algoritmul Roy-Floyd, înmulţirea

optimă a matricelor.

O(sqrt(N)) fracţională Determinarea primalităţii.

O(log N) logaritmică

Căutarea binară, căutarea într-un

arbore binar de căutare echilibrat,

aproximarea unor funcţii

matematice.

O(N∙log N)

supraliniară,

liniaritmică,

pseudoliniară

Majoritatea algoritmilor divide et

impera.

O(cN), c

constantă exponenţială

Determinarea tuturor

submulţimilor, găsirea tuturor

ieşirilor dintr-un labirint, probleme

la care se cer toate soluţiile.

O(1) constantă

Accesarea unui element dintr-un

tablou, interschimbarea a două

valori, apelarea unei funcţii,

efectuarea unei operaţii de un

număr finit de ori care nu depinde

de N.

O(α(N))

inversa

funcţiei

Ackermann

Operaţii optime pe mulţimi

disjuncte.

O(N∙log(log N)) Ciurul lui Eratosthenes

Page 19: Curs Logica Computationala.pdf

Algoritmi de sortare

21

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ţă.

Page 20: Curs Logica Computationala.pdf

Capitolul 2

22

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

Page 21: Curs Logica Computationala.pdf

Algoritmi de sortare

23

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(N

2)

Memorie suplimentară O(1)

Stabil DA

Page 22: Curs Logica Computationala.pdf

Capitolul 2

24

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

Page 23: Curs Logica Computationala.pdf

Algoritmi de sortare

25

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.

Page 24: Curs Logica Computationala.pdf

Capitolul 2

26

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(N

2)

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.

Page 25: Curs Logica Computationala.pdf

Algoritmi de sortare

27

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.

Page 26: Curs Logica Computationala.pdf

Capitolul 2

28

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

Page 27: Curs Logica Computationala.pdf

Algoritmi de sortare

29

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).

Page 28: Curs Logica Computationala.pdf

Capitolul 2

30

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) { int V = A[st]; --st; ++dr; while ( st < dr )

{ do --dr; while ( st < dr && A[dr] > V ); do ++st; while ( st < dr && A[st] < V );

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

} return dr; }

void Quicksort(int A[], int st, int dr) { if ( st < dr ) { int P = Partitie(A, st, dr);

Quicksort(A, st, P); Quicksort(A, P+1, dr); } }

Execiţiu: modificaţi funcţia Partitie

astfel încât aceasta să returneze, în

O(N), al k-lea cel mai mic element al

vectorului A. De exemplu, dacă

A = {1, 7, 5, 2, 4} şi k = 3, se va

returna 4.

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ă

Page 29: Curs Logica Computationala.pdf

Algoritmi de sortare

31

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:

Page 30: Curs Logica Computationala.pdf

Capitolul 2

32

int Partitie(int A[], int st, int dr) { // numar aleator din [st, dr] int poz = st + rand() % (dr-st+1); int tmp = A[poz]; A[poz] = A[st];

A[st] = tmp; int V = A[st]; --st; ++dr; while ( st < dr ) { do

--dr; while ( st < dr && A[dr] > V ); do ++st; while ( st < dr && A[st] < V ); if ( st < dr )

{ int tmp = A[st]; A[st] = A[dr]; A[dr] = tmp; } } return dr;

}

void Quicksort(int A[], int st, int dr) { while ( st < dr ) { int P = Partitie(A, st, dr);

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

Quicksort(A, P + 1, dr); dr = P; } } }

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.

Page 31: Curs Logica Computationala.pdf

Algoritmi de sortare

33

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.

Page 32: Curs Logica Computationala.pdf

Capitolul 2

34

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

Page 33: Curs Logica Computationala.pdf

Algoritmi de sortare

35

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 𝑙𝑜𝑔25 = 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.

Page 34: Curs Logica Computationala.pdf

Capitolul 2

36

void Interclasare(int A[], int st, int m, int dr) { // folosim numerotare de la 1, // deci trebuie declarat un element

// in plus int *B = new int[dr - st + 2]; int i = st, j = m + 1, k = 0; while ( i <= m && j <= dr ) if ( A[i] <= A[j] ) B[++k] = A[i++];

else B[++k] = A[j++]; // copiaza ce a mai ramas while ( i <= m ) B[++k] = A[i++]; while ( j <= dr )

B[++k] = A[j++]; // copiaza la loc secventa sortata for ( i = 1; i <= k; ++i ) A[st + i - 1] = B[i]; // sterge memoria auxiliara folosita delete []B;

}

void Merge_sort(int A[], int st, int dr) { if ( st < dr ) {

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

Numărarea inversiunilor poate

fi facută modificând primul

while astfel:

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

Unde NrInv este transmis prin

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

Page 35: Curs Logica Computationala.pdf

Algoritmi de sortare

37

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

Page 36: Curs Logica Computationala.pdf

Capitolul 2

38

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

Page 37: Curs Logica Computationala.pdf

Algoritmi de sortare

39

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.

Page 38: Curs Logica Computationala.pdf

Capitolul 2

40

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

Page 39: Curs Logica Computationala.pdf

Algoritmi de sortare

41

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 𝑁

2 până la 1 execută

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 𝑁

2 + 1,

𝑁

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 𝑁

2 + 1 nu este frunză,

adică are cel puţin un fiu. Asta înseamnă că acest fiu se află pe poziţia

2 ∙ 𝑁

2 + 1 = 2 ∙

𝑁

2 + 2 > 𝑁. Dar asta ar însemna că fiul se află în afara

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.

Page 40: Curs Logica Computationala.pdf

Capitolul 2

42

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.

Page 41: Curs Logica Computationala.pdf

Algoritmi de sortare

43

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

Page 42: Curs Logica Computationala.pdf

Capitolul 2

44

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

Page 43: Curs Logica Computationala.pdf

Algoritmi de sortare

45

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.

Page 44: Curs Logica Computationala.pdf

Capitolul 2

46

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

Page 45: Curs Logica Computationala.pdf

Algoritmi de sortare

47

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

𝑀𝑎𝑥𝑉𝑎𝑙

8 ∙ 𝑠𝑖𝑧𝑒𝑜𝑓(𝑖𝑛𝑡) + 1

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.

Page 46: Curs Logica Computationala.pdf

Capitolul 2

48

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

Page 47: Curs Logica Computationala.pdf

Algoritmi de sortare

49

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ă:

Page 48: Curs Logica Computationala.pdf

Capitolul 2

50

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

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

36210 = 3102 + 610 + 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.

Page 49: Curs Logica Computationala.pdf

Algoritmi de sortare

51

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],

Page 50: Curs Logica Computationala.pdf

Capitolul 2

52

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.

Page 51: Curs Logica Computationala.pdf

Algoritmi de sortare

53

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.

Page 52: Curs Logica Computationala.pdf

Capitolul 2

54

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

(de la dreapta spre stânga) ai numărului 110101101102, tot ce trebuie să

facem este să aplicăm operaţia ŞI între acest număr şi numărul 111112:

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

5

000001101012

000001101012 &

000000111112

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;

}

Page 53: Curs Logica Computationala.pdf

Algoritmi de sortare

55

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

Nume

algoritm

Timp de execuţie Memorie suplimentară

Stabil Caz

fav.

Caz

mediu

Caz

defav.

Caz

fav.

Caz

mediu

Caz

defav.

Bubble

sort O(N) O(N

2) O(N

2) O(1) DA

Insertion

sort O(N) O(N

2) O(N

2) O(1) DA

Quicksort O(N·log N)1 O(1) O(log N) O(log N) NU

Merge

sort O(N·log N) O(N) DA

Heapsort O(N·log N) O(1) NU

Introsort O(N·log N) O(log N) NU

Counting

sort O(N + MaxVal) O(MaxVal)

2 DA

Radix

sort O(k·N)

3 O(N) DA

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.

Page 54: Curs Logica Computationala.pdf

Capitolul 2

56

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++.

Page 55: Curs Logica Computationala.pdf

Tehnici de programare

57

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.

Page 56: Curs Logica Computationala.pdf

Capitolul 3

58

CUPRINS

3.1. Recursivitate ..............................................................................................59

3.2. Backtracking ..............................................................................................68

3.3. Divide et impera ........................................................................................82

3.4. Greedy........................................................................................................89

3.5. Programare dinamică................................................................................96

Page 57: Curs Logica Computationala.pdf

Tehnici de programare

59

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

Page 58: Curs Logica Computationala.pdf

Capitolul 3

60

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 𝑑𝑎𝑐ă 𝑛 = 01 𝑑𝑎𝑐ă 𝑛 = 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) ;

}

Page 59: Curs Logica Computationala.pdf

Tehnici de programare

61

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)

Page 60: Curs Logica Computationala.pdf

Capitolul 3

62

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.

Page 61: Curs Logica Computationala.pdf

Tehnici de programare

63

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

Page 62: Curs Logica Computationala.pdf

Capitolul 3

64

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).

Page 63: Curs Logica Computationala.pdf

Tehnici de programare

65

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

Page 64: Curs Logica Computationala.pdf

Capitolul 3

66

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.)

Page 65: Curs Logica Computationala.pdf

Tehnici de programare

67

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ă AC

3. Hanoi (1, A, B, C, D) soluţia trivială AD

4. Hanoi (1, C, A, B, D) soluţia trivială CD

5. Hanoi (n – 2, B, A, C, D)

Page 66: Curs Logica Computationala.pdf

Capitolul 3

68

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

Page 67: Curs Logica Computationala.pdf

Tehnici de programare

69

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.

Page 68: Curs Logica Computationala.pdf

Capitolul 3

70

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 1 2 3

1 3 2

2 1 3

2 3 1

3 1 2

3 2 1

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ă.

Page 69: Curs Logica Computationala.pdf

Tehnici de programare

71

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:

Page 70: Curs Logica Computationala.pdf

Capitolul 3

72

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 (Yn) 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(); }

Page 71: Curs Logica Computationala.pdf

Tehnici de programare

73

void perm(int K, int N, bool Fol[], int Sol[], ofstream &out) { if ( K > N ) {

for ( int i = 1; i <= N; ++i ) out << Sol[i] << ' '; out << endl; return; } for ( int i = 1; i <= N; ++i )

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

} }

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

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

out.close(); return 0; }

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).

Page 72: Curs Logica Computationala.pdf

Capitolul 3

74

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

3 2 1 2

1 3

2 1

2 3

3 1

3 2

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

Page 73: Curs Logica Computationala.pdf

Tehnici de programare

75

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

Page 74: Curs Logica Computationala.pdf

Capitolul 3

76

void aran(int K, int N, int P, bool Fol[], int Sol[], ofstream &out) { if ( K > P ) {

for ( int i = 1; i <= P; ++i ) out << Sol[i] << ' '; out << endl; return; } for ( int i = 1; i <= N; ++i ) if ( !Fol[i] )

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

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

citire(N, P); for ( int i = 1; i <= N; ++i ) Fol[i] = false; ofstream out("aran.out"); aran(1, N, P, Fol, Sol, out);

out.close(); return 0; }

c) Generarea 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

4 2 1 2

1 3

1 4

2 3

2 4

3 4

Page 75: Curs Logica Computationala.pdf

Tehnici de programare

77

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ă.

Page 76: Curs Logica Computationala.pdf

Capitolul 3

78

#include <fstream> using namespace std; const int maxN = 12, maxP = 12;

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

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

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

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

return 0; }

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ă.

Page 77: Curs Logica Computationala.pdf

Tehnici de programare

79

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 1 1 1 1

1 1 2

1 3

2 2

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.

Page 78: Curs Logica Computationala.pdf

Capitolul 3

80

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.

Page 79: Curs Logica Computationala.pdf

Tehnici de programare

81

#include <fstream> using namespace std; const int maxN = 20;

void citire(int &N) { ifstream in("part.in"); in >> N; in.close(); }

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

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

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

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

Page 80: Curs Logica Computationala.pdf

Capitolul 3

82

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.

Page 81: Curs Logica Computationala.pdf

Tehnici de programare

83

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

9 7 8 3 4 2 11

2

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

Problema se poate rezolva însă şi folosind tehnica divide et impera. Vom

folosi o 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

Page 82: Curs Logica Computationala.pdf

Capitolul 3

84

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

Page 83: Curs Logica Computationala.pdf

Tehnici de programare

85

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

9 5

4 6 8 9 12 15 15 16 21

4

5

9

21

15

DA

NU

DA

DA

DA

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.

Page 84: Curs Logica Computationala.pdf

Capitolul 3

86

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

Page 85: Curs Logica Computationala.pdf

Tehnici de programare

87

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;

Page 86: Curs Logica Computationala.pdf

Capitolul 3

88

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;

}

Page 87: Curs Logica Computationala.pdf

Tehnici de programare

89

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ă.

Page 88: Curs Logica Computationala.pdf

Capitolul 3

90

Î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

1 4

4 7

3 5

8 9

6 7

3

Page 89: Curs Logica Computationala.pdf

Tehnici de programare

91

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 = X1,

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

Page 90: Curs Logica Computationala.pdf

Capitolul 3

92

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:

1 4

3 5

4 7

6 7

8 9

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

Page 91: Curs Logica Computationala.pdf

Tehnici de programare

93

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> #include <algorithm> using namespace std;

const int maxN = 1001; struct spectacol { int inc, sf; };

bool cmp(const spectacol &x, const spectacol &y) { return x.sf < y.sf; } void citire(int &N, spectacol A[])

{ ifstream in("spectacole.in"); in >> N; for ( int i = 1; i <= N; ++i ) in >> A[i].inc >> A[i].sf; sort(A + 1, A + N + 1, cmp);

in.close(); }

void rezolvare(int N, spectacol A[]) { spectacol precedent = A[1]; int nr = 1;

for ( int i = 2; i <= N; ++i ) if ( A[i].inc > precedent.sf ) { precedent = A[i]; ++nr; }

ofstream out("spectacole.out"); out << nr << endl; out.close(); } int main() {

int N; spectacol A[maxN]; citire(N, A); rezolvare(N, A); return 0;

}

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

Page 92: Curs Logica Computationala.pdf

Capitolul 3

94

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

Page 93: Curs Logica Computationala.pdf

Tehnici de programare

95

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>

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

in.close(); }

void rezolvare(int S) {

int nr = 0; for ( int i = 0; monede[i]; ++i ) while ( S - monede[i] >= 0 ) { S -= monede[i]; ++nr; } ofstream out("suma.out");

out << nr << endl; out.close(); } int main() { int S; citire(S);

rezolvare(S); return 0; }

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.

Page 94: Curs Logica Computationala.pdf

Capitolul 3

96

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ă.

Page 95: Curs Logica Computationala.pdf

Tehnici de programare

97

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 𝑑𝑎𝑐ă 𝑛 = 01 𝑑𝑎𝑐ă 𝑛 = 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

Page 96: Curs Logica Computationala.pdf

Capitolul 3

98

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;

Page 97: Curs Logica Computationala.pdf

Tehnici de programare

99

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ă.

Page 98: Curs Logica Computationala.pdf

Capitolul 3

100

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

4

3 5

1 4 2

7 5 6 20

1 1 7 19 9

50

4 5 2 20 19

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:

Page 99: Curs Logica Computationala.pdf

Tehnici de programare

101

𝐵 𝑖 𝑗 = 𝐴 𝑖 [𝑗] 𝑑𝑎𝑐ă 𝑖 = 𝑗 = 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][Xj]. 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][Xj] am luat în cosiderare maximul valorilor B[Xi – 1][Xj] şi

B[Xi – 1][Xj – 1], la care am adăugat A[Xi][Xj]. Vom verifica dacă

B[Xi – 1][Xj] = B[Xi][Xj] – A[Xi][Xj], iar în caz afirmativ, X va lua

valoarea B[Xi – 1][Xj], iar dacă B[Xi – 1][Xj – 1] = B[Xi][Xj] – A[Xi][Xj]

(una dintre aceste două condiţii se va verifica de fiecare dată), X va lua

valoarea B[Xi – 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.

Page 100: Curs Logica Computationala.pdf

Capitolul 3

102

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

toate numerele date, adică 1 + 2 + 3 + ⋯ + 𝑁 =𝑁∙(𝑁+1)

2 numere. Memoria

folosită este tot O(N2) 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(); }

Page 101: Curs Logica Computationala.pdf

Tehnici de programare

103

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

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

{ B[1][1] = A[1][1]; for ( int i = 2; i <= N; ++i ) for ( int j = 1; j <= i; ++j ) B[i][j] = max(B[i - 1][j], B[i - 1][j - 1]) + A[i][j]; }

void reconst(int Xi, int Xj, int A[maxN][maxN], int B[maxN][maxN], ofstream &out) { if ( Xi == 1 && Xj == 1 ) {

out << A[1][1] << ' '; return; } 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] << ' '; }

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

citire(N, A); init(N, B); rez(N, A, B); ofstream out("triunghi.out"); int Xj = 1; for ( int i = 2; i <= N; ++i )

if ( B[N][i] > B[N][Xj] ) Xj = i; out << B[N][Xj] << endl; reconst(N, Xj, A, B, out); out.close();

return 0; }

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

folosiţi doar doi vectori.

Page 102: Curs Logica Computationala.pdf

Capitolul 3

104

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

3 2

7 3

10 8

3

35

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

1 1

1 2 1

1 3 3 1

1 4 6 4 1

Page 103: Curs Logica Computationala.pdf

Tehnici de programare

105

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> using namespace std; const int maxN = 13;

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

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

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

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

out.close(); 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

Page 104: Curs Logica Computationala.pdf

Capitolul 3

106

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(X1, 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> using namespace std; const int maxN = 1001; int fibo(int N, int memo[])

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

memo[N] = fibo(N - 2, memo) + fibo(N - 1, memo); return memo[N]; }

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

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

out.close(); 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.

Page 105: Curs Logica Computationala.pdf

Tehnici de programare

107

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

Page 106: Curs Logica Computationala.pdf

Capitolul 3

108

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.

Page 107: Curs Logica Computationala.pdf

Algoritmi matematici

109

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.

Page 108: Curs Logica Computationala.pdf

Capitolul 4

110

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

Page 109: Curs Logica Computationala.pdf

Algoritmi matematici

111

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).

Page 110: Curs Logica Computationala.pdf

Capitolul 4

112

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.

Page 111: Curs Logica Computationala.pdf

Algoritmi matematici

113

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.

Page 112: Curs Logica Computationala.pdf

Capitolul 4

114

int euclid_scaderi(int X, int Y) { while ( X != Y ) if ( X > Y ) X -= Y;

else Y -= X; return X; } int euclid_rec(int X, int Y)

{ if ( !Y ) return X; return euclid_rec(Y, X % Y); }

int euclid_optim(int X, int Y) { while ( Y ) { int r = X % Y;

X = Y; Y = r; } return X; } void euclid_ref(int X, int Y, int &cmmdc)

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

4.3. Algoritmul lui Euclid extins

Algoritmul lui Euclid extins este folosit pentru a 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:

Page 113: Curs Logica Computationala.pdf

Algoritmi matematici

115

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,

Page 114: Curs Logica Computationala.pdf

Capitolul 4

116

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!

Page 115: Curs Logica Computationala.pdf

Algoritmi matematici

117

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.

Page 116: Curs Logica Computationala.pdf

Capitolul 4

118

a) Metode clasice

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

N este sau nu prim este să parcurgem toate numerele de la 2 până la N

2 şi să

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:

Pentru fiecare i de la 2 la N

2 execută:

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

caz contrar, nici N

X ≥ N nu poate fi un divizor al lui lui N. Aşadar, este de

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.

Page 117: Curs Logica Computationala.pdf

Algoritmi matematici

119

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) { for ( int i = 2; i <= N / 2; ++i ) if ( N % i == 0 ) return false;

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

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

return true; }

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

return true; } int pi(int N) { int nr = 0;

for ( int i = 2; i <= N; ++i ) if ( e_prim3(i) ) ++nr; return nr; }

Page 118: Curs Logica Computationala.pdf

Capitolul 4

120

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

Page 119: Curs Logica Computationala.pdf

Algoritmi matematici

121

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:

Page 120: Curs Logica Computationala.pdf

Capitolul 4

122

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

Page 121: Curs Logica Computationala.pdf

Algoritmi matematici

123

(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 28 – 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.

Page 122: Curs Logica Computationala.pdf

Capitolul 4

124

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

Page 123: Curs Logica Computationala.pdf

Algoritmi matematici

125

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

Page 124: Curs Logica Computationala.pdf

Capitolul 4

126

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ă

considerând funcţia 𝑓 𝑖 = 2 ∙ 𝑖 + 1, găsindu-i inversul 𝑓−1 𝑖 =𝑖−1

2 şi

calculând 𝑓−1 2 ∙ 𝑖 + 1 2 =4∙𝑖2+4∙𝑖+1−1

2= 2 ∙ 𝑖2 + 2 ∙ 𝑖. Transformând

î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ă.

Page 125: Curs Logica Computationala.pdf

Algoritmi matematici

127

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

109

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

Page 126: Curs Logica Computationala.pdf

Capitolul 4

128

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.

Page 127: Curs Logica Computationala.pdf

Algoritmi matematici

129

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

Page 128: Curs Logica Computationala.pdf

Capitolul 4

130

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 𝐸3vom efectua

următoarele operaţii:

𝐸2 ← −1

3𝐸1 + 𝐸2

𝐸3 ← −4

3𝐸1 + 𝐸3

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

Page 129: Curs Logica Computationala.pdf

Algoritmi matematici

131

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

𝐸2: 1

3𝑦 +

7

3𝑧 = −

1

3

𝐸3: 1

3𝑦 −

5

3𝑧 =

23

3

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

𝐸3 ← −𝐸2 + 𝐸3

Sistemul devine:

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

𝐸2: 1

3𝑦 +

7

3𝑧 = −

1

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:

Page 130: Curs Logica Computationala.pdf

Capitolul 4

132

𝐴 =

𝑎11 𝑎12 … 𝑎1𝑁 𝑎21 𝑎22 … 𝑎2𝑁

.

.

.𝑎𝑁1 𝑎𝑁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𝑁

0 𝑎′22 𝑎′23 … 𝑎′2𝑁

0 0 𝑎′33 … 𝑎′3𝑁

.

.

.0 0 0 … 𝑎′𝑁𝑁

𝑏1

𝑏′2𝑏′3

.

.

.𝑏′𝑁

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

Page 131: Curs Logica Computationala.pdf

Algoritmi matematici

133

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 0 1 0 … 00 0 1 … 0

.

.

.0 0 0 … 1

𝑥1

𝑥2

𝑥3

.

.

.𝑥𝑁

Î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.

Page 132: Curs Logica Computationala.pdf

Capitolul 4

134

#include <fstream> using namespace std; const int maxn = 101; void citire(double A[maxn][maxn], int &N)

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

in.close(); } 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; }

} }

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

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

out.close(); return 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ă

Page 133: Curs Logica Computationala.pdf

Algoritmi matematici

135

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.

Page 134: Curs Logica Computationala.pdf

Capitolul 4

136

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:

Page 135: Curs Logica Computationala.pdf

Algoritmi matematici

137

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∙a

7

3 7 a∙a6

4 6 a3∙a

3

5 3 a∙a2

6 2 a∙a

Page 136: Curs Logica Computationala.pdf

Capitolul 4

138

Î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∙a

12

2 12 a2∙a

6

3 6 a2∙a

3

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 (𝑚𝑜𝑑 𝑝)

Page 137: Curs Logica Computationala.pdf

Algoritmi matematici

139

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:

Page 138: Curs Logica Computationala.pdf

Capitolul 4

140

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

adevărată, deoarece avem 𝑎20∙𝑑 = 𝑎𝑑 ≢ −1 (𝑚𝑜𝑑 𝑝).

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

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

Page 139: Curs Logica Computationala.pdf

Algoritmi matematici

141

𝑎𝑑 ≢ 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.

Page 140: Curs Logica Computationala.pdf

Capitolul 4

142

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

Page 141: Curs Logica Computationala.pdf

Algoritmi matematici

143

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:

𝜑 18 = 𝜑 2 ∙ 32 = 18 ∙1

2∙

2

3= 6. Cele şase numere coprime cu 18

ş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ă 𝜑 𝑌 )

Page 142: Curs Logica Computationala.pdf

Capitolul 4

144

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.

Page 143: Curs Logica Computationala.pdf

Algoritmi matematici

145

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

{ R /= i; R *= i – 1; while ( Yt % i == 0 ) Yt /= i; }

if ( Yt > 1 ) { R /= Yt; R *= Yt – 1; }

return R; }

void phi_interval(int Y, int A[]) { for ( int i = 2; i <= Y; ++i ) A[i] = i;

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

}

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

Page 144: Curs Logica Computationala.pdf

Capitolul 4

146

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ă.

Page 145: Curs Logica Computationala.pdf

Algoritmi matematici

147

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

Page 146: Curs Logica Computationala.pdf

Capitolul 4

148

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

Page 147: Curs Logica Computationala.pdf

Algoritmi matematici

149

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

Page 148: Curs Logica Computationala.pdf

Capitolul 4

150

4.9. Principiul includerii şi al excluderii

Fie intervalul [1, 100]. Câte numere din acest interval sunt divizibile

cu 5 sau cu 6? Putem afla uşor că există 100

5 = 20 de numere divizibile cu

5 şi 100

6 = 16 numere divizibile cu 6. Am putea fi tentaţi să dăm răspunsul

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ă

multiplii numărului 5∙6 = 30. Există 100

30 = 3 numere divizibile cu 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:

Page 149: Curs Logica Computationala.pdf

Algoritmi matematici

151

𝐴𝑖

𝑛

𝑖=1

=

= 𝐴𝑖 − 𝐴𝑖1⋂𝐴𝑖2

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

1≤𝑖1<𝑖2≤𝑛

𝑛

𝑖=1

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.

Page 150: Curs Logica Computationala.pdf

Capitolul 4

152

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 & (2

k – 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 𝑛 = 01 𝑛 = 1

𝑓 𝑛 − 2 + 𝑓 𝑛 − 1 𝑛 > 1

Atunci:

1 11 0

𝑛

= 𝑓(𝑛 + 1) 𝑓(𝑛)

𝑓(𝑛) 𝑓(𝑛 − 1)

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

ecuaţia:

𝑓(𝑛 + 2)𝑓(𝑛 + 1)

= 𝑎 𝑏𝑐 𝑑

∙ 𝑓(𝑛 + 1)

𝑓(𝑛)

Page 151: Curs Logica Computationala.pdf

Algoritmi matematici

153

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:

Page 152: Curs Logica Computationala.pdf

Capitolul 4

154

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!:

𝑍 𝑛 = 𝑛

5 +

𝑛

52 +

𝑛

53 + ⋯

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.

Page 153: Curs Logica Computationala.pdf

Algoritmi matematici

155

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

A 5 9 9 6 2 1

i 0 1 2 3 4 5

B 5 9 8 2 4 9

Page 154: Curs Logica Computationala.pdf

Capitolul 4

156

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:

Page 155: Curs Logica Computationala.pdf

Algoritmi matematici

157

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

A 3 1 3 1

i 0 1 2 3

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:

Page 156: Curs Logica Computationala.pdf

Capitolul 4

158

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

Page 157: Curs Logica Computationala.pdf

Algoritmi matematici

159

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

Page 158: Curs Logica Computationala.pdf

Capitolul 4

160

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:

Page 159: Curs Logica Computationala.pdf

Algoritmi matematici

161

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.

Page 160: Curs Logica Computationala.pdf

Capitolul 4

162

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

Page 161: Curs Logica Computationala.pdf

Algoritmi matematici

163

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𝑄

Page 162: Curs Logica Computationala.pdf

Capitolul 4

164

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:

Page 163: Curs Logica Computationala.pdf

Algoritmi matematici

165

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∙10

1 + 2∙10

0 = (((3∙10 + 6) ∙10) + 7)∙10 + 2. Putem aşadar să

Page 164: Curs Logica Computationala.pdf

Capitolul 4

166

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.

Page 165: Curs Logica Computationala.pdf

Algoritmi backtracking

167

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.

Page 166: Curs Logica Computationala.pdf

Capitolul 5

168

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

Page 167: Curs Logica Computationala.pdf

Algoritmi backtracking

169

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

0 0

0 0

1 1

1 2

2 2

1 1

2 1

2 2

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:

Page 168: Curs Logica Computationala.pdf

Capitolul 5

170

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:

Page 169: Curs Logica Computationala.pdf

Algoritmi backtracking

171

st A

(1, 1) true false

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)

(1, 1)

true true

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)

(1, 2)

(1, 1)

true true

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> using namespace std; const int maxn = 100;

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

void citire(int &N, bool A[maxn][maxn]) { ifstream in("labirint.in");

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

Page 170: Curs Logica Computationala.pdf

Capitolul 5

172

bool valid(int lin, int col, int N, bool A[maxn][maxn]) { if ( lin > 0 && lin <= N && col > 0 && col <= N )

if ( A[lin][col] == false ) return true; return false; }

void back(int k, int lin, int col, int N, bool A[maxn][maxn], stiva st[], ofstream &out)

{ st[k].lin = lin; st[k].col = col; 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; } } }

int main() { int N; bool A[maxn][maxn]; stiva st[maxn*maxn];

citire(N, A); ofstream out("labirint.out"); back(1, 1, 1, N, A, st, out); out.close(); return 0;

}

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.

Page 171: Curs Logica Computationala.pdf

Algoritmi backtracking

173

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 N2 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.

Page 172: Curs Logica Computationala.pdf

Capitolul 5

174

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.

Page 173: Curs Logica Computationala.pdf

Algoritmi backtracking

175

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

2 3

1

1 3

1 2

1 2 3

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

Page 174: Curs Logica Computationala.pdf

Capitolul 5

176

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.

Page 175: Curs Logica Computationala.pdf

Algoritmi backtracking

177

folosind backtracking folosind operaţii pe biţi

void back(int k, int N, bool st[], ofstream &out) { if ( k > N )

{ // am grija sa nu afisez // multimea vida bool afisat = 0; for ( int i = 1; i <= N; ++i ) if ( st[i] ) {

afisat = 1; out << i << ' '; } if ( afisat ) out << '\n'; return;

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

}

void submultimi_biti(int N, ofstream &out) { // reamintim ca 1 << N este // egal cu 2 la puterea N

int nr = 1 << N; for ( int i = 1; i < nr; ++i ) { // pentru fiecare bit al lui i for ( int j = 0; j < N; ++j ) if ( i & (1 << j) ) // daca e 1

out << j + 1 << ' '; // afisare out << '\n'; } }

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

Page 176: Curs Logica Computationala.pdf

Capitolul 5

178

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ă.

Page 177: Curs Logica Computationala.pdf

Algoritmi backtracking

179

// 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.

Page 178: Curs Logica Computationala.pdf

Capitolul 5

180

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.

Page 179: Curs Logica Computationala.pdf

Algoritmi backtracking

181

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...

Page 180: Curs Logica Computationala.pdf

Capitolul 5

182

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

} }

Page 181: Curs Logica Computationala.pdf

Algoritmi generali

183

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.

Page 182: Curs Logica Computationala.pdf

Capitolul 6

184

CUPRINS

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

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

Page 183: Curs Logica Computationala.pdf

Algoritmi generali

185

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

abbbaab

1

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ă.

Page 184: Curs Logica Computationala.pdf

Capitolul 6

186

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

Page 185: Curs Logica Computationala.pdf

Algoritmi generali

187

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

Page 186: Curs Logica Computationala.pdf

Capitolul 6

188

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, ..., |S2|} → {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.

Page 187: Curs Logica Computationala.pdf

Algoritmi generali

189

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

void KMP_prefix(char S2[], int pi[]) { pi[1] = 0; int k = 0; for ( int i = 2; S2[i]; ++i )

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

pi[i] = k; } }

void KMP_potrivire(char S1[], char S2[], int pi[]) { KMP_prefix(S2, pi); ofstream out("kmp.out");

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

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

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

Page 188: Curs Logica Computationala.pdf

Capitolul 6

190

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ă,

Page 189: Curs Logica Computationala.pdf

Algoritmi generali

191

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:

Page 190: Curs Logica Computationala.pdf

Capitolul 6

192

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

// avem nevoie de prototipul functiei // plus_min pentru a putea folosi // recursivitatea indirecta double plus_min(char [], int &); double paran(char expr[], int &k) {

if ( expr[k] == '(' ) { ++k; // sar peste '(' double ret = plus_min(expr, k); ++k; // sar peste ')' return ret;

} // returnez operandul return expr[k++] - '0'; } double inm_imp(char expr[], int &k) { double ret = paran(expr, k);

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

return ret; }

double plus_min(char expr[], int &k) { double ret = inm_imp(expr, k); while ( expr[k] == '+' ||

expr[k] == '-' ) if ( expr[k++] == '+' ) ret += inm_imp(expr, k); else ret -= inm_imp(expr, k); return ret;

} int main() { char expr[maxn]; ifstream in("expr.in");

in >> expr; in.close(); int k = 0; // pozitia curenta ofstream out("expr.out"); out << plus_min(expr, k); out.close();

return 0; }

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:

Page 191: Curs Logica Computationala.pdf

Algoritmi generali

193

#include <fstream> using namespace std; const int maxn = 1001; const char oper[2][3] = {"+-", "*/"};

double operatie(double a, double b, char op) { switch ( op ) { case '+': return a + b; case '-': return a - b;

case '*': return a * b; case '/': return a / b; } } 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; }

int main() { char expr[maxn]; ifstream in("expr.in");

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

out.close(); return 0; }

Aşa cum am precizat la început, problema se poate rezolva şi

nerecursiv, folosind în mod explicit forma poloneză postifixată a unei

Page 192: Curs Logica Computationala.pdf

Capitolul 6

194

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

Page 193: Curs Logica Computationala.pdf

Algoritmi generali

195

înţelegem ab, este de fapt egal cu 212

= 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);

void forma_pol(char expr[], char fpol[]) { int k = 0, p = 0; char st[maxn]; for ( int i = 0; expr[i]; ++i )

if ( expr[i] >= '0' && expr[i] <= '9' ) fpol[p++] = expr[i]; else if ( expr[i] == '(' ) st[k++] = expr[i]; else if ( expr[i] == ')' ) { while ( k - 1 >= 0 )

if ( st[k - 1] != '(' ) fpol[p++] = st[--k]; else break; --k; } else // am neaparat un operator { while ( k - 1 >= 0 )

if ( prio(st[k - 1]) >= prio(expr[i]) ) fpol[p++] = st[--k]; else break; st[k++] = expr[i];

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

int prio(char oper)

{ if ( oper == '-' || oper == '+' ) return 0; else if ( oper == '(' ) return -1; return 1;

} double eval(char expr[]) { char fpol[maxn]; forma_pol(expr, fpol);

// la sfarsit va contine // rezultatul final double st[maxn]; int p = 0; for ( int k = 0; fpol[k]; ++k ) { if ( fpol[k] >= '0' && fpol[k] <= '9' )

st[p++] = fpol[k] - '0'; else { st[p - 2] = operatie(st[p - 2], st[p - 1], fpol[k]); --p;

} } return st[0]; }

Page 194: Curs Logica Computationala.pdf

Capitolul 6

196

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.

Page 195: Curs Logica Computationala.pdf

Introducere în S.T.L.

197

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.

Page 196: Curs Logica Computationala.pdf

Capitolul 7

198

CUPRINS

7.1. Containere secvenţiale .......................................................................... 199

7.2. Containere adaptoare ............................................................................ 205

7.3. Containere asociative ............................................................................ 210

7.4. Algoritmi S.T.L......................................................................................... 220

Page 197: Curs Logica Computationala.pdf

Introducere în S.T.L.

199

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;

Page 198: Curs Logica Computationala.pdf

Capitolul 7

200

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

Page 199: Curs Logica Computationala.pdf

Introducere în S.T.L.

201

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;

Page 200: Curs Logica Computationala.pdf

Capitolul 7

202

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.

Page 201: Curs Logica Computationala.pdf

Introducere în S.T.L.

203

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

Page 202: Curs Logica Computationala.pdf

Capitolul 7

204

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.

Page 203: Curs Logica Computationala.pdf

Introducere în S.T.L.

205

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ă.

Page 204: Curs Logica Computationala.pdf

Capitolul 7

206

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

Page 205: Curs Logica Computationala.pdf

Introducere în S.T.L.

207

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");

Page 206: Curs Logica Computationala.pdf

Capitolul 7

208

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

Page 207: Curs Logica Computationala.pdf

Introducere în S.T.L.

209

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

Page 208: Curs Logica Computationala.pdf

Capitolul 7

210

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:

Page 209: Curs Logica Computationala.pdf

Introducere în S.T.L.

211

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

Page 210: Curs Logica Computationala.pdf

Capitolul 7

212

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.

Page 211: Curs Logica Computationala.pdf

Introducere în S.T.L.

213

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

Page 212: Curs Logica Computationala.pdf

Capitolul 7

214

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ă.

Page 213: Curs Logica Computationala.pdf

Introducere în S.T.L.

215

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

Page 214: Curs Logica Computationala.pdf

Capitolul 7

216

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

Page 215: Curs Logica Computationala.pdf

Introducere în S.T.L.

217

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().

Page 216: Curs Logica Computationala.pdf

Capitolul 7

218

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.

Page 217: Curs Logica Computationala.pdf

Introducere în S.T.L.

219

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;

Page 218: Curs Logica Computationala.pdf

Capitolul 7

220

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

Page 219: Curs Logica Computationala.pdf

Introducere în S.T.L.

221

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

Page 220: Curs Logica Computationala.pdf

Capitolul 7

222

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ă

Page 221: Curs Logica Computationala.pdf

Introducere în S.T.L.

223

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] << " ";

Page 222: Curs Logica Computationala.pdf

Capitolul 7

224

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

Page 223: Curs Logica Computationala.pdf

Introducere în S.T.L.

225

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.

Page 224: Curs Logica Computationala.pdf

Capitolul 7

226

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

Page 225: Curs Logica Computationala.pdf

Algoritmi genetici

227

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.

Page 226: Curs Logica Computationala.pdf

Capitolul 8

228

CUPRINS

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

8.2. Problema găsirii unei expresii ............................................................... 236

8.3. Rezolvarea sistemelor de ecuaţii .......................................................... 241

Page 227: Curs Logica Computationala.pdf

Algoritmi genetici

229

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

Page 228: Curs Logica Computationala.pdf

Capitolul 8

230

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.

Page 229: Curs Logica Computationala.pdf

Algoritmi genetici

231

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++

Page 230: Curs Logica Computationala.pdf

Capitolul 8

232

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,

Page 231: Curs Logica Computationala.pdf

Algoritmi genetici

233

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

Page 232: Curs Logica Computationala.pdf

Capitolul 8

234

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

Page 233: Curs Logica Computationala.pdf

Algoritmi genetici

235

număr aleator t, acesta reprezentând punctul în care se rup cei doi

cromozomi şi se recombină.

𝑜𝑝𝐼 𝑋 = 𝑥0, 𝑥1, … , 𝑥𝑛−1

𝑌 = 𝑦0 , 𝑦1 , … , 𝑦𝑛−1 𝑡 → 𝑥0, 𝑥1, … , 𝑥𝑡−1, 𝑦𝑡 , 𝑦𝑡+1 … , 𝑦𝑛−1

sau 𝑦0, 𝑦1 , … , 𝑦𝑡−1, 𝑥𝑡 , 𝑥𝑡+1 … , 𝑥𝑛−1

Forma cea mai întâlnită a operatorului de încrucişare este aceea în

care se generează un şir de numere aleatoare (ti), care vor reprezenta

punctele de tăietură:

𝑜𝑝𝐼 𝑋 = 𝑥0, 𝑥1, … , 𝑥𝑛−1

𝑌 = 𝑦0 , 𝑦1 , … , 𝑦𝑛−1 𝑡0, 𝑡1, … , 𝑡𝑘−1 | 𝑡𝑖 < 𝑡𝑖+1 →

→ 𝑥0, 𝑥1, … , 𝑥𝑡0−1, 𝑦𝑡0, 𝑦𝑡0+1 , … , 𝑦𝑡1−1 , 𝑥𝑡1

, 𝑥𝑡1+1, … , 𝑥𝑡2−1, 𝑦𝑡2, 𝑦𝑡2+1, …

Un exemplu în codificare binară:

𝑜𝑝𝐼 10101 010 10000010 101

11101 011 10111101 010 5,8,16 → (1010101110000010 010)

Operatorii genetici de 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.

Page 234: Curs Logica Computationala.pdf

Capitolul 8

236

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, ..., XN 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

* * +

4 4 1 2

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.

Page 235: Curs Logica Computationala.pdf

Algoritmi genetici

237

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:

Page 236: Curs Logica Computationala.pdf

Capitolul 8

238

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;

Page 237: Curs Logica Computationala.pdf

Algoritmi genetici

239

const int maxN = 1001; const int maxpop = 400; const int maxlg = 2*maxN + 1; const int maxeli = 50; const int prob_recomb =

(int)((double)0.80 * RAND_MAX); const int prob_mutatie = (int)((double)0.95 * RAND_MAX); const int mod = 16381; struct info {

int P[maxlg], fitness; }; void citire_init(int &N, int &S, info A[]) { ifstream in("expresie.in");

in >> N >> S; char x; int ops[maxN]; for ( int i = 1; i < N; ++i ) { in >> x; if ( x == '-' ) ops[i] = -1; else if ( x == '+' ) ops[i] = -2;

else ops[i] = -3; } ops[N] = -100; in.close(); for ( int i = 1; i < maxpop; ++i ) { for ( int j=1, k=1; j < 2*N;

j += 2, ++k ) { A[i].P[j] = 1 + rand() % N; A[i].P[j+1] = ops[k]; } } }

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

} int inm(int &k, int cr, info A[]) { int ret = paran(k, cr, A); while ( A[cr].P[k] == -3 )

{ ++k; ret *= paran(k, cr, A); ret %= mod; }

return ret; } int eval(int &k, int cr, info A[]) { int ret = inm(k, cr, A); while (A[cr].P[k] == -1 ||

A[cr].P[k] == -2) { if ( A[cr].P[k++] == -1 ) ret -= inm(k, cr, A); else ret += inm(k, cr, A);

ret %= mod; while ( ret < 0 ) ret += mod; } return ret; }

// </EVALUARE>

Page 238: Curs Logica Computationala.pdf

Capitolul 8

240

void calc_fitness(int N, int S, info A[]) { for ( int cr = 1; cr < maxpop; ++cr ) { int k = 1;

A[cr].fitness = abs(eval(k, cr, A) - S); } sort(A+1, A+maxpop); } void noua_gen(int N, info A[]) {

for ( int i = maxeli + 1; i < maxpop; ++i ) { if ( rand() < prob_recomb ) // recombinare { int i1, i2; do {

i1 = 1 + rand() % maxeli; i2 = 1 + rand() % maxeli; } while ( i1 == i2 ); int poz; do { poz = 1 + (rand() % (2*N - 1)); } while ( poz % 2 == 0 );

for ( int j = 1; j < poz; j += 2 ) A[i].P[j] = A[i1].P[j]; for ( int j = poz; j < 2*N; j += 2 ) A[i].P[j] = A[i2].P[j]; } if ( rand() < prob_mutatie ) // mutatie

{ int poz; do { poz = 1 + (rand() % (2*N - 1)); } while ( poz % 2 == 0 ); A[i].P[poz] = 1 + (rand() % N);

} } }

bool operator<(const info &x, const info &y) { return x.fitness < y.fitness; }

void start(int N, int S, info A[]) { do { noua_gen(N, A); calc_fitness(N, S, A);

} while ( A[1].fitness != 0 ); ofstream out("expresie.out"); for ( int i = 1; i < 2*N; i += 2 ) { out << A[1].P[i] << " ";

} out << endl; out.close(); } int main() {

int N, S; info *A = new info[maxpop]; srand((unsigned)time(0)); citire_init(N, S, A); start(N, S, A);

delete[] A; return 0; }

Page 239: Curs Logica Computationala.pdf

Algoritmi genetici

241

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

3 2

A1 + A2 - A3 = 2

A1 * A2 / A3 = 1

3 1 2

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

Page 240: Curs Logica Computationala.pdf

Capitolul 8

242

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.

Page 241: Curs Logica Computationala.pdf

Algoritmi genetici

243

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

Page 242: Curs Logica Computationala.pdf

Capitolul 8

244

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.

Page 243: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

245

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.

Page 244: Curs Logica Computationala.pdf

Capitolul 9

246

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

Page 245: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

247

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

0 1 1 1

0 1 0 0

0 0 0 0 1 1 1 0

6

1 1

2 1

3 1

3 2

3 3

3 4

4 4

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ă:

Page 246: Curs Logica Computationala.pdf

Capitolul 9

248

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

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

∞ ∞ ∞ ∞

∞ ∞ ∞ ∞

∞ ∞ ∞ ∞

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:

Page 247: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

249

A D

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ ∞ ∞

2 ∞ ∞ ∞

∞ ∞ ∞ ∞

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

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ ∞ ∞

2 3 ∞ ∞

∞ ∞ ∞ ∞

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

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ ∞ ∞

2 3 4 5

∞ ∞ ∞ ∞

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

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ ∞ ∞

2 3 4 5

∞ ∞ ∞ 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):

Page 248: Curs Logica Computationala.pdf

Capitolul 9

250

A D

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ ∞ 6

2 3 4 5

∞ ∞ ∞ 6

Singurul apel recursiv valid din poziţia (2, 4) este pentru poziţia (2,

3), obţinându-se:

A D

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ 7 6

2 3 4 5

∞ ∞ ∞ 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

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ 5 6

2 3 4 5

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

Page 249: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

251

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

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

{ return x >= 1 && y >= 1 && x <= N && y <= N; } void Lee(bool A[maxn][maxn], int N, int x, int y, int D[maxn][maxn])

{ for ( int i = 0; i < 4; ++i ) { int newx = x + dx[i]; int newy = y + dy[i]; 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); } } }

void init(int D[maxn][maxn], int N) { for ( int i = 1; i <= N; ++i ) for ( int j = 1; j <= N; ++j )

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

bool A[maxn][maxn]; int D[maxn][maxn]; citire(A, N); init(D, N); Lee(A, N, 1, 1, D);

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

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

Page 250: Curs Logica Computationala.pdf

Capitolul 9

252

î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

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

∞ ∞ ∞ ∞

∞ ∞ ∞ ∞

∞ ∞ ∞ ∞

p, u

Q: (1, 1)

Page 251: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

253

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

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ ∞ ∞

∞ ∞ ∞ ∞

∞ ∞ ∞ ∞

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

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ ∞ ∞

2 ∞ ∞ ∞

∞ ∞ ∞ ∞

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

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ ∞ ∞

2 3 4 ∞

∞ ∞ ∞ ∞

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ă:

Page 252: Curs Logica Computationala.pdf

Capitolul 9

254

A D

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ 5 ∞

2 3 4 5

∞ ∞ ∞ ∞

p u

Q: (1, 1) (2,1) (3,1) (3, 2) (3, 3) (3, 4) (2, 3)

Se extrage elementul (3, 4), care va actualiza 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

0 1 1 1

0 1 0 0

0 0 0 0

1 1 1 0

0 ∞ ∞ ∞

1 ∞ 5 6

2 3 4 5

∞ ∞ ∞ 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)

Page 253: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

255

Î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:

Page 254: Curs Logica Computationala.pdf

Capitolul 9

256

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:

Page 255: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

257

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.

Page 256: Curs Logica Computationala.pdf

Capitolul 9

258

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

-6 1 -3 4 5 -1 3 -8 -9 1

11

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(N

2) subsecvenţe şi fiecare dintre acestea

trebuie parcursă pentru a-i afla suma.

Putem implementa această metodă în felul următor:

Page 257: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

259

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

Page 258: Curs Logica Computationala.pdf

Capitolul 9

260

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

Page 259: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

261

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?

Page 260: Curs Logica Computationala.pdf

Capitolul 9

262

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

6 3 8 9 1 2 10 4 -1 11

5

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

Page 261: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

263

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

Page 262: Curs Logica Computationala.pdf

Capitolul 9

264

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();

}

Page 263: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

265

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

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

P[i] = j; } if ( L[i] > L[sol] ) sol = i; }

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

out << A[poz] << ' '; }

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

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

out.close(); return 0; }

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:

Page 264: Curs Logica Computationala.pdf

Capitolul 9

266

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:

Page 265: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

267

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

Page 266: Curs Logica Computationala.pdf

Capitolul 9

268

int cbin(int st, int dr, int val, int L[]) { while ( st < dr ) { int m = (st + dr) / 2;

if ( L[m] < val ) st = m + 1; else dr = m; } return st;

} void reconst(int N, int A[], int P[], int val, ofstream &out) { for ( int i = N; i; --i ) if ( P[i] == val )

{ reconst(i - 1, A, P, val - 1, out); out << A[i] << ' '; break; } }

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

{ L[i] = inf; int k = cbin(1, lg + 1, A[i], L); // creste lungimea celui mai lung // subsir?

if ( L[k] == inf ) ++lg; L[k] = A[i]; P[i] = k; }

return lg; } int main() { int N, A[maxn], L[maxn], P[maxn]; citire(A, N);

ofstream out("subsir.out"); int sol = cmlscm(A, N, L, P); out << sol << '\n'; reconst(N, A, P, sol, out); out.close();

return 0; }

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.

Page 267: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

269

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

gcbcataabbaggaacba

10

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ă.

Page 268: Curs Logica Computationala.pdf

Capitolul 9

270

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> #include <string> using namespace std; const int maxn = 101;

void citire(string &A, string &B) { ifstream in("sircom.in"); in >> A >> B; in.close(); }

int cmlsc(string &A, string &B, int L[maxn][maxn]) { for ( int i = 0; i <= A.length(); ++i ) L[i][0] = 0;

for ( int i = 0; i <= B.length(); ++i ) L[0][i] = 0; for ( int i = 1; i <= A.length(); ++i ) for ( int j = 1; j <= B.length(); ++j ) if ( A[i - 1] == B[j - 1] ) L[i][j] = L[i - 1][j - 1] + 1;

else { if ( L[i - 1][j] > L[i][j - 1] ) L[i][j] = L[i - 1][j]; else L[i][j] = L[i][j - 1]; }

return L[A.length()][B.length()]; }

Page 269: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

271

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ă

Page 270: Curs Logica Computationala.pdf

Capitolul 9

272

liniei curente, iar celălalt vector, L2, va reprezenta chiar linia curentă. Noua

formă a formulei de recurenţă este:

𝐿2 𝑗 = 𝐿1 𝑗 − 1 + 1 𝑑𝑎𝑐ă 𝐴 𝑖 = 𝐵[𝑗]

max(𝐿1 𝑗 , 𝐿2 𝑗 − 1 ) 𝑎𝑙𝑡𝑓𝑒𝑙

După fiecare calculare completă a lui L2, înainte de începerea unei

noi 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.

Page 271: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

273

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

4 3 2 5

64

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.

Page 272: Curs Logica Computationala.pdf

Capitolul 9

274

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;

Page 273: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

275

void citire(int D[], int &N) { ifstream in("inmopt.in"); in >> N;

for ( int i = 0; i <= N; ++i ) in >> D[i]; in.close(); } 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]; }

int main() { int N, D[maxn]; citire(D, N);

ofstream out("inmopt.out"); out << rezolvare(D, N); out.close(); return 0; }

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.

Page 274: Curs Logica Computationala.pdf

Capitolul 9

276

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 10 9 4 10 5 12 13 20

22 3 2

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ă

Page 275: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

277

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

Page 276: Curs Logica Computationala.pdf

Capitolul 9

278

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 = C; j >= A[i].G; --j ) if ( F[j] < F[j - A[i].G]+A[i].V ) { F[j] = F[j - A[i].G] + A[i].V; P[j] = i;

} } void reconst(obiect A[], int F[], int P[], int C, ofstream &out) { int max = F[C];

while ( F[C] == max ) --C; ++C; while ( P[C] ) { out << P[C] << ' ';

C -= A[ P[C] ].G; } }

int main() { int N, C; obiect A[maxn];

citire(A, N, C); int F[maxc], P[maxc]; ofstream out("rucsac1.out"); rezolvare(A, N, C, F, P); out << F[C] << '\n';

reconst(A, F, P, C, out); out.close(); return 0; }

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.

Page 277: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

279

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

10 9

4 10

5 12

13 20

32

2 2 3

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.

Page 278: Curs Logica Computationala.pdf

Capitolul 9

280

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

8 3 2 5 7 3 10

1 3 6 7

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

Page 279: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

281

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.

Page 280: Curs Logica Computationala.pdf

Capitolul 9

282

void dinamica(int A[], int N, int S) { bool F[maxn]; int P[maxn]; for ( int i = 0; i <= S; ++i )

F[i] = P[i] = 0; // 0 == false F[0] = true; for ( int i = 1; i <= N; ++i ) for ( int j = S; j >= A[i]; --j ) { F[j] |= F[j - A[i]];

if ( F[j - A[i]] && !P[j] ) P[j] = i; } ofstream out("plata1.out"); while ( S )

{ out << P[S] << ' '; S -= A[ P[S] ]; } out.close(); }

void random(int A[], int N, int S) { int sel[maxn], nesel[maxn]; // sel[0], nesel[0] sunt numarul de // elemente

sel[0] = sel[0] = 0; int stmp = 0; srand((unsigned)time(0)); for ( int i = 1; i <= N; ++i ) if ( rand() % 2 ) sel[ ++sel[0] ] = i, stmp += A[i]; else

nesel[ ++nesel[0] ] = i; while ( stmp != S ) if ( stmp > S ) { int poz = 1 + (rand() % sel[0]); stmp -= A[ sel[poz] ];

nesel[ ++nesel[0] ] = sel[poz]; sel[poz] = sel[ sel[0]-- ]; } else { int poz = 1 + (rand() % nesel[0]); stmp += A[ nesel[poz] ]; sel[ ++sel[0] ] = nesel[poz];

nesel[poz] = nesel[ nesel[0]-- ]; } ofstream out("plata1.out"); for ( int i = 1; i <= sel[0]; ++i ) out << sel[i] << ' '; out.close(); }

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.

Page 281: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

283

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

8 3 2 5 7 3 10

1 1 2 3 3

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.

Page 282: Curs Logica Computationala.pdf

Capitolul 9

284

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:

Page 283: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

285

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.

Page 284: Curs Logica Computationala.pdf

Capitolul 9

286

#include <fstream> using namespace std; const int maxn = 101; int nrpart(int N, int K,

int memo[maxn][maxn]) { if ( K > N ) return 0; if ( N == K ) return 1; if ( memo[N][K] != -1 ) return memo[N][K];

memo[N][K] = nrpart(N - K, K, memo) + nrpart(N, K + 1, memo); return memo[N][K]; }

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

for ( int j = 1; j < maxn; ++j ) memo[i][j] = -1; ifstream in("nrpart.in"); in >> N; in.close();

ofstream out("nrpart.out"); out << nrpart(N, 1, memo); out.close(); 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.

Page 285: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

287

Exemplu:

lev.in lev.out

afara

afacere

3

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].

Page 286: Curs Logica Computationala.pdf

Capitolul 9

288

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.

Page 287: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

289

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

94 100

5 6

6 8

3 4

2 7

1

115

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.

Page 288: Curs Logica Computationala.pdf

Capitolul 9

290

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, C2).

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

Page 289: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

291

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.

Page 290: Curs Logica Computationala.pdf

Capitolul 9

292

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.in rmq.out

9 3

8 1 9 3 4 4 5 2 6

1 3

5 8

3 3

1

2

9

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 log2(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:

Page 291: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

293

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 – 2

L + 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 – 2

L + 1 < x => 2

L > 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 – 2

L => 2

L + 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.

Page 292: Curs Logica Computationala.pdf

Capitolul 9

294

i \ j 1 2 3 4 5 6 7 8 9

0 8 1 9 3 4 4 5 2 6

1

1

1

3

3

4

4

2

2

2

1

1

3

3

2

2

3 1

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.

Page 293: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

295

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

}

Page 294: Curs Logica Computationala.pdf

Capitolul 9

296

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 0 0

-2 -1

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.

Page 295: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

297

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.

Page 296: Curs Logica Computationala.pdf

Capitolul 9

298

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][𝑗] 𝑜𝑝 𝑘 = −2

𝑇 𝑖 𝑘 ∙ 𝐹 𝑘 + 1 𝑗 + 𝐹 𝑖 [𝑘] ∙ 𝑇[𝑘 + 1][𝑗] 𝑜𝑝 𝑘 = −3

𝑗−1

𝑘=𝑖

şi

𝐹 𝑖 𝑗 =

𝐴 𝑖 𝑘 ∙ 𝐴 𝑘 + 1 𝑗 − 𝑇 𝑖 [𝑘] ∙ 𝑇[𝑘 + 1][𝑗] 𝑜𝑝 𝑘 = −1

𝐹 𝑖 𝑘 ∙ 𝐹 𝑘 + 1 [𝑗] 𝑜𝑝 𝑘 = −2

𝐹 𝑖 𝑘 ∙ 𝐹 𝑘 + 1 𝑗 + 𝑇 𝑖 [𝑘] ∙ 𝑇[𝑘 + 1][𝑗] 𝑜𝑝 𝑘 = −3

𝑗 −1

𝑘=𝑖

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> #include <iostream> using namespace std;

const int maxn = 101; int rezolvare(int, bool[], int[]); void citire(int &N, bool S[], int op[]) { ifstream in("paran.in"); in >> N;

for ( int i = 1; i <= N; ++i ) in >> S[i]; for ( int i = 1; i < N; ++i ) in >> op[i]; }

int main() { // op[i] = operatorul dintre // simbolul i si i + 1

int N, op[maxn]; bool S[maxn]; citire(N, S, op); ofstream out("paran.out"); out << rezolvare(N, S, op) << endl;

out.close(); return 0; }

Page 297: Curs Logica Computationala.pdf

Algoritmi de programare dinamică

299

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

Page 298: Curs Logica Computationala.pdf

Capitolul 9

300

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.

Page 299: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

301

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.

Page 300: Curs Logica Computationala.pdf

Capitolul 10

302

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

Page 301: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

303

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.

Page 302: Curs Logica Computationala.pdf

Capitolul 10

304

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 ax + by + 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ă:

Page 303: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

305

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

Page 304: Curs Logica Computationala.pdf

Capitolul 10

306

𝑚𝑑 =𝑦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: ax + by + c = 0. Rescriem ecuaţia în

felul următor: d: y = −a

b∙ x −

c

b. Observăm că ecuaţia unei drepte poate fi

calculată cu ajutorul pantei şi a unui punct care aparţine dreptei în felul

următor: d: y = mx –mx0 + y0. Comparând cele două forme ale ecuaţiei

rezultă că panta unei drepte d: ax + by + 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

Page 305: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

307

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ţă.

Page 306: Curs Logica Computationala.pdf

Capitolul 10

308

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.

Page 307: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

309

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ă

Page 308: Curs Logica Computationala.pdf

Capitolul 10

310

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

}

Page 309: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

311

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

2

𝑋1 𝑋2

𝑌1 𝑌2 +

𝑋2 𝑋3

𝑌2 𝑌3 + ⋯ +

𝑋𝑁 𝑋1

𝑌𝑁 𝑌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

Page 310: Curs Logica Computationala.pdf

Capitolul 10

312

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;

}

Page 311: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

313

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:

|𝐴 𝑃 | = 𝑖 +𝑙

2− 1

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:

𝑖 = 𝐴 𝑃 −𝑙

2+ 1

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:

Page 312: Curs Logica Computationala.pdf

Capitolul 10

314

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(Nh), unde

h este numărul de vârfuri ale înfăşurătorii convexe şi un algoritm de

complexitate O(Nlog N), care este eficient pentru orice set de puncte dat.

Algoritmul naiv se bazează pe observaţia că un segment [PxPy]

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(Nh), 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.

Page 313: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

315

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

Page 314: Curs Logica Computationala.pdf

Capitolul 10

316

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(P1, 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(Nh).

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.

Page 315: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

317

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;

Page 316: Curs Logica Computationala.pdf

Capitolul 10

318

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

Page 317: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

319

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.

Page 318: Curs Logica Computationala.pdf

Capitolul 10

320

Algoritmul de complexitate O(Nlog 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(Nlog 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ă

Page 319: Curs Logica Computationala.pdf

Algoritmi de geometrie computaţională

321

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();

Page 320: Curs Logica Computationala.pdf

Capitolul 10

322

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

Page 321: Curs Logica Computationala.pdf

Liste înlănţuite

323

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.

Page 322: Curs Logica Computationala.pdf

Capitolul 11

324

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

Page 323: Curs Logica Computationala.pdf

Liste înlănţuite

325

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ă:

Page 324: Curs Logica Computationala.pdf

Capitolul 11

326

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

Page 325: Curs Logica Computationala.pdf

Liste înlănţuite

327

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:

Page 326: Curs Logica Computationala.pdf

Capitolul 11

328

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

Page 327: Curs Logica Computationala.pdf

Liste înlănţuite

329

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:

Page 328: Curs Logica Computationala.pdf

Capitolul 11

330

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; LISTA = add_end(LISTA);

eroare!

nod *LISTA = NULL; LISTA = add_beg(LISTA); 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;

Page 329: Curs Logica Computationala.pdf

Liste înlănţuite

331

//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)

Page 330: Curs Logica Computationala.pdf

Capitolul 11

332

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

Page 331: Curs Logica Computationala.pdf

Liste înlănţuite

333

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

Page 332: Curs Logica Computationala.pdf

Capitolul 11

334

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) { return Old->link_; }

void del_beg(nod *&Old) // procedura { 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

Page 333: Curs Logica Computationala.pdf

Liste înlănţuite

335

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;

Page 334: Curs Logica Computationala.pdf

Capitolul 11

336

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_) { ... }

Page 335: Curs Logica Computationala.pdf

Liste înlănţuite

337

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;

}

Page 336: Curs Logica Computationala.pdf

Capitolul 11

338

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.

Page 337: Curs Logica Computationala.pdf

Liste înlănţuite

339

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

Page 338: Curs Logica Computationala.pdf

Capitolul 11

340

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

Page 339: Curs Logica Computationala.pdf

Liste înlănţuite

341

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 2 4 1 3 5

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> using namespace std;

struct nod { int info; nod *link_; }; nod *add_beg(nod *Old, int val)

{ nod *New = new nod; New->info = val; New->link_ = Old; return New; }

nod *add_end(nod *Old, int val) { if ( Old == NULL ) Old = add_beg(Old, val);

else { nod *New = new nod; New->link_ = NULL; New->info = val; nod *Temp;

for ( Temp = Old; Temp->link_ != NULL; Temp = Temp->link_ ); Temp->link_ = New; } return Old; }

Page 340: Curs Logica Computationala.pdf

Capitolul 11

342

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

Page 341: Curs Logica Computationala.pdf

Liste înlănţuite

343

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.

Page 342: Curs Logica Computationala.pdf

Capitolul 11

344

Î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.

Page 343: Curs Logica Computationala.pdf

Liste înlănţuite

345

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

Page 344: Curs Logica Computationala.pdf

Capitolul 11

346

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

Page 345: Curs Logica Computationala.pdf

Liste înlănţuite

347

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.

Page 346: Curs Logica Computationala.pdf

Capitolul 11

348

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

Page 347: Curs Logica Computationala.pdf

Liste înlănţuite

349

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)

Page 348: Curs Logica Computationala.pdf

Capitolul 11

350

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.

Page 349: Curs Logica Computationala.pdf

Liste înlănţuite

351

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

Page 350: Curs Logica Computationala.pdf

Capitolul 11

352

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

Page 351: Curs Logica Computationala.pdf

Liste înlănţuite

353

void del_mid(nod *&Old, int info) { if ( Old->info == info ) del_beg(Old); else

{ nod *Temp = Old; for ( ; Temp->link_ != NULL; Temp = Temp->link_ ) if ( Temp->link_->info == info ) break; if ( Temp->link_ != NULL )

{ nod *ToDel = Temp->link_; Temp->link_ = Temp->link_->link_; if ( Temp->link_ != NULL ) Temp->link_->_link = Temp; delete ToDel;

} } }

void view(nod *L) { for ( ; L; L = L->link_ ) cout << L->info << ' ';

cout << endl; }

int main() { // atentie la initializarea cu NULL! nod *LISTA = NULL;

int N, M, x; cin >> N >> M; for ( int i = 0; i < N; ++i ) { cin >> x; add_mid(LISTA, x);

} view(LISTA); for ( int i = 0; i < M; ++i ) { cin >> x; del_mid(LISTA, x); view(LISTA);

} return 0; }

Page 352: Curs Logica Computationala.pdf

Capitolul 11

354

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 Knuth1 ş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.

Page 353: Curs Logica Computationala.pdf

Teoria grafurilor

355

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.

Page 354: Curs Logica Computationala.pdf

Capitolul 12

356

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

Page 355: Curs Logica Computationala.pdf

Teoria grafurilor

357

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|.

Page 356: Curs Logica Computationala.pdf

Capitolul 12

358

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:

2𝑁∙(𝑁−1)

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.

Page 357: Curs Logica Computationala.pdf

Teoria grafurilor

359

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).

Page 358: Curs Logica Computationala.pdf

Capitolul 12

360

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

1 0 1 0 1 0 0

2 1 0 0 0 1 1

3 0 0 0 0 0 1

4 1 0 0 0 0 0

5 0 1 0 0 0 1

6 0 1 1 0 1 0

G[1] = {2, 4}

G[2] = {1, 5}

G[3] = {6}

G[4] = {1}

G[5] = {2, 6}

G[6] = {5, 2, 3}

După cum se poate vedea din acest exemplu, matricele de adiacenţă

au dezavantajul de consuma mult mai multă memorie 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

Page 359: Curs Logica Computationala.pdf

Teoria grafurilor

361

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.

Page 360: Curs Logica Computationala.pdf

Capitolul 12

362

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 { int nod; graf *link_; }; graf *G[maxn]; // vector clasic de liste inalntuite

#include <vector> using namespace std; // G[i] este vector S.T.L. vector<int> G[maxn];

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;

Page 361: Curs Logica Computationala.pdf

Teoria grafurilor

363

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) { graf *t = new graf; t->nod = y; t->link_ = G[x];

G[x] = t; }

void ad_vector(vector<int> G[], int x, int y) { // sintaxa identica pentru S.T.L. list

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 ) { cout << i << ": "; for ( graf *tmp = G[i]; tmp; tmp = tmp->link_ ) cout << tmp->nod << ' ';

cout << endl; }

for ( int i = 1; i <= N; ++i ) { cout << i << ": "; for ( int j = 0; j < G[i].size(); ++j ) cout << G[i][j] << ' ';

cout << endl; }

Page 362: Curs Logica Computationala.pdf

Capitolul 12

364

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.

Page 363: Curs Logica Computationala.pdf

Teoria grafurilor

365

Cu matrice de adiacenţă Cu vectori

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

int main() { int N; bool G[maxn][maxn]; ifstream in("graf.in"); ofstream out("graf.out");

in >> N; for ( int i = 1; i <= N; ++i ) { int grad = 0; for ( int j = 1; j <= N; grad += G[i][j++] ) in >> G[i][j];

out << grad << '\n'; } in.close(); out.close(); return 0; }

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

int main() { int N, M, x, y; vector<int> G[maxn]; ifstream in("graf.in"); in >> N >> M; for ( int i = 1; i <= M; ++i )

{ in >> x >> y; G[x].push_back(y); G[y].push_back(x); } ofstream out("graf.out"); for ( int i = 1; i <= N; ++i )

out << G[i].size() << '\n'; in.close(); out.close(); 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.

Page 364: Curs Logica Computationala.pdf

Capitolul 12

366

Cu matrice de adiacenţă Cu vectori

#include <fstream> using namespace std;

const int maxn = 101; int main() { int N, M, x, y; bool G[maxn][maxn];

ifstream in("graf.in"); in >> N >> M; for ( int i = 1; i <= N; ++i ) for ( int j = 1; j <= N; ++j ) { in >> x >> y; G[x][y] = 1;

} in.close(); int neor = 1; for ( int i = 1; i <= N; ++i ) for ( int j = 1; j <= N; ++j ) if ( G[i][j] && !G[j][i] )

neor = 0; ofstream out("graf.out"); out << neor; out.close(); return 0;

}

#include <fstream> #include <vector> using namespace std;

const int maxn = 101; bool cauta(const vector<int> &L, int nod) { for ( int i = 0; i < L.size(); ++i )

if ( L[i] == nod ) return 1; return 0; } int main() {

int N, M, x, y; vector<int> G[maxn]; ifstream in("graf.in"); in >> N >> M; for ( int i = 1; i <= M; ++i ) {

in >> x >> y; G[x].push_back(y); } in.close(); bool neor = 1; for ( int i = 1; i <= N; ++i )

for ( int j = 0; j < G[i].size(); ++j ) if ( !cauta(G[ G[i][j] ], i) ) neor = 0; ofstream out("graf.out"); out << neor; out.close(); return 0;

}

Page 365: Curs Logica Computationala.pdf

Teoria grafurilor

367

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:

Page 366: Curs Logica Computationala.pdf

Capitolul 12

368

#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.

Page 367: Curs Logica Computationala.pdf

Teoria grafurilor

369

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

Page 368: Curs Logica Computationala.pdf

Capitolul 12

370

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.

Page 369: Curs Logica Computationala.pdf

Teoria grafurilor

371

Exemplu:

dfs.in dfs.out

4 5

1 3

2 3

3 1

3 4

1 4

1 3 2 4

Există mai multe răspunsuri corecte, în funcţie de ordinea nodurilor

în listele de adiacenţă.

#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(); } 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); }

int main()

{ int N, M; bool V[maxn]; vector<int> G[maxn]; citire(G, N, M); for ( int i = 1; i <= N; ++i ) V[i] = false;

ofstream out("dfs.out"); DFS(G, 1, V, out); out.close(); return 0; }

Page 370: Curs Logica Computationala.pdf

Capitolul 12

372

Î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).

Page 371: Curs Logica Computationala.pdf

Teoria grafurilor

373

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.

Page 372: Curs Logica Computationala.pdf

Capitolul 12

374

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

Page 373: Curs Logica Computationala.pdf

Teoria grafurilor

375

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.

Page 374: Curs Logica Computationala.pdf

Capitolul 12

376

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

1 2

2 3

2 4

4 5

3

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:

Page 375: Curs Logica Computationala.pdf

Teoria grafurilor

377

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

Page 376: Curs Logica Computationala.pdf

Capitolul 12

378

void init(int D[], int N) { for ( int i = 1; i <= N; ++i ) D[i] = -1; }

void DFS(vector<int> G[], int nod, int dist, int D[]) { // putem folosi D ca sa vedem daca // un nod a mai fost sau nu vizitat if ( D[nod] != -1 )

return; D[nod] = dist; for ( int i = 0; i < G[nod].size(); ++i ) DFS(G, G[nod][i], dist + 1, D); }

int main() { int N; int D[maxn]; vector<int> G[maxn];

citire(G, N); init(D, N); DFS(G, 1, 0, D); int X = 1;

for ( int i = 2; i <= N; ++i ) if ( D[i] > D[X] ) X = i; 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

Page 377: Curs Logica Computationala.pdf

Teoria grafurilor

379

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.

Page 378: Curs Logica Computationala.pdf

Capitolul 12

380

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

Page 379: Curs Logica Computationala.pdf

Teoria grafurilor

381

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.

Page 380: Curs Logica Computationala.pdf

Capitolul 12

382

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.

Page 381: Curs Logica Computationala.pdf

Teoria grafurilor

383

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

Page 382: Curs Logica Computationala.pdf

Capitolul 12

384

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.

Page 383: Curs Logica Computationala.pdf

Teoria grafurilor

385

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.

Page 384: Curs Logica Computationala.pdf

Capitolul 12

386

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.

Page 385: Curs Logica Computationala.pdf

Teoria grafurilor

387

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

Page 386: Curs Logica Computationala.pdf

Capitolul 12

388

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.

Page 387: Curs Logica Computationala.pdf

Teoria grafurilor

389

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ă.

Page 388: Curs Logica Computationala.pdf

Capitolul 12

390

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

void citire(vector<int> G[], vector<int> GT[], int &N, int &M) { ifstream in("ctc.in"); in >> N >> M;

int x, y; for ( int i = 1; i <= M; ++i ) { in >> x >> y; G[x].push_back(y); GT[y].push_back(x);

} in.close(); } void DFS_G(vector<int> G[], int nod, bool V[], vector<int> &postord)

{ if ( V[nod] ) return; V[nod] = true; for ( int i=0; i < G[nod].size(); ++i ) DFS_G(G, G[nod][i], V, postord);

postord.push_back(nod); }

void DFS_GT(vector<int> GT[], int nod, bool V[], ofstream &out) {

if ( !V[nod] ) return; V[nod] = false; out << nod << ' '; for ( int i=0; i<GT[nod].size(); ++i )

DFS_GT(GT, GT[nod][i], V, out); } int main() { int N, M; vector<int> G[maxn], GT[maxn];

bool V[maxn]; citire(G, GT, N, M); for ( int i = 1; i <= N; ++i ) V[i] = false; vector<int> postord; for ( int i = 1; i <= N; ++i )

DFS_G(G, i, V, postord); ofstream out("ctc.out"); for ( int i = 1; i <= N; ++i ) if ( V[i] ) { DFS_GT(GT, i, V, out);

out << '\n'; } out.close(); return 0; }

Page 389: Curs Logica Computationala.pdf

Teoria grafurilor

391

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):

Page 390: Curs Logica Computationala.pdf

Capitolul 12

392

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.

Page 391: Curs Logica Computationala.pdf

Teoria grafurilor

393

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.

Page 392: Curs Logica Computationala.pdf

Capitolul 12

394

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?)

Page 393: Curs Logica Computationala.pdf

Teoria grafurilor

395

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:

Page 394: Curs Logica Computationala.pdf

Capitolul 12

396

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.

Page 395: Curs Logica Computationala.pdf

Teoria grafurilor

397

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

Page 396: Curs Logica Computationala.pdf

Capitolul 12

398

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> #include <vector>

using namespace std; const int maxn = 101; void citire(vector<int> G[], int &N, int &M) { ifstream in("euler.in"); in >> N >> M; int x, y;

for ( int i = 1; i <= M; ++i ) { in >> x >> y; G[x].push_back(y); G[y].push_back(x); } in.close();

} void euler(vector<int> G[], int nod, vector<int> &st) { for ( int i = 0; i < G[nod].size(); ++i ) {

// trebuie sters G[nod][i] din lista lui // nod si nod din lista lui G[nod][i] int temp = G[nod][i]; G[nod].erase(G[nod].begin() + i); for ( int j = 0; j < G[temp].size(); ++j ) if ( G[temp][j] == nod ) { G[temp].erase(G[temp].begin() + j);

break; } euler(G, temp, st); } st.push_back(nod); }

int main() {

int N, M; vector<int> G[maxn], st; citire(G, N, M); euler(G, 1, st); ofstream out("euler.out");

for ( int i = st.size() - 1; i >= 0; --i ) out << st[i] << ' '; out.close(); return 0;

}

Page 397: Curs Logica Computationala.pdf

Teoria grafurilor

399

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.

Page 398: Curs Logica Computationala.pdf

Capitolul 12

400

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

Page 399: Curs Logica Computationala.pdf

Teoria grafurilor

401

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.

Page 400: Curs Logica Computationala.pdf

Capitolul 12

402

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

void citire(int G[maxn][maxn], int &N, int &M, int &x) { ifstream in("hamilton.in"); in >> N >> M >> x; int p, q, c; for ( int i = 1; i <= M; ++i )

{ in >> p >> q >> c; G[p][q] = G[q][p] = c; } in.close(); }

void hamilton(int G[maxn][maxn], int N, int nod, int nr, int c, int &cmin, bool V[], int st[], int sol[]) { if ( c > cmin || V[nod] == true ) return; st[nr] = nod;

if ( nr == N && c < cmin ) { cmin = c; for ( int i = 1; i <= N; ++i ) sol[i] = st[i]; return;

} V[nod] = true; for ( int i = 1; i <= N; ++i ) if ( G[nod][i] ) hamilton(G, N, i, nr + 1, c + G[nod][i], cmin,

V, st, sol); V[nod] = false; }

int main() { int G[maxn][maxn], N, M, x; bool V[maxn];

citire(G, N, M, x); for ( int i = 1; i <= N; ++i ) V[i] = false; int st[maxn], sol[maxn]; int cmin = 1 << 30;

hamilton(G, N, x, 1, 0, cmin, V, st, sol); ofstream out("hamilton.out"); out << cmin << '\n'; for ( int i = 1; i <= N; ++i ) out << sol[i] << ' ';

out.close(); return 0; }

Page 401: Curs Logica Computationala.pdf

Teoria grafurilor

403

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)

Page 402: Curs Logica Computationala.pdf

Capitolul 12

404

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

Page 403: Curs Logica Computationala.pdf

Teoria grafurilor

405

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

Page 404: Curs Logica Computationala.pdf

Capitolul 12

406

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:

Page 405: Curs Logica Computationala.pdf

Teoria grafurilor

407

#include <fstream>

using namespace std; const int maxn = 101; const int inf = 1 << 29; void citire(int G[maxn][maxn], int &N) {

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

if ( i != j && !G[i][j] ) G[i][j] = inf; } in.close(); }

void roy_floyd(int G[maxn][maxn], int N) {

for ( int k = 1; k <= N; ++k ) for ( int i = 1; i <= N; ++i ) for ( int j = 1; j <= N; ++j ) G[i][j]=min(G[i][j],G[i][k]+G[k][j]); } int main() {

int G[maxn][maxn], N; citire(G, N); roy_floyd(G, N); ofstream out("rf.out"); for ( int i = 1; i <= N; ++i ) {

for ( int j = 1; j <= N; ++j ) out << G[i][j] << ' '; out << '\n'; } out.close(); return 0; }

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:

Page 406: Curs Logica Computationala.pdf

Capitolul 12

408

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

Page 407: Curs Logica Computationala.pdf

Teoria grafurilor

409

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

Page 408: Curs Logica Computationala.pdf

Capitolul 12

410

Î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> #include <vector> #include <utility> using namespace std; const int maxn = 101; const int inf = 1 << 29;

typedef pair<int, int> PER; void citire(vector<PER> G[], int &N, int &M) { ifstream in("dijkstra.in"); in >> N >> M;

int x, y, c; for ( int i = 1; i <= M; ++i ) { in >> x >> y >> c; G[x].push_back(make_pair(y,c)); G[y].push_back(make_pair(x,c));

} in.close(); } void drum(int N, int P[maxn], ofstream &out)

{ if ( !N ) return; drum(P[N], P, out); out << N << ' '; }

void Dijkstra(vector<PER> G[], int N, int D[], int P[]) { bool V[maxn]; for ( int i = 0; i <= N; ++i ) D[i] = inf, P[i] = 0, V[i] = false; D[1] = 0;

for ( int i = 1; i <= N; ++i ) { int min = 0; // aflu nodul min cu D[min] minim for ( int j = 1; j <= N; ++j ) if ( !V[j] && D[j] < D[min] )

min = j; V[min] = true; // incerc sa relaxez vecinii lui min vector<PER>::iterator j; for ( j = G[min].begin(); j != G[min].end(); ++j )

if (D[min]+j->second<D[j->first]) { D[j->first]=D[min]+j->second; P[ j->first ] = min; } } }

Page 409: Curs Logica Computationala.pdf

Teoria grafurilor

411

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 =

O(N2)), 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.

Page 410: Curs Logica Computationala.pdf

Capitolul 12

412

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

Page 411: Curs Logica Computationala.pdf

Teoria grafurilor

413

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

Page 412: Curs Logica Computationala.pdf

Capitolul 12

414

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

Page 413: Curs Logica Computationala.pdf

Teoria grafurilor

415

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ă

Page 414: Curs Logica Computationala.pdf

Capitolul 12

416

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

Page 415: Curs Logica Computationala.pdf

Teoria grafurilor

417

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

Page 416: Curs Logica Computationala.pdf

Capitolul 12

418

Pasul 2

i 1 2 3 4

Q 1 2 3

D 0 4 2 inf

P 0 1 1

Pasul 3

i 1 2 3 4

Q 1 2 3

D 0 4 1 inf

P 0 1 2

Pasul 4

i 1 2 3 4

Q 1 2 3 4

D 0 4 1 5

P 0 1 2 3

Pasul 5

i 1 2 3 4

Q 1 2 3 4

D 0 4 1 5

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.

Page 417: Curs Logica Computationala.pdf

Teoria grafurilor

419

Î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(); }

}

Page 418: Curs Logica Computationala.pdf

Capitolul 12

420

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

Page 419: Curs Logica Computationala.pdf

Teoria grafurilor

421

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.

Page 420: Curs Logica Computationala.pdf

Capitolul 12

422

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

}

Page 421: Curs Logica Computationala.pdf

Teoria grafurilor

423

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

Page 422: Curs Logica Computationala.pdf

Capitolul 12

424

Î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∙M2). 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:

Page 423: Curs Logica Computationala.pdf

Teoria grafurilor

425

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

Page 424: Curs Logica Computationala.pdf

Capitolul 12

426

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

Page 425: Curs Logica Computationala.pdf

Teoria grafurilor

427

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

Page 426: Curs Logica Computationala.pdf

Capitolul 12

428

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

Page 427: Curs Logica Computationala.pdf

Teoria grafurilor

429

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∙M

2), 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

Page 428: Curs Logica Computationala.pdf

Capitolul 12

430

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

}

Page 429: Curs Logica Computationala.pdf

Teoria grafurilor

431

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.

Page 430: Curs Logica Computationala.pdf

Capitolul 12

432

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

Page 431: Curs Logica Computationala.pdf

Teoria grafurilor

433

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.

Page 432: Curs Logica Computationala.pdf

Capitolul 12

434

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:

Page 433: Curs Logica Computationala.pdf

Teoria grafurilor

435

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.

Page 434: Curs Logica Computationala.pdf

Capitolul 12

436

#include <fstream> #include <vector> using namespace std;

const int maxn = 101; void citire(vector<int> G[], int &N, int &M) { int E; ifstream in("cuplaj.in");

in >> N >> M >> E; int x, y; for ( int i = 1; i <= E; ++i ) { in >> x >> y; G[x].push_back(y);

} in.close(); }

bool Cuplare(vector<int> G[], int i, int st[], int dr[], bool V[]) { if ( V[i] )

return false; V[i] = true; vector<int>::iterator v; for ( v = G[i].begin(); v != G[i].end(); ++v ) if ( !st[*v] )

{ st[*v] = i; dr[i] = *v; return true; } for ( v = G[i].begin();

v != G[i].end(); ++v ) if ( Cuplare(G, st[*v], st, dr, V) ) { st[*v] = i; dr[i] = *v; return true; }

return false; }

Page 435: Curs Logica Computationala.pdf

Teoria grafurilor

437

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.

Page 436: Curs Logica Computationala.pdf

Capitolul 12

438

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.

Page 437: Curs Logica Computationala.pdf

Teoria grafurilor

439

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:

Page 438: Curs Logica Computationala.pdf

Capitolul 12

440

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

Page 439: Curs Logica Computationala.pdf

Teoria grafurilor

441

bool operator<(const muchie &x, const muchie &y) { return x.c < y.c; }

int Find(int x, int T[]) { if ( T[x] == x ) return x; T[x] = Find(T[x], T);

return T[x]; } void Merge(int x, int y, int T[], int R[]) { if ( R[x] == R[y] )

{ T[y] = x; ++R[x]; } else if ( R[x] < R[y] ) T[x] = y; else T[y] = x;

}

void Kruskal(muchie E[], int N, int M) { int R[maxn], T[maxn], k = 0, cost = 0; // APM va avea intotdeauna N - 1 noduri.

muchie APM[maxn - 1]; for ( int i = 1; i <= N; ++i ) T[i] = i, R[i] = 0; sort(E + 1, E + M + 1); for ( int i = 1; i <= M; ++i )

if ( Find(E[i].x, T) != Find(E[i].y, T) ) { cost += E[i].c; APM[++k] = E[i]; Merge(Find(E[i].x, T), Find(E[i].y, T), T, R);

} ofstream out("kruskal.out"); out << cost << '\n'; for ( int i = 1; i < N; ++i ) out << APM[i].x << ' ' << APM[i].y << '\n';

out.close(); } int main() { int N, M;

muchie E[maxm]; citire(E, N, M); Kruskal(E, N, M); return 0; }

Page 440: Curs Logica Computationala.pdf

Capitolul 12

442

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

Page 441: Curs Logica Computationala.pdf

Teoria grafurilor

443

(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.

Page 442: Curs Logica Computationala.pdf

Capitolul 12

444

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[]);

Page 443: Curs Logica Computationala.pdf

Teoria grafurilor

445

void citire(vector<PER> G[], int &N, int &M) { ifstream in("prim.in"); in >> N >> M;

int x, y, c; for ( int i = 1; i <= M; ++i ) { in >> x >> y >> c; G[x].push_back(make_pair(y, c)); G[y].push_back(make_pair(x, c));

} in.close(); } int main() {

int N, M; vector<PER> G[maxn]; citire(G, N, M); PER APM[maxn - 1]; ofstream out("prim.out"); out << prim(G, N, APM) << '\n';

for ( int i = 1; i < N ; ++i ) out << APM[i].first << ' ' << APM[i].second << '\n'; out.close(); return 0; }

int prim(vector<PER> G[], int N, PER APM[]) { int D[maxn], vec[maxn], k = 0; for ( int i = 1; i <= N; ++i )

D[i] = inf, vec[i] = 1; vec[1] = 0; vector<PER>::iterator j; for ( j = G[1].begin(); j != G[1].end(); ++j ) D[ j->first ] = j->second; int cost = 0, min = 1;

for ( int i = 1; i < N; ++i ) { min = 1; for ( int j = 2; j <= N; ++j ) if ( vec[j] && D[j] < D[min] ) min = j;

cost += D[min]; APM[++k] = make_pair(min, vec[min]); vec[min] = 0; for ( j = G[min].begin(); j != G[min].end(); ++j ) if ( vec[j->first] && D[j->first] > j->second )

{ D[ j->first ] = j->second; vec[ j->first ] = min; } } 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.

Page 444: Curs Logica Computationala.pdf

Capitolul 12

446

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.

Page 445: Curs Logica Computationala.pdf

Structuri avansate de date

447

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.

Page 446: Curs Logica Computationala.pdf

Capitolul 13

448

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

Page 447: Curs Logica Computationala.pdf

Structuri avansate de date

449

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.

Page 448: Curs Logica Computationala.pdf

Capitolul 13

450

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

parte din nivelul 0), din nivelul 1 cu probabilitatea 1

2, iar în cazul general din

nivelul k cu probabilitatea 1

𝑘+1. Evident, un element sigur nu va face parte

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.

Page 449: Curs Logica Computationala.pdf

Structuri avansate de date

451

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 { int info; Node **link_;

Node(int v, int nivele) { info = v; link_ = new Node*[nivele]; for ( int i = 0; i < nivele; ++i ) link_[i] = NULL;

} };

struct List { int H; // inaltimea curenta a listei Node *Header;

List() { // maxH = 32, suficient pentru // 2^32 elemente, deoarece maxim // este O(log N) Header = new Node(0, maxH); 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

Page 450: Curs Logica Computationala.pdf

Capitolul 13

452

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

}

Page 451: Curs Logica Computationala.pdf

Structuri avansate de date

453

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

Page 452: Curs Logica Computationala.pdf

Capitolul 13

454

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.

Page 453: Curs Logica Computationala.pdf

Structuri avansate de date

455

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 109), 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

Page 454: Curs Logica Computationala.pdf

Capitolul 13

456

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

Page 455: Curs Logica Computationala.pdf

Structuri avansate de date

457

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 { int val; Node *link_; Node(int val) {

this->val = val; } };

struct Hash { Node **link_; Hash(int size) { link_ = new Node*[size];

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

Page 456: Curs Logica Computationala.pdf

Capitolul 13

458

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> #include <cstdlib>

#include <ctime> #include <list> using namespace std; const int maxH = 1 << 20; // 2 la 20 int h(int x) // functia de dispersie // e h(x) = x % (2 la 20) {

return x & (maxH - 1); } void Insert(int x, list<int> H[]) { H[ h(x) ].push_front(x); }

bool Search(int x, list<int> H[]) { // pentru cautare se parcurge // secvential lista int hash = h(x); list<int>::iterator i; for ( i = H[hash].begin();

i != H[hash].end(); ++i ) if ( *i == x ) return true; return false; }

void Remove(int x, list<int> H[]) {

H[ h(x) ].remove(x); } int main() { // 2 la 20 poate fi prea mult pentru // un tablou local list<int> *H = new list<int>[maxH];

srand((unsigned)time(0)); for ( int i = 1; i <= 2010; ++i ) Insert(rand()*rand(), H); for ( int i = 1; i <= 2010; ++i )

cout << Search(rand()*rand(), H) << '\n'; return 0; }

Page 457: Curs Logica Computationala.pdf

Structuri avansate de date

459

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:

0 < A < 1, preferându-se (conform lui Knuth) 𝐴 = 5−1

2 ≅

0.618033989 …

{x} partea fracţionară a lui x

Page 458: Curs Logica Computationala.pdf

Capitolul 13

460

[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:

Page 459: Curs Logica Computationala.pdf

Structuri avansate de date

461

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

Page 460: Curs Logica Computationala.pdf

Capitolul 13

462

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.

Page 461: Curs Logica Computationala.pdf

Structuri avansate de date

463

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

Page 462: Curs Logica Computationala.pdf

Capitolul 13

464

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

7 3

1 -6 8 10 13 5 4

1 2 5

2 5 1

1 4 6

-6

1

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.

Page 463: Curs Logica Computationala.pdf

Structuri avansate de date

465

Î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]:

Page 464: Curs Logica Computationala.pdf

Capitolul 13

466

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.

Page 465: Curs Logica Computationala.pdf

Structuri avansate de date

467

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

Page 466: Curs Logica Computationala.pdf

Capitolul 13

468

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> using namespace std; const int maxN = 101; const int maxArb = 1 << 8; const int inf = 1 << 30;

void citire(int &N, int &T, int A[], ifstream &in) { in >> N >> T; for ( int i = 1; i <= N; ++i ) in >> A[i]; }

void build(int Arb[], int A[], int nod, int st, int dr) { if ( st == dr ) { Arb[nod] = A[st]; return;

} int m = (st + dr) / 2, fiu = 2*nod; build(Arb, A, fiu, st, m); build(Arb, A, fiu + 1, m + 1, dr);

Arb[nod] = min(Arb[fiu], Arb[fiu + 1]); }

Page 467: Curs Logica Computationala.pdf

Structuri avansate de date

469

int query(int Arb[], int nod, int st, int dr, int x, int y) { if ( st > y || dr < x ) // interval invalid return INT_MAX;

if ( x <= st && dr <= y ) // solutie return Arb[nod]; int m = (st + dr) / 2, fiu = 2*nod; return min(query(Arb, fiu, st, m, x, y),

query(Arb, fiu+1, m+1, dr, x, y)); } void update(int Arb[], int nod, int st, int dr, int x, int y) { if ( st > x || dr < x )

return; if ( st == dr ) { Arb[nod] = y; return; }

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

int main() { int N, T; int A[maxN], Arb[maxArb];

ifstream in("RMQ2.in"); citire(N, T, A, in); ofstream out("RMQ2.out"); build(Arb, A, 1, 1, N); while ( T-- ) {

int op, x, y; in >> op >> x >> y; if ( op == 1 ) out<<query(Arb, 1, 1, N, x, y) << '\n'; else

update(Arb, 1, 1, N, x, y); } in.close(); out.close(); return 0; }

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.

Page 468: Curs Logica Computationala.pdf

Capitolul 13

470

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(N2) 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.

Page 469: Curs Logica Computationala.pdf

Structuri avansate de date

471

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.

Page 470: Curs Logica Computationala.pdf

Capitolul 13

472

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

Page 471: Curs Logica Computationala.pdf

Structuri avansate de date

473

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

Page 472: Curs Logica Computationala.pdf

Capitolul 13

474

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

8 3

1 -3 8 7 9 1 3 4

1 2 6

2 4 -4

1 3 5

22

20

Page 473: Curs Logica Computationala.pdf

Structuri avansate de date

475

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.

Page 474: Curs Logica Computationala.pdf

Capitolul 13

476

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

Page 475: Curs Logica Computationala.pdf

Structuri avansate de date

477

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:

Page 476: Curs Logica Computationala.pdf

Capitolul 13

478

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[]);

Page 477: Curs Logica Computationala.pdf

Structuri avansate de date

479

void citire(int &N, int &T, int A[], int S[], ifstream &in) { in >> N >> T; // S trebuie initializat cu 0

// se poate folosi si memset for ( int i = 1; i <= N; ++i ) S[i] = 0; for ( int i = 1; i <= N; ++i ) { in >> A[i];

Actual(N, i, A[i], S); } } int Suma(int i, int S[]) // query { int rez = 0;

for ( ; i > 0; i -= i ^ (i & (i - 1)) ) rez += S[i]; return rez; } // update void Actual(int N, int i, int v, int S[])

{ for ( ; i <= N; i += i ^ (i & (i - 1)) ) S[i] += v; }

int main() { int N, T, A[maxN], S[maxN]; ifstream in("sume.in"); citire(N, T, A, S, in);

ofstream out("sume.out"); while ( T-- ) { int op, x, y; in >> op >> x >> y;

if ( op == 1 ) out << Suma(y, S) - Suma(x-1, S) << '\n'; else Actual(N, x, y, S); } in.close();

out.close(); return 0; }

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.

Page 478: Curs Logica Computationala.pdf

Capitolul 13

480

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 – 2

l + 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,

Page 479: Curs Logica Computationala.pdf

Structuri avansate de date

481

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

Page 480: Curs Logica Computationala.pdf

Capitolul 13

482

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

Page 481: Curs Logica Computationala.pdf

Structuri avansate de date

483

(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

Page 482: Curs Logica Computationala.pdf

Capitolul 13

484

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:

Page 483: Curs Logica Computationala.pdf

Structuri avansate de date

485

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:

Page 484: Curs Logica Computationala.pdf

Capitolul 13

486

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;

Page 485: Curs Logica Computationala.pdf

Structuri avansate de date

487

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.

Page 486: Curs Logica Computationala.pdf

Capitolul 13

488

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:

Page 487: Curs Logica Computationala.pdf

Structuri avansate de date

489

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

Page 488: Curs Logica Computationala.pdf

Capitolul 13

490

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

Page 489: Curs Logica Computationala.pdf

Structuri avansate de date

491

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.

Page 490: Curs Logica Computationala.pdf

Capitolul 13

492

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

Page 491: Curs Logica Computationala.pdf

Structuri avansate de date

493

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

Page 492: Curs Logica Computationala.pdf

Capitolul 13

494

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

Page 493: Curs Logica Computationala.pdf

Structuri avansate de date

495

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

Page 494: Curs Logica Computationala.pdf

Capitolul 13

496

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:

Page 495: Curs Logica Computationala.pdf

Structuri avansate de date

497

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

Page 496: Curs Logica Computationala.pdf

Capitolul 13

498

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

Page 497: Curs Logica Computationala.pdf

Structuri avansate de date

499

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) { nod *fiu; // salvam fiul nenul if ( T->st == NULL ) fiu = T->dr; else

fiu = T->st; // T este inlocuit cu fiul sau nenul delete T; T = fiu; } void RemoveCazIII(nod *T)

{ // vom inlocui nodul T cu // predecesorul sau in parcurgerea // in ordine, adica cel mai din // dreapta nod al subarborelui stang // al lui T. Implementarea prezentata // este iterativa. Implementarea

// recursiva necesita mai putine // operatii cu pointeri. nod **pred = &T->st; while ( (*pred)->dr != NULL ) pred = &(*pred)->dr;

T->val = (*pred)->val; if ( (*pred)->st == NULL ) RemoveCazI(*pred); else RemoveCazII(*pred); }

void Remove(int x, nod *&T) { if ( T == NULL ) return; // se foloseste algoritmul de cautare

// intr-un arbore pentru a gasi nodul // care trebuie sters. if ( T->val == x ) { if ( T->st == NULL && T->dr == NULL ) RemoveCazI(T); else if ( T->st == NULL ||

T->dr == NULL ) RemoveCazII(T); else RemoveCazIII(T); } else if ( T->val < x ) Remove(x, T->dr);

else Remove(x, T->st); }

Page 498: Curs Logica Computationala.pdf

Capitolul 13

500

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ă.

Page 499: Curs Logica Computationala.pdf

Structuri avansate de date

501

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)

Page 500: Curs Logica Computationala.pdf

Capitolul 13

502

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ă.

Page 501: Curs Logica Computationala.pdf

Structuri avansate de date

503

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

Număr test Inserări Căutări Ştergeri Timp (s) Faţă de

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ă?

Page 502: Curs Logica Computationala.pdf

Capitolul 13

504

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.

Page 503: Curs Logica Computationala.pdf

Structuri avansate de date

505

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.

Page 504: Curs Logica Computationala.pdf

Capitolul 13

506

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.

Page 505: Curs Logica Computationala.pdf

Structuri avansate de date

507

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)

Page 506: Curs Logica Computationala.pdf

Capitolul 13

508

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.

Page 507: Curs Logica Computationala.pdf

Structuri avansate de date

509

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ă

Page 508: Curs Logica Computationala.pdf

Capitolul 13

510

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:

Page 509: Curs Logica Computationala.pdf

Structuri avansate de date

511

void RotDr(nod *&T) { nod *temp = T->dr; T->dr = temp->st; temp->st = T;

T = temp; } void RotSt(nod *&T) { nod *temp = T->st; T->st = temp->dr;

temp->dr = T; T = temp; }

void Echilibrare(nod *&T) { if ( T->st != NULL && T->st->pr > T->pr ) RotSt(T); else if ( T->dr != NULL && T->dr->pr > T->pr )

RotDr(T); }

Funcţiile de inserare respectiv de ştergere pot fi implementate

astfel:

void Insert(int x, nod *&T)

{ if ( T == NULL ) { T = new nod(x); return; }

if ( T->val < x ) Insert(x, T->dr); else if ( T->val > x ) Insert(x, T->st); Echilibrare(T); }

void Remove(int x, nod *&T) {

if ( T == NULL ) return; if ( T->val < x ) Remove(x, T->dr); else if ( T->val > x ) Remove(x, T->st);

else { if ( T->st == NULL && T->dr == NULL ) { delete T; T = NULL; } else if ( T->st == NULL || T->dr == NULL )

T->st != NULL ? RotSt(T) : RotDr(T); else T->st->pr > T->dr->pr ? RotSt(T) : RotDr(T); Remove(x, T); } }

Page 510: Curs Logica Computationala.pdf

Capitolul 13

512

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

Nr. Inserări Căutări Ştergeri Timp (s) Faţă de

liste de salt

Faţă de

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

Page 511: Curs Logica Computationala.pdf

Structuri avansate de date

513

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.

Page 512: Curs Logica Computationala.pdf

Capitolul 13

514

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

Page 513: Curs Logica Computationala.pdf

Bibliografie

515

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.

Page 514: Curs Logica Computationala.pdf

Algoritmică

516

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.

Page 515: Curs Logica Computationala.pdf

Bibliografie

517

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.

Page 516: Curs Logica Computationala.pdf

Algoritmică

518

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.