Sunteți pe pagina 1din 53

CUPRINS

1: NOŢIUNI INTRODUCTIVE; ALGORITMI DE SORTARE…………............…….....…………......


1.1. Definiţii……………………………………………………............…………………………………………………
1.2. Necesitatea studierii algoritmilor………………………….............…………………………………..
1.3. Sortarea……………………………………………………………………………….............……………………
1.3.1 Sortarea prin inserţie……………….………………………………………............………..
1.3.2. Metoda Divide-and-conquer...............................................................
1.3.3. Sortarea prin interclasare (Merge Sort)………………………………............…….
1.3.4. Heap Sort……………………………………………………………..............…………………….
1.3.5. Sortarea rapidă (Quick Sort)………………………………………............……………….

2: TEHNICI DE PROGRAMARE: PROGRAMARE DINAMICᾸ; GREEDY;


BACKTRACKING ………………............................................................................................
2.1.Programarea dinamică…………………………………………………………………………….............
2.1.1. Problema tăierii barei de oţel…………………………………………………...............
2.1.2. Problema înmulţirii unui şir de matrici……………………………………...........
2.2.Metoda Geedy………………………………………………………………………………………….........
2.2.1.Problema selecţiei activităţilor……………………………………………...............
2.3.Metoda Backtracking……………………………………………………………………………............
2.3.1 Problema reginelor...............................................................................

3: NOŢIUNI DESPRE STRUCTURI DE DATE…………………………………………..............………….


3.1. Tipuri abstracte de date………………………………………………………………………….........
3.2. ADT Lista…………………………………………………………………………………………………......
3.2.1. Operaţia de căutare într-o listă………………………………………………….......
3.2.2. Inserarea unui element la începutul listei…………………………………….....
3.2.3. Ştergerea unui element dintr-o listă………………………………………........
3.2.4. Căutarea şi inserarea unui element într-o listă cu santinelă………....
3.3. Stiva……………………………………………………………………………………………………..…........
3.4. Coada…………………………………………………………………………………………………….......…
3.5. Tabele direct adresabile; Tabele de dispersie……………………………………….........…
3.5.1.Tabele direct adresabile…………………………………………………………..........
3.5.2.Tabele de dispersie…………………………………………………………………...........

4:STRUCTURI DE DATE AVANSATE…………......................………………………………………………


4.1. Arbore binar de căutare………………………………………....………………………………….......
4.1.1. Traversarea unui arbore binar de căutare………………………………............
4.1.2. Căutarea într-un arbore binar de căutare………………………………............
4.1.3. Minimul, maximul şi succesorul unui nod într-un arbore binar de
căutare ...........................................................................................................
4.1.4. Inserarea într-un arbore binar de căutare…………………………….........……
4.1.5. Ştergerea unui nod într-un arbore binar de căutare…………….........……
4.2.Arbore roşu şi negru……………………………………………………………………………..........….
4.3.Elemente legate de grafuri……………………………………………………………………..........…
4.3.1 Parcurgerea în lăţime a unui graf……………………………………………..................
4.3.2. Noţiunea drumului de lungime minimă………………………………….................
4.3.3. Parcurgerea în adâncime a unui graf………………………………………...............
4.3.4. Sortare topologică…………………………………………………………………...............
4.3.5. Componentă tare conexă a unui graf……………………………………….............

1
4.4.Arbori de acoperire minimi………………………………………………………………………............
4.4.1.Algoritmul lui Kruskal de determinare a unui arbore de acoperire
minim .............................................................................................................

4.4.2.Algoritmul lui Prim de determinare a unui arbore de acoperire


minim ............................................................................................................

4.5.Drumuri minime într-un graf……………………………………………………………………..........


4.5.1.Reprezentarea drumurilor minime………………………………………….........…
4.5.2. Relaxarea……………………………………………………………………………...........…
4.5.3.Algoritmul Bellman-Ford………………………………………………………..........…
4.5.4.Drumuri minime de sursă unică în grafuri orientate aciclice...............
4.5.5.Algoritmul lui Dijkstra…………………………………………………………...............

5. ÎNTREBĂRI RECAPITULATIVE………………………………………………………….............…............

Bibliografie....................................................................................................................

2
Algoritmi si structuri de date
Autori: Cristian Bologa, G.C. Silaghi
1. NOŢIUNI INTRODUCTIVE; ALGORITMI DE SORTARE
1.1. Definiţii
Un algoritm este o procedură de calcul bine definită care primeşte o valoare sau o
mulţime de valori ca date de intrare şi produce o valoare sau mulţime de valori ca date de
ieşire. Este deci un şir de paşi care transformă datele de intrare în date de ieşire.
Putem lua ca exemplu definirea problemei de sortare a unui şir de numere.
Datele de intrare (inputul) îl reprezintă o secvenţă de n numere (a1, a2, … an).
Datele de ieşire (outputul) îl reprezintă o permutare (sau o reordonare) (a1’, a2’,
…an ) a secvenţei de numere furnizată la intrare astfel încât a1’< a2’< …<an’.

Un algoritm de sortare, pentru o anumită secvenţă de numere (reprezentând


inputul) 31, 41, 59, 26 va produce la ieşire drept output secvenţa 26, 31, 41, 59.
O instanţă a problemei reprezintă mulţimea tuturor datelor de intrare (care
satisface restricţiile impuse în definirea problemei) date necesare pentru a determina o
soluţie a problemei.
Un algoritm se spune că este corect dacă pentru orice secvență de intrare care
satisface condițiile problemei va produce la ieșire un rezultat (output) corect. Spunem că
algoritmul respectiv rezolvă problema computațională furnizată.
O structură de date reprezintă un mod de a organiza și stoca datele astfel încât să
facilităm accesul la ele și modificarea acestora.
Pseudocodul este o modalitate de a descrie algoritmi; este similar cu schemele
logice. Un algoritm descris în pseudocod poate apoi să fie implementat în orice limbaj de
programare (C, Pascal etc).

1.2. Necesitatea studierii algoritmilor


Analiza unui algoritm înseamnă identificarea resurselor necesare execuției
algoritmului (memorie, lăţime de bandă pentru comunicații, hardware necesar etc). În
analiză, considerăm că utilizăm un model generic de calculator cu următoarele
caracteristici:
 Un singur procesor

 Model random-access machine pentru procesor: instrucţiunile se execută secvențial


(una după alta) și nu există posibilitate de concurență

 Se presupune că instrucțiunile permise pe procesor se execută toate într-un timp


constant

 Presupunem că procesorul poate lucra cu date de tip întreg sau flotant, fără ca
precizia să fie de larg interes (în demersul nostru de analiză a algoritmului)

Dacă calculatoarele ar avea o viteză de execuție infinită, atunci orice metodă


corectă de rezolvare a unei probleme ar fi potrivită. Dar calculatoarele au viteze de execuție
limitate. În acelaşi timp, spațiile de memorare ale calculatoarelor sunt ieftine dar nu
gratuite. Problema este că nu orice algoritm corect funcționează în mod utilizabil pe orice
resurse de calcul.

3
Eficiența unui algoritm se referă la resursele de calcul și memorie necesare pentru
execuția algoritmului. Practic este un studiu teoretic al performanţei (în primul rând) şi al
resurselor utilizate. Progresele tehnologice din ultima perioadă fac ca importanţa mărimii
memoriei solicitate de un algoritm să scadă ca importanţă. Ceea ce trebuie să intereseze
programatorul este reducerea timpului de execuţie al programului. Există aplicaţii “real
time” la care constrângerile de timp sunt cruciale.
Un algoritm la care timpul de execuţie nu depinde de dimensiunea problemei este
un algoritm de timp constant a cărui complexitate se notează cu O(1). Timpul de execuţie al
unui algoritm de complexitate O(n) creşte liniar (direct proporţional) cu valoarea lui n. Un
algoritm liniar are complexitatea O(n), unul pătratic O(n2) iar unul cubic O(n3). Un algoritm
se numeşte polinomial dacă are o complexitate egală cu O(p(n)) unde p(n) este o funcţie
polinomială. Altă clasă de algoritmi sunt cei exponenţiali.
Un algoritm este mai eficient decât altul dacă are o rată de creştere mai mică,
pentru cazul cel mai defavorabil. Vorbim de eficienţa asimptotică a algoritmilor atunci când
rata de creştere a timpului de execuţie devine esenţială considerând mărimi mari ale setului
de date de intrare.

1.3. Sortarea
Sortarea este o operaţie des întâlnită în rezolvarea problemelor de natură
algoritmică. Problema sortării unei mulţimi de obiecte se poate reduce la problema sortării
cheilor asociate acestora. După ce cheile sunt sortate, folosind informaţia de asociere (care
leagă cheia de obiectul căreia îi aparţine), se pot rearanja obiectele în ordinea în care au
fost aranjate cheile lor.
Dându-se o secvenţă de elemente caracterizate de valorile e1, e2, ... en E, operaţia
de sortare este operaţia de găsire a unei permutări a secvenţei ei1, ei2, ... ein astfel încât eij 
eik, oricare ar fi ij  ik, unde “” este o relaţie de ordine definită peste mulţimea E.
O metodă de sortare se spune că este stabilă dacă după sortare, ordinea relativă a
elementelor cu chei egale coincide cu cea iniţială, element esenţial în special în cazul în care
se execută sortarea după mai multe chei.
O cerinţă fundamentală care se formulează faţă de metodele de sortare a
tablourilor se referă la utilizarea cât mai economică a zonei de memorie disponibile. Astfel,
algoritmii care utilizează doar zona de memorie alocată tabloului, fără a fi necesar un
tablou suplimentar se numesc sortări "în situ".

1.3.1 Sortarea prin inserţie


Ideea sortării prin inserţie este următoarea:
 Pornim cu un şir gol de numere

 Luăm câte un număr din șirul inițial și îl plasăm în şirul sortat la poziția
corespunzătoare

 Plasarea numărului în șir la poziția corespunzătoare se face prin comparare


succesivă de la stânga la dreapta sau invers

Pseudocodul aferent sortării prin inserţie este prezentat în figura 1.1:

4
Figura 1.1 Algoritmul de sortare prin inserţie

1.3.2. Metoda Divide-and-conquer


Numită şi divide et impera (divide şi stăpâneşte), aceasta este o tehnică sau o
metodă de programare în care:
 problema iniţială se sparge în subprobleme cu structură similară cu
problema originală, dar de dimensiune mai mică.

 aceste subprobleme sunt rezolvate recursiv

 soluţiile recursive sunt combinate pentru a produce soluţia problemei


iniţiale

Metoda se poate aplica în rezolvarea unei probleme care îndeplineşte următoarele


condiţii:
 se poate descompune în două sau mai multe subprobleme

 aceste suprobleme sunt independente una faţă de alta (o subproblemă nu


se rezolvă pe baza alteia şi nu foloseşte rezultatele celeilalte)

 aceste subprobleme sunt similare cu problema iniţială

 la rândul lor subproblemele se pot descompune (dacă este necesar) în alte


subprobleme mai simple

 aceste subprobleme simple se pot soluţiona imediat prin algoritmul


simplificat

1.3.3. Sortarea prin interclasare (Merge Sort)


Sortarea prin interclasare se bazează pe următorul principiu: pentru a sorta un
vector cu n elemente, îl împărţim în 2 vectori, care, odată sortaţi, se interclasează. Conform
strategiei Divide and Conquer, descompunerea unui vector în alţi doi vectori care urmează
a fi sortaţi are loc până când avem de sortat vectori de un element.
Sortarea prin interclasare are deci 3 paşi:

5
 Divide –şirul A de n elemente se împarte în două subşiruri de n/2 elemente
(în cazul în care n este impar dimensiunea primului şir este cu 1 mai mare
decât dimensiunea celui de al doilea şir)

 Conquer –se sortează recursiv cele două subşiruri

 Combine –se combină prin interclasare cele două şiruri sortate obţinute la
pasul Conquer, rezultând un singur şir sortat

Recursivitatea se încheie la şiruri de lungime 1, care sunt implicit deja sortate.


În continuare vom descrie procedura MERGE(A,p,q,r) care primeşte ca şi parametru
şirul A, poziţia p de la care începe sortarea, poziţia r la care se încheie sortarea şi q o valoare
între p şir, valoare care va împărţi şirul A[p]..A[q] în două subşiruri A[p..q] şi A[q+1]..A[r].
Complexitatea procedurii Merge este ϴ(n) unde n=r-p+1 este numărul elementelor
interclasate. Merge sort nu este o sortare “in situ”, ea necesită spaţiu suplimentar pentru a
păstra subşirurile care apoi se vor interclasa.
Figura 1.2 prezintă algoritmul procedurii MERGE(A,p,q,r):

Figura 1.2 Algoritmul de interclasare a două şiruri ordonate crescător


În figura 1.3 este prezentat algoritmul procedurii Merge-Sort:

Figura 1.3. Algoritmul sortării prin interclasare

1.3.4. Heap Sort


Heap Sort sau Sortarea prin metoda ansamblelor este cunoscută şi sub denumirea
de “sortare pe bază de arbore”, deoarece vectorul de sortat, organizat ca o movilă,
corespunde unui arbore. Heapsort utilizează o structură de date numită heap.

6
Un heap (o movilă) este un vector care poate fi vizualizat sub forma unui arbore
binar aproape complet cu proprietatea că cheia fiecărui nod din arbore este mai mare decât
cheile descendenţilor (deci fiecare nod are o cheie mai mică sau egală cu cea a tatălui său).
Ultimul rând al arborelui se completează de la stânga la dreapta.
Un heap poate fi reprezentat sub forma unui arbore binar sau sub forma unui
vector. Astfel, dacă v este un vector care conţine reprezentarea unui heap avem
următoarea proprietate:
Elementele v[left]..v[n] îndeplinesc condiţia de structură a heapului:
 i>left avem: v[i]>v[2*i], dacă 2*in, respectiv
v[i]>v[2*i+1] dacă 2*i+1n.
Evident, pentru valori ale lui i mai mari decât n/2 nu se pune problema îndeplinirii
condiţiilor de mai sus.
Figura 1.4 prezintă un heap descris atât sub forma unui arbore cât şi sub formă de
şir de elemente.

Figura 1.4 Un exemplu de heap descris printr-un arbore respectiv printr-un şir
Ca să putem folosi Heap Sort va trebui să transformăm şirul de intrare într-un heap.
Vom crea iniţial procedura MAX-HEAPIFY(A,i) care primeşte ca şi parametri un vector A şi
un indice i din vector. Atunci când apelăm MAX-HEAPIFY se presupune că subarborii având
ca rădăcini nodurile Left(i) şi Right(i) sunt heapuri. Dacă elementul A(i) este mai mic decât
cel puţin unul din descendenţii Left(i) şi Right(i), înseamnă că acest element nu respectă
proprietatea de heap (deci nu avem un heap şi va trebui transformat ca să devină heap).
Sarcina procedurii MAX-HEAPIFY este să “scufunde” în heap valoarea A(i) astfel încât
subarborele care are în rădăcină valoarea elementului de indice i sa devină un heap.
Figura 1.5 prezintă algoritmul MAX-HEAPIFY:

Figura 1.5 Algoritmul MAX-HEAPIFY de refacere a proprietăţii de heap


Vom construi heapul de jos în sus (de la frunze) (care sunt heap-uri de heapsize=1)
(figura 1.6).

7
Figura 1.6. Algoritmul de creare a unui heap
Prezentăm în figura 1.7 algoritmul de sortare utilizând un heap bazat pe algoritmii
BUILD-MAX-HEAP şi MAX-HEAPIFY.

Figura 1.7 Algoritmul de sortare utilizând un heap

1.3.5. Sortarea rapidă (Quick Sort)


Quick Sort este un algoritm de sortare (inventat de Tony Hoare in 1962) care pentru
un şir de n elemente are un timp de execuţie ϴ(n2) în cazul cel mai defavorabil. În ciuda
acestei comportări proaste pentru cazul cel mai defavorabil, acest algoritm este deseori cea
mai bună soluţie practică deoarece are o comportare medie remarcabilă: timpul mediu de
execuţie este ϴ(nlgn) şi constanta ascunsă în formula lui ϴ(nlgn) este destul de mică. Este o
sortare in situ (adică e o sortare în spaţiul alocat şirului de intrare).
Algoritmul Quick Sort se bazează pe tehnica de programare “Divide and Conquer”,
bazându-se pe următorii 3 paşi:
 Divide: Şirul A[p..r] este împărţit (rearanjat) în două subşiruri nevide A[p..q-1] şi
A[q+1..r] astfel încât fiecare element al subşirului A[p..q-1] să fie mai mic sau egal
cu A[q] (element denumit element pivot) şi orice element al subsirului A[q+1..r]
este mai mare sau egal cu A[q]. Indicele q este calculat de procedura de
partiţionare. Deci elementele mai mici decât pivotul vor fi mutate în stânga
pivotului iar elementele mai mari decât pivotul vor fi mutate în dreapta pivotului.

 Conquer: Cele două subşiruri A[p..q-1] şi A[q+1..r] sunt sortate prin apeluri
recursive ale algoritmului de sortare rapidă

 Combine: Cele două subşiruri sunt sortate pe loc, nu este nevoie de nici o
combinare, şirul A[p..r] este ordonat.

Prezentăm în figura 1.8 algoritmul procedurii QUICKSORT:

Figura 1.8 Algoritmul QUICK-SORT


Pentru ordonarea întregului şir A, iniţial se apelează QUICKSORT(A,1,lungime[A]).

8
Prezentăm în figura 1.9 algoritmul procedurii PARTITION care realizează
partiţionarea şirului:

Figura 1.9 Partiţionarea unui şir în cazul QUICK-SORT


Un algoritm se numeşte aleator dacă comportarea lui depinde nu numai de valorile
de intrare ci şi de valorile produse de un generator de numere aleatoare. Vom presupune
că dispunem de un generator de numere aleatoare numit RANDOM. Un apel al procedurii
RANDOM(p,r) va produce un număr întreg aleator între p şi r (inclusiv). Fiecare număr
întreg din acest interval va avea aceeaşi probabilitate de apariţie.
În Quick Sort vom selecta aleator un element din subşirul A[p..r] care va juca rolul
pivotului. La fiecare pas al sortării rapide, înainte de partiţionarea vectorului vom
interschimba elementul A[p] cu acest element aleator ales. Practic, această modificare a
algoritmului asigură că elementul pivot x=A[p] să fie cu aceeaşi probabilitate orice element
dintre cele r-p+1 elemente ale vectorului A[p..r]. Rezultatul este că partiţionarea vectorului
de intrare va fi în medie, rezonabil de echilibrată. Modificările în algoritmul deja prezentat
sunt minore. Vom avea implementată schimbarea elementului ce va deveni pivot înainte de
apelul propriu zis al procedurii PARTITION. Prezentăm în figura 1.10 algoritmul de alegere
aleatoare a elementului pivot:

Figura 1.10 Algoritmul de alegere aleatoare a elementului pivot pentru QUICK-SORT


De asemenea, noua procedură QUICKSORT va apela procedura RANDOMIZED-
PARTTION în loc de vechea procedură PARTITION. Prezentăm în figura 1.11 algoritmul noii
proceduri denumită RANDOMIZED-QUICKSORT:

Figura 1.11 Algoritmul de sortare rapidă în cazul alegerii aleatoare a elementului


pivot

9
2. TEHNICI DE PROGRAMARE: PROGRAMARE DINAMICᾸ; GREEDY; BACKTRACKING

2.1. Programarea dinamică

Tehnicile de programare sunt modalităţi generale de elaborare a algoritmilor. Ele


reprezintă doar nişte tipare de organizare a acţiunilor ("scheme" de algoritmi), nu
garantează şi succesul acestora. Pentru a avea succes trebuie îndeplinite nişte condiţii
suplimentare, care sunt specifice şi se demonstrează separat pentru fiecare problemă în
parte.
Programarea dinamică este o tehnică de proiectare a unui algoritm care permite
rezolvarea unei clase de probleme. Programarea dinamică, la fel ca şi metoda Divide and
Conquer, rezolvă problemele combinând soluţiile unor subprobleme. Spre deosebire de
abordarea din Divide and Conquer, programarea dinamică este aplicabilă atunci când
subproblemele nu sunt independente, adică subproblemele au în comun sub-subprobleme.
Astfel, un algoritm de tipul Divide and Conquer ar presupune mai multe calcule decât ar fi
necesar dacă s-ar rezolva repetat aceste sub-subprobleme comune.
Programarea dinamică rezolvă fiecare sub-subproblemă o singură dată şi salvează
rezultatul într-o tabelă cu rezultate. Se evită astfel ca o sub-subproblemă să fie rezolvată de
mai multe ori.
Programarea dinamică se aplică la probleme de optimizare, probleme cu
următoarele caracteristici:
 Au mai multe soluţii posibile, fiecare caracterizate printr-o valoare

 Dorim să identificăm o soluţie cu valoarea cea mai mică/cea mai mare. O


asemenea soluţie se numeşte “o soluţie optimă” a problemei, prin contrast
cu “soluţia optimă” deoarece pot fi mai multe soluţii care realizează soluţia
optimă.

Dezvoltarea unui algoritm bazat pe programarea dinamică poate fi împărţită într-o


secvenţă de 4 paşi:
1. Se caracterizează structura unui soluții optime

2. În mod recursiv, se definește valoarea unui soluții optime

3. Se calculează valoarea unei soluții optime pornindu-se de jos în sus

4. Se calculează soluția optimă pe baza informației obţinute până în acest pas

Paşii 1->3 sunt baza unei abordări de tip programare dinamică. Pasul 4 poate fi
omis dacă se doreşte doar calculul unei singure soluţii optime.

10
2.1.1. Problema tăierii barei de oţel

Compania Sterling Enterprises cumpără bare de oţel lungi şi le taie în bucăţi mai
scurte pe care apoi le revinde. Se cunoaşte preţul pi cu care compania vinde o bară de
lungime i (i este o valoare întreagă). Se cere determinarea locurilor unde se vor face
tăieturile la o bară de lungime n, astfel încât să se obţină câştigul maxim rn.
 Input: n ca fiind lungimea iniţială a barei precum şi preţurile pi pentru i=1..n. Deci p i
este preţul unei bare de lungime i.

 Output: Soluţia optimă va însemna tăierea barei în k bucăţi, deci obţinerea partiţiei
i1, i2, … ik cu i1+i2+…ik=n astfel încât suma rn=pi1+p i2+…+pik să fie maximă

Observaţii: Tăierile nu costă nimic (sunt gratis) iar lungimile sunt întotdeauna numere
întregi.
Având în vedere că din 1 în 1 avem posibilitatea de a tăia sau nu bara, înseamnă că
pentru n-1 poziţii avem posibilitatea de a tăia sau nu. Numărul maxim de partiţii posibil
pentru o bară de lungime n este deci 2n-1.
În figura 2.1 prezentăm un exemplu de cum vom tăia bare de lungime 1, 2, ...10
pornind de la preţul unei bare de lungime 1, 2, … 10.

Figura 2.1 Un exemplu de tăiere optimă a unei bare de oţel de diverse lungimi (1->10)
Problema tăierii barei de oţel este o problemă cu structură optimă: soluţia optimă a
acestei probleme incorporează soluţii optime ale subproblemelor.
Rezolvarea problemei se va baza pe o soluţie recursivă. Descompunerea recursivă a
problemei tăierii barei de oţel se va face astfel:
 Se taie o piesă de lungime i

 Bara rămasă de lungime n-i se taie în mod recursiv

 rn= max1<=i<=n(pi+rn-i)

Algoritmul recursiv de tăiere a barei de oţel este descris în figura 2.2:

Figura 2.2 Algoritmul recursiv de tăiere a unei bare de oţel

11
Va trebui să maximizăm profitul la tăierea bucăţii de lungime i respectiv bucăţii de
lungime n-i care a rămas. Recursivitatea continuă doar pentru a doua bucată care a rămas.
Abordarea în algoritmul descris în figura 2.2 este de tip top-down.
Linia 5 este echivalent cu a scrie:
rn =max(pn, r1+rn-1, r2+rn-2, …, r n-1+ r1)
unde pn este echivalentul de a nu tăia deloc bara.

Problema este că algoritmul CUT-ROD este foarte lent. Pentru n=40 rezolvarea va
ţine cel puţin câteva minute, poate chiar o oră. La fiecare creştere a lui n cu 1, timpul de
execuţie se va dubla. De ce este ineficient acest CUT-ROD? Pentru că el se autoapelează de
mai multe ori cu aceeaşi parametri, rezolvând astfel aceleaşi subprobleme în mod repetat
(adică acelaşi apel CUT-ROD(p,j) se face de mai multe ori ceea ce este ineficient).
Încercăm să elaborăm un algoritm astfel încât fiecare subproblemă să fie rezolvată
o singură dată. Vom utiliza o zonă de memorie suplimentară unde să se salveze rezultatele
fiecărei subprobleme. Timpul de execuţie exponențial se poate reduce astfel la un timp de
execuţie polinomial dacă numărul de subprobleme este polinomial și rezolvarea unei
subprobleme este polinomială.
Vom putea folosi 2 abordări:
 Abordare top-down cu memo-izare: se scrie procedura recursivă în mod
obişnuit, dar o modificăm astfel încât să salvăm rezultatul fiecărei subprobleme
într-un şir. Înainte să se apeleze recursivitatea, se verifică dacă rezultatul dorit
este deja salvat în şir

 Abordare bottom-up: sortăm subproblemele în funcţie de ordinul lor de


mărime și le rezolvăm cele mai mici la început

În figura 2.3 prezentăm algoritmul de tăiere a barei de oţel utilizând programarea


dinamică cu o abordare top-down:

Figura 2.3 Algoritmul top-down de tăiere a barei de oţel utilizând programare dinamică
Acelaşi algoritm dar utilizând o abordare BOTTOM-UP este prezentat în figura 2.4:

12
Figura 2.4 Algoritmul bottom-up de tăiere a barei de oţel utilizând programare dinamică
Algoritmul prezentat în anterior oferă valoarea optimă în cazul tăierii barei de oţel
dar nu oferă şi soluţia adică lista mărimilor bucăţilor tăiate. Pentru a reconstrui soluţia, vom
extinde abordarea anterioară prin memorarea în fiecare pas nu doar a valorii soluţiei
optime ci şi a alegerii făcute pentru a se ajunge la soluţia optimă.
Prezentăm în figura 2.5 algoritmul de tăiere a barei de oţel cu memorarea în şirul s
a mărimilor bucăţilor ce vor fi tăiate.

Figura 2.5 Algoritmul de tăiere a barei de oţel cu memorarea mărimilor bucăţilor ce vor fi
tăiate

2.1.2. Problema înmulţirii unui şir de matrici

Următorul exemplu de programare dinamică este algoritmul care rezolvă problema


înmulţirii unui şir de matrici. Se dă un şir (o secvenţă A1, A2,… An) de n matrici care trebuie
înmulţite, dorindu-se calcularea produsului A1A2 …An
Spunem că un produs de matrice este complet parantezat fie dacă este format
dintr-o unică matrice, fie dacă este produsul a două produse de matrice care sunt la rândul
lor complet parantezate. Datorită faptului că înmulţirea matricilor este asociativă, toate
parantezările conduc la acelaşi rezultat (matrice produs).
Pseudocodul aferent înmulţirii a 2 matrici este prezentat în figura 2.6.

13
INMULTIRE-MATRICE(A,B)
1: If coloane[A]!=linii[B]
2: scrie “dimensiuni incompatibile”
3: else
4: for i=1 to linii[A]
5: for j=1 to coloane[B]
6: C[i,j]=0
7: for k=1 to coloane[A]
8: C[i,j]=C[i,j]+A[i,k]*B[k,j]
9: returneaza C
Figura 2.6 Algoritmul de înmulţire a 2 matrici

Dacă avem o matrice A(p,q) pe care dorim să o înmulţim cu o matrice B(q,r) pentru
a rezulta matricea produs C(p,r) vor fi necesare p*q*r înmulţiri scalare.
Exemplu: Considerăm A1(10,100), A2(100,5) şi A3(5,50). Se pune problema când vom avea
un număr minim de înmulţiri scalare pentru a obţine produsul celor 3 matrici: când vom
calcula (A1A2)A3 sau când vom calcula A1(A2A3)
A1A2 –vom avea 10*100*5=5000 de înmulţiri scalare pt a calcula A1A2 de dimensiune 10x5.
Apoi vom avea 10*5*50=2500 înmulţiri scalare pentru a calcula (A1A2)A3 deci în total 7500
de înmulţiri scalare.
A2A3 -vom avea 100*5*50=25000 de înmulţiri scalare pentru a calcula A2A3 de dimensiune
100x50. Apoi vom avea 10*100*50=50000 înmulţiri scalare pentru a calcula A1(A2A3) deci în
total 75000 de înmulţiri scalare
În concluzie prima variantă de parantezare ne oferă un algoritm de 10 ori mai rapid.
Problema pe care urmează să o rezolvăm are următorul enunţ: Se dă un şir de
matrici (A1A2…An) care trebuie înmulţite. Matricea Ai are dimensiunile pi-1 x pi. Să se pună
parantezele pentru a rezolva produsul celor n matrici astfel încât numărul de înmulţiri
scalare să fie minim.
P(n) va fi numărul de parantezări distincte ale unui şir de matrici (altfel spus va fi
numărul de posibile soluţii pentru un şir de n matrici). Vom demonstra că verificarea
exhaustivă a tuturor parantezărilor nu conduce la un algoritm eficient.
Formula recursivă de calcul a lui P(n) este prezentată în figura 2.7:

Figura 2.7 Formula recursivă de calcul a numărului de parantezări posibile pentru


un produs de n matrici
Soluţia recurenţei pentru calculul lui P(n) va fi Ω(2n). Numărul de soluţii este
exponenţial în n şi în concluzie metoda forţei brute a căutării exhaustive este o strategie
proastă de determinare a modalităţii de parantezare a şirului de matrici.
Pentru a calcula AiAi+1..Aj (vom nota acest produs cu Ai..j) va trebui să găsim un k
optim astfel încât să punem o paranteză între A k şi Ak+1. În consecinţă:
 Costul lui Ai..j este egal cu costul lui Ai..k plus costul lui Ak+1..j plus costul
înmulţirii acestor două matrici

 Dacă costurile lui Ai..k şi Ak+1..j sunt optime, atunci şi costul lui A i..j este optim

O soluţie optimă a unei instanţe a problemei înmulţirii şirului de matrice conţine


soluţii optime pentru instanţe ale subproblemelor. Existenţa substructurilor optime în
cadrul unei soluţii optime este una din caracteristicile cadrului de aplicare a metodei
programării dinamice.

14
Pentru a determina subproblemele necesare abordării prin prisma programării
dinamice, observăm că avem câte o subproblemă pentru fiecare alegere a lui i şi j, cu
1<=i<=j<=n. În total vom avea Cn2+n=ϴ(n) subprobleme.
Un algoritm recursiv poate întâlni fiecare subproblemă de mai multe ori pe ramuri
diferite ale arborelui său de recurenţă. Această proprietate de suprapunere a
subproblemelor este una din caracteristicile programării dinamice. Astfel, rezolvarea
fiecărei subprobleme Ai..j o păstrăm într-un tabel de dimensiuni nxn cu liniile i şi coloanele j.
Figura 2.8 prezintă algoritmul bottom-up de determinare a costurilor înmulţirii
şirului de matrici precum şi a indicilor pentru care s-a obţinut costul optim.

Figura 2.8 Algoritmul bottom-up de determinare a costurilor înmulţirii unui şir de


matrici

Algoritmul recursiv prezentat în figura 2.9 ne oferă afişarea parantezării optime a


produsului (AiAi+1…Aj) pe baza tabelului s calculate de procedura MATRIX-CHAIN-ORDER şi a
indicilor i şi j. Apelul iniţial PRINT-OPTIMAL-PARENS(s,1,n) ne oferă parantezarea optimă
pentru produsul (A1A2..An).

Figura 2.9 Algoritmul recursiv de afişare a parantezării optime pentru un produs de n


matrici

Descoperirea sub-structurii optimale:


1. Arătăm că solutia problemei se obtine printr-o alegere, care implică rezolvarea a
unei sau mai multe subprobleme

15
2. La o problemă, presupunem că ni se furnizează alegerea care să conducă la soluţia
optimală

3. Având această alegere, determinăm care sunt subproblemele, şi cum caracterizăm


cel mai bine spaţiul rezultat al subproblemelor

4. Arătăm că solutiile la subproblemele utilizate in cadrul solutiei optimale sunt ele la


rândul lor optimale, prin repetarea raţionamentului de mai sus

Identificăm următoarele:
Primul pas al rezolvării unei probleme de optimizare utilizând programarea
dinamică constă în caracterizarea structurii unei soluţii optime. Spunem că problema
prezintă o substructură optimă dacă o soluţie optimă a problemei include soluţii optime ale
subproblemelor. În consecinţă,
Avem două elemente cheie pe care o problemă de optimizare trebuie să le aibă
pentru ca să putem aplica programarea dinamică:
 Când o problemă prezintă o sub-structură optimală, este un candidat pentru
programarea dinamică

 Se construieşte soluţia optimă a problemei prin combinarea unor soluţii optimale la


subprobleme

Substructura optimă variază în funcţie de domeniile problemei în două moduri:


1. Câte subprobleme utilizează soluţia optimală a problemei

2. Câte posibilități de alegere avem pentru a determina subproblemele utilizate


de soluţia optimală

În cazul tăierii barei de oţel de dimensiune n, avem o subproblemă de dimensiune


n-i, dar trebuie să considerăm n variante pentru i pentru a determina care din ele conduce
spre o soluţie optimă. În cazul problemei parantezării şirului de matrici în vederea înmulţirii
lor, avem două subprobleme prin împărţirea şirului AiAi+1...A j în două subşiruri A iAi+1…Ak
respectiv Ak+1Ak+2…Aj care necesită apoi rezolvarea amândurora în mod optim. Practic există
j-i candidaţi pentru alegerea indexului k.

2.2. Metoda Greedy

Metoda Greedy (greedy = lacom) este o metodă generală de proiectare a


algoritmilor care constă în construirea soluţiei globale optimale printr-un şir de soluţii cu
caracter de optim local atunci când este posibilă exprimarea “optimului global” ca o
combinaţie de o “optime locale”. Algoritmii greedy sunt în general simpli şi sunt folositi la
probleme de optimizare, cum ar fi: să se găsească cea mai bună ordine de executare a unor
lucrări, să se găsească cel mai scurt drum într-un graf etc.

2.2.1 Problema selecţiei activităţilor

Se pune problema planificării unor activităţi care necesită utilizarea unei resurse
comune.
Datele de intrare (inputul) este reprezentat de o mulţime S={a1, a2, …an} de activităţi
care necesită o resursă ce poate fi utilizată doar de o singură activitate la un moment dat.

16
Fiecare activitate ai are un moment de start si şi un moment de terminare fi cu 0<=si<fi<∞.
O activitate ai are loc în intervalul [si,fi). Două activităţi ai şi aj sunt compatibile dacă si>=f j
sau sj>=fi (intervalele lor nu se suprapun).
Datele de ieşire (outputul) reprezintă subsetul maximal de activităţi compatibile.
Prezentăm în figura 2.10 un exemplu format din 11 activităţi (i=1,2, ..11) fiecare cu
momentul său de start si şi momentul de terminare fi.

Figura 2.10 Un exemplu de 11 activităţi având şirul momentelor de terminare sortat


ascendent

În exemplul prezentat în figura 2.10, {a3, a9, a11} reprezintă un subset de activităţi
compatibile; acesta nu este un subset maximal deoarece {a1, a4, a8, a11} este mai mare.
Acesta din urmă este cel mai mare subset de activităţi compatibile; în acelaşi timp un alt
subset maximal este şi {a2, a4, a9, a11}.
Substructura optimală a problemei selecţiei activităţilor –putem uşor constata că
problema selecţiei activităţilor expune substructura optimă. Vom considera Sij mulţimea
activităţilor care încep după finalizarea activităţii ai şi se termină înainte de începutul
activităţii aj (slide –corectaţi “se termină” în loc de încep).
Presupunem că subsetul maximal de activităţi compatibile din Sij este Aij şi acesta
include o activitate ak.
Incluzând ak în soluţia optimă pentru Sij, am împărţit problema Sij în două
subprobleme S ik şi Skj cărora trebuie să le găsim subsetul maximal de activităţi. Sik sunt
activităţi care încep după ce activitatea ai s-a terminat şi se termină înainte ca activitatea ak
să înceapă iar S kj sunt activităţi care încep după ce activitatea ak s-a terminat şi se termină
înainte ca activitatea aj să înceapă.
Dacă Aik = Aij∩Sik şi Akj=Aij∩Skj atunci Aij=Aik ᴜ{ak} ᴜAkj unde Aik conţine activităţile din
Aij care se termină înainte ca ak să înceapă iar Akj conţine activităţile din Aij care pornesc
după ce ak se termină.
Deci soluţia optimală pentru Sij include soluţiile optimale pentru Sik şi Skj. Acest mod
de a caracteriza substructura optimă ne sugerează că am putea rezolva problema selecţiei
activităţilor utilizând programarea dinamică.
Dacă c[i,j] este mărimea unei soluţii optimale pentru Sij atunci c[i,j]=c[i,k]+c[k,j]+1.
Pentru sij trebuie să determinăm acea activitate ak pentru care are loc recurenţa din
figura 2.11.

Figura 2.11 Formula recurentă pentru calculul mărimii soluţiei optimale la problema
selecţiei activităţilor

Putem elabora un algoritm recursiv top-down pe care să îl memoizăm sau putem să


folosim o abordare bottom-up şi să completăm succesiv intrările în această matrice.
Întrebarea este dacă am putea să adăugăm o activitate la soluţia optimă fără să
rezolvăm mai întâi toate subproblemele? Aceasta ne-ar ajuta să scăpăm de luarea în
considerare a tuturor variantelor care apar în formula recurentă 2.11. Astfel, în problema
alegerii activităţilor vom considera o singură alegere şi anume alegerea greedy.

17
Intuitiv, alegerea greedy este acea activitate care lasă resursele disponibile pentru
cât de multe activităţi disponibile, deci vom alege activitatea cu cel mai devreme moment
de terminare (dacă mai mult de o activitate din S are cel mai devreme moment de
terminare atunci vom putea alege oricare din aceste activităţi). Altfel spus, dacă activităţile
sunt sortate în ordinea crescătoare a timpului de terminare, atunci alegerea greedy este
activitatea a1.
Făcând o alegere greedy, ne rămâne o singură subproblemă de rezolvat: găsirea
activităţilor care pornesc după ce a1 s-a terminat. De ce nu putem considera activităţile care
se termină înainte ca a1 să înceapă? Deoarece avem s1<f1 şi f1 este cel mai devreme moment
de terminare al oricărei activităţi şi deci nici o activitate nu poate avea timp de terminare
mai mic sau egal decât s1. Deci toate activităţile compatibile cu a1 trebuie să înceapă după
ce a1 se termină.
Condiţia structurii optimale ne spune că dacă alegerea greedy este optimală atunci
şi rezultatul optim al problemei se compune din alegerea greedy şi rezultatul optimal al
subproblemei rămase.
O soluţie greedy recursivă care rezolvă problema selecţiei activităţilor este
prezentată în figura 2.12. Procedura RECURSIVE-ACTIVITY-SELECTOR primeşte ca parametri
timpii de început şi de sfârşit al activităţilor (reprezentaţi de vectorii s şi f), indicele i care
defineşte subproblema care trebuie rezolvată şi mărimea j a problemei originale. Această
procedură va returna mulţimea maximă de activităţilor compatibile din Si. Presupunem că
cele j activităţi primite ca date de intrare sunt deja ordonate crescător după timpul de
terminare a lor. Dacă nu sunt sortate, am putea face sortarea lor într-un timp de
complexitate O(nlgn).

Figura 2.12 Algoritmul recursiv greedy de selecţie a activităţilor

Procedura GREEDY-ACTIVITY-SELECTOR este o versiune iterativă a procedurii


RECURSIVE-ACTIVITY-SELECTOR. Şi ea presupune că pornim de la un şir de activităţi sortat
ascendent după timpul de finalizare a activităţilor. Procedura prezentată în figura 2.13
colectează activităţile selectate într-o mulţime A şi o returnează când această activitate este
finalizată.

Figura 2.13 Varianta greedy iterativă a algoritmului de selecţie a activităţilor

18
În concluzie, proprietatea alegerii greedy poate fi enunţată astfel: alegem
alternativa care apare ca şi cea mai promiţătoare la problema curentă, fără a rezolva
subproblemele. De fapt, aceasta diferenţiază tehnica greedy de programarea dinamică prin
faptul că aceasta din urmă rezolvă subproblemele (câte o singură dată) pentru a putea
decide care este cea mai bună alegere. Utilizând greedy, facem alegerea care ni se pare cea
mai bună la un anumit moment, după care rezolvăm subproblema rămasă. Programarea
dinamică rezolvă subproblemele înainte să facă alegerea, în timp ce greedy face alegerea
înainte să rezolve orice subproblemă. Programarea dinamică lucrează în general cu o
strategie bottom-up (sau top-down cu memo-izare) în timp ce greedy foloseşte în general o
strategie top-down, făcând alegeri greedy una după alta, reducând astfel instanţa
problemei la o instanţă mai mică.

2.3. Metoda Backtracking

În practică se întâlnesc un număr mare de probleme care au un număr mare de


variante ce se pot încerca, dar doar o parte din ele îndeplinesc condiţiile specifice
problemei. O abordare simplistă a acestui gen de probleme poate fi următoarea: Se
generează toate variantele posibile după care se elimină cele care nu îndeplinesc condiţiile
specific (“brute force attack”). În cele mai multe cazuri o astfel de abordare este păguboasă,
dacă nu chiar imposibilă.
O astfel de problemă poate fi caracterizată astfel:
 soluţia lor poate fi pusă sub forma unui vector S=v1,v2,v3…vn cu v1S1,v2S2,.....,vnSn;
 mulţimile S1,S2,S3…Sn sunt multimi finite, iar elementele lor se consideră că se află într-o
relaţie de ordine bine stabilită
 există anumite relaţii între componentele v1, v2,… vn, numite condiţii interne
Practic, dacă nu cunoaştem tehnica backtracking, suntem tentaţi să generăm toate
elementele produsului cartezian S1S2S3…Sn şi fiecare element să fie testat dacă este
soluţie. Metoda de generare a tuturor soluţiilor posibile şi apoi de determinare a soluţiilor
rezultat prin verificarea îndeplinirii condiţiilor interne necesită foarte mult timp (timpul
cerut de această investigare exhaustivă este exponenţial). În cadrul tehnicii backtracking nu
se generează toate soluţiile posibile, ci numai acelea care îndeplinesc anumite condiţii
specifice problemei, numite condiţii interne (în unele lucrări de specialitate acestea mai
sunt numite şi condiţii de validare). În cadrul acestei tehnici, elementele vectorului x
primesc pe rând valori, în sensul că lui vk i se atribuie o valoare numai dacă au fost atribuite
deja valori lui v1, v2, vk-1. Odată o valoare pentru vk stabilită, nu se trece direct la atribuirea
de valori lui vk+1, ci se verifică condiţiile de continuare referitoare la v1, v2, vk care stabilesc
situaţiile în care are sens să trecem la determinarea unei valori pentru vk+1.
Conceptual, tehnica backtracking caută sistematic soluţia într-un spaţiu al soluţiilor
organizat sub forma unui arbore. Fiecare nod din arbore defineşte o stare a problemei.
Toate drumurile de la rădăcină la frunze formează spaţiul stărilor. O soluţie a problemei
reprezintă acele stări pentru care există drum de la rădăcină la un nod etichetat cu un n-
uplu. Pot exista arbori statici (în cazul în care arborele astfel construit nu depinde de
instanţa problemei) şi arbori dinamici (în cazul în care alegerea valorii vi depinde de una sau
mai multe valori din mulţimea {v1, v2, …vi-1})
Algoritmul acestei tehnici poate fi descris astfel:

1)se alege primul element v1 ce aparţine lui S1

19
2)se presupun generate elementele v1,v2,v3…vk-1 aparţinând mulţimilor S1 S2 S3…Sk-1 pentru
generarea lui vk se alege primul element din Sk disponibil şi pentru valoarea aleasă se
testează îndeplinirea condiţiilor de continuare.

Pot apărea următoarele situaţii :

a)nu s-a găsit un astfel de element, caz în care se reia căutarea considerând generate
elementele v1,v2,v3…vk-1 iar vk se reia de la urmatorul element al mulţimii Sk rămas netestat

b)a fost găsit, caz în care se testează dacă acesta îndeplineşte condiţiile de continuare,
aparând astfel alte două posibilităţi:

b1) vk îndeplineşte condiţiile de continuare. Dacă s-a ajuns la soluţia finală (k=n) atunci se
afişează soluţia obtinută. Dacă nu s-a ajuns la soluţia finală se trece la generarea
elementului următor vk+1;

b2) vk nu îndeplineste condiţiile de continuare. Se încearcă următoarea valoare


disponibilă din Sk. Dacă nu se găseşte nici o valoare în Sk care să îndeplinească condiţiile de
continuare, se revine la elementul vk-1 şi se reia algoritmul pentru o nouă valoare a acestuia.
Algoritmul se încheie când au fost luate în considerare toate elementele lui v1.

Algoritmul general al metodei backtracking este prezentat în figura 2.14.

Figura 2.14 Algoritmul general de rezolvare a unei probleme utilizând metoda backtracking

Backtracking este o metodă care poate furniza toate soluţiile unei probleme.

2.3.1 Problema reginelor


Problema reginelor (sau a damelor) are următorul enunţ: Să se afişeze toate
posibilităţile de a aşeza pe o tablă de şah de dimensiune nxn, n dame astfel încât oricare 2
din acestea să nu se atace reciproc. Două dame se atacă una pe cealaltă dacă sunt aşezate
pe aceeaşi linie sau pe aceeaşi coloană sau pe aceeaşi diagonală (principală sau secundară).
Vom considera xi linia pe care vom aşeza dama de pe coloana i. Printr-o astfel de
reprezentare ne asigurăm că nu vom aşeza două dame pe aceeaşi coloană. Atunci, pentru o
soluţie parţială Sk={x1, x2, … xk} condiţiile de continuare includ:
 xi≠xj oricare ar fi j=1,..k –{i} (sau altfel spus 1<=i≠j<=k) (prin acesta verificăm că nu
am aşezat dama k pe o linie pe care să se găsească deja o altă damă)

20
 |xi-xj|≠|i-j| pentru orice j=1,..k-{i} (prin aceasta ne asigurăm că dama k pe care am
aşezat-o nu se atacă pe una din diagonale cu damele deja aşezate).

Un algoritm nerecursiv de rezolvare a problemei damelor se prezintă în figura 2.15:

Figura 2.15 Algoritmul nerecursiv de rezolvare a problemei damelor

21
3. NOŢIUNI DESPRE STRUCTURI DE DATE

3.1. Tipuri abstracte de date

Un tip abstract de date (Abstract Data Type ADT) este un tip de date necesar unui
analist, dar care este posibil să nu existe în limbajul de programare ceea ce impune
necesitatea implementării lui.
De exemplu tipul abstract Boolean reprezentat prin cele două valori de adevăr true
şi false. În limbajul Basic acesta este implementat prin şiruri de caractere (“true” şi “false”)
dar în limbajul C nu există acest tip de date, programatorii putându-se folosi de faptul că
valoarea 0 înseamnă întotodeauna “false” iar orice valoare diferită de 0 (exemplu valoarea
1) înseamnă “true”. Dacă doreşte, programatorul poate să-şi implementeze un tip abstract
de date Boolean. Specificarea unui ADT presupune specificarea:
 Mulţimii valorilor admisibile

 Mulţimii operaţiilor pe acele valori

 Utilizărilor posibile ale acelei ADT

De exemplu, avem cazul unei structuri abstracte de date de tip coadă (queue)
(structură de date de tip FIFO First In First Out –primul intrat primul ieşit). Cunoştinţele
necesare unui analist privesc doar comportamentul unui ADT. În cazul unei ADT queue,
analistul trebuie să cunoască cum se face:
 Inserarea in coadă: la sfârşit

 Extragerea de elemente din coadă: la început

Analistul nu trebuie să ştie implementarea cozii, programatorul ADT-ului se va


ocupa de acest aspect. Există 2 implementări posibile pentru o coadă:
 Un masiv + o variabilă auxiliară care memorează sfârşitul cozii

 Un masiv + 2 variabile auxiliare: sfârşitul cozii şi începutul acesteia

 Implementare cu pointeri: 2 variabile auxiliare: sfârşitul listei şi începutul


acesteia

Acesta are implicaţii pentru programatorul ADT-ului care poate alege structuri de
date corespunzătoare pentru implementare în timp ce pentru utilizatorul ADT-ului,
comportamentul ADT-ului este observat prin efectul şi rezultatul realizării operaţiilor
specificate.

3.2. ADT Lista


O listă este o secvenţă de 0 sau mai multe elemente de acelaşi tip denumite noduri
noduri între care există o relaţie de ordine determinată de poziţia lor relativă. Ea este deci o
mulţime eşalonată de elemente de acelaşi tip având un număr arbitrar de elemente.
Numărul n al nodurilor se numeşte lungimea listei. Dacă n=0, lista este vidă. Dacă n>=1, a1
este primul nod iar an este ultimul nod. Pentru 1<i<n, ai precede pe ai+1 şi succede pe ai-1.
O listă se poate implementa în 2 moduri: folosind un şir sau folosind pointeri.
Figura 3.1 prezintă structura unei liste implementate cu ajutorul unui şir.

22
Figura 3.1 O implementare statică a unei liste cu ajutorul unui şir

Implementarea unei liste utilizând un şir prezintă avantaje şi dezavantaje.


Avantajul este accesul rapid la oricare din elementele listei, acces care se face cu
ajutorul indicelui elementului din şir. Dacă dorim o regăsire pe baza numărului de ordine al
elementelor, vom utiliza indici (sau indecşi), în acest caz timpul de acces la oricare din
elementele listei (elemente de tablou) este constant, indiferent de poziţie. De aceea,
accesul la elementele listei prezintă o complexitate O(1).
Dezavantajul unei astfel de structuri statice de memorie este că dimensiunea ei
trebuie specificată la compilare, nemaiputându-se apoi modifica această dimensiune. Deci
este necesară estimarea dimensiunii tabloului (cu ajutorul căreia se va implementa lista)
înainte de compilare. Tot ca dezavantaj, operaţiile de insert şi delete sunt de complexitate
sporită.
În cazul implementării unei liste ca şi structura dinamică (utilizând pointeri), accesul
la elementele unei liste liniare se face secvenţial, pornind de la capul listei (adresa primului
nod al listei) până la ultimul element al ei, ceea ce măreşte uneori considerabil timpul de
acces la un anumit element.
Figura 3.2 prezintă o listă de n elemente implementată cu ajutorul pointerilor.
Regăsirea unui element se face destul de încet, vorbind de o complexitate O(n). În schimb
operaţiile de insert şi delete se fac uşor. Un alt avantaj al implementării unei liste folosind
pointeri este că nu trebuie specificată dimensiunea maximă, gestiunea spaţiului de stocare
este optimă, utilizând alocarea dinamică de memorie.

Figura 3.2 O implementare dinamică a unei liste utilizând pointeri

Există două variante de implementare a unei liste utilizând pointeri:


 Acces la listă printr-un pointer

 Santinele la început şi/sau sfârşit

Santinela reprezintă un element de listă gol care marchează începutul sau sfârşitul
listei. Avantajul santinelei este că permite inserarea uşoară de elemente la început /
sfârşitul listei.

23
Lista înlănţuită este o structură de date în care obiectele sunt aranjate într-o ordine
liniară. La implementarea prin şiruri, ordinea este determinată de indici în timp ce la
implementarea prin pointeri, ordinea este determinată de un pointer care arată către
obiectul următor.
Pentru o listă liniară este obligatoriu să existe o variabilă, declarată în timpul
compilării, denumită cap de listă (head) care să păstreze adresa primului element al listei.
Pierderea acestei valori va duce la imposibilitatea accesării elementelor listei liniare.
În cazul unei liste dublu înlănţuite, la un element x, x.next arată către elementul
succesor iar x.prev arată către elementul predecesor. Dacă x.prev == NULL, atunci x este
primul element, denumit head. Dacă x.next == NULL, atunci x este ultimul element,
denumit tail.
În cazul listelor simplu înlănţuite, fiecare element al listei conţine adresa
următorului element al listei. Ultimul element poate conţine ca adresă de legătură fie
constanta NULL sau NIL (sau constanta 0 care nu indică nici un alt element), fie adresa
primului element al listei, în cazul listelor liniare circulare. În cazul unei liste circulare dublu
înlănţuite, ultimul element din listă pointează către head iar primul element din listă
pointează către tail.
Dacă lista este sortată, cheile apar în ordine sortată.

3.2.1. Operaţia de căutare într-o listă


Figura 3.3 prezintă algoritmul de căutare a elementului k în lista L folosind o
căutare liniară simplă şi returnând un pointer către primul element cu cheia k din lista L.
Dacă nu se găseşte nici un obiect cu cheia k în listă, atunci se returnează NIL.

Figura 3.3 Algoritmul de căutare unui element într-o listă înlănţuită


Pentru a căuta într-o listă cu n elemente, timpul de execuţie al procedurii LIST-
SEARCH este ϴ(n), în cazul cel mai defavorabil, caz în care trebuie traversată întreaga listă
pentru a găsi elementul cu cheia k.

3.2.2. Inserarea unui element la începutul listei


Figura 3.4 prezintă algoritmul de inserare a unui element x într-o listă L.

Figura 3.4 Algoritmul de inserare a unui element într-o listă înlănţuită

3.2.3. Ştergerea unui element dintr-o listă


Pentru a şterge un element dintr-o listă avem nevoie de un pointer către acesta.
Dacă nu avem acest pointer, atunci va trebui găsit prin LIST-SEARCH.
Figura 3.5 prezintă algoritmul de ştergere a elementului indicat de pointerul x.

24
Figura 3.5 Algoritmul de ştergere a unui element într-o listă înlănţuită

LIST-DELETE şterge un element într-un timp ϴ(1), dar dacă dorim să ştergem un
element cu o cheie dată atunci în cel mai defavorabil caz vom avea nevoie de un timp ϴ(n)
datorită necesităţii apelului procedurii LIST-SEARCH care să ne întoarcă un pointer către
elementul care conţine cheia k.
Dacă ignorăm condiţiile de head şi tail ale listei, operaţia de delete se transformă
într-un algoritm mult mai scurt, prezentat în figura 3.6.

Figura 3.6 Algoritmul simplificat de ştergere a unui element într-o listă înlănţuită

3.2.4. Căutarea şi inserarea unui element într-o listă cu santinelă


O santinelă este un obiect dummy care marchează începutul sau sfârşitul listei. De
exemplu, noi vom introduce în lista L un obiect L.NIL care reprezintă un NIL dar care are
toate atributele celorlalte elemente din listă. De câte ori vom avea în pseudocod o referinţă
către NIL noi o vom înlocui cu o referinţă către L.NIL.
Atributul L.nil.next indică capul listei (head) iar L.nil.prev indică coada (sfârşitul)
listei (tail). Atributul next al lui tail respectiv atributul prev al lui tail indică spre L.nil. Figura
3.7 indică modificările care apar în procedura de căutare respectiv inserare într-o listă
dublu înlănţuită circulară cu santinelă.

Figura 3.7 Algoritmul de căutare respectiv inserare într-o listă dublu înlănţuită circulară cu
santinelă

3.3. Stiva
Stiva este o structură de tip LIFO (Last In First Out) –elementul ce ar urma să fie
şters este ultimul element adăugat într-o astfel de structură. Există două modalităţi de a
implementa o stivă: folosind un şir (un array) sau folosind o structură înlănţuită cu pointeri.

25
Operaţia de inserare într-o stivă este cel mai adesea denumită PUSH iar operaţia de
extragere este denumită POP. Figura 3.8 prezintă un exemplu de stivă cu cel mult n
elemente implementată folosind un array S[1..n].

Figura 3.8 Un exemplu de stivă implementată cu un şir

Şirul are un atribut S.top care indexează ultimul element adăugat în stivă. Stiva
conţine elementele S[1..S.top], unde S[1] este elementul de la baza stivei iar S[S.top] este
elementul din vârf. Când S.top=0 stiva nu conţine nici un element şi este deci goală. Putem
testa dacă stiva este goală cu ajutorul operaţiei STACK-EMPTY din figura 3.9. Într-o stivă
goală (vidă) nu se poate face operaţia POP de extragere a unui element. Dacă S.top este
mai mare decât n atunci stiva este plină. În pseudocodul prezentat în figura 3.9 nu ne facem
griji cu privire la această posibilitate.

Figura 3.9 Procedurile de depunere şi extragere dintr-o stivă

Fiecare din cele 3 operaţii pe stivă din figura 3.9 au un timp de execuţie O(1).

3.4. Coada
Coada este o structură de tip FIFO (First In First Out). Într-o elementul care va fi
şters este întotdeauna cel mai vechi element adăugat în coadă. Operaţia de adăugare a
unui element într-o coadă se numeşte ENQUEUE iar operaţia de extragere a unui element
dintr-o coadă se numeşte DEQUEUE. La fel ca operaţia POP de extragere a unui element
dintr-o stivă, şi operaţia DEQUEUE de extragere a unui element dintr-o coadă nu primeşte
nici un argument.
Figura 3.10 prezintă operaţiile de inserare respective extragere din coadă, fără a se
verifica dacă nu cumva încercăm să extragem un element dintr-o coadă goală sau dacă
încercăm să inserăm un element într-o coadă plină.

26
Figura 3.10 Operaţiile de inserare respectiv extragere dintr-o coadă

Fiecare din cele 2 operaţii pe coadă din figura 3.10 au un timp de execuţie O(1).

3.5. Tabele direct adresabile; Tabele de dispersie

Multe aplicaţii necesită o structură de date care să suporte doar operaţii esenţiale
pentru un dicţionar: INSERT, SEARCH şi DELETE. O tabelă de dispersie (hash table) este o
structură de date care implementează dicţionare. Deşi căutarea unui element într-o tabelă
de dispersie poate ţine (în anumite condiţii) la fel de mult ca şi căutarea unui element într-o
listă înlănţuită (ϴ(n)), în practică această structură se comportă foarte bine. În anumite
circumstanţe, timpul mediu de căutare a unui element într-o astfel de structură este de
complexitate O(1).
Un hash table generalizează noţiunea unui şir (array) care foloseşte un indice
pentru adresarea directă a elementelor lui. Astfel, orice poziţie poate fi examinată într-un
timp O(1). Când numărul de chei stocate într-o astfel de structură este relative scăzut faţă
de numărul total de posibile chei, tabelele de dispersie sunt o alternativă foarte bună la
adresarea directă pe care o are un array. În loc de a utiliza cheia direct ca şi index pentru a
adresa un element al şirului, într-o tabelă de dispersie indexul este calculat pe baza cheii
(conceptul de hashing).

3.5.1 Tabele direct adresabile

Adresarea directă este o tehnică care funcţionează bine dacă universul U al cheilor
este rezonabil de mic. Presupunem că o aplicaţie necesită elemente dintr-un dicţionar în
care fiecare element are o cheie asociată din mulţimea U={0,1,…m-1}. Vom presupune că
nu există două elemente cu aceeaşi cheie. Pentru reprezentarea mulţimii dinamice se
foloseşte un şir (o tabelă direct adresabilă) T[0..m-1] în care fiecare poziţie (sau slot)
corespunde unei chei din U. Figura 3.11 ilustrează acest concept: Fiecare element din
universul U={0,1,…9} corespunde unui index în tabela direct adresabilă T. Mulţimea
K={2,3,5,8} a elementelor mulţimii determină sloturi în tabelă care conţin pointeri către
elemente. Slotul k pointează spre un element din mulţime având cheia k. Dacă mulţimea K
nu conţine un element cu cheia k, atunci valoarea din tabela T este T[k]=NIL.

27
Figura 3.11 Exemplificarea conceptului de tabelă direct adresabilă
Uneori, pentru a salva spaţiu, în loc să păstrăm cheia şi datele adiţionale într-un
obiect extern tabelei direct adresabile, cu un pointer într-un slot al tabelei către obiect, am
putea stoca stoca obiectul chiar în slot. În acest ultim caz, un element cu cheia k este stocat
în slotul k.
Figura 3.12 prezintă operaţiile pe astfel de tabele direct adresabile.

Figura 3.12 Operaţiile pe tabele direct adresabile

Fiecare din aceste operaţii au un timp de execuţie O(1).

3.5.2. Tabele de dispersie

Dacă universul de chei U este mare (adică m are o valoare mare), alocarea unui şir
de dimensiune m nu este fezabilă iar uneori este imposibilă. În general în aceste cazuri,
numărul de chei K care se stochează este mic în comparaţie cu mărimea lui U.
Când numărul de chei K stocat în dictionar este mult mai mic decât universul U al
tuturor posibilelor chei, o tabelă de dispersie va necesita mult mai puţin spaţiu decât o
tabelă direct adresabilă. Scopul de fapt este reducerea spaţiului de stocare cu menţinerea
în acelaşi timp a facilităţii de căutare a unui element într-un timp de complexitate O(1). In
cazul tabelelor de dispersie, timpul O(1) de căutare este valabil pentru cazul mediu de
utilizare, în timp ce la tabelele direct adresabile timpul O(1) are loc şi pentru cazul cel mai
defavorabil.
În cazul tabelelor de dispersie, pentru a determina locul în care va fi stocat
elementul cu cheia k, vom utiliza o funcţie de dispersie (hash function):
h:U->{0,1,…m-1}
Funcţia h mapează universul U al cheilor în sloturile tabelei hash T[0..m-1].
Mărimea m a tabelei T este în mod obişnuit mult mai mică decât universul U. Fiecare
element cu cheia k este salvat în locaţia T[h[k]]. Se spune că h[k] este valoarea hash a cheii
k. Figura 3.13 ilustrează aceste concepte.

28
Figura 3.13 Ilustrarea conceptului de tabelă de dispersie

În figura 3.13, cheile k2 şi k5 ocupă acelaşi slot –în acest caz spunem că cele 2 valori
intră în coliziune. Există tehnici de rezolvare a conflictelor create de coliziuni. Bineînţeles,
ideal ar fi să se evite coliziunile –aceasta se poate face printr-o alegere potrivită a funcţiei h.
Totuşi, datorită faptului că mărimea universului U este mai mare decât m, obligatoriu vor
exista cel puţin două valori care să aibe acelaşi cod hash. Alegerea funcţiei hash trebuie să
minimizeze numărul de coliziuni.
Cea mai folosită tehnică de rezolvare a coliziunilor este cea prin înlănţuire. Toate
elementele care au acelaşi cod hash vor fi plasate în aceeaşi listă înlănţuită. Slotul j conține
un pointer către lista care salvează elementele care au j ca şi valoare hash. Dacă nici un
element nu are valoare de hash pe j, atunci T[j] = NIL.
Figura 3.14 prezintă modalitatea de rezolvare a coliziunilor prin înlănţuire.

Figura 3.14 Rezolvarea coliziunilor prin liste înlănţuite

Figura 3.15 prezintă operaţiile care se fac pe o tabelă de dispersie atunci când
rezolvarea coliziunilor se face prin înlănţuire.

29
Figura 3.15 Operaţiile pe o tabelă de dispersie ce utilizează liste înlănţuite pentru valorile în
coliziune

În adresarea directă, elementele vor fi salvate direct în tabela de dispersie. Deci


fiecare slot din tabela de dispersie conţine fie un element fie NIL. La căutare, se examinează
fiecare slot unde fie vom identifica elementul fie vom avea NIL. Într-o astfel de abordare nu
vom avea liste înlănţuite sau elemente stocate în afara tabelei precum în tabelele cu
înlăntuire. La inserare, se testează locaţiile din tabelă până să se găsească una liberă pentru
inserare. Tabela de dispersie se poate umple, deci în mod maxim vom avea α=1.
În loc să urmărim pointerii, vom calcula secvenţa de sloturi care va fi examinată.
Pentru a realiza inserarea utilizând adresarea directă, vom examina tabela hash până vom
găsi un slot în care să punem cheia. În loc să lucrăm într-o ordine 0,1,...m-1 (care necesită
un timp de căutare ϴ(n)), secvenţa poziţiilor depinde de cheia care va fi inserată. Pentru a
determina care sloturi să fie examinate, vom extinde funcţia de dispersie astfel:
h:U x {0,1…m-1}->{0,1,…m-1}.
Cu adresarea directă, vom solicita pentru fiecare k o secvenţă de analizat:
{h(k,0), h(k,1), … h(k,m-1)} care reprezintă o permutare a lui {0,1,…m-1}, fiecare poziţie a
tabelei de dispersie fiind eventual considerată ca un slot pentru o nouă cheie pe măsura
umplerii tabelei.
În pseudocodul prezentat în figura 3.16, presupunem că elementele tabelei de
dispersie T sunt chei fără informaţii adiţionale, cheile k sunt identice cu elementele
conţinând conţinând cheia k. Fiecare slot conţine o cheie sau NIL dacă slotul este gol.

Figura 3.16 Inserarea şi căutarea într-o tabelă de dispersie

Trei tehnici sunt folosite pentru a calcula secvenţa de chei necesară adresării directe:
1. Probarea liniară:

Dacă h’:U->{0,1,…m-1} este o funcţie de dispersie obişnuită auxiliară, atunci


probarea liniară va utiliza următoarea funcţie de dispersie: h(k,i)=(h’(k)+i) mod m cu
i=0,1,…m-1.

30
Dându-se cheia k, vom încerca slotul T[h’(k)] dacă este liber; apoi vom încerca slotul

T[h (k)+1] şi tot aşa până la slotul T[m-1]. Vom continua apoi cu sloturile T[0], T[1] până
la T[h’(k)-1].
2. Probarea pătratică

Dacă h’:U->{0,1,…m-1} este o funcţie de dispersie obişnuită auxiliară, atunci


h(k,i)=(h’(k)+c1i+c2i2) mod m, cu c1 şi c2 două constante pozitive iar i=0,1,…m-1. Prima poziţie
încercată va fi T[h’(k)] apoi următoarele poziţii depind într-o manieră pătratică de valorile
lui i.
3. Dispersia dublă

Dacă se consideră două funcţii de dispersie auxiliare h1 şi h2, atunci h(k,i)=(h1(k)+ih2(k))


mod m.
De exemplu se poate considera h1(k)=k mod m şi h2(k)=1+(k mod (m-1)) cu m număr prim.
Pentru k=123456 şi m=701 vom aveam h1(k)=80 şi h2(k)=257 deci vom proba întâi poziţia
80 apoi vom încerca to al 257-lea slot (modulo 701) până vom găsi slotul liber.

31
STRUCTURI DE DATE AVANSATE

4.1. Arbore binar de căutare


Un arbore binar de căutare se poate reprezenta ca o structură înlănţuită în care
fiecare nod este un obiect. Fiecare nod al arborelui conţine cheia, date auxiliare şi 3
referinţe: left care pointează spre nodul copil stânga, right care pointează spre nodul copil
dreapta şi p care pointează spre nodul rădăcină. Dacă un copil lipseşte atunci referinţa spre
acel copil va fi NIL.
Un arbore binar de căutare este un arbore binar (adică fiecare nod are cel mult 2
copii) care are următoarele proprietăţi:
 Dacă x este un nod oarecare în arbore şi y este un nod în subarborele stâng atunci
y.key<=x.key şi

 Dacă x este un nod oarecare în arbore şi y este un nod în subarborele drept atunci
x.key<=y.key

4.1.1. Traversarea unui arbore binar de căutare

Pentru arborii binari, se pot considera următoarele traversări:


o Traversare în preordine: se vizitează nodul rădăcină, apoi subarborele stâng şi în
final subarborele drept (R St Dr).
o Traversare în inordine: se vizitează subarborele stâng, apoi nodul rădăcină şi în final
subarborele drept (St R Dr)
o Traversare în postordine: se vizitează subarborele stâng, apoi subarborele drept şi
în final nodul rădăcină (St Dr R)
Pentru fiecare din cele 3 traversări, la vizitarea fiecărui subarbore se aplică din nou
aceeaşi regulă de vizitare
Considerăm arborele binar de căutare din figura 4.1:

Figura 4.1. Un exemplu de arbore binar de căutare

Traversarea în preordine a arborelui din figura de mai sus dă naştere următorului


şir: 15 6 3 2 4 7 13 9 18 17 20.
Pentru traversarea în inordine vom avea: 2 3 4 6 7 9 13 15 17 18 20.
Pentru traversarea în postordine vom avea: 2 4 3 9 13 7 6 17 20 18 15.
Consecinţa acestei proprietăţi a arborelui binar de căutare este că traversând în
inordine nodurile unui astfel de arbore obţinem un şir crescător (după valoarea cheii) de
articole.

32
Figura 4.2 prezintă algoritmul de afişare a elementelor unui arbore binar traversat
în inordine:

Figura 4.2 Parcurgerea în inordine a unui arbore binar de căutare

4.1.2. Căutarea într-un arbore binar de căutare

Figura 4.3 prezintă algoritmul de căutare într-un arbore binar de căutare. Procedura
primeşte un pointer către rădăcina arborelui şi o cheie dată k care urmează să fie căutată în
arbore. Procedura va returna un pointer către nodul cu cheia k (dacă acest nod există) sau
NIL în caz contrar.

Figura 4.3 Algoritmul recursiv de căutare într-un arbore binar de căutare

Figura 4.4 prezintă acelaşi algoritm dar iterativ (deci fără a utiliza recursivitatea):

Figura 4.4. Algoritmul iterativ de căutare într-un arbore binar de căutare

4.1.3. Minimul, maximul şi succesorul unui nod într-un arbore binar de căutare

Într-un arbore binar de căutare, întotdeauna vom putea găsi elementul minim
folosind un pointer care porneşte de la rădăcină şi urmează întotdeauna copilul din stânga.
Figura 4.5 ilustrează acest algoritm.

Figura 4.5 Algoritmul de căutare a minimului într-un arbore binar de căutare

Analog, poate avea loc căutarea elementului maxim într-un arbore binar de căutare
urmărind de data aceasta întotdeauna copilul din dreapta. Figura 4.6 prezintă acest
algoritm.

33
Figura 4.6 Algoritmul de căutare a maximului într-un arbore binar de căutare

Dându-se un nod într-un arbore binar de căutare, uneori dorim să cunoaştem


“succesorul” acestui element în ordinea sortată determinată de parcurgerea în inordine a
acestui arbore. Dacă toate cheile sunt distincte, atunci succesorul unui nod cu cheia x este
nodul cu cea mai mică cheie mai mare decât x.key. Proprietatea unui arbore binar de
căutare ne permite să determinăm succesorul unui nod fără să comparăm cheile. Procedura
prezentată în figura 4.7 returnează un pointer către succesorul nodului x (dacă acesta
există) şi NIL în cazul în care x deja conţine cea mai mare cheie în arborele binar de căutare.

Figura 4.7 Algoritmul de găsire a succesorului unui element într-un arbore binar de căutare

4.1.4. Inserarea într-un arbore binar de căutare

Inserarea unui element va trebui să menţină proprietatea de bază a unui arbore


binar de căutare.
Procedura TREE-INSERT din figura 4.8 primeşte ca şi parametru un nod z pentru
care z.key=v (unde v este valoarea care urmează să fie inserată), z.left=NIL şi z.right=NIL.

Figura 4.8 Inserarea unui element într-un arbore binar de căutare

34
4.1.5. Ştergerea unui nod într-un arbore binar de căutare

În cadrul operaţiei de ştergere a unui nod într-un arbore binar de căutare vom avea
nevoie de un algoritm care înlocuieşte un subarbore cu un alt subarbore.

Procedura TRANSPLANT din figura 4.9 înlocuieşte un subarbore ca şi copil al


părintelui său cu un alt subarbore. Procedura înlocuieşte subarborele având ca rădăcină
nodul pointat de u cu subarborele pointat de v. Astfel, părintele nodului u va deveni
părintele nodului v.

Figura 4.9 Înlocuirea unui subarbore cu un alt subarbore

Figura 4.10 prezintă algoritmul de ştergere a unui nod dintr-un arbore binar de
căutare.

Figura 4.10 Algoritmul de ştergere a unui nod într-un arbore binar de căutare

4.2 Arbore roşu şi negru

Operaţiile pe un arbore binar de căutare se fac într-un timp O(h) unde h este
înălţimea arborelui. Aceste operaţii sunt rapide în cazul în care înălţimea arborelui este

35
mică. Dacă înălţimea este mare (arbori creaţi dezechilibraţi sau dezechilibraţi în urma unor
inserări sau ştergeri succesive) atunci timpul acestor operaţii nu este mai bun decât în cazul
unei liste înlănţuite.
Arborii roşu şi negru garantează un timp O(lgn) în cazul cel mai defavorabil pentru
operaţiile de bază în acest arbore.
Un arbore roșu-şi-negru este un arbore binar de căutare la care fiecare nod are bit
suplimentar, reprezentând culoarea: roșu sau negru. Fiecare nod de arbore conține
următoarele campuri: cheia, culoarea, nodul stâng, nodul drept și părintele.
Un arbore roșu şi negru este un arbore binar de căutare cu următoarele proprietăţi:
1. Fiecare nod este sau roșu sau negru
2. Rădăcina este negru
3. Fiecare frunză (NIL) este neagră
4. Dacă un nod este roșu, atunci amândoi copii sunt negrii
5. Pentru fiecare nod, toate căile simple de la nodul respectiv la frunzele descendente
conțin același număr de noduri negre
Punând constrângeri asupra culorilor nodurilor oricărei căi simple de la rădăcină la
frunze, arborii roşu şi negru asigură că nici una din aceste chei nu este de două ori mai
lungă decât oricare alta; în consecinţă arborele este aproximativ balansat.
Într-un arbore roşu şi negru vom utiliza o singură santinelă pentru a reprezenta NIL.
Santinela T.nil este un obiect cu aceleaşi atribute ca un nod oarecare din arbore. Culoarea
santinelei este negru iar celelalte atribute p, left, right şi key pot avea valori arbitrare. Aşa
cum se vede în figura 4.11 punctul b, toţi pointerii spre NIL au fost înlocuiţi cu pointeri către
santinela T.nil. Vom utiliza santinela astfel încât vom putea trata orice copil NIL al unui nod
x ca un nod ordinar a cărui părinte este x.

36
Figura 4.11 Un exemplu de arbore roşu şi negru
Operaţiile TREE-INSERT şi TREE-DELETE într-un arbore cu n noduri se execută într-
un timp O(log(n)). Aceste operaţii schimbă arboreal, deci este posibil ca arborele rezultat să
nu mai îndeplinească condiţiile de arbore roşu şi negru. Vor fi necesare corecţii în arbore
pentru a păstra proprietăţile arborelui. Pentru a restaura aceste proprietăţi, vor trebui
schimbate culori ale unor noduri ale arborelui şi de asemenea schimbată structura de
pointeri.
Vom schimba structura de pointeri prin intermediul operaţiei de rotaţie care este o
operaţie locală într-un arbore de căutare care menţine proprietăţile arborelui binar de
căutare. Figura 4.12 prezintă cele două tipuri de rotaţii: rotaţii la stânga şi rotaţii la dreapta.

37
Figura 4.12 Operaţiile de rotaţie pe un arbore binar de căutare
Când facem o rotaţie la stânga a unui nod x, presupunem că copilul dreapta y nu
este T.nil; x poate fi orice nod în arbore a cărui copil dreapta nu este T.nil. Operaţia de
rotaţie la stânga (transformarea configuraţiei din dreapta figurii 4.14 în configuraţia din
stânga) îl face pe y noua rădăcină a subarborelui, cu x ca şi copilul stânga a lui y iar copilul
stânga a lui y (adică β) devine copilul dreapta a lui x.
Algoritmul LEFT-ROTATE din figura 4.13 presupune că x.right≠T.nil şi că părintele
rădăcinii este T.nil.

Figura 4.13 Algoritmul de rotaţie la stânga într-un arbore binar de căutare


Procedura RB-INSERT din figura 4.14 va insera în arborele roşu şi negru T nodul z
(cu cheia deja încărcată în acest nou nod).

38
Figura 4.14 Algoritmul de inserare a unui nod într-un arbore roşu şi negru
Datorită faptului că colorarea în roşu a nodului proaspăt inserat ar putea încălca
proprietatea de colorare a arborilor roşu şi negru, vom apela procedura RB-INSERT-
FIXUP(T,z) (prezentată în figura 4.15) la linia 17 pentru a restaura această proprietate.

Figura 4.15 Algoritmul de restaurare a proprietăţilor unui arbore roşu şi negru după
inserarea unui nod
Ca şi alte operaţii de bază într-un arbore roşu şi negru, operaţia de ştergere a unui
nod se va face într-un timp O(lg n). În primul rând este necesară modificarea procedurii
TRANSPLANT (figura 4.9) făcută la arborii binar de căutare. Noua procedură RB-
TRANSPLANT este prezentată în figura 4.16.

39
Figura 4.16 Procedura de înlocuire a unui arbore cu un alt subarbore pentru un arbore roşu
şi negru

4.3. Elemente legate de grafuri

Vom considera un graf G=(V,E) unde V reprezintă mulţimea nodurilor iar E


reprezintă mulţimea arcelor (sau mulţimea muchiilor).
Există două moduri standard de a reprezenta un graf G: ca o mulţime de liste de
adiacenţă sau ca o matrice de adiacenţă. Fiecare din cele două variante se aplică atât
grafurilor neorientate cât şi grafurilor orientate. Reprezentarea prin liste de adiacenţă este
de preferat pentru reprezentarea grafurilor rare –adică a grafurilor pentru care mulţimea
arcelor este mult mai mică decât multimea nodurilor (grafuri cu densitate mică). Pentru
grafurile dense este de preferat reprezentarea cu ajutorul matricilor de adiacenţă.
O listă de adiacenţă ca modalitate de reprezentare a unui graf reprezintă un şir Adj
de V liste, câte una pentru fiecare nod din graf. Pentru fiecare nod uϵV, Adj[u] conţine toate
nodurile v din graf pentru care există o muchie (un arc) (u,v)ϵE. Deci Adj[u] este format din
totalitatea vârfurilor adiacente lui u în G. Notaţia G.Adj[u] se foloseşte pentru a ne referi la
lista de adiacenţă a nodului u.
Dacă G este un graf orientat, suma lungimilor tuturor listelor de adiacenţă este |E|
deoarece un arc (u,v) este reprezentat doar prin vϵ Adj[u]. Dacă G este un graf neorientat,
suma lungimilor tuturor listelor de adiacenţă este 2|E| deoarece un arc (u,v) apare atât in
lista de adiacenţă a lui u (v apare în Adj[u]) cât şi în lista de adiacenţă a lui v (u apare în
Adj[v]).
Atât pentru grafuri orientate cât şi pentru grafuri neorientate, spaţiul de memorie
necesar este ϴ(V+E).
Dacă graful este ponderat (graf cu costuri) cu ponderile dată de o funcţie de cost
w:E->R, atunci ponderea w(u,v) al muchiei (u,v)ϵE va fi memorată împreună cu vârful v în
lista de adiacenţă a lui u.
Un dezavantaj al listelor de adiacenţă este că nu oferă un mod mai rapid de a
determina dacă există un arc (u,v) între cele două noduri altfel decât a căuta pe v în lista de
adiacenţă a lui u adică în Adj[u]. Matricea de adiacenţă rezolvă această problemă dar
folosind asimptotic mai multă memorie.
Într-o matrice de adiacenţă presupunem că nodurile sunt numerotate arbitrar cu
1,2,…|V|. Vom folosi o matrice A de dimensiuni |V|x|V| astfel încât elementele aij
îndeplinesc condiţia din figura 4.17:

Figura 4.17 Matricea de adiacenţă a unui graf

Matricea de adiacenţă ocupă un spaţiu ϴ(V2), indiferent de numărul de arce


(muchii) ale grafului.

40
La un graf neorientat, (u,v) şi (v,u) reprezintă aceeaşi muchie, în consecinţă
matricea este simetrică faţă de diagonala principală (deci matricea de adiacenţă este
propria sa transpusă AT=A).
Pentru un graf ponderat, putem stoca costurile arcelor ca elemente ale matricii de
adiacenţă, iar acolo unde arcul nu există putem stoca NIL, ∞ sau 0.
Vom putea accesa un atribut d al unui unui nod v folosind notaţia v.d. De
asemenea, vom putea accesa un atribut f al unui arc (u,v) folosind notaţia (u,v).f.

4.3.1 Parcurgerea în lăţime a unui graf


Există două modalităţi de parcurgere a grafurilor: parcurgerea în lăţime (breadth
first) respectiv parcurgerea în adâncime (depth first).
În cazul parcurgerii în lăţime, pornind de la un graf G(V,E) şi un nod s de start, vom
explora în mod sistematic arcele din G pentru a descoperi fiecare nod care este accesibil
pornind de la nodul s. Algoritmul calculează distanţa (cel mai mic număr de muchii) de la
nodul s la fiecare nod care este conectat la s. Algoritmul explorează frontiera dintre
nodurile descoperite şi cele nedescoperite în lăţime, adică descoperă toate nodurile la
distanţă k, apoi cele la distanţă k+1 etc. Algoritmul este funcţional atât pentru un graf
neorientat cât şi pentru un graf orientat.
Parcurgerea în lăţime colorează fiecare nod cu alb, gri sau negru pentru a ţine
evidenţa avansării. Se presupune că iniţial toate nodurile din graf sunt colorate în alb (adică
sunt nedescoperite), devin mai târziu gri şi apoi negre. Nodurile gri şi negru sunt noduri
care au fost descoperite. Dacă avem arcul (u,v)ϵE şi u este nod negru, atunci v va fi negru
sau gri (toate nodurile adiacente unui nod negru au fost descoperite).
Algoritmul produce un “arbore de lăţime” având ca rădăcină nodul s, care conţine
toate aceste vârfuri conectate la s. Pentru fiecare vârf v accesibil din s, calea din “arborele
de lăţime” de la s la v corespunde celui mai scurt drum de la s la v în G, adică un drum care
conţine un număr minim de muchii. De fiecare dată când căutarea descoperă un nod alb v
în cursul scanării listei de adiacenţă al unui nod deja descoperit u, nodul v şi arcul (u,v) este
adăugat în acest arbore. Vom spune că u este predecesorul sau părintele lui v în arborele de
lăţime. Deoarece un vârf este descoperit cel mult o dată, el poate avea cel mult un părinte.
Procedura de căutare în lăţime din figura 4.18 presupune că graful de intrare G(V,E)
este reprezentat utilizând liste de adiacenţă.

41
Figura 4.18 Algoritmul de parcurgere în lăţime pentru un graf reprezentat cu liste de
adiacenţă

4.3.2. Noţiunea drumului de lungime minimă


Vom defini calea cea mai scurtă (sau drumul de lungime minimă) δ(s,v) din s în v ca
fiind numărul minim de muchii ale oricărui drum de la s la v sau ∞ dacă nu există un drum
de la s la v.
Dacă avem un graf G=(V,E) şi s un nod arbitrar din V, atunci pentru orice arc (u,v)ϵE
avem δ(s,v)<=δ(s,u)+1. (dacă u este accesibil din s atunci şi v este accesibil din s. În acest
caz, cel mai scurt drum de la s la v nu poate fi mai lung decât cel mai scurt drum de la s la u
la care se adaugă 1 corespunzător muchiei (u,v)).
Dacă se execută BFS pornind de la un nod s, la terminare, pentru fiecare nod v
pentru care există o cale de la s, valoarea v.d calculată de algoritmul BFS satisface
v.d=δ(s,v).
Procedura PRINT-PATH din figura 4.19 prezintă algoritmul de afişare a drumurilor
de lungime minimă pornind de la nodul sursă s până la nodul destinaţie v.

Figura 4.19 Procedura de afişare a drumurilor de lungime minimă pornind de la s la v

4.3.3. Parcurgerea în adâncime a unui graf


Parcurgerea în adâncime presupune o căutare în graf cât mai adâncă oricând acest
lucru este posibil. În căutarea in adâncime, muchiile sunt explorate pornind de la vârful v
cel mai recent descoperit care mai are încă muchii neexplorate care pleacă din el. Dacă
toate muchiile unui nod v au fost explorate, atunci algoritmul face back-tracking la

42
următorul nod care trebuie explorat după v. Această operaţie continuă până când au fost
descoperite toate nodurile accesibile din vârful sursă iniţial.
Vom folosi aceeaşi convenție de colorare şi anume alb, gri şi negru. Pentru fiecare
nod folosim 2 ştampile de timp:
 v.d momentul în care nodul a fost descoperit pentru prima dată (când este
marcat gri)

 v.f momentul în care parcurgerea termină lista de adiacență a lui v (când


nodul va fi marcat negru)

Acest marcaj de timp este o valoare întreagă cuprinsă între 1 şi 2|V| deoarece
există un moment de descoperire şi un moment de terminare pentru fiecare din cele |V|
noduri ale grafului.

Figura 4.20 Algoritmul de căutare în adâncime


Figura 4.20 prezintă algoritmul de căutare în adâncime pentru un graf neorientat
sau orientat.

4.3.4. Sortare topologică


Căutarea în adâncime poate fi folosită pentru o sortare topologică a unui graf
orientat aciclic. O sortare topologică a unui graf orientat aciclic G=(V,E) este o ordonare
liniară a tuturor vârfurilor sale astfel încât, dacă G conţine o muchie (u,v) atunci u apare
înaintea lui v în ordonare. Dacă graful nu este aciclic, atunci nu este posibilă sortarea
topologică. O sortare topologică a unui graf poate fi văzută ca o ordonare a vârfurilor sale
de-a lungul unei linii orizontale astfel încât toate muchiile sale merg de la stânga la dreapta.
Figura 4.21 prezintă un exemplu de sortare topologică a unor obiecte de
îmbrăcăminte care trebuie îmbrăcate într-o anumită ordine. De exemplu, ciorapii trebuie
îmbrăcaţi înaintea pantofilor. O muchie orientată (u,v) din graful aciclic din figura 4.21
punctul (a) indică faptul că articolul de îmbrăcăminte u trebuie îmbrăcat înaintea articolului
v. Punctul (b) prezintă graful orientat aciclic sortat topologic ca o ordonare a vârfurilor de-a

43
lungul unei linii orizontale. Muchiile orientate merg de la stânga la dreapta. Vârfurile sunt
aranjate de la stânga la dreapta în ordinea descrescătoare a timpului de terminare.

Figura 4.21 Un exemplu de sortare topologică

Algoritmul din figura 4.22 sortează topologic un graf orientat aciclic:

Figura 4.22 Algoritmul simplu de sortare topologică a unui graf orientat aciclic

44
4.3.5. Componentă tare conexă a unui graf
O componentă tare conexă a unui graf orientat G=(V,E) este o mulţime maximală de
vârfuri U V, astfel încât, pentru fiecare pereche de vârfuri u şi v din U există un drum de la
u la v precum şi de la v la u. Altfel spus, între oricare două noduri din componentă există cel
puţin un drum şi nu mai există nici un nod în afara componentei legat printr-un drum de un
nod al componentei.

Algoritmul din figura 4.23 determină componentele tare conexe ale unui graf
orientat G=(V,E) folosind două căutări în adâncime, una în G şi una în GT.

Figura 4.23 Algoritmul de determinare a componentelor tare conexe dintr-un graf orientat

4.4. Arbori de acoperire minimi

La proiectarea circuitelor electronice, de multe ori este necesară legarea pinilor mai
multe componente, conectându-i cu fire, fiecare fir conectând doi pini. Pentru
interconectarea unei mulţimi de n pini vor fi necesare n-1 fire. Având în vedere că pot fi mai
multe aranjamente de interconectare, se cere să se determine cablarea care foloseşte cea
mai mică cantitate de cabluri.
Vom folosi un graf neorientat conex, G=(V,E) unde V este mulţimea pinilor şi E este
mulţimea interconectărilor posibile dintre perechile de pini; pentru fiecare pereche de pini
(u,v)ϵE, avem un cost w(u,v) ce reprezintă costul cablului de la pinul u la pinul v. Dorim să
determinăm submulţimea aciclică T E care conectează toate nodurile din V şi al cărei cost
total w(T)=∑(u,v)ϵT w(u,v) este minim.
Deoarece mulţimea T este aciclică şi conectează toate nodurile, ea trebuie să
formeze un arbore care va fi denumit arbore de acoperire deoarece “acoperă” graful G.
Problema determinării arborelui T va fi numită problema arborelui de acoperire minim.
Vom prezenta doi algoritmi de determinare a arborelui de acoperire minim: algoritmul lui
Kruskal şi algoritmul lui Prim.
Algoritmii pe care îi vom prezenta pentru determinarea arborelui de acoperire
minim sunt algoritmi greedy. Această strategie greedy este implementată în algoritmul
generic din figura 4.24, care dezvoltă arborele de acoperire minim adăugând o muchie o
dată. Algoritmul foloseşte o mulţime A care este întotdeauna o submulţime a unui arbore
de acoperire minim. La fiecare pas este determinată o muchie (u,v) care poate fi adăugată
la A, respectând proprietatea de mai sus, adică AU{(u,v)} este, de asemenea, o submulţime
a unui arbore de acoperire minim. Numim o astfel de muchie o muchie sigură (safe) pentru
A, deoarece poate fi adăugată, în siguranţă, mulţimii A, respectând proprietatea de mai sus.

45
Figura 4.24 Algoritmul generic de determinare a unui arbore de acoperire minim
Partea dificilă este găsirea unei muchii sigure în linia 3 a algoritmului. Vom defini o
tăietură (S, V-S) a unui graf neorientat G=(V,E) ca o partiţie a lui V. Spunem că o muchie
(u,v)ϵE traversează tăietura (S,V-S) dacă unul din punctele sale terminale este în S şi celălalt
este în S-V. Spunem că o tăietură respectă mulţimea de muchii A dacă nici o muchie din A
nu traversează tăietura. O muchie este o muchie uşoară care traversează o tăietură dacă
are costul minim dintre toate muchiile care traversează tăietura. Pot exista mai multe
muchii uşoare care traversează o tăietură dacă ele au costuri egale. Generalizând, spunem
că o muchie este o muchie uşoară care satisface o proprietate dată dacă are costul minim
dintre toate muchiile care satisfac acea proprietate.

4.4.1. Algoritmul lui Kruskal de determinare a unui arbore de acoperire minim

Algoritmul lui Kruskal de determinare a unui arbore de acoperire minim se bazează


pe algoritmul generic prezentat în figura 4.24. Algoritmul găseşte o muchie sigură (u,v)
dintre toate muchiile care conectează 2 arbori în pădurea de arbori pentru a o adăuga la
pădurea dezvoltată. Muchia sigură (u,v) este muchia cu costul minim care uneşte doi arbori
din pădurea respectivă.
Algoritmul lui Kruskal, prezentat în figura 4.25, foloseşte o structură de date pentru
mulţimi disjuncte pentru a reprezenta mai multe mulţimi de elemente disjuncte. Fiecare
mulţime conţine vârfurile unui arbore din pădurea curentă. Funcţia FIND-SET(u) returnează
un element reprezentativ din mulţimea care îl conţine pe u. Astfel, putem determina dacă
două vârfuri u şi v aparţin aceluiaşi arbore testând dacă FIND-SET(u) este egal cu FIND-
SET(v). Combinarea arborilor este realizată de procedura UNION.

Figura 4.25 Algoritmul lui Kruskal de determinare a unui arbore de acoperire minim

4.4.2. Algoritmul lui Prim de determinare a unui arbore de acoperire minim

Precum algoritmul lui Kruskal, algoritmul lui Prim este un caz particular al
algoritmului generic pentru determinarea unui arbore de acoperire minim pentru un graf
conex. Algoritmul lui Prim are proprietatea că muchiile din mulţimea A formează
întotdeauna un singur arbore. Arborele se formează pornind de la un vârf arbitrar r şi creşte

46
până acoperă toate vârfurile din V. La fiecare pas, se adaugă arborelui o muchie uşoară care
uneşte mulţimea A cu un vârf izolat din GA=(V,A). La fel ca algoritmul lui Kruskal, algoritmul
lui Prim foloseşte o tehnică gredy deoarece arborelui ii este adăugată la fiecare pas o
muchie care adaugă cel mai mic cost la costul total al arborelui.
În procedura MST-PRIM din figura 4.26, graful G, rădăcina r a arborelui minim de
acoperire care urmează să fie dezvoltat şi costurile w sunt parametri de intrare. În timpul
execuţiei, toate vârfurile care nu sunt în arbore sunt într-o coadă de prioritate Q bazată pe
un câmp cheie. Pentru fiecare vârf v, atributul v.key este costul minim al oricărei muchii
care uneşte pe v cu un vârf din arbore.

Figura 4.26 Algoritmul lui Prim de determinare a unui arbore de acoperire minim

4.5. Drumuri minime într-un graf

Problema drumului minim apare deseori în practică. Un exemplu ar fi găsirea


drumului cel mai scurt dintre 2 oraşe.
Fiind dat un graf orientat cu costuri G=(V,E) şi având funcţia de cost w:E->R care
asociază fiecărei muchii câte un cost exprimat printr-un număr real. Costul w(p) al unei căi
p={v0, v1, … vk} este suma costurilor de pe calea p (figura 4.27).

Figura 4.27 Costul unei căi exprimat ca suma costurilor muchiilor de pe acea cale

Costul unui drum minim (costul optim) de la u la v se defineşte în figura 4.28.

Figura 4.28 Costul drumului minim

Un drum minim de la u la v este orice drum p cu proprietatea w(p)=δ(u,v).


În continuare ne vom axa pe problema drumului minim de sursă unică. Însă, există
mai multe variante ale problemei drumului minim, şi anume:
Problema drumurilor minime de destinaţie unică: Să se găsească de la fiecare nod vϵV
drumul minim până la un vârf destinaţie t prestabilit. Această problemă poate fi pusă şi
invers: calea cea mai scurtă de la o sursă s la fiecare nod v dintr-un graf.
Problema drumurilor minime de sursă şi destinaţie unică: Să se determine un drum minim
de la u la v pentru u şi v date. Dacă rezolvăm problema drumurilor minime de sursă unică
pentru vârful u atunci rezolvăm şi această problemă. De fapt, nu se cunosc algoritmi pentru

47
rezolvarea acestei probleme care să fie asimptotic mai rapizi decât cel mai bun algoritm
pentru determinarea drumurilor minime de sursă unică, în cazul cel mai defavorabil.
Problema drumurilor minime pentru surse şi destinaţii multiple: Să se determine drumul
minim de la u la v pentru fiecare pereche de vârfuri u şi v. Această problemă poate fi
rezolvată, de exemplu, prin aplicarea algoritmului pentru sursă unică pentru fiecare vârf al
grafului.
De regulă, algoritmii pentru determinarea drumurilor minime exploatează
proprietatea că un drum minim între două vârfuri conţine alte drumuri optimale. Această
proprietate de optimalitate substructurală este specifică atât programării dinamice cât şi
metodei greedy. Algoritmul Dijkstra aplică metoda greedy iar algoritmul Floyd-Warshall
pentru determinarea drumurilor minime pentru toate perechile de vârfuri este un algoritm
de programare dinamică.
Proprietatea de optimalitate substructurală a drumurilor minime poate fi enunţată
astfel:
Dacă se dă un graf orientat G=(V,E) şi o funcţie de cost w:E->R pe graful G, şi fie
p={v0, v1, …vk} calea de cost minim între 2 noduri v0 şi vk; atunci pentru orice 0<=i<=j<=k, p-
ij={vi,vi+1, …vj} este calea de cost minim între vi şi vj.
Considerând un graf G=(V,E), pentru fiecare nod vϵV vom memora predecesorul
acestuia v.π, care poate fi un alt nod sau NIL. Algoritmii de determinare a drumului minim
setează predecesorii astfel încât dacă se merge de la un nod v înapoi, vom găsi calea
minimă (printr-o traversare în ordine inversă) de la sursa s până la nodul v.

4.5.1. Reprezentarea drumurilor minime

Deseori dorim nu doar să determinăm lungimea drumului minim ci şi vârfurile care


compun acest drum minim. Pentru reprezentarea acestor drumuri minime vom utiliza o
reprezentare similară celei considerate la parcurgerea în lăţime a arborilor. Dându-se un
graf G=(V,E) vom păstra pentru fiecare nod vϵV un predecesor v.π care este fie un alt nod
fie NIL. Algoritmii pentru determinarea drumurilor minime ce urmează să fie prezentaţi
determină π astfel încât pentru fiecare vârf v, lanţul de predecesori care începe cu vârful v
să corespundă unei traversări în ordine inversă a unui drum minim de la s la v. Astfel,
pentru orice vârf v pentru care v.π≠NIL vom putea utiliza procedura PRINT-PATH(G,s,v)
pentru tipărirea unui drum minim de la s la v.
Vom considera subgraful predecesor Gπ=(Vπ,Eπ) indus de valorile lui π, unde Vπ este
mulţimea nodurilor din V care au proprietatea că au predecesor diferit de NIL reunită cu
mulţimea constând din nodul sursă:
Vπ={vϵV:v.π≠NIL}ᴜ{s}
Mulţimea de muchii orientate Eπ este mulţimea de muchii indusă de valorile lui π
pentru vârfurile din Vπ:
Eπ={(v.π,v)ϵE:vϵVπ-{s}}
Atunci Gπ este un “arbore al drumurilor minime” adică este un arbore cu rădăcina s
conţinând câte un drum minim de la sursa s la fiecare vârf al grafului G care este accesibil
din s.

4.5.2. Relaxarea

Algoritmii care urmează să fie prezentaţi în continuare utilizează tehnica de


relaxare. Pentru fiecare vârf vϵV vom păstra un atribut v.d reprezentând o margine
superioară a costului unui drum minim de la sursa s la vârful v. Numim d.v o estimare a

48
valorii drumului minim. Estimările drumurilor minime şi predecesorii sunt iniţializaţi prin
procedura din figura 4.29.

Figura 4.29 Iniţializarea estimărilor drumurilor minime şi a predecesorilor într-un graf G cu


sursa s
Termenul de relaxare semnifică de fapt o operaţie care determină decrementarea
unei margini superioare. În procesul de relaxare a unei muchii (u,v) se verifică dacă drumul
minim până la vârful v (determinat până în acel moment) poate fi îmbunătăţit pe baza unui
drum care să treacă prin u, şi dacă da, atunci se reactualizează v.d şi v.π. Practic, un pas de
relaxare poate determina descreşterea valorii v.d reprezentând valoarea drumului minim
de la s la v şi reactualizarea câmpului v.π care conţine predecesorul vârfului v. Procedura
RELAX este prezentată în figura 4.30.

Figura 4.30 Procedura de relaxare a unei muchii (u,v) într-un graf cu costuri

4.5.3. Algoritmul Bellman-Ford

Algoritmul Bellman-Ford rezolvă problema drumurilor minime cu sursă unică în


cazul general al unui graf care poate avea şi costuri negative. Dându-se un graf orientat
G=(V,E), un vârf sursă s şi funcţia de cost w:E->R, algoritmul Bellman Ford returnează o
valoare booleană indicând dacă există sau nu un ciclu de cost negativ accesibil din vârful
sursă s considerat. În cazul în care un astfel de ciclu există, algoritmul semnalează că nu
există soluţie iar dacă nu există un astfel de ciclu, algoritmul produce drumurile minime şi
costurile corespunzătoare lor. Procedura BELLMAN-FORD este prezentată în figura 4.31 şi
utilizează tehnica relaxării prin descreşterea estimării valorii v.d adică valoarea drumului
minim de la vârful sursă s până la fiecare vârf vϵV până la obţinerea adevăratului cost
minim δ(s,v). Algoritmul returnează TRUE dacă şi numai dacă nu conţine cicluri de cost
negativ accesibile din sursă.

Figura 4.31 Algoritmul Belmann-Ford

49
4.5.4. Drumuri minime de sursă unică în grafuri orientate aciclice

Prin relaxarea arcelor într-un graf orientat aciclic (DAG directed acyclic graph) cu
costuri conform unei ordonări topologice a vârfurilor sale putem calcula drumurile minine
de sursă unică într-un timp ϴ(V+E). Drumurile minime sunt întotdeauna bine definite într-
un DAG datorită faptului că acesta nu poate avea cicluri de cost negativ, chiar dacă graful
conţine arce de cost negativ.
Algoritmul începe prin sortarea topologică a grafului pentu impunerea unei ordini
liniare a vârfurilor. Dacă există un drum de la u la v atunci u precede v în ordinea
topologică. Algoritmul efectuează o singură trecere peste vârfurile sortate topologic şi pe
măsură ce fiecare vârf este procesat, sunt relaxate toate muchiile care pornesc din acel
vârf.
Algoritmul de căutare a drumurilor minime într-un graf orientat aciclic este
prezentat în figura 4.32.

Figura 4.32 Algoritmul de căutare a drumurilor minime într-un graf orientat aciclic

4.5.5. Algoritmul lui Dijkstra

Algoritmul lui Dijkstra rezolvă problema drumurilor de lungime minimă cu sursă unică
într-un graf orientat G=(V,E) cu costuri nenegative. Deci în acdrul acestui algoritm vom avea
w(u,v)>=0 pentru orice arc (u,v)ϵE.
Algoritmul lui Dijkstra gestionează o mulţime S de noduri pentru a cărui elemente
algoritmul a calculat deja costurile finale ale drumurilor minime de la sursa s. Algoritmul
selectează câte un vârf u din mulţimea V-S pentru care estimarea drumului minim este
minimă, introduce acest vârf u în mulţimea S şi relaxează arcele divergente din din vârful u.
Figura 4.33 prezintă algoritmul lui Dijkstra.

Figura 4.33 Algoritmul lui Dijkstra de determinare a drumurilor de lungime minimă


într-un graf orientat cu costuri nenegative

50
5. ÎNTREBĂRI RECAPITULATIVE

1. Ce este un algoritm?
2. Ce este o instanţă a unei probleme?
3. Ce este pseudocodul?
4. Ce înseamnă studierea eficienţei unui algoritm?
5. Cum se defineşte operaţia de sortare?
6. Când spunem despre o metodă de sortare că este stabilă şi când spunem că ea este
“in situ”?
7. Prezentaţi principiul şi pseudocodul algoritmului de sortare prin inserţie.
8. Care sunt principiile şi în ce condiţii se poate aplica metoda Divide and Conquer?
9. Prezentaţi principiul şi pseudocodul algoritmului de sortare prin interclasare.
10. Ce este un heap şi ce proprietăţi îndeplinesc elementele lui?
11. Prezentaţi şi explicaţi algoritmul de transformare a unui şir de intrare într-un heap
şi algoritmul de creare a unui heap.
12. Prezentaţi şi explicaţi algoritmul de sortare utilizând un heap.
13. Prezentaţi principiul şi algoritmul sortării rapide.
14. Prezentaţi şi explicaţi algoritmul de partiţionare a unui şir în cazul sortării rapide
(ambele variante, inclusiv cel cu alegerea aleatoare a elementului pivot).
15. Ce sunt tehnicile de programare?
16. Principiile programării dinamice.
17. Prezentaţi şi explicaţi algoritmul recursiv al tăierii barei de oţel.
18. Prezentaţi şi explicaţi algoritmul tăierii barei de oţel utilizând metoda top-down din
programarea dinamică.
19. Prezentaţi şi explicaţi algoritmul tăierii barei de oţel utilizând metoda bottom-up
din programarea dinamică cu memorarea mărimilor bucăţilor ce vor fi tăiate
20. Prezentaţi şi explicaţi algoritmul bottom-up de determinare a costurilor înmulţirii
unui şir de matrici.
21. Prezentaţi şi explicaţi algoritmul recursiv de afişare a parantezării optime pentru un
produs de n matrici.
22. Principiile metodei greedy.
23. Prezentaţi cum se utilizează metoda greedy în cazul problemei selecţiei activităţilor.
24. Prezentaţi şi explicaţi algoritmul recursiv greedy de selecţie a activităţilor.
25. Prezentaţi şi explicaţi varianta greedy iterativă a algoritmului de selecţie a
activităţilor.
26. Care sunt paşii pe care îi vom utiliza într-o strategie greedy?
27. Principiile şi algoritmul metodei backtracking.
28. Prezentaţi şi explicaţi algoritmul nerecursiv de rezolvare a problemei damelor.
29. Ce este un tip de date şi ce este un tip abstract de date (ADT Abstract Data Type)?
30. Ce este o listă, cum se poate implementa o listă şi ce avantaje şi dezavantaje are
fiecare modalitate de implementare?

51
31. Prezentaţi şi explicaţi algoritmii de căutare, inserare în capul listei şi stergere într-o
listă înlănţuită.
32. Prezentaţi şi explicaţi algoritmul de căutare respectiv inserare într-o listă dublu
înlănţuită circulară cu santinelă
33. Ce este o stivă şi care sunt procedurile de depunere şi extragere dintr-o stivă?
34. Ce este o coadă şi care sunt procedurile de depunere şi extragere dintr-o coadă?
35. Ce este o tabelă direct adresabilă şi care sunt operaţiile într-o tabelă direct
adresabilă?
36. Ce este o tabelă de dispersie, ce este o funcţie de dispersie, ce sunt şi cum se
rezolvă coliziunile?
37. Prezentaţi şi explicaţi procedurile de inserare şi căutare într-o tabelă de dispersie.
38. Ce este un arbore binar de căutare?
39. Prezentaţi modalitătile de traversare a unui arbore binar şi algoritmii pentru fiecare
din această modalitate.
40. Prezentaţi şi explicaţi algoritmii iterativ respectiv recursiv de căutare într-un arbore
binar de căutare.
41. Prezentaţi şi explicaţi algoritmii de căutare a minimului, a maximului şi a
succesorului într-un arbore binar de căutare.
42. Prezentaţi şi explicaţi algoritmul de inserare într-un arbore binar de căutare.
43. Prezentaţi şi explicaţi algoritmul de ştergere într-un arbore binar de căutare.
44. Ce este un arbore roşu şi negru şi ce proprietăţi are?
45. Prezentaţi şi explicaţi algoritmul de rotaţie la stânga într-un arbore binar de
căutare.
46. Prezentaţi şi explicaţi algoritmul de inserare într-un arbore roşu şi negru.
47. Prezentaţi şi explicaţi algoritmul de restaurare a proprietăţilor unui arbore roşu şi
negru după inserarea unui nod.
48. Prezentaţi şi explicaţi algoritmul de restaurare a proprietăţilor unui arbore roşu şi
negru după ştergerea unui nod.
49. Ce este un graf şi care sunt cele două moduri de a reprezenta un graf?
50. Prezentaţi şi explicaţi algoritmul de parcurgere în lăţime pentru un graf reprezentat
cu liste de adiacenţă.
51. Ce este drumul de lungime minimă şi care este algoritmul de afişare a drumurilor
de lungime minimă?
52. Prezentaţi şi explicaţi algoritmul de parcurgere în adâncime pentru un graf
reprezentat cu liste de adiacenţă.
53. Ce este o sortare topologică şi care este algoritmul simplu de sortare topologică?
54. Ce este o componentă tare conexă şi care este algoritmul de determinare a
componentelor tare conexe dintr-un graf orientat?
55. Ce este un arbore de acoperire minim şi care este algoritmul generic de
determinare a unui arbore de acoperire minim?
56. Prezentaţi şi explicaţi algoritmul lui Kruskal de determinare a unui arbore de
acoperire minim.

52
57. Prezentaţi şi explicaţi algoritmul lui Prim de determinare a unui arbore de acoperire
minim.
58. Ce este un drum minim într-un graf şi care sunt variantele problemei drumului
minim?
59. Ce este relaxarea şi care este procedura de relaxare a unei muchii (u,v) într-un graf
cu costuri
60. Prezentaţi şi explicaţi algoritmul Bellman-Ford de rezolvare a problemei drumurilor
minime cu sursă unică în cazul general al unui graf care poate avea şi costuri
negative.
61. Prezentaţi şi explicaţi algoritmul de căutare a drumurilor minime într-un graf
orientat aciclic
62. Prezentaţi şi explicaţi algoritmul lui Dijkstra de determinare a drumurilor de
lungime minimă.

Bibliografie:

• T.H. Cormen; C.E. Leiserson; R.L. Rivest, C. Stein –Introduction to algorithms, ed. 3-
a, MIT Press, 2009, (ed. 1-a este tradusa în limba română).
• Aho A, Hopcroft J, Ullman J– Data Structures and Algorithms, Addison Wesley, 1983
• C. Bologa –Algoritmi şi structuri de date, Editura Risoprint, 2006.
• C. Giumale, I. Negreanu, S. Călinoiu, -Proiectarea şi analiza algoritmilor. Algoritmi de
Sortare, ed. ALL, Bucuresti, 1996
• D. Knuth - Arta programării calculatoarelor, vol, 1, 2, 3, ed. Teora, 1999 (traducere)

53

S-ar putea să vă placă și