Documente Academic
Documente Profesional
Documente Cultură
AIP. Manual de Algoritmi in C++ PDF
AIP. Manual de Algoritmi in C++ PDF
FUNDAMENTALI
O PERSPECTIV C++
RZVAN ANDONIE ILIE GRBACEA
ALGORITMI
FUNDAMENTALI
O PERSPECTIV C++
Editura Libris
Cluj-Napoca, 1995
Referent: Leon Livovschi
Coperta: Zolt n Albert
ISBN 973-96494-5-9
Cuvnt nainte
Leon Livovschi
v
n clipa cnd exprimm un lucru, reuim,
n mod bizar, s-l i depreciem.
Maeterlinck
Prefa
*
Fiierele surs ale tuturor exemplelor aproximativ 3400 de linii n 50 de fiiere pot fi obinute
pe o dischet MS-DOS, printr-o comand adresat editurii.
vii
viii Principii de algoritmi i C++ Cuprins
*
Autorii pot fi contactai prin pot, la adresa: Universitatea Transilvania, Catedra de electronic i
calculatoare, Politehnicii 1-3, 2200 Braov, sau prin E-mail, la adresa: algoritmi&c++@lbvi.sfos.ro
Cuprins
1. PRELIMINARII 1
1.1 Ce este un algoritm? 1
1.2 Eficiena algoritmilor 5
1.3 Cazul mediu i cazul cel mai nefavorabil 6
1.4 Operaie elementar 8
1.5 De ce avem nevoie de algoritmi eficieni? 9
1.6 Exemple 10
1.6.1 Sortare 10
1.6.2 Calculul determinanilor 10
1.6.3 Cel mai mare divizor comun 11
1.6.4 Numerele lui Fibonacci 12
1.7 Exerciii 13
ix
x Principii de algoritmi i C++ Cuprins
6.8 Cele mai scurte drumuri care pleac din acelai punct 134
6.9 Implementarea algoritmului lui Dijkstra 137
6.10 Euristica greedy 143
6.10.1 Colorarea unui graf 143
6.10.2 Problema comis-voiajorului 144
6.11 Exerciii 145
EPILOG 271
BIBLIOGRAFIE SELECTIV 273
Lista de notaii
n
combinri de n luate cte k
k
R mulimea numerelor reale nenegative
+ +
N ,R mulimea numerelor naturale (reale) strict pozitive
B mulimea constantelor booleene {true, false}
(x) | [P(x)] exist un x astfel nct P(x)
(x) | [P(x)] pentru orice x astfel nct P(x)
x cel mai mare ntreg mai mic sau egal cu x
x cel mai mic ntreg mai mare sau egal cu x
O, , notaie asimptotic (vezi Seciunea 5.1.1)
atribuire
(a, b) muchia unui graf orientat
{a, b} muchia unui graf neorientat
xiii
1. Preliminarii
1
2 Preliminarii Capitolul 1
Un algoritm este compus dintr-o mulime finit de pai, fiecare necesitnd una sau
mai multe operaii. Pentru a fi implementabile pe calculator, aceste operaii
trebuie s fie n primul rnd definite, adic s fie foarte clar ce anume trebuie
executat. n al doilea rnd, operaiile trebuie s fie efective, ceea ce nseamn c
n principiu, cel puin o persoan dotat cu creion i hrtie trebuie s poat
efectua orice pas ntr-un timp finit. De exemplu, aritmetica cu numere ntregi este
efectiv. Aritmetica cu numere reale nu este ns efectiv, deoarece unele numere
sunt exprimabile prin secvene infinite. Vom considera c un algoritm trebuie s
se termine dup un numr finit de operaii, ntr-un timp rezonabil de lung.
Programul este exprimarea unui algoritm ntr-un limbaj de programare. Este bine
ca nainte de a nva concepte generale, s fi acumulat deja o anumit experien
practic n domeniul respectiv. Presupunnd c ai scris deja programe ntr-un
limbaj de nivel nalt, probabil c ai avut uneori dificulti n a formula soluia
pentru o problem. Alteori, poate c nu ai putut decide care dintre algoritmii care
rezolvau aceeai problem este mai bun. Aceast carte v va nva cum s evitai
aceste situaii nedorite.
Studiul algoritmilor cuprinde mai multe aspecte:
i) Elaborarea algoritmilor. Actul de creare a unui algoritm este o art care nu
va putea fi niciodat pe deplin automatizat. Este n fond vorba de
mecanismul universal al creativitii umane, care produce noul printr-o
sintez extrem de complex de tipul:
tehnici de elaborare (reguli) + creativitate (intuiie) = soluie.
Un obiectiv major al acestei cri este de a prezenta diverse tehnici
fundamentale de elaborare a algoritmilor. Utiliznd aceste tehnici, acumulnd
i o anumit experien, vei fi capabili s concepei algoritmi eficieni.
ii) Exprimarea algoritmilor. Forma pe care o ia un algoritm ntr-un program
trebuie s fie clar i concis, ceea ce implic utilizarea unui anumit stil de
programare. Acest stil nu este n mod obligatoriu legat de un anumit limbaj de
programare, ci, mai curnd, de tipul limbajului i de modul de abordare.
Astfel, ncepnd cu anii 80, standardul unanim acceptat este cel de
programare structurat. n prezent, se impune standardul programrii
orientate pe obiect.
iii) Validarea algoritmilor. Un algoritm, dup elaborare, nu trebuie n mod
necesar s fie programat pentru a demonstra c funcioneaz corect n orice
situaie. El poate fi scris iniial ntr-o form precis oarecare. n aceast
form, algoritmul va fi validat, pentru a ne asigura c algoritmul este corect,
independent de limbajul n care va fi apoi programat.
iv) Analiza algoritmilor. Pentru a putea decide care dintre algoritmii ce rezolv
aceeai problem este mai bun, este nevoie s definim un criteriu de apreciere
a valorii unui algoritm. n general, acest criteriu se refer la timpul de calcul
i la memoria necesar unui algoritm. Vom analiza din acest punct de vedere
toi algoritmii prezentai.
Seciunea 1.1 Ce este un algoritm? 3
45 19 19
22 38
11 76 76
5 152 152
2 304
1 608 608
855
de sub nmulitor. Se aplic regula, pn cnd numrul de sub denmulit este 1. n
final, adunm toate numerele din coloana nmulitorului care corespund, pe linie,
unor numere impare n coloana denmulitului. n cazul nostru, obinem:
19 + 76 + 152 + 608 = 855.
Cu toate c pare ciudat, aceasta este tehnica folosit de hardware-ul multor
calculatoare. Ea prezint avantajul c nu este necesar s se memoreze tabla de
nmulire. Totul se rezum la adunri i nmuliri/mpriri cu 2 (acestea din urm
fiind rezolvate printr-o simpl decalare).
Pentru a reprezenta algoritmul, vom utiliza un limbaj simplificat, numit
pseudo-cod, care este un compromis ntre precizia unui limbaj de programare i
uurina n exprimare a unui limbaj natural. Astfel, elementele eseniale ale
algoritmului nu vor fi ascunse de detalii de programare neimportante n aceast
faz. Dac suntei familiarizat cu un limbaj uzual de programare, nu vei avea nici
4 Preliminarii Capitolul 1
function russe(A, B)
arrays X, Y
{iniializare}
X[1] A; Y[1] B
i 1 {se construiesc cele dou coloane}
while X[i] > 1 do
X[i+1] X[i] div 2 {div reprezint mprirea ntreag}
Y[i+1] Y[i]+Y[i]
i i+1
{adun numerele Y[i] corespunztoare numerelor X[i] impare}
prod 0
while i > 0 do
if X[i] este impar then prod prod+Y[i]
i i1
return prod
Un programator cu experien va observa desigur c tablourile X i Y nu sunt de
fapt necesare i c programul poate fi simplificat cu uurin. Acest algoritm
poate fi programat deci n mai multe feluri, chiar folosind acelai limbaj de
programare.
Pe lng algoritmul de nmulire nvat n coal, iat c mai avem un algoritm
care face acelai lucru. Exist mai muli algoritmi care rezolv o problem, dar i
mai multe programe care pot descrie un algoritm.
Acest algoritm poate fi folosit nu doar pentru a nmuli pe 45 cu 19, dar i pentru
a nmuli orice numere ntregi pozitive. Vom numi (45, 19) un caz (instance).
Pentru fiecare algoritm exist un domeniu de definiie al cazurilor pentru care
algoritmul funcioneaz corect. Orice calculator limiteaz mrimea cazurilor cu
care poate opera. Aceast limitare nu poate fi ns atribuit algoritmului respectiv.
nc o dat, observm c exist o diferen esenial ntre programe i algoritmi.
Dac un algoritm necesit timp n ordinul lui n, vom spune c necesit timp liniar,
iar algoritmul respectiv putem s-l numim algoritm liniar. Similar, un algoritm
este ptratic, cubic, polinomial, sau exponenial dac necesit timp n ordinul lui
2 3 k n
n , n , n , respectiv c , unde k i c sunt constante.
Un obiectiv major al acestei cri este analiza teoretic a eficienei algoritmilor.
Ne vom concentra asupra criteriului timpului de execuie. Alte resurse necesare
(cum ar fi memoria) pot fi estimate teoretic ntr-un mod similar. Se pot pune i
probleme de compromis memorie - timp de execuie.
Vom vedea mai trziu c timpul de execuie pentru sortarea prin selecie este
ptratic, independent de ordonarea iniial a elementelor. Testul if T[ j] < minx
este executat de tot attea ori pentru oricare dintre cazuri. Relativ micile variaii
ale timpului de execuie se datoreaz doar numrului de executri ale atribuirilor
din ramura then a testului.
La sortarea prin inserie, situaia este diferit. Pe de o parte, insert(U) este foarte
rapid, deoarece condiia care controleaz bucla while este mereu fals. Timpul
necesar este liniar. Pe de alt parte, insert(V) necesit timp ptratic, deoarece
bucla while este executat de i1 ori pentru fiecare valoare a lui i. (Vom analiza
acest lucru n Capitolul 5).
Dac apar astfel de variaii mari, atunci cum putem vorbi de un timp de execuie
care s depind doar de mrimea cazului considerat? De obicei considerm
analiza pentru cel mai nefavorabil caz. Acest tip de analiz este bun atunci cnd
timpul de execuie al unui algoritm este critic (de exemplu, la controlul unei
centrale nucleare). Pe de alt parte ns, este bine uneori s cunoatem timpul
mediu de execuie al unui algoritm, atunci cnd el este folosit foarte des pentru
cazuri diferite. Vom vedea c timpul mediu pentru sortarea prin inserie este tot
ptratic. n anumite cazuri ns, acest algoritm poate fi mai rapid. Exist un
algoritm de sortare (quicksort) cu timp ptratic pentru cel mai nefavorabil caz, dar
cu timpul mediu n ordinul lui n log n. (Prin log notm logaritmul ntr-o baz
oarecare, lg este logaritmul n baza 2, iar ln este logaritmul natural). Deci, pentru
cazul mediu, quicksort este foarte rapid.
Analiza comportrii n medie a unui algoritm presupune cunoaterea a priori a
distribuiei probabiliste a cazurilor considerate. Din aceast cauz, analiza pentru
cazul mediu este, n general, mai greu de efecuat dect pentru cazul cel mai
nefavorabil.
Atunci cnd nu vom specifica pentru ce caz analizm un algoritm, nseamn c
eficiena algoritmului nu depinde de acest aspect (ci doar de mrimea cazului).
function Wilson(n)
{returneaz true dac i numai dac n este prim}
if n divide ((n1)! + 1) then return true
else return false
Dac considerm calculul factorialului i testul de divizibilitate ca operaii
elementare, atunci eficiena testului de primalitate este foarte mare. Dac
considerm c factorialul se calculeaz n funcie de mrimea lui n, atunci
eficiena testului este mai slab. La fel i cu testul de divizibilitate.
Deci, este foarte important ce anume definim ca operaie elementar. Este oare
adunarea o operaie elementar? n teorie, nu, deoarece i ea depinde de lungimea
operanzilor. Practic, pentru operanzi de lungime rezonabil (determinat de modul
de reprezentare intern), putem s considerm c adunarea este o operaie
elementar. Vom considera n continuare c adunrile, scderile, nmulirile,
mpririle, operaiile modulo (restul mpririi ntregi), operaiile booleene,
comparaiile i atribuirile sunt operaii elementare.
nou main, nu i ntr-un nou algoritm. Astfel, pentru n = 10, pe maina veche,
algoritmul nou necesit 10 secunde, adic de o sut de ori mai mult dect
algoritmul vechi. Pe vechiul calculator, algoritmul nou devine mai performant
doar pentru cazuri mai mari sau egale cu 20.
1.6 Exemple
Poate c v ntrebai dac este ntr-adevr posibil s accelerm att de spectaculos
un algoritm. Rspunsul este afirmativ i vom da cteva exemple.
1.6.1 Sortare
Algoritmii de sortare prin inserie i prin selecie necesit timp ptratic, att n
cazul mediu, ct i n cazul cel mai nefavorabil. Cu toate c aceti algoritmi sunt
exceleni pentru cazuri mici, pentru cazuri mari avem algoritmi mai eficieni. n
capitolele urmtoare vom analiza i ali algoritmi de sortare: heapsort, mergesort,
quicksort. Toi acetia necesit un timp mediu n ordinul lui n log n, iar heapsort
i mergesort necesit timp n ordinul lui n log n i n cazul cel mai nefavorabil.
Pentru a ne face o idee asupra diferenei dintre un timp ptratic i un timp n
ordinul lui n log n, vom meniona c, pe un anumit calculator, quicksort a reuit
s sorteze n 30 de secunde 100.000 de elemente, n timp ce sortarea prin inserie
ar fi durat, pentru acelai caz, peste nou ore. Pentru un numr mic de elemente
ns, eficiena celor dou sortri este asemntoare.
Un prim algoritm pentru aflarea celui mai mare divizor comun al ntregilor
pozitivi m i n, notat cu cmmdc(m, n), se bazeaz pe definiie:
function cmmdc-def (m, n)
i min(m, n) + 1
repeat i i 1 until i divide pe m i n
return i
Timpul este n ordinul diferenei dintre min(m, n) i cmmdc(m, n).
Exist, din fericire, un algoritm mult mai eficient, care nu este altul dect celebrul
algoritm al lui Euclid.
function Euclid(m, n)
if n = 0 then return m
else return Euclid(n, m mod n)
Prin m mod n notm restul mpririi ntregi a lui m la n. Algoritmul funcioneaz
pentru orice ntregi nenuli m i n, avnd la baz cunoscuta proprietate
cmmdc(m, n) = cmmdc(n, m mod n)
Timpul este n ordinul logaritmului lui min(m, n), chiar i n cazul cel mai
nefavorabil, ceea ce reprezint o mbuntire substanial fa de algoritmul
precedent. Pentru a fi exaci, trebuie s menionm c algoritmul originar al lui
Euclid (descris n Elemente, aprox. 300 a.Ch.) opereaz prin scderi succesive,
i nu prin mprire. Interesant este faptul c acest algoritm se pare c provine
dintr-un algoritm i mai vechi, datorat lui Eudoxus (aprox. 375 a.Ch.).
f 0 = 0; f 1 = 1
f n = f n 1 + f n 2 pentru n2
h 2kh+t
k k +t
2
n n div 2
return j
V recomandm s comparai aceti trei algoritmi, pe calculator, pentru diferite
valori ale lui n.
14 Preliminarii Capitolul 1
1.7 Exerciii
1.1 Aplicai algoritmii insert i select pentru cazurile T = [1, 2, 3, 4, 5, 6] i
U = [6, 5, 4, 3, 2, 1]. Asigurai-v c ai neles cum funcioneaz.
1.4 Elaborai un algoritm care s returneze cel mai mare divizor comun a trei
ntregi nenuli.
Soluie:
function Euclid-trei(m, n, p)
return Euclid(m, Euclid(n, p))
1.6 Elaborai un algoritm care returneaz cel mai mare divizor comun a doi
termeni de rang oarecare din irul lui Fibonacci.
Indicaie: Un algoritm eficient se obine folosind urmtoarea proprietate *,
valabil pentru oricare doi termeni ai irului lui Fibonacci:
cmmdc( f m , f n ) = f cmmdc(m, n)
0 1
1.7 Fie matricea M = . Calculai produsul vectorului ( f n1 , f n ) cu
1 1
m
matricea M , unde f n1 i f n sunt doi termeni consecutivi oarecare ai irului lui
Fibonacci.
*
Aceast surprinztoare proprietate a fost descoperit n 1876 de Lucas.
2. Programare
orientat pe obiect
Dei aceast carte este dedicat n primul rnd analizei i elaborrii algoritmilor,
am considerat util s folosim numeroii algoritmi care sunt studiai ca un pretext
pentru introducerea elementelor de baz ale programrii orientate pe obiect n
limbajul C++. Vom prezenta n capitolul de fa noiuni fundamentale legate de
obiecte, limbajul C++ i de abstractizarea datelor n C++, urmnd ca, pe baza
unor exemple detaliate, s conturm n capitolele urmtoare din ce n ce mai clar
tehnica programrii orientate pe obiect. Scopul urmrit este de a surprinde acele
aspecte strict necesare formrii unei impresii juste asupra programrii orientate pe
obiect n limbajul C++, i nu de a substitui cartea de fa unui curs complet de
C++.
14
Seciunea 2.1 Conceptul de obiect 15
utilizarea direct a obiectelor. Din acest punct de vedere, menionm dou mari
categorii de limbaje:
Limbaje care ofer doar faciliti de abstractizarea datelor i ncapsulare, cum
sunt Ada i Modula-2. De exemplu, n Ada, datele i procedurile care le
manipuleaz pot fi grupate ntr-un pachet (package).
Limbaje orientate pe obiect, care adaug abstractizrii datelor noiunea de
motenire.
Dei definiiile de mai sus restrng mult mulimea limbajelor calificabile ca
orientate pe obiect, aceste limbaje rmn totui foarte diverse, att din punct de
vedere al conceptelor folosite, ct i datorit modului de implementare. S-au
conturat trei mari familii, fiecare accentund un anumit aspect al noiunii de
obiect: limbaje de clase, limbaje de cadre (frames) i limbaje de tip actor.
Limbajul C++ * aparine familiei limbajelor de clase. O clas este un tip de date
care descrie un ansamblu de obiecte cu aceeai structur i acelai comportament.
Clasele pot fi mbogite i completate pentru a defini alte familii de obiecte. n
acest mod se obin ierarhii de clase din ce n ce mai specializate, care motenesc
datele i metodele claselor din care au fost create. Din punct de vedere istoric
primele limbaje de clase au fost Simula (1973) i Smalltalk-80 (1983). Limbajul
Simula a servit ca model pentru o ntreg linie de limbaje caracterizate printr-o
organizare static a tipurilor de date.
S vedem acum care sunt principalele deosebiri dintre limbajele C i C++, precum
i modul n care s-au implementat intrrile/ieirile n limbajul C++.
++
2.2.1 Diferenele dintre limbajele C i C+
*
Limbaj dezvoltat de Bjarne Stroustrup la nceputul anilor 80, n cadrul laboratoarelor Bell de la
AT&T, ca o extindere orientat pe obiect a limbajului C.
Seciunea 2.2 Limbajul C++ 17
n acest exemplu, funcia maxim() este declarat ca o funcie de tip float cu doi
parametri tot de tip float, motiv pentru care constanta ntreag 3 este convertit
n momentul apelului la tipul float. Declaraia unei funcii const n prototipul
funciei, care conine tipul valorii returnate, numele funciei, numrul i tipul
parametrilor. Diferena dintre definiie i declaraie noiuni valabile i pentru
variabile const n faptul c definiia este o declaraie care provoac i
rezervare de spaiu sau generare de cod. Declararea unei variabile se face prin
precedarea obligatorie a definiiei de cuvntul cheie extern. i o declaraie de
funcie poate fi precedat de cuvntul cheie extern, accentund astfel c funcia
este definit altundeva.
Definirea unor funcii foarte mici, pentru care procedura de apel tinde s dureze
mai mult dect executarea propriu-zis, se realizeaz n limbajul C++ prin
funciile inline.
(Prin apelarea funciei putchar(), putem afla care din cele dou funcii maxim()
este efectiv invocat).
n limbajul C++ nu este obligatorie definirea variabilelor locale strict la nceputul
blocului de instruciuni. n exemplul de mai jos, tabloul buf i ntregul i pot fi
utilizate din momentul definirii i pn la sfritul blocului n care au fost
definite.
18 Programare orientat pe obiect Capitolul 2
#define DIM 5
void f( ) {
int buf[ DIM ];
struct punct {
float x; /* coordonatele unui */
float y; /* punct din plan */
};
void g( ) {
const int dim = 5;
struct punct buf[ dim ];
sim2( buf[ i ] );
print( buf[ i ] );
}
}
#include <stdio.h>
int main( ) {
puts( "\n main." );
r
main.
f( )
iiiii 4 3 3 4
g( )
(-2.5, -0.0) (-1.5, -1.0) (-0.5, -2.0)
( 0.5, -3.0) ( 1.5, -4.0)
---
suprind prin faptul c funcia float maxim( float, float ) este invocat
naintea funciei main(). Acest lucru este normal, deoarece variabila x trebuie
iniializat naintea lansrii n execuie a funciei main().
++
2.2.2 Intrri/ieiri n limbajul C+
#include <iostream.h>
int main( ) {
cout << "\nTermenul sirului lui Fibonacci de rang ... ";
int n;
cin >> n;
return 0;
}
Biblioteca standard C++ conine definiiile unor clase care reprezint diferite
tipuri de fluxuri de comunicaie (stream-uri). Fiecare flux poate fi de intrare, de
ieire, sau de intrare/ieire. Operaia primar pentru fluxul de ieire este
inserarea de date, iar pentru cel de ieire este extragerea de date. Fiierul prefix
(header) iostream.h conine declaraiile fluxului de intrare (clasa istream), ale
fluxului de ieire (clasa ostream), precum i declaraiile obiectelor cin i cout:
Operaiile de inserare i extragere sunt realizate prin funciile membre ale claselor
ostream i istream. Deoarece limbajul C++ permite existena unor funcii care
suprancarc o parte din operatorii predefinii, s-a convenit ca inserarea s se fac
prin suprancarcarea operatorului de decalare la stnga <<, iar extragerea prin
suprancrcarea celui de decalare la dreapta >>. Semnificaia secvenei de
instruciuni
cin >> n;
cout << " este " << fib2( n );
este deci urmtoarea: se citete valoarea lui n, apoi se afieaz irul " este "
urmat de valoarea returnat de funcia fib2().
Fluxurile de comunicaie cin i cout lucreaz n mod implicit cu terminalul
utilizatorului. Ca i pentru programele scrise n C, este posibil redirectarea lor
spre alte dispozitive sau n diferite fiiere, n funcie de dorina utilizatorului.
Pentru sistemele de operare UNIX i DOS, redirectrile se indic adugnd
22 Programare orientat pe obiect Capitolul 2
typedef struct {
int min; /* marginea inferioara a intervalului */
int max; /* marginea superioara a intervalului */
int v; /* valoarea, min <= v, v < max */
} intErval;
Efectul acestor definiii const n rezervarea de spaiu pentru fiecare din datele
membre ale obiectelor numar, indice i limita. n plus, datele membre din
numar sunt iniializate cu valorile 80 (min), 32 (max) i 64 (v). Iniializarea, dei
corect din punct de vedere sintactic, face imposibl funcionarea tipului
intErval, deoarece marginea inferioar nu este mai mic dect cea superioar.
Deocamdat nu avem nici un mecanism pentru a evita astfel de situaii.
Pentru manipularea obiectelor de tip intErval, putem folosi atribuiri la nivel de
structur:
limita = numar;
funcia de citire val() pur i simplu returneaz valoarea v. Practic, aceste dou
funcii implementeaz o form de ncapsulare, izolnd reprezentarea intern a
obiectului de restul programului.
Utiliznd consecvent cele dou metode ale tipului intErval, obinem obiecte ale
cror valori sunt cu certitudine ntre limitele admisibile. De exemplu, utiliznd
metodele atr() i val(), instruciunea
indice.v = numar.v + 1;
devine
Deoarece numar are valoarea 64, iar domeniul indice-lui este 32, ..., 64,
instruciunea de mai sus semnaleaz depirea domeniului variabilei indice i
provoac terminarea executrii programului.
Aceast implementare este departe de a fi complet i comod de utilizat. Nu ne
referim acum la aspecte cum ar fi citirea (sau scrierea) obiectelor de tip
intErval, operaie rezolvabil printr-o funcie de genul
indice.v = numar.v + 1;
++
2.3.2 Tipul intErval n limbajul C+
class intErval {
public:
int atr( int );
int val( ) { return v; }
private:
int verDom( int );
intErval numar;
intErval indice, limita;
Aceste obiecte pot fi atribuite ntre ele (fiind structuri atribuirea se va face
membru cu membru):
limita = numar;
indice.atr( numar.val( ) + 1 );
indice.v = numar.v + 1;
care, dei corect din punct de vedere sintactic, este incorect semantic, deoarece
v este un membru private, deci inaccesibil prin intermediul obiectelor indice i
numar.
Dup cum se observ, au disprut argumentele de tip intErval* i intErval ale
funciilor atr(), respectiv val(). Cauza este faptul c funciile membre au un
argument implicit, concretizat n obiectul invocator, adic obiectul care
selecteaz funcia. Este o convenie care ntrete i mai mult atributul de funcie
membr (metod) deoarece permite invocarea unei astfel de funcii numai prin
obiectul respectiv.
Definirea funciilor membre se poate face fie n corpul clasei, fie n exteriorul
acestuia. Funciile definite n corpul clasei sunt considerate implicit inline, iar
pentru cele definite n exteriorul corpului se impune precizarea statutului de
funcie membr. nainte de a defini funciile atr() i verDom(), s observm c
funcia val(), definit n corpul clasei intErval, ncalc de dou ori cele
precizate pn aici. n primul rnd, nu selecteaz membrul v prin intermediul unui
obiect, iar n al doilea rnd, v este privat! Dac funcia val() ar fi fost o funcie
obinuit, atunci observaia ar fi fost ct se poate de corect. Dar val() este
funcie membr i atunci:
Nu poate fi apelat dect prin intermediul unui obiect invocator i toi membrii
utilizai sunt membrii obiectului invocator.
Seciunea 2.3 Clase n limbajul C++ 27
class intErval {
public:
// operatorul de atribuire corespunzator functiei atr()
int operator =( int i ) { return v = verDom( i ); }
private:
int verDom( int );
indice = (int)numar + 1;
sau direct
indice = numar + 1;
Sunt dou ntrebri la care trebuie s rspundem referitor la funcia de mai sus:
Care este semnificaia testului if ( is >> i )?
De ce se returneaz istream-ul?
Seciunea 2.3 Clase n limbajul C++ 29
intErval indice;
definiia
intErval indice;
class intErval {
public:
intErval( int = 1, int = 0 );
~intErval( ) { }
private:
int verDom( int );
Se observ apariia unei noi funcii membre, numit ~intErval(), al crui corp
este vid. Ea se numete destructor, nu are tip i nici argumente, iar numele ei este
obinut prin precedarea numelui clasei de caracterul ~. Rolul destructorului este
opus celui al constructorului, n sensul c realizeaz operaiile necesare
distrugerii corecte a obiectului. Destructorul este invocat automat, nainte de a
elibera spaiul alocat datelor membre ale obiectului care nceteaz s mai existe.
Un obiect nceteaz s mai existe n urmtoarele situaii:
Obiectele definite ntr-o funcie sau bloc de instruciuni (obiecte cu existen
local) nceteaz s mai existe la terminarea executrii funciei sau blocului
respectiv.
Obiectele definite global, n exteriorul oricrei funcii, sau cele definite
static (obiecte cu existen static) nceteaz s mai existe la terminarea
programului.
Obiectele alocate dinamic prin operatorul new (obiecte cu existen dinamic)
nceteaz s mai existe la invocarea operatorului delete.
Ca i n cazul constructorilor, prezena destructorului ntr-o clas este opional,
fiind lsat la latitudinea proiectantului clasei.
Pentru a putea fi inclus n toate fiierele surs n care este utilizat, definiia unei
clase se introduce ntr-un fiier header (prefix). n scopul evitrii includerii de
mai multe ori a aceluiai fiier (includeri multiple), se recomand ca fiierele
header s aib structura
32 Programare orientat pe obiect Capitolul 2
#ifndef simbol
#define simbol
// continutul fisierului
#endif
unde simbol este un identificator unic n program. Dac fiierul a fost deja inclus,
atunci identificatorul simbol este deja definit, i deci, toate liniile situate ntre
#ifndef i #endif vor fi ignorate. De exemplu, n fiierul intErval.h, care
conine definiia clasei intErval, identificatorul simbol ar putea fi
__INTeRVAL_H. Iat coninutul acestui fiier:
#ifndef __INTeRVAL_H
#define __INTeRVAL_H
#include <iostream.h>
class intErval {
public:
intErval( int = 1, int = 0 );
~intErval( ) { }
private:
int verDom( int );
#endif
Funciile membre se introduc ntr-un fiier surs obinuit, care este legat dup
compilare de programul executabil. Pentru clasa intErval, acest fiier este:
#include "intErval.h"
#include <stdlib.h>
exit( 1 );
}
min = v = inf;
max = sup;
}
#include <iostream.h>
#include "intErval.h"
int main( ) {
cout << "\nTermenul sirului lui Fibonacci de rang ... ";
return 0;
}
2.4 Exerciii *
2.1 Scriei un program care determin termenul de rang n al irului lui
Fibonacci prin algoritmii fib1 i fib3.
2.2 Care sunt valorile maxime ale lui n pentru care algoritmii fib1, fib2 i fib3
returneaz valori corecte? Cum pot fi mrite aceste valori?
Soluie: Presupunnd c un long este reprezentat pe 4 octei, atunci cel mai mare
numr Fibonacci reprezentabil pe long este cel cu rangul 46. Lucrnd pe
unsigned long, se poate ajunge pn la termenul de rang 47. Pentru aceste
ranguri, timpii de execuie ai algoritmului fib1 difer semnificativ de cei ai
algoritmilor fib2 i fib3.
*
Chiar dac nu se precizeaz explicit, toate implementrile se vor realiza n limbajul C++.
Seciunea 2.4 Exerciii 35
n 1 n 1
+ pentru 0 < k < n
k 1 k
n
=
k
1 altfel
n n!
=
m m !(n m)!
dar i ineficient, deoarece numrul apelurilor recursive este foarte mare (vezi
Exerciiul 8.1). Programul complet este:
#include <iostream.h>
void main( ) {
int n, m;
for ( n = 0; n < N; n++ )
for ( m = 0; m < M; m++ ) r[n][m] = 0;
tr = 0;
cout << "\nCombinari de (maxim " << N << ") ... ";
cin >> n;
cout << " luate cate ... ";
cin >> m;
cout << "sunt " << C( n, m ) << '\n';
Se observ c C(1,1) a fost invocat de 210 ori, iar C(2,2) de 126 de ori!
3. Structuri elementare
de date
3.1 Liste
O list este o colecie de elemente de informaie (noduri) aranjate ntr-o anumit
ordine. Lungimea unei liste este numrul de noduri din list. Structura
corespunztoare de date trebuie s ne permit s determinm eficient care este
primul/ultimul nod n structur i care este predecesorul/succesorul (dac exist)
unui nod dat. Iat cum arat cea mai simpl list, lista liniar:
O list circular este o list n care, dup ultimul nod, urmeaz primul, deci
fiecare nod are succesor i predecesor.
Operaii curente care se fac n liste sunt: inserarea unui nod, tergerea
(extragerea) unui nod, concatenarea unor liste, numrarea elementelor unei liste
etc. Implementarea unei liste se poate face n principal n dou moduri:
Implementarea secvenial, n locaii succesive de memorie, conform ordinii
nodurilor n list. Avantajele acestei tehnici sunt accesul rapid la
predecesorul/succesorul unui nod i gsirea rapid a primului/ultimului nod.
Dezavantajele sunt inserarea/tergerea relativ complicat a unui nod i faptul
c, n general, nu se folosete ntreaga memorie alocat listei.
Implementarea nlnuit. n acest caz, fiecare nod conine dou pri:
informaia propriu-zis i adresa nodului succesor. Alocarea memoriei fiecrui
nod se poate face n mod dinamic, n timpul rulrii programului. Accesul la un
nod necesit parcurgerea tuturor predecesorilor si, ceea ce poate lua ceva mai
37
38 Structuri elementare de date Capitolul 3
mult timp. Inserarea/tergerea unui nod este n schimb foarte rapid. Se pot
folosi dou adrese n loc de una, astfel nct un nod s conin pe lng adresa
nodului succesor i adresa nodului predecesor. Obinem astfel o list dublu
nlnuit, care poate fi traversat n ambele direcii.
Listele implementate nlnuit pot fi reprezentate cel mai simplu prin tablouri. n
acest caz, adresele sunt de fapt indici de tablou. O alternativ este s folosim
tablouri paralele: s memorm informaia fiecrui nod (valoarea) ntr-o locaie
VAL[i] a tabloului VAL[1 .. n], iar adresa (indicele) nodului su succesor ntr-o
locaie LINK[i] a tabloului LINK[1 .. n]. Indicele de tablou al locaiei primului
nod este memorat n variabila head. Vom conveni ca, pentru cazul listei vide, s
avem head = 0. Convenim de asemenea ca LINK[ultimul nod din list] = 0.
Atunci, VAL[head] va conine informaia primului nod al listei, LINK[head]
adresa celui de-al doilea nod, VAL[LINK[head]] informaia din al doilea nod,
LINK[LINK[head]] adresa celui de-al treilea nod etc.
Acest mod de reprezentare este simplu dar, la o analiz mai atent, apare o
problem esenial: cea a gestionrii locaiilor libere. O soluie elegant este s
reprezentm locaiile libere tot sub forma unei liste nlnuite. Atunci, tergerea
unui nod din lista iniial implic inserarea sa n lista cu locaii libere, iar
inserarea unui nod n lista iniial implic tergerea sa din lista cu locaii libere.
Aspectul cel mai interesant este c, pentru implementarea listei de locaii libere,
putem folosi aceleai tablouri. Avem nevoie de o alt variabil, freehead, care va
conine indicele primei locaii libere din VAL i LINK. Folosim aceleai convenii:
dac freehead = 0 nseamn c nu mai avem locaii libere, iar LINK[ultima locaie
liber] = 0.
Vom descrie n continuare dou tipuri de liste particulare foarte des folosite.
3.1.1 Stive
3.1.2 Cozi
O coad (queue) este o list liniar n care inserrile se fac doar n capul listei,
iar extragerile doar din coada listei. Cozile se numesc i liste FIFO (First In First
Out).
O reprezentare secvenial interesant pentru o coad se obine prin utilizarea
unui tablou C[0 .. n1], pe care l tratm ca i cum ar fi circular: dup locaia
C[n1] urmeaz locaia C[0]. Fie tail variabila care conine indicele locaiei
40 Structuri elementare de date Capitolul 3
predecesoare primei locaii ocupate i fie head variabila care conine indicele
locaiei ocupate ultima oar. Variabilele head i tail au aceeai valoare atunci i
numai atunci cnd coada este vid. Iniial, avem head = tail = 0. Inserarea i
tergerea (extragerea) unui nod necesit timp constant.
function insert-queue(x, C[0 .. n1])
{adaug nodul x n capul cozii}
head (head+1) mod n
if head = tail then return coad plin
C[head] x
return succes
function delete-queue(C[0 .. n1])
{terge nodul din coada listei i l returneaz}
if head = tail then return coad vid
tail (tail+1) mod n
x C[tail]
return x
Este surprinztor faptul c testul de coad vid este acelai cu testul de coad
plin. Dac am folosi toate cele n locaii, atunci nu am putea distinge ntre situaia
de coad plin i cea de coad vid, deoarece n ambele situaii am avea
head = tail. n consecin, se folosesc efectiv numai n1 locaii din cele n ale
tabloului C, deci se pot implementa astfel cozi cu cel mult n1 noduri.
3.2 Grafuri
Un graf este o pereche G = <V, M>, unde V este o mulime de vrfuri, iar
M V V este o mulime de muchii. O muchie de la vrful a la vrful b este
notat cu perechea ordonat (a, b), dac graful este orientat, i cu mulimea
{a, b}, dac graful este neorientat. n cele ce urmeaz vom presupune c vrfurile
a i b sunt diferite. Dou vrfuri unite printr-o muchie se numesc adiacente. Un
drum este o succesiune de muchii de forma
(a 1 , a 2 ), (a 2 , a 3 ), , (a n1 , a n )
sau de forma
{a 1 , a 2 }, {a 2 , a 3 }, , {a n1 , a n }
dup cum graful este orientat sau neorientat. Lungimea drumului este egal cu
numrul muchiilor care l constituie. Un drum simplu este un drum n care nici un
vrf nu se repet. Un ciclu este un drum care este simplu, cu excepia primului i
ultimului vrf, care coincid. Un graf aciclic este un graf fr cicluri. Un subgraf
al lui G este un graf <V', M'>, unde V' V, iar M' este format din muchiile din M
care unesc vrfuri din V'. Un graf parial este un graf <V, M">, unde M" M.
Seciunea 3.2 Grafuri 41
Un graf neorientat este conex, dac ntre oricare dou vrfuri exist un drum.
Pentru grafuri orientate, aceast noiune este ntrit: un graf orientat este tare
conex, dac ntre oricare dou vrfuri i i j exist un drum de la i la j i un drum
de la j la i.
n cazul unui graf neconex, se pune problema determinrii componentelor sale
conexe. O component conex este un subgraf conex maximal, adic un subgraf
conex n care nici un vrf din subgraf nu este unit cu unul din afar printr-o
muchie a grafului iniial. mprirea unui graf G = <V, M> n componentele sale
conexe determin o partiie a lui V i una a lui M.
Un arbore este un graf neorientat aciclic conex. Sau, echivalent, un arbore este un
graf neorientat n care exist exact un drum ntre oricare dou vrfuri *. Un graf
parial care este arbore se numete arbore parial.
Vrfurilor unui graf li se pot ataa informaii numite uneori valori, iar muchiilor li
se pot ataa informaii numite uneori lungimi sau costuri.
Exist cel puin trei moduri evidente de reprezentare ale unui graf:
Printr-o matrice de adiacen A, n care A[i, j] = true dac vrfurile i i j sunt
adiacente, iar A[i, j] = false n caz contrar. O variant alternativ este s-i dm
lui A[i, j] valoarea lungimii muchiei dintre vrfurile i i j, considernd
A[i, j] = + atunci cnd cele dou vrfuri nu sunt adiacente. Memoria necesar
2
este n ordinul lui n . Cu aceast reprezentare, putem verifica uor dac dou
vrfuri sunt adiacente. Pe de alt parte, dac dorim s aflm toate vrfurile
adiacente unui vrf dat, trebuie s analizm o ntreag linie din matrice.
Aceasta necesit n operaii (unde n este numrul de vrfuri n graf),
independent de numrul de muchii care conecteaz vrful respectiv.
Prin liste de adiacen, adic prin ataarea la fiecare vrf i a listei de vrfuri
adiacente lui (pentru grafuri orientate, este necesar ca muchia s plece din i).
ntr-un graf cu m muchii, suma lungimilor listelor de adiacen este 2m, dac
graful este neorientat, respectiv m, dac graful este orientat. Dac numrul
muchiilor n graf este mic, aceast reprezentare este preferabil din punct de
vedere al memoriei necesare. Este posibil s examinm toi vecinii unui vrf
dat, n medie, n mai puin de n operaii. Pe de alt parte, pentru a determina
dac dou vrfuri i i j sunt adiacente, trebuie s analizm lista de adiacen a
lui i (i, posibil, lista de adiacen a lui j), ceea ce este mai puin eficient dect
consultarea unei valori logice n matricea de adiacen.
Printr-o list de muchii. Aceast reprezentare este eficient atunci cnd avem
de examinat toate muchiile grafului.
*
n Exerciiul 3.2 sunt date i alte propoziii echivalente care caracterizeaz un arbore.
42 Structuri elementare de date Capitolul 3
nivelul adncimea
2 0 alpha
1 1 beta gamma
valoarea vrfului
adresa fiului stng
adresa fiului drept
c d
arborelui este nlimea rdcinii; nivelul unui vrf este nlimea arborelui,
minus adncimea acestui vrf.
Reprezentarea unui arbore cu rdcin se poate face prin adrese, ca i n cazul
listelor nlnuite. Fiecare vrf va fi memorat n trei locaii diferite, reprezentnd
informaia propriu-zis a vrfului (valoarea vrfului), adresa celui mai vrstnic fiu
i adresa urmtorului frate. Pstrnd analogia cu listele nlnuite, dac se
cunoate de la nceput numrul maxim de vrfuri, atunci implementarea arborilor
cu rdcin se poate face prin tablouri paralele.
Dac fiecare vrf al unui arbore cu rdcin are pn la n fii, arborele respectiv
este n-ar. Un arbore binar poate fi reprezentat prin adrese, ca n Figura 3.2.
Observm c poziiile pe care le ocup cei doi fii ai unui vrf sunt semnificative:
lui a i lipsete fiul drept, iar b este fiul stng al lui a.
k
ntr-un arbore binar, numrul maxim de vrfuri de adncime k este 2 . Un arbore
binar de nlime i are cel mult 2 1 vrfuri, iar dac are exact 2 1 vrfuri, se
i+1 i+1
2 3
4 5 6 7
8 9 10 11 12 13 14 15
reprezentat un arbore binar complet cu zece vrfuri, obinut din arborele plin din
Figura 3.3, prin eliminarea vrfurilor 11, 12, 13, 14 i 15. Tatl unui vrf
reprezentat n T[i], i > 1, se afl n T[i div 2]. Fiii unui vrf reprezentat n T[i] se
afl, dac exist, n T[2i] i T[2i+1].
T [1]
T [2] T [3]
3.4 Heap-uri
Un heap (n traducere aproximativ, grmad ordonat) este un arbore binar
complet, cu urmtoarea proprietate, numit proprietate de heap: valoarea fiecrui
vrf este mai mare sau egal cu valoarea fiecrui fiu al su. Figura 3.5 prezint un
exemplu de heap.
Acelai heap poate fi reprezentat secvenial prin urmtorul tablou:
10 7 9 4 7 5 2 2 1 6
T[1] T[2] T[3] T[4] T[5] T[6] T[7] T[8] T[9] T[10]
Caracteristica de baz a acestei structuri de dat este c modificarea valorii unui
vrf se face foarte eficient, pstrndu-se proprietatea de heap. Dac valoarea unui
vrf crete, astfel nct depete valoarea tatlui, este suficient s schimbm ntre
ele aceste dou valori i s continum procedeul n mod ascendent, pn cnd
proprietatea de heap este restabilit. Vom spune c valoarea modificat a fost
filtrat ( percolated ) ctre noua sa poziie. Dac, dimpotriv, valoarea vrfului
10
7 9
4 7 5 2
2 1 6
scade, astfel nct devine mai mic dect valoarea cel puin a unui fiu, este
suficient s schimbm ntre ele valoarea modificat cu cea mai mare valoare a
fiiilor, apoi s continum procesul n mod descendent, pn cnd proprietatea de
heap este restabilit. Vom spune c valoarea modificat a fost cernut (sifted
down) ctre noua sa poziie. Urmtoarele proceduri descriu formal operaiunea de
modificare a valorii unui vrf ntr-un heap.
procedure alter-heap(T[1 .. n], i, v)
{T[1 .. n] este un heap; lui T[i], 1 i n, i se atribuie
valoarea v i proprietatea de heap este restabilit}
x T[i]
T[i] v
if v < x then sift-down(T, i)
else percolate(T, i)
procedure sift-down(T[1 .. n], i)
{se cerne valoarea din T[i]}
ki
repeat
jk
{gsete fiul cu valoarea cea mai mare}
if 2j n and T[2j] > T[k] then k 2j
if 2j < n and T[2j+1] > T[k] then k 2j+1
interschimb T[ j] i T[k]
until j = k
procedure percolate(T[1 .. n], i)
{se filtreaz valoarea din T[i]}
ki
repeat
jk
if j > 1 and T[ j div 2] < T[k] then k j div 2
interschimbT[ j] i T[k]
until j = k
Heap-ul este structura de date ideal pentru determinarea i extragerea maximului
dintr-o mulime, pentru inserarea unui vrf, pentru modificarea valorii unui vrf.
Sunt exact operaiile de care avem nevoie pentru a implementa o list dinamic de
prioriti: valoarea unui vrf va da prioritatea evenimentului corespunztor.
Evenimentul cu prioritatea cea mai mare se va afla mereu la rdcina heap-ului,
iar prioritatea unui eveniment poate fi modificat n mod dinamic. Algoritmii care
efectueaz aceste operaii sunt:
function find-max(T[1 .. n])
{returneaz elementul cel mai mare din heap-ul T}
return T[1]
Seciunea 3.4 Heap-uri 47
6 9
2 7 5 2
7 4 10
7 10 5 2
2 4 7
7 10
2 4 7
se transform succesiv n:
10 10
7 6 7 7
2 4 7 2 4 6
Subarborele de nivel 2 din dreapta este deja heap. Dup acest pas, tabloul T
devine:
1 10 9 7 7 5 2 2 4 6
Urmeaz apoi s repetm procedeul i pentru nivelul 3, obinnd n final heap-ul
din Figura 3.5.
Un min-heap este un heap n care proprietatea de heap este inversat: valoarea
fiecrui vrf este mai mic sau egal cu valoarea fiecrui fiu al su. Evident,
rdcina unui min-heap va conine n acest caz cel mai mic element al heap-ului.
n mod corespunztor, se modific i celelalte proceduri de manipulare a
heap-ului.
Seciunea 3.4 Heap-uri 49
Chiar dac heap-ul este o structur de date foarte atractiv, exist totui i
operaii care nu pot fi efectuate eficient ntr-un heap. O astfel de operaie este, de
exemplu, gsirea unui vrf avnd o anumit valoare dat.
Conceptul de heap poate fi mbuntit n mai multe feluri. Astfel, pentru aplicaii
n care se folosete mai des procedura percolate dect procedura sift-down,
renteaz ca un vrf neterminal s aib mai mult de doi fii. Aceasta accelereaz
procedura percolate. i un astfel de heap poate fi implementat secvenial.
Heap-ul este o structur de date cu numeroase aplicaii, inclusiv o remarcabil
tehnic de sortare, numit heapsort.
procedure heapsort(T[1 .. n])
{sorteaz tabloul T}
make-heap(T)
for i n downto 2 do
interschimb T[1] i T[i]
sift-down(T[1 .. i1], 1)
Structura de heap a fost introdus (Williams, 1964) tocmai ca instrument pentru
acest algoritm de sortare.
function find1(x)
{returneaz eticheta mulimii care l conine pe x}
return set[x]
procedure merge1(a, b)
{fuzioneaz mulimile etichetate cu a i b}
i a; j b
if i > j then interschimb i i j
for k j to N do
if set[k] = j then set[k] i
Dac consultarea sau modificarea unui element dintr-un tablou conteaz ca o
operaie elementar, atunci se poate demonstra (Exerciiul 3.7) c o serie de n
operaii merge1 i find1 necesit, pentru cazul cel mai nefavorabil i pornind de la
2
starea iniial, un timp n ordinul lui n .
ncercm s mbuntim aceti algoritmi. Folosind n continuare acelai tablou,
vom reprezenta fiecare mulime ca un arbore cu rdcin inversat. Adoptm
urmtoarea tehnic: dac set[i] = i, atunci i este att eticheta unei mulimi, ct i
rdcina arborelui corespunztor; dac set[i] = j i, atunci j este tatl lui i ntr-un
arbore. De exemplu, tabloul:
1 2 3 2 1 3 4 3 3 4
set[1] set[2] set[10]
reprezint arborii:
1 2 3
5 4 6 8 9
7 10
procedure merge2(a, b)
{fuzioneaz mulimile etichetate cu a i b}
if a < b then set[b] a
else set[a] b
O serie de n operaii find2 i merge2 necesit, pentru cazul cel mai nefavorabil i
2
pornind de la starea iniial, un timp tot n ordinul lui n (Exerciiul 3.7). Deci,
deocamdat, nu am ctigat nimic fa de prima variant a acestor algoritmi.
Aceasta deoarece dup k apeluri ale lui merge2, se poate s ajungem la un arbore
de nlime k, astfel nct un apel ulterior al lui find2 s ne pun n situaia de a
parcurge k muchii pn la rdcin.
Pn acum am ales (arbitrar) ca elementul minim s fie eticheta unei mulimi.
Cnd fuzionm doi arbori de nlime h 1 i respectiv h 2 , este bine s facem astfel
nct rdcina arborelui de nlime mai mic s devin fiu al celeilalte rdcini.
Atunci, nlimea arborelui rezultat va fi max(h 1 , h 2 ), dac h 1 h 2 , sau h 1 +1, dac
h 1 = h 2 . Vom numi aceast tehnic regul de ponderare. Aplicarea ei implic
renunarea la convenia ca elementul minim s fie eticheta mulimii respective.
Avantajul este c nlimea arborilor nu mai crete att de rapid. Putem demonstra
(Exerciiul 3.9) c folosind regula de ponderare, dup un numr arbitrar de
fuzionri, pornind de la starea iniial, un arbore avnd k vrfuri va avea nlimea
maxim lg k.
nlimea arborilor poate fi memorat ntr-un tablou H[1 .. N], astfel nct H[i] s
conin nlimea vrfului i n arborele su curent. n particular, dac a este
eticheta unei mulimi, H[a] va conine nlimea arborelui corespunztor. Iniial,
H[i] = 0 pentru 1 i N. Algoritmul find2 rmne valabil, dar vom modifica
algoritmul de fuzionare.
procedure merge3(a, b)
{fuzioneaz mulimile etichetate cu a i b;
presupunem c a b}
if H[a] = H[b]
then H[a] H[a]+1
set[b] a
else if H[a] > H[b]
then set[b] a
else set[a] b
O serie de n operaii find2 i merge3 necesit, pentru cazul cel mai nefavorabil i
pornind de la starea iniial, un timp n ordinul lui n log n.
Continum cu mbuntirile, modificnd algoritmul find2. Vom folosi tehnica
comprimrii drumului, care const n urmtoarele. Presupunnd c avem de
determinat mulimea care l conine pe x, traversm (conform cu find2) muchiile
care conduc spre rdcina arborelui. Cunoscnd rdcina, traversm aceleai
52 Structuri elementare de date Capitolul 3
6 6
4 9 4 9 20 10
11 1 10 8 11 1 8 21 16 12
12 20
21 16
(a) (b)
muchii din nou, modificnd acum fiecare vrf ntlnit n cale astfel nct s
conin direct adresa rdcinii. Folosind tehnica comprimrii drumului, nu mai
este adevrat c nlimea unui arbore cu rdcina a este dat de H[a]. Totui,
H[a] reprezint n acest caz o limit superioar a nlimii i procedura merge3
rmne, cu aceast observaie, valabil. Algoritmul find2 devine:
function find3(x)
{returneaz eticheta mulimii care l conine pe x}
rx
while set[r] r do r set[r]
{r este rdcina arborelui}
ix
while i r do
j set[i]
set[i] r
ij
return r
De exemplu, executnd operaia find3(20) asupra arborelui din Figura 3.6a,
obinem arborele din Figura 3.6b.
Algoritmii find3 i merge3 sunt o variant considerabil mbuntit a
procedurilor de tip find i merge. O serie de n operaii find3 i merge3 necesit,
pentru cazul cel mai nefavorabil i pornind de la starea iniial, un timp n ordinul
lui n lg N, unde lg este definit astfel:
lg N = min{k | lg lg ... lg N 0}
14243
de k ori
Seciunea 3.5 Structuri de mulimi disjuncte 53
3.6 Exerciii
3.1 Scriei algoritmii de inserare i de tergere a unui nod pentru o stiv
implementat prin tehnica tablourilor paralele.
3.5 Fie T[1 .. 12] un tablou, astfel nct T[i] = i, pentru i < 12. Determinai
starea tabloului dup fiecare din urmtoarele apeluri de procedur, aplicate
succesiv:
make-heap(T); alter-heap(T, 12, 10); alter-heap(T, 1, 6); alter-heap(T, 5, 6)
54 Structuri elementare de date Capitolul 3
3.7 n situaia n care, consultarea sau modificarea unui element din tablou
conteaz ca o operaie elementar, demonstrai c timpul de execuie necesar
pentru o secven de n operaii find1 i merge1, pornind din starea iniial i
2
pentru cazul cel mai nefavorabil, este n ordinul lui n . Demonstrai aceeai
proprietate pentru find2 i merge2.
Soluie: find1 necesit un timp constant i cel mai nefavorabil caz l reprezint
secvena:
merge1(N, N1); find1(N)
merge1(N1, N2); find1(N)
merge1(Nn+1, Nn); find1(N)
n aceast secven, merge1(Ni+1, Ni) necesit un timp n ordinul lui i. Timpul
2
total este n ordinul lui 1+2++n = n(n+1)/2, deci n ordinul lui n . Simetric,
merge2 necesit un timp constant i cel mai nefavorabil caz l reprezint secvena:
merge2(N, N1); find2(N)
merge2(N1, N2); find2(N),
merge2(Nn+1, Nn); find2(N)
n care find2(i) necesit un timp n ordinul lui i etc.
3.12 Gsii un algoritm pentru a determina dac un graf neorientat este conex.
Folosii o structur de mulimi disjuncte.
Indicaie: Presupunem c graful este reprezentat printr-o list de muchii.
Considerm iniial c fiecare vrf formeaz o submulime (n acest caz, o
component conex a grafului). Dup fiecare citire a unei muchii {a, b} operm
fuzionarea merge3(find3(a), find3(b)), obinnd astfel o nou component conex.
Procedeul se repet, pn cnd terminm de citit toate muchiile grafului. Graful
este conex, dac i numai dac tabloul set devine constant. Analizai eficiena
algoritmului.
n general, prin acest algoritm obinem o partiionare a vrfurilor grafului n
submulimi dou cte dou disjuncte, fiecare submulime coninnd exact vrfurile
cte unei componente conexe a grafului.
4.1 Tablouri
n mod surprinztor, ncepem cu tabloul, structur fundamental, predefinit n
majoritatea limbajelor de programare. Necesitatea de a elabora o nou structur
de acest tip provine din urmtoarele inconveniente ale tablourilor predefinite,
inconveniente care nu sunt proprii numai limbajelor C i C++:
Numrul elementelor unui tablou trebuie s fie o expresie constant, fixat n
momentul compilrii.
Pe parcursul execuiei programului este imposibil ca un tablou s fie mrit sau
micorat dup necesiti.
56
Seciunea 4.1 Tablouri 57
Diferena fundamental dintre tipul abstract pe care l vom elabora i tipul tablou
predefinit const n alocarea dinamic, n timpul execuiei programului, a
spaiului de memorie necesar stocrii elementelor sale. n limbajul C, alocarea
dinamic se realizeaz prin diversele variante ale funciei malloc(), iar
eliberarea zonelor alocate se face prin funcia mfree(). Limbajul C++ a introdus
alocarea dinamic n structura limbajului. Astfel, pentru alocare avem operatorul
new. Acest operator returneaz adresa * zonei de memorie alocat, sau valoarea 0
dac alocarea nu s-a putut face. Pentru eliberarea memoriei alocate prin
intermediul operatorului new, se folosete un alt operator numit delete.
Programul urmtor exemplific detaliat funcionarea acestor doi operatori.
#include <iostream.h>
#include "intErval.h"
int main( ) {
// Operatorul new are ca argumente numele unui tip T
// (predefinit sau definit de utilizator) si dimensiunea
// zonei care va fi alocata. Valoarea returnata este de
// tip "pointer la T". Operatorul new returneaza 0 in
// cazul in care alocarea nu a fost posibila.
*
n limbajul C++, tipul de dat care conine adrese este numit pointer. n continuare, vom folosi
termenul pointer, doar atunci cnd ne referim la tipul de dat. Termenul adres va fi folosit
pentru a ne referi la valoarea datelor de tip pointer.
58 Tipuri abstracte de date Capitolul 4
delete [ ] pf;
delete [ ] pi;
delete i;
delete f;
delete [ ] pi_m;
delete m;
return 0;
}
delete [ ] pointer;
#include <iostream.h>
class X {
public:
X( ) { cout << '*'; }
~X( ) { cout << '~'; }
private:
int x;
};
int main( ) {
cout << '\n';
X *p =new X [ 4 ];
delete p;
p = new X [ 2 ];
delete [ ] p;
_new_handler = no_mem;
void no_mem( ) {
cerr << "\n\n no mem. \n\n";
exit( 1 );
}
set_new_handler( no_mem );
Noul tip, numit tablou, va avea ca date membre numrul de elemente i adresa
zonei de memorie n care sunt memorate acestea. Datele membre fiind private,
adic inaccesibile din exteriorul clasei, oferim posibilitatea obinerii numrului
elementelor tabloului prin intermediul unei funcii membre publice numit
size(). Iat definiia complet a clasei tablou.
class tablou {
public:
// constructorii si destructorul
tablou( int = 0 ); // constructor (numarul de elemente)
tablou( const tablou& ); // constructor de copiere
~tablou( ) { delete a; } // elibereaza memoria alocata
private:
int d; // numarul elementelor (dimensiunea) tabloului
int *a; // adresa zonei alocate
tablou x( n );
// ...
cout << x[ i ];
cin >> x[ i ];
i = j = k;
// unde i, j si k sunt variabile de orice tip predefinit
S vedem ce trebuie s facem ca, prin noul operator de atribuire definit, s putem
scrie
iT = jT = kT;
// iT, jT si kT sunt obiecte de tip tablou
return *this;
tablou x;
// ...
tablou y = x; // se invoca constructorul de copiere
Utilitatea clasei tablou este strict limitat la tablourile de ntregi, dei un tablou
de float, char, sau de orice alt tip T, se manipuleaz la fel, funciile i datele
membre fiind practic identice. Pentru astfel de situaii, limbajul C++ ofer
posibilitatea generrii automate de clase i funcii pe baza unor abloane
(template). Aceste abloane, numite i clase parametrice, respectiv funcii
parametrice, depind de unul sau mai muli parametri care, de cele mai multe ori,
sunt tipuri predefinite sau definite de utilizator.
ablonul este o declaraie prin care se specific forma general a unei clase sau
funcii. Iat un exemplul simplu: o funcie care returneaz maximul a dou valori
de tip T.
Acest ablon se citete astfel: max() este o funcie cu dou argumente de tip T,
care returneaz maximul celor dou argumente, adic o valoare de tip T. Tipul T
poate fi orice tip predefinit, sau definit de utilizator, cu conditia s aib definit
operatorul de comparare >, fr de care funcia max() nu poate funciona.
64 Tipuri abstracte de date Capitolul 4
*
n prezent sunt utilizate dou modele generale pentru instanierea (generarea) abloanelor, fiecare
cu anumite avantaje i dezavantaje. Reprezentative pentru aceste modele sunt compilatoarele
Borland C++ i translatoarele Cfront de la AT&T. Ambele modele sunt compatibile cu plasarea
abloanelor n fiiere header.
Seciunea 4.1 Tablouri 65
tablou<float> y( 16 );
tablou<int> x( 32 );
tablou<unsigned char> z( 64 );
#ifndef __TABLOU_H
#define __TABLOU_H
#include <iostream.h>
protected:
int d; // numarul elementelor (dimensiunea) tabloului
T *a; // adresa zonei alocate
char v; // indicator verificare indice
template<class T>
tablou<T>::tablou( int dim ) {
a = 0; v = 0; d = 0; // valori implicite
if ( dim > 0 ) // verificarea dimensiunii
a = new T [ d = dim ]; // alocarea memoriei
}
66 Tipuri abstracte de date Capitolul 4
template<class T>
void tablou<T>::init( const tablou<T>& t ) {
a = 0; v = 0; d = 0; // valori implicite
if ( t.d > 0 ) { // verificarea dimensiunii
a = new T [ d = t.d ]; // alocarea si copierea elem.
for ( int i = 0; i < d; i++ ) a[ i ] = t.a[ i ];
v = t.v; // duplicarea indicatorului
} // pentru verificarea indicilor
}
return z;
}
// citirea elementelor
for ( int i = 0; i < n; is >> t[ i++ ] );
return is;
}
68 Tipuri abstracte de date Capitolul 4
return os;
}
Aceti operatori sunt utilizabili doar dac obiectelor de tip T li se pot aplica
operatorii de extragere/inserare >>, respectiv <<. n caz contrar, orice ncercare de
a aplica obiectelor de tip tablou<T> operatorii mai sus definii, va fi semnalata ca
eroare la compilarea programului.
Operatorul de extragere (citire) >> prezint o anumit particularitate fa de
celelalte funcii care opereaz asupra tablourilor: trebuie s modifice chiar
dimensiunea tabloului. Dou variante de a realiza aceast operaie, dintre care una
prin intermediul funciei newsize( ), sunt discutate n Exerciiile 4.2 i 4.3.
Marcarea erorilor la citire se realizeaz prin modificarea corespunztoare a strii
istream-ului prin
is.clear( ios::failbit );
Dup cum am precizat n Seciunea 2.3.2, starea unui istream se poate testa
printr-un simplu if ( cin >> ... ). Odat ce un istream a ajuns ntr-o stare
de eroare, nu mai rspunde la operatorii respectivi, dect dup ce este readus la
starea normal de utilizare prin instruciunea
is.clear();
Clasa stiva<T> este un tip nou, derivat din clasa tablou<T>. n limbajul C++,
derivarea se indic prin specificarea claselor de baz (pot fi mai multe!), imediat
dup numele clasei.
Fiecare clas de baz este precedat de atributul public sau private, prin care
se specific modalitatea de motenire. O clas derivat public este un subtip al
clasei de baz, iar una derivat private este un tip nou, distinct fa de tipul de
baz.
Clasa derivat motenete toi membrii clasei de baz, cu excepia constructorilor
i destructorilor, dar nu are acces la membrii private ai clasei de baz. Atunci
cnd este necesar, acest incovenient poate fi evitat prin utilizarea n clasa de baz
a nivelului de acces protected n locul celui private. Membrii protected sunt
membri privai, dar accesibili claselor derivate. Nivelul de acces al membrilor
motenii se modific prin derivare astfel:
Membrii neprivai dintr-o clas de baz public i pstreaz nivelele de acces
i n clasa derivat.
Membrii neprivai dintr-o clas de baz privat devin membri private n clasa
derivat.
Revenind la clasa stiva<T>, putem spune c motenete de la clasa de baz
tablou<T> membrii
int d;
T *a;
#ifndef __STIVA_H
#define __STIVA_H
#include <iostream.h>
#include "tablou.h"
private:
int s; // indicele ultimului element inserat
};
#endif
intereseaz doar algoritmul. Dar, cum implementm efectiv aceast funcie, astfel
nct s cuprindem ambele situaii? ntrebarea poate fi formulat n contextul mult
mai general al tratrii excepiilor. Rezolvarea unor cazuri particulare, a
excepiilor de la anumite reguli, problem care nu este strict de domeniul
programrii, poate da mai puine dureri de cap prin aplicarea unor principii foarte
simple. Iat, de exemplu, un astfel de principiu formulat de Winston Churchill:
Nu m intrerupei n timp ce ntrerup.
Tratarea excepiilor devine o chestiune foarte complicat, mai ales n cazul
utilizrii unor funcii sau obiecte dintr-o bibliotec. Autorul unei biblioteci de
funcii (obiecte) poate detecta excepiile din timpul execuiei dar, n general, nu
are nici o idee cum s le trateze. Pe de alt parte, utilizatorul bibliotecii tie ce s
fac n cazul apariiei unor excepii, dar nu le poate detecta. Noiunea de excepie,
noiune acceptat de Comitetul de standardizare ANSI C++, introduce un
mecanism consistent de rezolvare a unor astfel de situaii. Ideea este ca, n
momentul cnd o funcie detecteaz o situaie pe care nu o poate rezolva, s
semnaleze (throw) o excepie, cu sperana c una din funciile (direct sau
indirect) invocatoare va rezolva apoi problema. O funcie care este pregtit
pentru acest tip de evenimente i va anuna n prealabil disponibilitatea de a trata
(catch) excepii.
Mecanismul schiat mai sus este o alternativ la tehnicile tradiionale, atunci cnd
acestea se dovedesc a fi inadecvate. El ofer o cale de separare explicit a
secvenelor pentru tratarea erorilor de codul propriu-zis, programul devenind
astfel mai clar i mult mai uor de ntreinut. Din pcate, la nivelul anului 1994,
foarte puine compilatoare C++ implementeaz complet mecanismul throw
catch. Revenim de aceea la stilul clasic, stil independent de limbajul de
programare folosit. Uzual, la ntlnirea unor erori se acioneaz n unul din
urmtoarele moduri:
Se termin programul.
Se returneaz o valoare reprezentnd eroare.
Se returneaz o valoare legal, programul fiind lsat ntr-o stare ilegal.
Se invoc o funcie special construit de programator pentru a fi apelat n caz
de eroare.
Terminarea programului se realizeaz prin revenirea din funcia main(), sau prin
invocarea unei funcii de bibliotec numit exit(). Valoarea returnat de main(),
precum i argumentul ntreg al funciei exit(), este interpretat de sistemul de
operare ca un cod de retur al programului. Un cod de retur nul (zero) semnific
executarea corect a programului.
Pn n prezent, am utilizat tratarea excepiilor prin terminarea programului n
clasa intErval. Un alt exemplu de tratare a excepiilor se poate remarca la
operatorul de indexare din clasa tablou<T>. Aici am utilizat penultima alternativ
72 Tipuri abstracte de date Capitolul 4
din cele patru enunate mai sus: valoarea returnat este legal, dar programul nu a
avut posibilitatea de a trata eroarea.
Pentru stiv i, de fapt, pentru multe din structurile implementate aici i
susceptibile la situaii de excepie, am ales varianta a doua: returnarea unei valori
reprezentnd eroare. Pentru a putea distinge ct mai simplu situaiile normale de
cazurile de excepie, am convenit ca funcia pop() s transmit elementul din
vrful stivei prin intermediul unui argument de tip referin, valoarea returnat
efectiv de funcie indicnd existena sau inexistena acestui element. Astfel,
secvena
while( s.pop( v ) ) {
// ...
}
se execut att timp ct n stiva s mai sunt elemente, variabila v avnd de fiecare
dat valoarea elementului din vrful stivei. Funcia push() are un comportament
asemntor, secvena
while( s.push( v ) ) {
// ...
}
void f( ) {
stiva<int> x( 16 );
stiva<int> y = x;
x = y;
}
Vom utiliza structura de heap descris n Seciunea 3.4 pentru implementarea unei
clase definit prin operaiile de inserare a unei valori i de extragere a maximului.
Clasa parametric heap<T> seamn foarte mult cu clasele stiva<T> i
coada<T>. Diferenele apar doar la implementarea operaiilor de inserare n heap
i de extragere a maximului. Definiia clasei heap<T> este:
#ifndef __HEAP_H
#define __HEAP_H
#include <iostream.h>
#include <stdlib.h>
#include "tablou.h"
74 Tipuri abstracte de date Capitolul 4
protected:
int h; // indicele ultimului element din heap
do {
j = k;
if ( j > 1 && A[ k ] > A[ j/2 ] ) k = j/2;
T tmp = A[ j ]; A[ j ] = A[ k ]; A[ k ] = tmp;
} while ( j != k );
}
Seciunea 4.2 Stive, cozi, heap-uri 75
do {
j = k;
if ( 2*j <= n && A[ 2*j ] > A[ k ] ) k = 2*j;
if ( 2*j < n && A[ 2*j+1 ] > A[ k ] ) k = 2*j+1;
T tmp = A[ j ]; A[ j ] = A[ k ]; A[ k ] = tmp;
} while ( j != k );
}
#endif
#include "intErval.h"
#include "heap.h"
int main( ) {
heap<intErval> hi( SIZE );
intErval v( SIZE );
76 Tipuri abstracte de date Capitolul 4
cout << "Inserare in heap (^Z/#" << (SIZE - 1) << ")\n... ";
while ( cin >> v ) {
hi.insert( v );
cout << "... ";
}
cin.clear( );
return 0;
}
#include "intErval.h"
int main( ) {
intErval x1;
const intErval x2( 20, 10 );
x1 = x2;
return 0;
}
Dei nu este invocat explicit, operatorul de conversie la int este aplicat variabilei
constante x2. nainte de a discuta motivul acestei invocri, s ne oprim puin
asupra manipulrii obiectelor constante. Pentru acest tip de variabile (variabile
constante!), aa cum este x2, se invoc doar funciile membre declarate explicit
const, funcii care nu modific obiectul invocator. O astfel de funcie fiind i
Seciunea 4.2 Stive, cozi, heap-uri 77
Acelai efect l are i definirea non-const a obiectului x2, dar scopul nu este de a
elimina mesajul, ci de a nelege (i de a elimina) cauza lui.
Atribuirea x1 = x2 ar trebui rezolvat de operatorul de atribuire generat automat
de compilator, pentru fiecare clas. n cazul nostru, acest operator nu se invoc,
deoarece atribuirea poate fi rezolvat numai prin intermediul funciilor membre
explicit definite:
x2 este convertit la int prin operator int( ), conversie care genereaz i
mesajul discutat mai sus
Rezultatul conversiei este atribuit lui x1 prin operator =(int).
Din pcate, rezultatul atribuirii este incorect. n loc ca x2 s fie copiat n x1, va fi
actualizat doar valoarea v a lui x1 cu valoarea v lui x2. Evident c, n exemplul
de mai sus, x1 va semnala depirea domeniului su.
Soluia pentru eliminarea acestei aparente anomalii, generate de interferena
dintre operator int( ) i operator =(int), const n definirea explicit a
operatorului de atribuire pentru obiecte de tip intErval:
Particularitatea acestuia const doar n tipul valorii returnate, const T&, valoare
imposibil de modificat. Consistena declaraiei const, asociat operatorului de
indexare, este dat de ctre proiectantul clasei i nu poate fi verificat semantic
de ctre compilator. O astfel de declaraie poate fi ataat chiar i operatorului de
78 Tipuri abstracte de date Capitolul 4
indexare obinuit (cel non-const), cci el nu modific nici una din datele membre
ale clasei tablou<T>. Ar fi ns absurd, deoarece tabloul se modific de fapt prin
modificarea elementelor sale.
tergerea unui nod din list (operaie care necesit cunoaterea nu numai a
adresei elementului de eliminat, ci i a celui anterior):
// ...
// eliberarea spatiului de memorie alocat nodului de
// adresa a, nod tocmai eliminat din lista
listei i, desigur, a nodurilor, fiind invizibil din exterior. Conteaz doar tipul
informaiilor din list i nimic altceva. Iat de ce clasa nod<E> poate fi n
ntregime nepublic:
tablou<int> T( 32 );
T[ 31 ] = 1;
n cazul listelor, locul indicelui este luat de elementul curent. Ca i indicele, care
nu este memorat n clasa tablou, acest element curent nu are de ce s fac parte
din structura clasei lista<T>. Putem avea oricte elemente curente,
corespunztoare orictor parcurgeri, tot aa cum un tablou poate fi adresat prin
orici indici. Analogia tablou-list se sfrete aici. Locul operatorului de
indexare [] nu este luat de o funcie membr, ci de o clas special numit
iterator<E>.
ntr-o variant minim, datele membre din clasa iterator<E> sunt:
Seciunea 4.3 Clasa lista<E> 81
adic adresa nodului actual (curent) i adresa adresei primului element al listei.
De ce adresa adresei? Pentru ca iteratorul s rmn funcional i n situaia
eliminrii primului element din list. Operatorul (), numit n terminologia
specific limbajului C++ iterator, este cel care implementeaz efectiv operaia de
parcurgere
return os;
}
return *this;
}
return *this;
}
Seciunea 4.3 Clasa lista<E> 83
while( a ) {
nod<E> *pn = a->next;
delete a;
a = pn;
}
head = 0;
}
protected:
nod( const E& v ): val( v ) { next = 0; }
private:
nod<E> *head; // adresa primul nod din lista
private:
nod<E>* const *phead;
nod<E> *a;
};
4.4 Exerciii
4.1 n cazul alocrii dinamice, este mai rentabil ca memoria s se aloce n
blocuri mici sau n blocuri mari?
Soluie: Rulai urmtorul program. Atenie, stiva programului trebuie s fie
suficient de mare pentru a rezista apelurilor recursive ale funciei
alocareDinmica().
#include <iostream.h>
main( ) {
for ( unsigned i = 1024; i > 32; i /= 2 ) {
nivel = 1; raport = 0;
alocareDinamica( 64 * i - 1 );
}
return 1;
}
Rezultatele obinute sunt clar n favoarea blocurilor mari. Explicaia const n
faptul c fiecrui bloc alocat i se adaug un antet necesar gestionrii zonelor
ocupate i a celor libere, zone organizate n dou liste nlnuite.
#include <iostream.h>
#include "tablou.h"
int main( ) {
tablou<int> y( 12 );
if ( dN > 0 ) {
aN = new T [ dN ]; // alocarea dinamica a memoriei
for ( int i = d < dN? d: dN; i--; )
aN[ i ] = a[ i ]; // alocarea dinamica a memoriei
}
else
dN = 0;
return *this;
}
private:
int head; // indicele ultimei locatii ocupate
int tail; // indicele locatiei predecesoare primei
// locatii ocupate
};
#include <iostream.h>
#include "stiva.h"
#include "coada.h"
void main( ) {
int n, i = 0;
cout << "Numarul elementelor ... "; cin >> n;
stiva<int> st( n );
coada<int> cd( n );
#include <iostream.h>
#include "tablou.h"
#include "lista.h"
main( ) {
lista<PTI> tablist;
return 1;
}
Vom dezvolta n acest capitol aparatul matematic necesar pentru analiza eficienei
algoritmilor, ncercnd ca aceast incursiune matematic s nu fie excesiv de
formal. Apoi, vom arta, pe baza unor exemple, cum poate fi analizat un
algoritm. O atenie special o vom acorda tehnicilor de analiz a algoritmilor
recursivi.
Cu alte cuvinte, O( f ) (se citete ordinul lui f ) este mulimea tuturor funciilor t
mrginite superior de un multiplu real pozitiv al lui f, pentru valori suficient de
mari ale argumentului. Vom conveni s spunem c t este n ordinul lui f (sau,
echivalent, t este n O( f ), sau t O( f )) chiar i atunci cnd valoarea f (n) este
negativ sau nedefinit pentru anumite valori n < n 0 . n mod similar, vom vorbi
despre ordinul lui f chiar i atunci cnd valoarea t(n) este negativ sau nedefinit
pentru un numr finit de valori ale lui n; n acest caz, vom alege n 0 suficient de
mare, astfel nct, pentru n n 0 , acest lucru s nu mai apar. De exemplu, vom
vorbi despre ordinul lui n/log n, chiar dac pentru n = 0 i n = 1 funcia nu este
definit. n loc de t O( f ), uneori este mai convenabil s folosim notaia
t(n) O( f (n)), subnelegnd aici c t(n) i f (n) sunt funcii.
89
90 Analiza eficienei algoritmilor Capitolul 5
Fie un algoritm dat i fie o funcie t : N R astfel nct o anumit implementare
a algoritmului s necesite cel mult t(n) uniti de timp pentru a rezolva un caz de
mrime n, n N. Principiul invarianei (menionat n Capitolul 1) ne asigur c
orice implementare a algoritmului necesit un timp n ordinul lui t. Mai mult,
acest algoritm necesit un timp n ordinul lui f pentru orice funcie f : N R
pentru care t O( f ). n particular, t O(t). Vom cuta n general s gsim cea
mai simpl funcie f, astfel nct t O( f ).
Proprietile de baz ale lui O( f ) sunt date ca exerciii (Exerciiile 5.15.7) i
este recomandabil s le studiai nainte de a trece mai departe.
Notaia asimptotic definete o relaie de ordine parial ntre funcii i deci, ntre
eficiena relativ a diferiilor algoritmi care rezolv o anumit problem. Vom da
n continuare o interpretare algebric a notaiei asimptotice. Pentru oricare dou
funcii f , g : N R , definim urmtoarea relaie binar: f g dac O( f ) O(g).
Relaia este o relaie de ordine parial n mulimea funciilor definite pe N i
cu valori n R (Exerciiul 5.6). Definim i o relaie de echivalen: f g dac
O( f ) = O(g).
n mulimea O( f ) putem nlocui pe f cu orice alt funcie echivalent cu f. De
exemplu, lg n ln n log n i avem O(lg n) = O(ln n) = O(log n). Notnd cu O(1)
ordinul funciilor mrginite superior de o constant, obinem ierarhia:
3 3
= O(n /2) = O(n )
chiar dac pentru 0 n 6 polinomul este negativ. Exerciiul 5.8 trateaz cazul
unui polinom oarecare.
Seciunea 5.1 Notaia asimptotic 91
Notaia O( f ) este folosit pentru a limita superior timpul necesar unui algoritm,
msurnd eficiena algoritmului respectiv. Uneori este util s estimm i o limit
inferioar a acestui timp. n acest scop, definim mulimea
+
( f ) = {t : N R | (c R ) (n 0 N) (n n 0 ) [t(n) cf (n)]}
Muli algoritmi sunt mai uor de analizat dac considerm iniial cazuri a cror
mrime satisface anumite condiii, de exemplu s fie puteri ale lui 2. n astfel de
situaii, folosim notaia asimptotic condiionat. Fie f : N R o funcie
arbitrar i fie P : N B un predicat.
+
O( f | P) = {t : N R (c R ) (n 0 N) (n n 0 )
[P(n) t(n) cf (n)]}
Notaia O( f ) este echivalent cu O( f | P), unde P este predicatul a crui valoare
este mereu true. Similar, se obin notaiile ( f | P) i ( f | P).
92 Analiza eficienei algoritmilor Capitolul 5
O funcie f : N R este eventual nedescresctoare, dac exist un n 0 , astfel
nct pentru orice n n 0 avem f (n) f (n+1), ceea ce implic prin inducie c,
pentru orice n n 0 i orice m n, avem f (n) f (m). Fie b 2 un ntreg oarecare.
O funcie eventual nedescresctoare este b-neted dac f (bn) O( f (n)). Orice
funcie care este b-neted pentru un anumit b 2 este, de asemenea, b-neted
pentru orice b 2 (demonstrai acest lucru!); din aceast cauz, vom spune pur i
simplu c aceste funcii sunt netede. Urmtoarea proprietate asambleaz aceste
definiii, demonstrarea ei fiind lsat ca exerciiu.
Proprietatea 5.1 Fie b 2 un ntreg oarecare, f : N R o funcie neted i
t : N R o funcie eventual nedescresctoare, astfel nct
*
a pentru n = 1
t ( n) =
t ( n / 2) + t ( n / 2) + bn pentru n 1
+
unde a, b R sunt constante arbitrare. Este dificil s analizm direct aceast
ecuaie. Dac considerm doar cazurile cnd n este o putere a lui 2, ecuaia devine
a pentru n = 1
t ( n) =
2t (n / 2) + bn pentru n > 1 o putere a lui 2
Prin tehnicile pe care le vom nva la sfritul acestui capitol, ajungem la relaia
t(n) (n log n | n este o putere a lui 2)
Pentru a arta acum c t (n log n), mai trebuie doar s verificm dac t este
eventual nedescresctoare i dac n log n este neted.
Prin inducie, vom demonstra c (n 1) [t(n) t(n+1)]. Pentru nceput, s notm
c
t(1) = a 2(a+b) = t(2)
Fie n > 1. Presupunem c pentru orice m < n avem t(m) t(m+1). n particular,
t(n/2) t((n+1)/2)
t(n/2) t((n+1)/2)
Seciunea 5.1 Notaia asimptotic 93
Atunci,
t(n) = t(n/2)+t(n/2)+bn t((n+1)/2)+t((n+1)/2)+b(n+1) = t(n+1)
n fine, mai rmne s artm c n log n este neted. Funcia n log n este eventual
nedescresctoare i
2n log(2n) = 2n(log 2 + log n) = (2 log 2)n + 2n log n
O(n + n log n) = O(max(n, n log n)) = O(n log n)
De multe ori, timpul de execuie al unui algoritm se exprim sub forma unor
inegaliti de forma
t (n) pentru n n0
t ( n) 1
t ( n / 2) + t ( n / 2) + cn pentru n > n0
i, simultan
t (n) pentru n n0
t (n) 2
t ( n / 2) + t ( n / 2) + dn pentru n > n0
+ +
pentru anumite constante c, d R , n 0 N i pentru dou funcii t 1 , t 2 : N R .
Notaia asimptotic ne permite s scriem cele dou inegaliti astfel:
t(n) t(n/2) + t(n/2) + O(n)
respectiv
t(n) t(n/2) + t(n/2) + (n)
Aceste dou expresii pot fi scrise i concentrat:
t(n) t(n/2) + t(n/2) + (n)
Definim funcia
1 pentru n = 1
f ( n) =
f ( n / 2 ) + f ( n / 2 ) + n pentru n 1
Considerm algoritmul select din Seciunea 1.3. Timpul pentru o singur execuie
a buclei interioare poate fi mrginit superior de o constant a. n total, pentru un i
dat, bucla interioar necesit un timp de cel mult b+a(ni) uniti, unde b este o
constant reprezentnd timpul necesar pentru iniializarea buclei. O singur
execuie a buclei exterioare are loc n cel mult c+b+a(ni) uniti de timp, unde c
este o alt constant. Algoritmul dureaz n total cel mult
n 1
d + ( c + b + a( n i ))
i =1
2
de unde deducem c algoritmul necesit un timp n O(n ). O analiz similar
asupra limitei inferioare arat c timpul este de fapt n (n ). Nu este necesar s
2
considerm cazul cel mai nefavorabil sau cazul mediu, deoarece timpul de
execuie este independent de ordonarea prealabil a elementelor de sortat.
n acest prim exemplu am dat toate detaliile. De obicei, detalii ca iniializarea
buclei nu se vor considera explicit. Pentru cele mai multe situaii, este suficient s
alegem ca barometru o anumit instruciune din algoritm i s numrm de cte
ori se execut aceast instruciune. n cazul nostru, putem alege ca barometru
testul din bucla interioar, acest test executndu-se de n(n1)/2 ori. Exerciiul
5.23 ne sugereaz c astfel de simplificri trebuie fcute cu discernmnt.
Seciunea 5.2 Tehnici de analiz a algoritmilor 95
Vom estima acum timpul mediu necesar pentru un caz oarecare. Presupunem c
elementele tabloului T sunt distincte i c orice permutare a lor are aceeai
probabilitate de apariie. Atunci, dac 1 k i, probabilitatea ca T[i] s fie cel
de-al k-lea cel mai mare element dintre elementele T[1], T[2], , T[i] este 1/i.
Pentru un i fixat, condiia T[i] < T[i1] este fals cu probabilitatea 1/i, deci
probabilitatea ca s se execute comparaia x < T[ j], o singur dat nainte de
ieirea din bucla while, este 1/i. Comparaia x < T[ j] se execut de exact dou
ori tot cu probabilitatea 1/i etc. Probabilitatea ca s se execute comparaia de
exact i1 ori este 2/i, deoarece aceasta se ntmpl att cnd x < T[1], ct i cnd
T[1] x < T[2]. Pentru un i fixat, numrul mediu de comparaii este
c i = 11/i + 21/i ++ (i2)1/i + (i1)2/i = (i+1)/2 1/i
n
Pentru a sorta n elemente, avem nevoie de ci comparaii, ceea ce este egal cu
i= 2
(n +3n)/4 H n (n )
2 2
n
unde prin H n = i 1 (log n) am notat al n-lea element al seriei armonice
i =1
(Exerciiul 5.17).
Se observ c algoritmul insert efectueaz pentru cazul mediu de dou ori mai
puine comparaii dect pentru cazul cel mai nefavorabil. Totui, n ambele
situaii, numrul comparaiilor este n (n ).
2
mai nefavorabil. Cu toate acestea, pentru cazul cel mai favorabil, cnd iniial
tabloul este ordonat cresctor, timpul este n O(n). De fapt, n acest caz, timpul
este i n (n), deci este n (n).
96 Analiza eficienei algoritmilor Capitolul 5
5.2.3 Heapsort
Vom analiza, pentru nceput, algoritmul make-heap din Seciunea 3.4. Definim ca
barometru instruciunile din bucla repeat a algoritmului sift-down. Fie m numrul
maxim de repetri al acestei bucle, cauzat de apelul lui sift-down(T, i), unde i este
fixat. Notm cu j t valoarea lui j dup ce se execut atribuirea j k la a t-a
repetare a buclei. Evident, j 1 = i. Dac 1 < t m, la sfritul celei de-a (t1)-a
repetri a buclei, avem j k i k 2j. n general, j t 2j t1 pentru 1 < t m.
Atunci,
n j m 2j m1 4j m2 2
m1
i
Din (*) deducem c n/2+3n repetri ale buclei repeat sunt suficiente pentru a
construi un heap, deci make-heap necesit un timp t O(n). Pe de alt parte,
deoarece orice algoritm pentru formarea unui heap trebuie s utilizeze fiecare
element din tablou cel puin o dat, t (n). Deci, t (n). Putei compara acest
timp cu timpul necesar algoritmului slow-make-heap (Exerciiul 5.28).
Pentru cel mai nefavorabil caz, sift-down(T[1 .. i1], 1) necesit un timp n
O(log n) (Exerciiul 5.27). innd cont i de faptul c algoritmul make-heap este
Seciunea 5.2 Tehnici de analiz a algoritmilor 97
liniar, rezult c timpul pentru algoritmul heapsort pentru cazul cel mai
nefavorabil este n O(n log n). Mai mult, timpul de execuie pentru heapsort este
de fapt n (n log n), att pentru cazul cel mai nefavorabil, ct i pentru cazul
mediu.
1 pentru n = 1
t ( n) =
2t (n 1) + 1 pentru n > 1
programare care admite exprimarea recursiv se poate face aproape n mod direct.
i= 0
0 pentru n = 0
f ( n) =
f (n 1) + n pentru n > 0
Exist, din fericire, i tehnici care pot fi folosite aproape automat pentru a rezolva
anumite clase de recurene. Vom ncepe prin a considera ecuaii recurente liniare
omogene, adic de forma
a 0 t n + a 1 t n1 + + a k t n k = 0 (*)
Soluiile acestei ecuaii sunt fie soluia trivial x = 0, care nu ne intereseaz, fie
soluiile ecuaiei
a0x + a1x + + ak = 0
k k1
S exemplificm prin recurena care definete irul lui Fibonacci (din Seciunea
1.6.4):
t n = t n1 + t n2 n2
t n t n1 t n2 = 0
x x1=0
2
Seciunea 5.3 Analiza algoritmilor recursivi 101
t n = c1r1n + c2r2n
de unde determinm
c 1,2 = 1 / 5
1
Deci, t n = 1 / 5 ( r1n r2n ) . Observm c r 1 = = (1 + 5 )/2, r 2 = i obinem
n
t n = 1 / 5 ( () )
n
a 0 t n + a 1 t n1 + + a k t n k = b p(n)
n
(**)
unde b este o constant, iar p(n) este un polinom n n de grad d. Ideea general
este ca, prin manipulri convenabile, s reducem un astfel de caz la o form
omogen.
3t n 6t n1 = 3
n+1
t n+1 2t n = 3
n+1
x 5x + 6 = 0
2
adic (x2)(x3) = 0.
Intuitiv, observm c factorul (x2) corespunde prii stngi a recurenei iniiale,
n timp ce factorul (x3) a aprut ca rezultat al manipulrilor efectuate, pentru a
scpa de parte dreapt.
(a 0 x + a 1 x + + a k )(xb)
k k1 d+1
=0
t n 2t n1 = 1
Seciunea 5.3 Analiza algoritmilor recursivi 103
Avem nevoie de dou condiii iniiale. tim c t 0 = 0; pentru a gsi cea de-a doua
condiie calculm
t 1 = 2t 0 + 1
tn = 2 1
n
Din faptul c numrul de mutri a unor discuri nu poate fi negativ sau constant,
deoarece avem n mod evident t n n, deducem c c 2 > 0. Avem atunci t n (2 )
n
i deci, t n (2 ). Putem obine chiar ceva mai mult. Substituind soluia general
n
1 = t n 2t n1 = c 1 + c 2 2 2(c 1 + c 2 2 ) = c 1
n n1
t k = 4t k1 + 2
k
T(n) = c 1 n + c 2 n
2
Rezult
T(n) = 4T(n/2) + n
2
n>1
Procednd la fel, ajungem la recurena
t k = 4t k1 + 4
k
cu ecuaia caracteristic
2
(x4) = 0
T(n) = c 1 n + c 2 n lg n
2 2
i obinem
) + c2
k k1 k
T(2 ) = 3T(2
t k = 3t k1 + c2
k
cu ecuaia caracteristic
(x3)(x2) = 0
tk = c13 + c22
k k
+ c2n
lg n
T(n) = c 1 3
i, deoarece
lg b lg a
a =b
Seciunea 5.3 Analiza algoritmilor recursivi 105
obinem
+ c2n
lg 3
T(n) = c 1 n
deci,
T(n) O(n
lg 3
| n este o putere a lui 2)
+
Proprietatea 5.2 Fie T : N R o funcie eventual nedescresctoare
T(n) = aT(n/b) + cn
k
n > n0
5.4 Exerciii
5.1 Care din urmtoarele afirmaii sunt adevrate?
n O(n )
2 3
i)
n O(n )
3 2
ii)
O(2 )
n+1 n
iii) 2
iv) (n+1)! O(n!)
pentru orice funcie f : N R , f O(n) [ f O(n )]
* 2 2
v)
vi) pentru orice funcie f : N R , f O(n) [2 O(2 )]
* f n
106 Analiza eficienei algoritmilor Capitolul 5
n
i = 1+2++n O(1+2++n) = O(max(1, 2, , n)) = O(n)
i =1
+
5.11 Fie f , g : N R . Demonstrai c:
+
i) lim f (n) / g (n) R O( f ) = O(g)
n
i) O( f ) = O(g)
ii) ( f ) = (g)
iii) f (g)
n ! 2 n (n / e) n (1 + (1 / n))
unde e = 1,71828 .
n
nou constant. n fine, ntregul algoritm necesit d + ( c + b + aT[i ]) uniti de
i =1
timp, unde d este o alt constant. Simplificnd, obinem (c+b)n+as+d. Timpul
t(n, s) depinde deci de doi parametri independeni n i s. Avem: t (n+s) sau,
innd cont de Exerciiul 5.19, t (max(n, s)).
Soluie:
d d
2 k lg(n / 2 k ) = (2 d +1 1) lg n (2 k k )
k =0 k =0
5.27 Analizai algoritmii percolate i sift-down pentru cel mai nefavorabil caz,
presupunnd c opereaz asupra unui heap cu n elemente.
Indicaie: n cazul cel mai nefavorabil, algoritmii percolate i sift-down necesit
un timp n ordinul exact al nlimii arborelui complet care reprezint heap-ul,
adic n (lg n) = (log n).
5.30 Demonstrai c, pentru cel mai nefavorabil caz, orice algoritm de sortare
prin comparaie necesit un timp n (n log n). n particular, obinem astfel, pe
alt cale, rezultatul din Exerciiul 5.29.
Soluie: Orice sortare prin comparaie poate fi interpretat ca o parcurgere a unui
arbore binar de decizie, prin care se stabilete ordinea relativ a elementelor de
sortat. ntr-un arbore binar de decizie, fiecare vrf neterminal semnific o
comparaie ntre dou elemente ale tabloului T i fiecare vrf terminal reprezint o
permutare a elementelor lui T. Executarea unui algoritm de sortare corespunde
parcurgerii unui drum de la rdcina arborelui de decizie ctre un vrf terminal.
La fiecare vrf neterminal se efectueaz o comparaie ntre dou elemente T[i] i
T[ j]: dac T[i] T[ j] se continu cu comparaiile din subarborele stng, iar n
caz contrar cu cele din subarborele drept. Cnd se ajunge la un vrf terminal,
nseamn c algoritmul de sortare a reuit s stabileasc ordinea elementelor din
T.
112 Analiza eficienei algoritmilor Capitolul 5
Fiecare din cele n! permutri a celor n elemente trebuie s apar ca vrf terminal
n arborele de decizie. Vom lua ca barometru comparaia ntre dou elemente ale
tabloului T. nlimea h a arborelui de decizie corespunde numrului de
comparaii pentru cel mai nefavorabil caz. Deoarece cutm limita inferioar a
timpului, ne intereseaz doar algoritmii cei mai performani de sortare, deci putem
presupune c numrul de vrfuri este minim, adic n!. Avem: n! 2 (demonstrai
h
5.31 Analizai algoritmul heapsort pentru cel mai favorabil caz. Care este cel
mai favorabil caz?
n t = n t1 /2 n t1 /2
Deci,
n t n t1 /2 n/2
t
Fie m = 1 + lg n. Deducem:
n m n/2 < 1
m
Pui n faa unei probleme pentru care trebuie s elaborm un algoritm, de multe
ori nu tim cum s ncepem. Ca i n orice alt activitate, exist cteva principii
generale care ne pot ajuta n aceast situaie. Ne propunem s prezentm n
urmtoarele capitole tehnicile fundamentale de elaborare a algoritmilor. Cteva
din aceste metode sunt att de generale, nct le folosim frecvent, chiar dac
numai intuitiv, ca reguli elementare n gndire.
113
114 Algoritmi greedy Capitolul 6
ceea ce este acelai lucru cu a minimiza timpul mediu de ateptare, care este T/n.
De exemplu, dac avem trei clieni cu t 1 = 5, t 2 = 10, t 3 = 3, sunt posibile ase
ordini de servire. n primul caz, clientul 1 este servit primul, clientul 2 ateapt
Ordinea T
1 2 3 5+(5+10)+(5+10+3) = 38
1 3 2 5+(5+3)+(5+3+10) = 31
2 1 3 10+(10+5)+(10+5+3) = 43
2 3 1 10+(10+3)+(10+3+5) = 41
3 1 2 3+(3+5)+(3+5+10) = 29 optim
3 2 1 3+(3+10)+(3+10+5) = 34
pn este servit clientul 1 i apoi este servit, clientul 3 ateapt pn sunt servii
clienii 1, 2 i apoi este servit. Timpul total de ateptare a celor trei clieni este
38.
Algoritmul greedy este foarte simplu: la fiecare pas se selecteaz clientul cu
timpul minim de servire din mulimea de clieni rmas. Vom demonstra c acest
algoritm este optim. Fie
I = (i 1 i 2 i n )
o permutare oarecare a ntregilor {1, 2, , n}. Dac servirea are loc n ordinea I,
avem
116 Algoritmi greedy Capitolul 6
n
T ( I ) = t i1 + (t i1 + t i2 ) + (t i1 + t i2 + t i3 ) + ... = n t i1 + (n 1)t i2 + ... = (n k + 1) t i k
k =1
Presupunem acum c I este astfel nct putem gsi doi ntregi a < b cu
t ia > t ib
150 150
140 10 90 60
130 10 40 50 30 30
110 20 20 20
80 30 10 10
30 50
(a) (b)
11 11
10 6 10 9
9 2 8 5 1 4
8 3 7 3
7 4 2 6
1 5
(a) (b)
q1 + q2
q1 q2
#ifndef __VP_H
#define __VP_H
#include <iostream.h>
class vp {
public:
vp( int vf = 0, float pd = 0 ) { v = vf; p = pd; }
int v; float p;
};
class nod {
public:
int lu; // lungimea
int st; // fiul stang
int dr; // fiul drept
};
main( ) {
tablou<int> l;
cout << "Siruri: "; cin >> l;
cout << "Arborele de interclasare: ";
cout << interopt( l ) << '\n';
return 1;
}
[ 6 ] 30 10 20 30 50 10
este:
Valoarea fiecrui nod este precedat de indicele fiului stng i urmat de cel al
fiului drept, indicele -1 reprezentnd legtura inexistent. Formatele de citire i
scriere ale tablourilor sunt cele stabilite n Seciunea 4.1.3.
Fie un text compus din urmtoarele litere (n paranteze figureaz frecvenele lor
de apariie):
S (10), I (29), P (4), O (9), T (5)
Conform metodei greedy, construim un arbore binar fuzionnd cele dou litere cu
frecvenele cele mai mici. Valoarea fiecrui vrf este dat de frecvena pe care o
reprezint.
4+5
1 0
4 5
1 18 0
1
9 9
0
4 5
n final, ajungem la arborele din Figura 6.3, n care fiecare vrf terminal
corespunde unei litere din text.
Pentru a obine codificarea binar a literei P, nu avem dect s scriem secvena de
0-uri i 1-uri n ordinea apariiei lor pe drumul de la rdcin ctre vrful
corespunztor lui P: 1011. Procedm similar i pentru restul literelor:
S (11), I (0), P (1011), O (100), T (1010)
Pentru un text format din n litere care apar cu frecvenele f 1 , f 2 , , f n , un arbore
de codificare este un arbore binar cu vrfurile terminale avnd valorile
f 1 , f 2 , , f n , prin care se obine o codificare binar a textului. Un arbore de
codificare nu trebuie n mod necesar s fie construit dup metoda greedy a lui
Huffman, alegerea vrfurilor care sunt fuzionate la fiecare pas putndu-se face
124 Algoritmi greedy Capitolul 6
1 57 0
1 28 29 I
0
S 10 18 0
1
1 9 0 0 O
P 4 5 T
iar suma lungimilor muchiilor din A s fie ct mai mic. Cutm deci o
submulime A de cost total minim. Aceast problem se mai numete i problema
conectrii oraelor cu cost minim, avnd numeroase aplicaii.
Graful parial <V, A> este un arbore (Exerciiul 6.11) i este numit arborele
parial de cost minim al grafului G (minimal spanning tree). Un graf poate avea
mai muli arbori pariali de cost minim i acest lucru se poate verifica pe un
exemplu.
Vom prezenta doi algoritmi greedy care determin arborele parial de cost minim
al unui graf. n terminologia metodei greedy, vom spune c o mulime de muchii
este o soluie, dac constituie un arbore parial al grafului G, i este fezabil, dac
nu conine cicluri. O mulime fezabil de muchii este promitoare, dac poate fi
completat pentru a forma soluia optim. O muchie atinge o mulime dat de
vrfuri, dac exact un capt al muchiei este n mulime. Urmtoarea proprietate va
fi folosit pentru a demonstra corectitudinea celor doi algoritmi.
Proprietatea 6.2 Fie G = <V, M> un graf neorientat conex n care fiecare muchie
are un cost nenegativ. Fie W V o submulime strict a vrfurilor lui G i fie
A M o mulime promitoare de muchii, astfel nct nici o muchie din A nu
atinge W. Fie m muchia de cost minim care atinge W. Atunci, A {m} este
promitoare.
Demonstraie: Fie B un arbore parial de cost minim al lui G, astfel nct A B
(adic, muchiile din A sunt coninute n arborele B). Un astfel de B trebuie s
existe, deoarece A este promitoare. Dac m B, nu mai rmne nimic de
demonstrat. Presupunem c m B. Adugndu-l pe m la B, obinem exact un ciclu
(Exerciiul 3.2). n acest ciclu, deoarece m atinge W, trebuie s mai existe cel
puin o muchie m' care atinge i ea pe W (altfel, ciclul nu se nchide). Eliminndu-
l pe m', ciclul dispare i obinem un nou arbore parial B' al lui G. Costul lui m
este mai mic sau egal cu costul lui m', deci costul total al lui B' este mai mic sau
egal cu costul total al lui B. De aceea, B' este i el un arbore parial de cost minim
al lui G, care include pe m. Observm c A B' deoarece muchia m', care atinge
W, nu poate fi n A. Deci, A {m} este promitoare.
Mulimea iniial a candidailor este M. Cei doi algoritmi greedy aleg muchiile
una cte una ntr-o anumit ordine, aceast ordine fiind specific fiecrui
algoritm.
1 2 1 2
1 2 3 1 2 3
4 4 6 4
6 5
4 5 6 4 5 6
3 8 3
7
4 3 4 3
7 7
(a) (b)
apoi se adaug repetat muchia de cost minim nealeas anterior i care nu formeaz
cu precedentele un ciclu. Alegem astfel #V1 muchii. Este uor de dedus c
obinem n final un arbore (revedei Exerciiul 3.2). Este ns acesta chiar arborele
parial de cost minim cutat?
nainte de a rspunde la ntrebare, s considerm, de exemplu, graful din Figura
6.4a. Ordonm cresctor (n funcie de cost) muchiile grafului: {1, 2}, {2, 3},
{4, 5}, {6, 7}, {1, 4}, {2, 5}, {4, 7}, {3, 5}, {2, 4}, {3, 6}, {5, 7}, {5, 6} i apoi
aplicm algoritmul. Structura componentelor conexe este ilustrat, pentru fiecare
pas, n Tabelul 6.1.
Mulimea A este iniial vid i se completeaz pe parcurs cu muchii acceptate
Proprietatea 6.3 n algoritmul lui Kruskal, la fiecare pas, graful parial <V, A>
formeaz o pdure de componente conexe, n care fiecare component conex este
la rndul ei un arbore parial de cost minim pentru vrfurile pe care le conecteaz.
n final, se obine arborele parial de cost minim al grafului G.
Proprietatea 6.4 n algoritmul lui Prim, la fiecare pas, <U, A> formeaz un
arbore parial de cost minim pentru subgraful <U, A> al lui G. n final, se obine
arborele parial de cost minim al grafului G.
tablou<muchie> A( n - 1 ); int nA = 0;
set s( n );
do {
muchie m;
if ( !h.delete_max( m ) )
{ cerr << "\n\nKruskal -- heap vid.\n\n"; return A = 0; }
Seciunea 6.7 Implementarea algoritmului lui Kruskal 131
if ( ucomp != vcomp ) {
s.merge3( ucomp, vcomp );
A[ nA++ ] = m;
}
} while ( nA != n - 1 );
return A;
}
Diferenele care apar sunt mai curnd precizri suplimentare, absolut necesare n
trecerea de la descrierea unui algoritm la implementarea lui. Astfel, graful este
transmis ca parametru, prin precizarea numrului de vrfuri i a muchiilor. Pentru
muchii, reprezentate prin cele dou vrfuri i costul asociat, am preferat n locul
listei, structura simpl de tablou M, structur folosit i la returnarea arborelui de
cost minim A.
Operaia principal efectuat asupra muchiilor este alegerea muchiei de cost
minim care nc nu a fost considerat. Pentru implementarea acestei operaii,
folosim un min-heap. La fiecare iteraie, se extrage din heap muchia de cost
minim i se ncearc inserarea ei n arborele A.
Rulnd programul
main( ) {
int n;
cout << "\nVarfuri... ";
cin >> n;
tablou<muchie> M;
cout << "\nMuchiile si costurile lor... ";
cin >> M;
#ifndef __MUCHIE_H
#define __MUCHIE_H
class muchie {
public:
muchie( int iu = 0, int iv = 0, float ic = 0. )
{ u = iu; v = iv; cost = ic; }
int u, v;
float cost;
};
#endif
Seciunea 3.5, elementele canonice sunt difereniate prin faptul c set[i] are
valoarea i. Avnd n vedere c set[i] este indicele n tabloul set al tatlui
elementului i, putem asocia elementelor canonice proprietatea set[i] < 0. Prin
aceast convenie, valoarea absolut a elementelor canonice poate fi oarecare.
Atunci, de ce s nu fie chiar nlimea arborelui?
n concluzie, pentru reprezentarea structurii de mulimi disjuncte, este necesar un
singur tablou, numit set, cu tot attea elemente cte are i mulimea. Valorile
iniiale ale elemetelor tabloului set sunt -1. Aceste iniializri vor fi realizate
prin constructor. Interfaa public a clasei set trebuie s conin funciile
merge3() i find3(), adaptate corepunztor. Tratarea situaiilor de excepie care
pot s apar la invocarea acestor funcii (indici de mulimi n afara intervalului
permis) se realizeaz prin activarea procedurii de verificare a indicilor n tabloul
set.
Aceste considerente au condus la urmtoarele definiii ale funciilor membre din
clasa set.
#include "set.h"
// reuniunea propriu-zisa
if ( set[ a ] == set[ b ] ) set[ set[ b ] = a ]--;
else if ( set[ a ] < set[ b ] ) set[ b ] = a;
else set[ a ] = b;
return;
}
134 Algoritmi greedy Capitolul 6
int i = x;
while ( i != r )
{ int j = set[ i ]; set[ i ] = r; i = j; }
return r;
}
#ifndef __SET_H
#define __SET_H
#include "heap.h"
class set {
public:
set( int );
void merge3( int, int );
int find3 ( int );
private:
tablou<int> set;
};
#endif
Spunem c un drum de la surs ctre un alt vrf este special, dac toate vrfurile
intermediare de-a lungul drumului aparin lui S. Algoritmul lui Dijkstra lucreaz
n felul urmtor. La fiecare pas al algoritmului, un tablou D conine lungimea
celui mai scurt drum special ctre fiecare vrf al grafului. Dup ce adugm un
nou vrf v la S, cel mai scurt drum special ctre v va fi, de asemenea, cel mai scurt
dintre toate drumurile ctre v. Cnd algoritmul se termin, toate vrfurile din graf
sunt n S, deci toate drumurile de la surs ctre celelalte vrfuri sunt speciale i
valorile din D reprezint soluia problemei.
Presupunem, pentru simplificare, c vrfurile sunt numerotate, V = {1, 2, , n},
vrful 1 fiind sursa, i c matricea L d lungimea fiecrei muchii, cu L[i, j] = +,
dac muchia (i, j) nu exist. Soluia se va construi n tabloul D[2 .. n]. Algoritmul
este:
function Dijkstra(L[1 .. n, 1 .. n])
{iniializare}
C {2, 3, , n} {S = V \C exist doar implicit}
for i 2 to n do D[i] L[1, i]
{bucla greedy}
repeat n2 times
v vrful din C care minimizeaz D[v]
C C \ {v} {i, implicit, S S {v}}
for fiecare w C do
D[w] min(D[w], D[v]+L[v, w])
return D
Pentru graful din Figura 6.5, paii algoritmului sunt prezentai n Tabelul 6.3.
Observm c D nu se schimb dac mai efectum o iteraie pentru a-l scoate i pe
{2} din C. De aceea, bucla greedy se repet de doar n2 ori.
Se poate demonstra urmtoarea proprietate:
50
1 2
10 30
5 100 5
10 20
4 50
3
Pasul v C D
iniializare {2, 3, 4, 5} [50, 30, 100, 10]
1 5 {2, 3, 4} [50, 30, 20, 10]
2 4 {2, 3} [40, 30, 20, 10]
3 3 {2} [35, 30, 20, 10]
Tabelul 6.3 Algoritmul lui Dijkstra aplicat grafului din Figura 6.5.
dac nu reuim s scdem i ordinul timpului necesar pentru alegerea lui v din
bucla repeat. De aceea, vom ine vrfurile v din C ntr-un min-heap, n care
fiecare element este de forma (v, D[v]), proprietatea de min-heap referindu-se la
valoarea lui D[v]. Numim algoritmul astfel obinut Dijkstra-modificat. S l
analizm n cele ce urmeaz.
Iniializarea min-heap-ului necesit un timp n O(n). Instruciunea C C \ {v}
const n extragerea rdcinii min-heap-ului i necesit un timp n O(log n).
Pentru cele n2 extrageri este nevoie de un timp n O(n log n).
Pentru a testa dac D[w] > D[v]+L[v, w], bucla for interioar const acum n
inspectarea fiecrui vrf w din C adiacent lui v. Fiecare vrf v din C este introdus
n S exact o dat i cu acest prilej sunt testate exact muchiile adiacente lui; rezult
c numrul total de astfel de testri este de cel mult m. Dac testul este adevrat,
trebuie s l modificm pe D[w] i s operm un percolate cu w n min-heap, ceea
ce necesit din nou un timp n O(log n). Timpul total pentru operaiile percolate
este deci n O(m log n).
n concluzie, algoritmul Dijkstra-modificat necesit un timp n
O(max(n, m) log n). Dac graful este conex, atunci m n i timpul este n
O(m log n). Pentru un graf rar este preferabil s folosim algoritmul
Dijkstra-modificat, iar pentru un graf dens algoritmul Dijkstra este mai eficient.
Este uor de observat c, ntr-un graf G neorientat conex, muchiile celor mai
scurte drumuri de la un vrf i la celelalte vrfuri formeaz un arbore parial al
celor mai scurte drumuri pentru G. Desigur, acest arbore depinde de alegerea
rdcinii i i el difer, n general, de arborele parial de cost minim al lui G.
Problema gsirii celor mai scurte drumuri care pleac din acelai punct se poate
pune i n cazul unui graf neorientat.
while( g( w ) ) {
// ...
}
[5]: { { 5; 10 } { 4; 100 } { 3; 30 } { 2; 50 } }
{ }
{ { 4; 50 } { 2; 5 } }
{ { 2; 20 } }
{ { 4; 10 } }
genereaz rezultatele:
vp w;
// initializare
for ( int i = 0; i < n; i++ )
P[ i ] = vp( s, MAXFLOAT );
for ( iterator<vp> g = G[ s ]; g( w ); )
{ C.insert( w ); P[ w ] = vp( s, w ); }
P[ s ] = vp( 0, 0 );
sau cu un operator
Dei era mai natural s folosim operatorul de atribuire =, nu l-am putut folosi
deoarece este operator binar, iar aici avem nevoie de 3 operanzi: n membrul stng
obiectul invocator i n membrul drept vrful, mpreun cu ponderea. Folosind
noul operator (), secvena de iniializare devine mai scurt i mai eficient:
Seciunea 6.9 Implementarea algoritmului lui Dijkstra 141
vp w;
// initializare
for ( int i = 0; i < n; i++ )
P[ i ]( s, MAXFLOAT );
for ( iterator<vp> g = G[ s ]; g( w ); )
{ C.insert( w ); P[ w ]( s, w ); }
P[ s ]( 0, 0 );
vp v;
float dw;
// bucla greedy
for ( i = 1; i < n - 1; i++ ) {
C.delete_max( v ); g = G[ v ];
while ( g( w ) )
if ( (float)P[ w ] > (dw = (float)P[ v ] + (float)w) )
C.insert( vp( w, P[ w ]( v, dw ) ) );
}
return P;
#include <iostream.h>
#include <values.h>
#include "tablou.h"
#include "heap.h"
#include "muchie.h"
#include "lista.h"
#include "vp.h"
heap<vp> C( m );
tablou<vp> P( n );
vp v, w; // muchii
float dw; // distanta
// initializare
for ( int i = 0; i < n; i++ )
P[ i ]( s, MAXFLOAT );
for ( iterator<vp> g = G[ s ]; g( w ); )
C.insert( w ); P[ w ]( s, w );
P[ s ]( 0, 0 );
// bucla greedy
for ( i = 1; i < n - 1; i++ ) {
C.delete_max( v ); g = G[ v ];
while ( g( w ) )
if ( (float)P[ w ] > ( dw = (float)P[ v ] + (float)w ) )
C.insert( vp( w, P[ w ]( v, dw ) ) );
}
return P;
}
main( ) {
int n, m = 0; // #varfuri si #muchii
muchie M;
return 0;
}
Fie G = <V, M> un graf neorientat, ale crui vrfuri trebuie colorate astfel nct
oricare dou vrfuri adiacente s fie colorate diferit. Problema este de a obine o
colorare cu un numr minim de culori.
Folosim urmtorul algoritm greedy: alegem o culoare i un vrf arbitrar de
pornire, apoi considerm vrfurile rmase, ncercnd s le colorm, fr a
144 Algoritmi greedy Capitolul 6
schimba culoarea. Cnd nici un vrf nu mai poate fi colorat, schimbm culoarea i
vrful de start, repetnd procedeul.
Dac n graful din Figura 6.6 pornim cu vrful 1 i l colorm n rou, mai putem
colora tot n rou vrfurile 3 i 4. Apoi, schimbm culoarea i pornim cu vrful 2,
colorndu-l n albastru. Mai putem colora cu albastru i vrful 5. Deci, ne-au fost
suficiente dou culori. Dac colorm vrfurile n ordinea 1, 5, 2, 3, 4, atunci se
obine o colorare cu trei culori.
Rezult c, prin metoda greedy, nu obinem dect o soluie euristic, care nu este
n mod necesar soluia optim a problemei. De ce suntem atunci interesai ntr-o
astfel de rezolvare? Toi algoritmii cunoscui, care rezolv optim aceast
problem, sunt exponeniali, deci, practic, nu pot fi folosii pentru cazuri mari.
Algoritmul greedy euristic propus furnizeaz doar o soluie acceptabil, dar este
simplu i eficient.
Un caz particular al problemei colorrii unui graf corespunde celebrei probleme a
colorrii hrilor: o hart oarecare trebuie colorat cu un numr minim de culori,
astfel nct dou ri cu frontier comun s fie colorate diferit. Dac fiecrui vrf
i corespunde o ar, iar dou vrfuri adiacente reprezint ri cu frontier
comun, atunci hrii i corespunde un graf planar, adic un graf care poate fi
desenat n plan fr ca dou muchii s se intersecteze. Celebritatea problemei
const n faptul c, n toate exemplele ntlnite, colorarea s-a putut face cu cel
mult 4 culori. Aceasta n timp ce, teoretic, se putea demonstra c pentru o hart
oarecare este nevoie de cel mult 5 culori. Recent * s-a demonstrat pe calculator
faptul c orice hart poate fi colorat cu cel mult 4 culori. Este prima demonstrare
pe calculator a unei teoreme importante.
Problema colorrii unui graf poate fi interpretat i n contextul planificrii unor
activiti. De exemplu, s presupunem c dorim s executm simultan o mulime
de activiti, n cadrul unor sli de clas. n acest caz, vrfurile grafului reprezint
activiti, iar muchiile unesc activitile incompatibile. Numrul minim de culori
necesare pentru a colora graful corespunde numrului minim de sli necesare.
1 2 5
La: 2 3 4 5 6
De la:
1 3 10 11 7 25
2 6 12 8 26
3 9 4 20
4 5 15
5 18
Tabelul 6.4 Matricea distanelor pentru problema comis-voiajorului.
Se cunosc distanele dintre mai multe orae. Un comis-voiajor pleac dintr-un ora
i dorete s se ntoarc n acelai ora, dup ce a vizitat fiecare din celelalte
orae exact o dat. Problema este de a minimiza lungimea drumului parcurs. i
pentru aceast problem, toi algoritmii care gsesc soluia optim sunt
exponeniali.
Problema poate fi reprezentat printr-un graf neorientat, n care oricare dou
vrfuri diferite ale grafului sunt unite ntre ele printr-o muchie, de lungime
nenegativ. Cutm un ciclu de lungime minim, care s se nchid n vrful
iniial i care s treac prin toate vrfurile grafului.
Conform strategiei greedy, vom construi ciclul pas cu pas, adugnd la fiecare
iteraie cea mai scurt muchie disponibil cu urmtoarele proprieti:
nu formeaz un ciclu cu muchiile deja selectate (exceptnd pentru ultima
muchie aleas, care completeaz ciclul)
nu exist nc dou muchii deja selectate, astfel nct cele trei muchii s fie
incidente n acelai vrf
De exemplu, pentru ase orae a cror matrice a distanelor este dat n Tabelul
6.4, muchiile se aleg n ordinea: {1, 2}, {3, 5}, {4, 5}, {2, 3}, {4, 6}, {1, 6} i se
obine ciclul (1, 2, 3, 5, 4, 6, 1) de lungime 58. Algoritmul greedy nu a gsit
ciclul optim, deoarece ciclul (1, 2, 3, 6, 4, 5, 1) are lungimea 56.
6.11 Exerciii
6.1 Presupunnd c exist monezi de:
146 Algoritmi greedy Capitolul 6
6.10 Pe lng codul Huffman, vom considera aici i un alt cod celebru, care nu
se obine ns printr-o metod greedy, ci printr-un algoritm recursiv.
n
Un cod Gray este o secven de 2 elemente astfel nct:
i) fiecare element este un ir de n bii
ii) oricare dou elemente sunt diferite
iii) oricare dou elemente consecutive difer exact printr-un bit (primul element
este considerat succesorul ultimului element)
Se observ c un cod Gray nu este de tip prefix. Elaborai un algoritm recursiv
pentru a construi codul Gray pentru orice n dat. Gndii-v cum ai putea utiliza
un astfel de cod.
Indicaie: Pentru n = 1 putem folosi secvena (0, 1). Presupunem c avem un cod
Gray pentru n1, unde n > 1. Un cod Gray pentru n poate fi construit prin
concatenarea a dou subsecvene. Prima se obine prefixnd cu 0 fiecare element
al codului Gray pentru n1. A doua se obine citind n ordine invers codul Gray
pentru n1 i prefixnd cu 1 fiecare element rezultat.
6.11 Demonstrai c graful parial definit ca arbore parial de cost minim este
un arbore.
Indicaie: Artai c orice graf conex cu n vrfuri are cel puin n1 muchii i
revedei Exerciiul 3.2.
6.16 n graful din Figura 6.5, gsii pe unde trec cele mai scurte drumuri de la
vrful 1 ctre toate celelalte vrfuri.
6.17 Scriei algoritmul greedy pentru colorarea unui graf i analizai eficiena
lui.
6.20 ntr-un graf orientat, un drum este hamiltonian dac trece exact o dat
prin fiecare vrf al grafului, fr s se ntoarc n vrful iniial. Fie G un graf
orientat, cu proprietatea c ntre oricare dou vrfuri exist cel puin o muchie.
Artai c n G exist un drum hamiltonian i elaborai algoritmul care gsete
acest drum.
6.21 Este cunoscut c orice numr natural i poate fi descompus n mod unic
ntr-o sum de termeni ai irului lui Fibonacci (teorema lui Zeckendorf). Dac
prin k >> m notm k m+2, atunci
i = f k1 + f k2 + ... f kr
unde
k 1 >> k 2 >> >> k r >> 0
este cel mai mare termen din irul lui Fibonacci pentru care f k1 i ; singura
valoare posibil pentru f k este cel mai mare termen pentru care f k2 i f k1 etc.
2
2
Termenul 3/4cn domin pe ceilali cnd n este suficient de mare, ceea ce
nseamn c algoritmul B este n esen cu 25% mai rapid dect algoritmul A. Nu
am reuit ns s schimbm ordinul timpului, care rmne ptratic.
Putem s continum n mod recursiv acest procedeu, mprind subcazurile n
subsubcazuri etc. Pentru subcazurile care nu sunt mai mari dect un anumit prag
n 0 , vom folosi tot algoritmul A. Obinem astfel algoritmul C, cu timpul
t (n) pentru n n 0
t C ( n) = A
3t C ( n / 2) + t (n) pentru n > n 0
lg 3
Conform rezultatelor din Seciunea 5.3.5, t C (n) este n ordinul lui n . Deoarece
lg 3 1,59, nseamn c de aceast dat am reuit s mbuntim ordinul
timpului.
Iat o descriere general a metodei divide et impera:
149
150 Algoritmi divide et impera Capitolul 7
function divimp(x)
{returneaz o soluie pentru cazul x}
if x este suficient de mic then return adhoc(x)
{descompune x n subcazurile x 1 , x 2 , , x k }
for i 1 to k do y i divimp(x i )
{recompune y 1 , y 2 , , y k n scopul obinerii soluiei y pentru x}
return y
unde adhoc este subalgoritmul de baz folosit pentru rezolvarea micilor subcazuri
ale problemei n cauz (n exemplul nostru, acest subalgoritm este A).
Un algoritm divide et impera trebuie s evite descompunerea recursiv a
subcazurilor suficient de mici, deoarece, pentru acestea, este mai eficient
aplicarea direct a subalgoritmului de baz. Ce nseamn ns suficient de mic?
n exemplul precedent, cu toate c valoarea lui n 0 nu influeneaz ordinul
lg 3
timpului, este influenat ns constanta multiplicativ a lui n , ceea ce poate
avea un rol considerabil n eficiena algoritmului. Pentru un algoritm divide et
impera oarecare, chiar dac ordinul timpului nu poate fi mbuntit, se dorete
optimizarea acestui prag n sensul obinerii unui algoritm ct mai eficient. Nu
exist o metod teoretic general pentru aceasta, pragul optim depinznd nu
numai de algoritmul n cauz, dar i de particularitatea implementrii.
Considernd o implementare dat, pragul optim poate fi determinat empiric, prin
msurarea timpului de execuie pentru diferite valori ale lui n 0 i cazuri de mrimi
diferite.
n general, se recomand o metod hibrid care const n: i) determinarea
teoretic a formei ecuaiilor recurente; ii) gsirea empiric a valorilor
constantelor folosite de aceste ecuaii, n funcie de implementare.
Revenind la exemplul nostru, pragul optim poate fi gsit rezolvnd ecuaia
t A (n) = 3t A (n/2) + t(n)
Empiric, gsim n 0 67, adic valoarea pentru care nu mai are importan dac
aplicm algoritmul A n mod direct, sau dac continum descompunerea. Cu alte
cuvinte, atta timp ct subcazurile sunt mai mari dect n 0 , este bine s continum
descompunerea. Dac continum ns descompunerea pentru subcazurile mai mici
dect n 0 , eficiena algoritmului scade.
Observm c metoda divide et impera este prin definiie recursiv. Uneori este
posibil s eliminm recursivitatea printr-un ciclu iterativ. Implementat pe o
main convenional, versiunea iterativ poate fi ceva mai rapid (n limitele
unei constante multiplicative). Un alt avantaj al versiunii iterative ar fi faptul c
economisete spaiul de memorie. Versiunea recursiv folosete o stiv necesar
Seciunea 7.1 Tehnica divide et impera 151
Care din aceti doi algoritmi este oare mai eficient? Pentru cazul cel mai
favorabil, iterbin2 este, evident, mai bun. Pentru cazul cel mai nefavorabil,
ordinul timpului este acelai, numrul de executri ale buclei while este acelai,
dar durata unei bucle while pentru iterbin2 este ceva mai mare; deci iterbin1 este
preferabil, avnd constanta multiplicativ mai mic. Pentru cazul mediu,
compararea celor doi algoritmi este mai dificil: ordinul timpului este acelai, o
bucl while n iterbin1 dureaz n medie mai puin dect n iterbin2, n schimb
iterbin1 execut n medie mai multe bucle while dect iterbin2.
2 (2 k 1 + 2 k 2 + ... + 2 + 1) = 2 2 k = 2n
154 Algoritmi divide et impera Capitolul 7
elemente.
Putem considera (conform Exerciiului 7.7) c algoritmul merge(T, U, V) are
timpul de execuie n (#U + #V), indiferent de ordonarea elementelor din U i V.
Separarea lui T n U i V necesit tot un timp n (#U + #V). Timpul necesar
algoritmului mergesort pentru a sorta orice tablou de n elemente este atunci
t(n) t(n/2)+t(n/2)+(n). Aceast ecuaie, pe care am analizat-o n Seciunea
5.1.2, ne permite s conchidem c timpul pentru mergesort este n (n log n). S
reamintim timpii celorlali algoritmi de sortare, algoritmi analizai n Capitolul 5:
pentru cazul mediu i pentru cazul cel mai nefavorabil insert i select necesit un
timp n (n ), iar heapsort un timp n (n log n).
2
subcazurile s fie de mrimi ct mai apropiate (sau, alfel spus, subcazurile s fie
ct mai echilibrate).
*
Spaiul suplimentar utilizat de algoritmul mergesort poate fi independent de numrul elementelor
tabloului de sortat. Detaliile de implementare a unei astfel de strategii se gsesc n D. E. Knuth,
Tratat de programarea calculatoarelor. Sortare i cutare, Seciunea 5.2.4.
Seciunea 7.4 Mergesort n clasele tablou<T> i lista<E> 155
return *this;
}
156 Algoritmi divide et impera Capitolul 7
return *this;
}
if ( a && b )
// ambele liste sunt nevide;
// stabilim primul nod din lista interclasata
if ( a->val > b->val ) { head = b; b = b->next; }
else { head = a; a = a->next; }
else
// cel putin una din liste este vida;
// nu avem ce interclasa
return a? a: b;
// interclasarea propriu-zisa
nod<E> *c = head; // ultimul nod din lista interclasata
while ( a && b )
if ( a->val > b->val ) { c->next = b; c = b; b = b->next; }
else { c->next = a; c = a; a = a->next; }
void f( ) {
mergesort( (nod<int> *)0 );
}
private:
T *a; // adresa zonei de sortat
T *x; // zona auxiliara de interclasare
Sortarea, de fapt transformarea tabloului t ntr-un tablou sortat, este realizat prin
constructorul
Seciunea 7.4 Mergesort n clasele tablou<T> i lista<E> 161
private:
nod<E>* mergesort( nod<E>* );
nod<E>* merge( nod<E>*, nod<E>* );
};
Putem considera c exist o constant real pozitiv c, astfel nct t(i) ci +c/2
2
pentru n. Avem
d fiind o alt constant. Expresia i +(ni1) i atinge maximul atunci cnd i este
2 2
Dac lum c 2d, obinem t(n) cn +c/2. Am artat c, dac c este suficient de
2
mare, atunci t(n) cn +c/2 pentru orice n 0, adic, t O(n ). Analog se arat c
2 2
t (n ).
2
Am artat, totodat, care este cel mai nefavorabil caz: atunci cnd, la fiecare nivel
de recursivitate, procedura pivot este apelat o singur dat. Dac elementele lui T
sunt distincte, cazul cel mai nefavorabil este atunci cnd iniial tabloul este
ordonat cresctor sau descresctor, fiecare partiionare fiind total neechilibrat.
Pentru acest cel mai nefavorabil caz, am artat c algoritmul quicksort necesit un
timp n (n ).
2
Mai precis, fie n 0 i d dou constante astfel nct pentru orice n > n 0 , avem
n n 1
t(n) dn + 1/n (t (l 1) + t (n l )) = dn + 2/n t (i )
l =1 i=0
n
n 1 x2 lg e 2
n
n2 lg e 2
i lg i x lg x dx =
2
lg x
4
x
2
lg n
4
n
i = n0 +1 x = n0 +1 x =n 0 +1
pentru n 0 1.
Rezult c timpul mediu pentru quicksort este n O(n log n). Pe lng ordinul
timpului, un rol foarte important l are constanta multiplicativ. Practic, constanta
multiplicativ pentru quicksort este mai mic dect pentru heapsort sau
mergesort. Dac pentru cazul cel mai nefavorabil se accept o execuie ceva mai
lent, atunci, dintre tehnicile de sortare prezentate, quicksort este algoritmul
preferabil.
mediana irului T[i], T[(i+j) div 2], T[ j]. Preul pltit pentru aceast modificare
este o uoar cretere a constantei multiplicative.
Algoritmul adhocmed5 este elaborat special pentru a gsi mediana a exact cinci
elemente. S notm c adhocmed5 necesit un timp n O(1).
Fie m aproximarea medianei tabloului T, gsit prin algoritmul pseudomed.
Deoarece m este mediana tabloului S, avem
#{i {1, , s} | S[i] m} s/2
Fiecare element din S este mediana a cinci elemente din T. n consecin, pentru
fiecare i, astfel nct S[i] m, exist i 1 , i 2 , i 3 ntre 5i4 i 5i, astfel ca
Deci,
#{i {1, , n} | T[i] m} 3s/2 = 3n/5/2
= 3(n4)/5/2 = 3(n4)/10 (3n12)/10
Similar, din relaia
#{i {1, , s} | S[i] < m} < s/2
care este echivalent cu
#{i {1, , s} | S[i] m} > s/2
deducem
#{i {1, , n} | T[i] m} > 3n/5/2
= 3n/10 = 3(n9)/10 (3n27)/10
Deci,
#{i {1, , n} | T[i] < m} < (7n+27)/10
n concluzie, m aproximeaz mediana lui T, fiind al k-lea cel mai mic element al
lui T, unde k este aproximativ ntre 3n/10 i 7n/10. O interpretare grafic ne va
ajuta s nelegem mai bine aceste relaii. S ne imaginm elementele lui T
dispuse pe cinci linii, cu posibila excepie a cel mult patru elemente (Figura 7.1).
Presupunem c fiecare din cele n/5 coloane este ordonat nedescresctor, de sus
n jos. De asemenea, presupunem c linia din mijloc (corespunztoare tabloului S
din algoritm) este ordonat nedescresctor, de la stnga la dreapta. Elementul
subliniat corespunde atunci medianei lui S, deci lui m. Elementele din interiorul
dreptunghiului sunt mai mici sau egale cu m. Dreptunghiul conine aproximativ
3/5 din jumtatea elementelor lui T, deci n jur de 3n/10 elemente.
Seciunea 7.6 Selecia unui element dintr-un tablou 169
n particular, putem determina mediana unui tablou n timp liniar, att pentru
cazul mediu ct i pentru cazul cel mai nefavorabil. Fa de algoritmul naiv, al
crui timp este n ordinul lui n log n, mbuntirea este substanial.
*
O prim soluie a acestei probleme a fost dat n 1976 de W. Diffie i M. E. Hellman. ntre timp s-
au mai propus i alte protocoale.
Seciunea 7.7 O problem de criptologie 171
function dlog(g, a, p)
A 0; k 1
repeat
A A+1
k kg
until (a = k mod p) or (A = p)
return A
Dac logaritmul nu exist, funcia dlog va returna valoarea p. De exemplu, nu
A
exist un ntreg A, astfel nct 3 = 2 mod 7. Algoritmul de mai sus este ns
extrem de ineficient. Dac p este un numr prim impar, atunci este nevoie n
medie de p/2 repetri ale buclei repeat pentru a ajunge la soluie (presupunnd c
aceasta exist). Dac pentru efecuarea unei bucle este necesar o microsecund,
atunci timpul de execuie al algoritmului poate fi mai mare dect vrsta
Pmntului! Iar aceasta se ntmpl chiar i pentru un numr zecimal p cu doar 24
de cifre.
Cu toate c exist i algoritmi mai rapizi pentru calcularea logaritmilor discrei,
nici unul nu este suficient de eficient dac p este un numr prim cu cteva sute de
cifre. Pe de alt parte, nu se cunoate pn n prezent un alt mod de a-l obine pe x
din p, g, a i b, dect prin calcularea logaritmului discret.
Desigur, Alice i Bob trebuie s poat calcula rapid exponenierile de forma
A
a = g mod p, cci altfel ar fi i ei pui n situaia Evei. Urmtorul algoritm pentru
calcularea exponenierii nu este cu nimic mai subtil sau eficient dect cel pentru
logaritmul discret.
function dexpo1(g, A, p)
a1
for i 1 to A do a ag
return a mod p
Faptul c x y z mod p = ((x y mod p) z) mod p pentru orice x, y, z i p, ne permite
s evitm memorarea unor numere extrem de mari. Obinem astfel o prim
mbuntire:
function dexpo2(g, A, p)
a1
for i 1 to A do a ag mod p
return a
Din fericire pentru Alice i Bob, exist un algoritm eficient pentru calcularea
exponenierii i care folosete reprezentarea binar a lui A. S considerm pentru
nceput urmtorul exemplu
25 2 2 2 2
x = (((x x) ) ) x
172 Algoritmi divide et impera Capitolul 7
25
L-am obinut deci pe x prin doar dou nmuliri i patru ridicri la ptrat. Dac
n expresia
25 2 2 2 2
x = (((x x) 1) 1) x
nlocuim fiecare x cu un 1 i fiecare 1 cu un 0, obinem secvena 11001, adic
25
reprezentarea binar a lui 25. Formula precedent pentru x are aceast form,
25 24 24 12 2
deoarece x = x x, x = (x ) etc. Rezult un algoritm divide et impera n care
se testeaz n mod recursiv dac exponentul curent este par sau impar.
function dexpo(g, A, p)
if A = 0 then return 1
if A este impar then a dexpo(g, A1, p)
return (ag mod p)
else a dexpo(g, A/2, p)
return (aa mod p)
Fie h(A) numrul de nmuliri modulo p efectuate atunci cnd se calculeaz
dexpo(g, A, p), inclusiv ridicarea la ptrat. Atunci,
0 pentru A = 0
h( A) = 1 + h( A 1) pentru A impar
1 + h( A / 2) altfel
Dac M(p) este limita superioar a timpului necesar nmulirii modulo p a dou
numere naturale mai mici dect p, atunci calcularea lui dexpo(g, A, p) necesit un
timp n O(M(p) h(A)). Mai mult, se poate demonstra c timpul este n
O(M(p) log A), ceea ce este rezonabil. Ca i n cazul cutrii binare, algoritmul
dexpo este mai curnd un exemplu de simplificare dect de tehnic divide et
impera.
Vom nelege mai bine acest algoritm, dac considerm i o versiune iterativ a
lui.
function dexpoiter1(g, A, p)
c 0; a 1
{fie Ak Ak 1 ... A0 reprezentarea binar a lui A}
for i k downto 0 do
c 2c
a aa mod p
if A i = 1 then c c + 1
a ag mod p
return a
Fiecare iteraie folosete una din identitile
Seciunea 7.7 O problem de criptologie 173
2c c 2
g mod p = (g ) mod p
2c+1 c 2
g mod p = g(g ) mod p
n funcie de valoarea lui A i (dac este 0, respectiv 1). La sfritul pasului i,
valoarea lui c, n reprezentare binar, este Ak Ak 1 ... Ai . Reprezentrea binar a lui
A este parcurs de la stnga spre dreapta, invers ca la algoritmul dexpo. Variabila
c a fost introdus doar pentru a nelege mai bine cum funcioneaz algoritmul i
putem, desigur, s o eliminm.
Dac parcurgem reprezentarea binar a lui A de la dreapta spre stnga, obinem un
alt algoritm iterativ la fel de interesant.
function dexpoiter2(g, A, p)
n A; y g; a 1
while n > 0 do
if n este impar then a ay mod p
y yy mod p
n n div 2
return a
Pentru a compara aceti trei algoritmi, vom considera urmtorul exemplu.
15 2 2 2
Algoritmul dexpo l calculeaz pe x sub forma (((1 x) x) x) x, cu apte nmuliri;
2 2 2 2
algoritmul dexpoiter1 sub forma (((1 x) x) x) x, cu opt nmuliri; iar dexpoiter2
2 4 8
sub forma 1 x x x x , tot cu opt nmuliri (ultima din acestea fiind pentru
16
calcularea inutil a lui x ).
Se poate observa c nici unul din aceti algoritmi nu minimizeaz numrul de
15
nmuliri efectuate. De exemplu, x poate fi obinut prin ase nmuliri, sub forma
2 2 2 15
((x x) x) x. Mai mult, x poate fi obinut prin doar cinci nmuliri (Exerciiul
7.22).
algoritm de nmulire matricial al crui timp s fie ntr-un ordin mai mic dect
n . Pe de alt parte, este clar c (n ) este o limit inferioar pentru orice
3 2
unde
C11 = A11 B11 + A12 B21 C12 = A11 B12 + A12 B22
C21 = A21 B11 + A22 B21 C 22 = A21 B12 + A22 B22
Pentru n = 2, nmulirile i adunrile din relaiile de mai sus sunt scalare; pentru
n > 2, aceste operaii sunt ntre matrici de n/2 n/2 elemente. Operaia de adunare
matricial este cea clasic. n schimb, pentru fiecare nmulire matricial, aplicm
recursiv aceste partiionri, pn cnd ajungem la submatrici de 2 2 elemente.
Pentru a obine matricea C, este nevoie de opt nmuliri i patru adunri de matrici
de n/2 n/2 elemente. Dou matrici de n/2 n/2 elemente se pot aduna ntr-un
timp n (n ). Timpul total pentru algoritmul divide et impera rezultat este
2
t(n) 8t(n/2) + (n )
2
Definim funcia
1 pentru n = 1
f ( n) =
8 f (n / 2) + n pentru n 1
2
de metoda clasic.
n timp ce nmulirea matricilor necesit un timp cubic, adunarea matricilor
necesit doar un timp ptratic. Este, deci, de dorit ca n formulele pentru
calcularea submatricilor C s folosim mai puine nmuliri, chiar dac prin aceasta
mrim numrul de adunri. Este ns acest lucru i posibil? Rspunsul este
afirmativ. n 1969, Strassen a descoperit o metod de calculare a submatricilor
C ij , care utilizeaz 7 nmuliri i 18 adunri i scderi. Pentru nceput, se
calculeaz apte matrici de n/2 n/2 elemente:
P = ( A11 + A22 ) ( B11 + B22 )
Q = ( A21 + A22 ) B11
R = A11 ( B12 B22 )
S = A22 ( B21 B11 )
T = ( A11 + A12 ) B22
U = ( A21 A11 ) ( B11 + B22 )
V = ( A12 A22 ) ( B21 + B22 )
176 Algoritmi divide et impera Capitolul 7
t(n) 7t(n/2) + (n )
2
t O(n ). Algoritmul lui Strassen este deci mai eficient dect algoritmul clasic
2,81
de nmulire matricial.
Metoda lui Strassen nu este unic: s-a demonstrat c exist exact 36 de moduri
diferite de calcul a submatricilor C ij , fiecare din aceste metode utiliznd 7
nmuliri.
2,81
Limita O(n ) poate fi i mai mult redus dac gsim un algoritm de nmulire a
matricilor de 2 2 elemente cu mai puin de apte nmuliri. S-a demonstrat ns
c acest lucru nu este posibil. O alt metod este de a gsi algoritmi mai eficieni
pentru nmulirea matricilor de dimensiuni mai mari dect 2 2 i de a
descompune recursiv pn la nivelul acestor submatrici. Datorit constantelor
multiplicative implicate, exceptnd algoritmul lui Strassen, nici unul din aceti
algoritmi nu are o valoare practic semnificativ.
Pe calculator, s-a putut observa c, pentru n 40, algoritmul lui Strassen este mai
eficient dect metoda clasic. n schimb, algoritmul lui Strassen folosete
memorie suplimentar.
Poate c este momentul s ne ntrebm de unde provine acest interes pentru
nmulirea matricilor. Importana acestor algoritmi * deriv din faptul c operaii
frecvente cu matrici (cum ar fi inversarea sau calculul determinantului) se bazeaz
pe nmuliri de matrici. Astfel, dac notm cu f (n) timpul necesar pentru a nmuli
dou matrici de n n elemente i cu g(n) timpul necesar pentru a inversa o
matrice nesingular de n n elemente, se poate arta c f (g).
*
S-au propus i metode complet diferite. Astfel, D. Coppersmith i S. Winograd au gsit n 1987 un
2,376
algoritm cu timpul n O(n ).
Seciunea 7.9 nmulirea numerelor ntregi mari 177
v-ai confruntat deja cu aceast problem. Acelai lucru s-a ntmplat n 1987,
atunci cnd s-au calculat primele 134 de milioane de cifre ale lui . n criptologie,
numerele ntregi mari sunt de asemenea extrem de importante (am vzut acest
lucru n Seciunea 7.7). Operaiile aritmetice cu operanzi ntregi foarte mari nu
mai pot fi efectuate direct prin hardware, deci nu mai putem presupune, ca pn
acum, c operaiile necesit un timp constant. Reprezentarea operanzilor n
virgul flotant ar duce la aproximri nedorite. Suntem nevoii deci s
implementm prin software operaiile aritmetice respective.
n cele ce urmeaz, vom da un algoritm divide et impera pentru nmulirea
ntregilor foarte mari. Fie u i v doi ntregi foarte mari, fiecare de n cifre zecimale
j j1
(convenim s spunem c un ntreg k are j cifre dac k < 10 , chiar dac k < 10 ).
Dac s = n/2, reprezentm pe u i v astfel:
u = 10 w + x, v = 10 y + z, unde 0 x < 10 , 0 z < 10
s s s s
u w x
v y z
n / 2 n / 2
ntregii w i y au cte n/2 cifre, iar ntregii x i z au cte n/2 cifre. Din relaia
uv = 10 wy + 10 (wz+xy) + xz
2s s
y v div 10 ; z v mod 10
s s
return nmulire(w, y) 10
2s
+ nmulire(x, z)
178 Algoritmi divide et impera Capitolul 7
Notm cu t d (n) timpul necesar acestui algoritm, n cazul cel mai nefavorabil,
pentru a nmuli doi ntregi de n cifre. Avem
t d (n) 3t d (n/2) + t d (n/2) + (n)
Fie t(n) timpul necesar algoritmului modificat pentru a nmuli doi ntregi, fiecare
cu cel mult n cifre. innd cont c w+x i y+z pot avea cel mult 1+n/2 cifre,
obinem
t(n) t(n/2) + t(n/2) + t(1+n/2) + O(n)
Prin definiie, funcia t este nedescresctoare. Deci,
t(n) 3t(1+n/2) + O(n)
Notnd T(n) = t(n+2) i presupunnd c n este o putere a lui 2, obinem
T(n) 3T(n/2) + O(n)
Prin metoda iteraiei (ca n Exerciiul 7.24), putei arta c
T O(n
lg 3
| n este o putere a lui 2)
Seciunea 7.9 nmulirea numerelor ntregi mari 179
t O(n
lg 3
| n este o putere a lui 2)
innd din nou cont c t este nedescresctoare, aplicm Proprietatea 5.1 i
obinem t O(n ).
lg 3
7.10 Exerciii
7.1 Demonstrai c procedura binsearch se termin ntr-un numr finit de pai
(nu cicleaz).
Indicaie: Artai c binrec(T[i .. j], x) este apelat ntotdeauna cu i j i c
binrec(T[i .. j], x) apeleaz binrec(T[u .. v], x) ntotdeauna astfel nct
vu < ji
7.3 Observai c bucla while din algoritmul insert (Seciunea 1.3) folosete o
cutare secvenial (de la coad la cap). S nlocuim aceast cutare secvenial
cu o cutare binar. Pentru cazul cel mai nefavorabil, ajungem oare acum ca
timpul pentru sortarea prin inserie s fie n ordinul lui n log n?
7.4 Artai c timpul pentru iterbin2 este n (1), (log n), (log n) pentru
cazurile cel mai favorabil, mediu i respectiv, cel mai nefavorabil.
7.5 Fie T[1 .. n] un tablou ordonat cresctor de ntregi diferii, unii putnd fi
negativi. Dai un algoritm cu timpul n O(log n) pentru cazul cel mai nefavorabil,
care gsete un index i, 1 i n, cu T[i] = i, presupunnd c acest index exist.
180 Algoritmi divide et impera Capitolul 7
else p trat ( a , m , n )
Demonstrai c t m O(n).
184 Algoritmi divide et impera Capitolul 7
Putem considera c exist constanta real pozitiv c astfel nct t m (i) ci+c,
pentru 0 i n 0 . Prin ipoteza induciei specificate parial presupunem c
t(i) ci+c, pentru orice 0 i < n. Atunci
t m (n) dn+c+cn/2 = cn+c+dncn/2 cn+c
deoarece putem s alegem constanta c suficient de mare, astfel nct cn/2 dn.
Am artat deci prin inducie c, dac c este suficient de mare, atunci t m (n) cn+c,
pentru orice n 0. Adic, t m O(n).
15
7.22 Artai cum poate fi calculat x prin doar cinci nmuliri (inclusiv ridicri
la ptrat).
15 2 2 2 2 1
Soluie: x = (((x ) ) ) x
Seciunea 7.10 Exerciii 185
lg 7
7.24 Demonstrai c algoritmul lui Strassen necesit un timp n O(n ),
folosind de aceast dat metoda iteraiei.
Soluie: Fie dou constante pozitive a i c, astfel nct timpul pentru algoritmul
lui Strassen este
t(n) 7t(n/2) + cn
2
cn (7/4) + a7
2 lg n lg n
+ an O(n
lg 4+lg 7lg 4 lg 7 lg 7
= cn )
n 1 n 1
+ pentru 0 < k < n
n k 1 k
=
k
1 altfel
n mod direct:
function C(n, k)
if k = 0 or k = n then return 1
else return C(n1, k1) + C(n1, k)
Multe din valorile C(i, j), i < n, j < k, sunt calculate n mod repetat (vezi
n
Exerciiul 2.5). Deoarece rezultatul final este obinut prin adunarea a de 1,
k
n
rezult c timpul de execuie pentru un apel C(n, k) este n ( ).
k
185
186 Algoritmi de programare dinamic Capitolul 8
0 1 2 ... k1 k
0 1
1 1 1
2 1 2 1
M
n 1 n 1
n1
k 1 k
n
n
k
(acesta este desigur triunghiul lui Pascal), obinem un algoritm mai eficient. De
fapt, este suficient s memorm un vector de lungime k, reprezentnd linia curent
din triunghiul lui Pascal, pe care s-l reactualizm de la dreapta la stnga. Noul
algoritm necesit un timp n O(nk). Pe aceast idee se bazeaz i algoritmul fib2
(Capitolul 1). Am ajuns astfel la primul principiu de baz al programrii
dinamice: evitarea calculrii de mai multe ori a aceluiai subcaz, prin memorarea
rezultatelor intermediare.
Putem spune c metoda divide et impera opereaz de sus n jos (top-down),
descompunnd un caz n subcazuri din ce n ce mai mici, pe care le rezolv apoi
separat. Al doilea principiu fundamental al programrii dinamice este faptul c ea
opereaz de jos n sus (bottom-up). Se pornete de obicei de la cele mai mici
subcazuri. Combinnd soluiile lor, se obin soluii pentru subcazuri din ce n ce
mai mari, pn se ajunge, n final, la soluia cazului iniial.
Programarea dinamic este folosit de obicei n probleme de optimizare. n acest
context, conform celui de-al treilea principiu fundamental, programarea dinamic
este utilizat pentru a optimiza o problem care satisface principiul optimalitii:
ntr-o secven optim de decizii sau alegeri, fiecare subsecven trebuie s fie de
asemenea optim. Cu toate c pare evident, acest principiu nu este ntotdeauna
valabil i aceasta se ntmpl atunci cnd subsecvenele nu sunt independente,
adic atunci cnd optimizarea unei secvene intr n conflict cu optimizarea
celorlalte subsecvene.
Pe lng programarea dinamic, o posibil metod de rezolvare a unei probleme
care satisface principiul optimalitii este i tehnica greedy. n Seciunea 8.6 vom
ilustra comparativ aceste dou tehnici.
Seciunea 8.1 Trei principii fundamentale ale programrii dinamice 187
8.2 O competiie
n acest prim exemplu de programare dinamic nu ne vom concentra pe principiul
optimalitii, ci pe structura de control i pe ordinea rezolvrii subcazurilor. Din
aceast cauz, problema considerat n aceast seciune nu va fi o problem de
optimizare.
S ne imaginm o competiie n care doi juctori A i B joac o serie de cel mult
2n1 partide, ctigtor fiind juctorul care acumuleaz primul n victorii.
Presupunem c nu exist partide egale, c rezultatele partidelor sunt independente
ntre ele i c pentru orice partid exist o probabilitate p constant ca s ctige
juctorul A i o probabilitate q = 1p ca s ctige juctorul B.
Ne propunem s calculm P(i, j), probabilitatea ca juctorul A s ctige
competiia, dat fiind c mai are nevoie de i victorii i c juctorul B mai are
nevoie de j victorii pentru a ctiga. n particular, la nceputul competiiei aceast
probabilitate este P(n, n), deoarece fiecare juctor are nevoie de n victorii. Pentru
1 i n, avem P(0, i) = 1 i P(i, 0) = 0. Probabilitatea P(0, 0) este nedefinit.
Pentru i, j 1, putem calcula P(i, j) dup formula:
P(i, j) = pP(i1, j) + qP(i, j1)
algoritmul corespunztor fiind:
function P(i, j)
if i = 0 then return 1
if j = 0 then return 0
return pP(i1, j) + qP(i, j1)
188 Algoritmi de programare dinamic Capitolul 8
Figura 8.1 Apelurile recursive efectuate dup un apel al func iei P(i, j).
Fie t(k) timpul necesar, n cazul cel mai nefavorabil, pentru a calcula
probabilitatea P(i, j), unde k = i+j.
Avem:
t(1) a
t(k) 2t(k1) + c, k>1
a i c fiind dou constante. Prin metoda iteraiei, obinem t O(2 ), iar dac
k
recursive (Figura 8.1), observm c este identic cu cel pentru calculul ineficient al
coeficienilor binomiali:
C(i+j, j) = C((i1)+j, j) + C(i+( j1), j1)
Din Exerciiul 8.1 rezult c numrul total de apeluri recursive este
i + j
2 2
j
2n
Timpul de execuie pentru un apel P(n, n) este deci n ( ). innd cont i de
n
Exerciiul 8.3, obinem c timpul pentru calculul lui P(n, n) este n
O(4 ) (4 /n). Aceasta nseamn c, pentru valori mari ale lui n, algoritmul
n n
este ineficient.
Pentru a mbunti algoritmul, vom proceda ca n cazul triunghiului lui Pascal.
Tabloul n care memorm rezultatele intermediare nu l vom completa, ns, linie
cu linie, ci pe diagonal. Probabilitatea P(n, n) poate fi calculat printr-un apel
serie(n, p) al algoritmului
Seciunea 8.2 O competiie 189
function serie(n, p)
array P[0..n, 0..n]
q 1p
for s 1 to n do
P[0, s] 1; P[s, 0] 0
for k 1 to s1 do
P[k, sk] pP[k1, sk] + qP[k, sk1]
for s 1 to n do
for k 0 to ns do
P[s+k, nk] pP[s+k1, nk] + qP[s+k, nk1]
return P[n, n]
Deoarece n esen se completeaz un tablou de n n elemente, timpul de
execuie pentru un apel serie(n, p) este n (n ). Ca i n cazul coeficienilor
2
n general, vom spune c un produs de matrici este complet parantezat, dac este:
i) o singur matrice, sau ii) produsul a dou produse de matrici complet
parantezate, nconjurat de paranteze. Pentru a afla n mod direct care este ordinea
optim de efectuare a nmulirilor matriciale, ar trebui s parantezm expresia lui
M n toate modurile posibile i s calculm de fiecare dat care este numrul de
nmuliri scalare necesare.
S notm cu T(n) numrul de moduri n care se poate paranteza complet un produs
de n matrici. S presupunem c decidem s facem prima tietur ntre a i-a i a
(i+1)-a matrice a produsului
M = (M 1 M 2 M i )(M i+1 M i+2 M n )
cu T(1) = 1. De aici, putem calcula toate valorile lui T(n). De exemplu, T(5) = 14,
T(10) = 4862, T(15) = 2674440. Valorile lui T(n) sunt cunoscute ca numerele
catalane. Se poate demonstra c
1 2 n 2
T ( n) =
n n 1
i o alegem pe cea optim, pentru i k < i+s. A doua situaie este de fapt o
particularizare a celei de-a treia situaii, cu s = 1.
j=1 2 3 4
s=3
2 0 1335 1845
s=2
3 0 9078
s=1
4 0
s=0
*
Problema nmulirii nlnuite optime a matricilor poate fi rezolvat i prin algoritmi mai eficieni.
Astfel, T. C. Hu i M. R. Shing au propus, (n 1982 i 1984), un algoritm cu timpul de execuie n
O(n log n).
Seciunea 8.3 nmulirea nlnuit a matricilor 193
function minmat(i, j)
{returneaz produsul matricial M i M i+1 M j
calculat prin m[i, j] nmuliri scalare;
se presupune c i r[i, j] j}
if i = j then return M i
arrays U, V
U minmat(i, r[i, j])
V minmat(r[i, j]+1, j)
return produs(U, V)
unde funcia produs(U, V) calculeaz n mod clasic produsul matricilor U i V. n
exemplul nostru, produsul ABCD se va calcula n mod optim cu 2856 nmuliri
scalare, corespunztor parantezrii: ((A(BC))D).
00 01 02 03 10 11 12 13
a
0 1
mai sus (definit ca int a[2][5]) este de tip pointer la un tablou cu cinci
elemente ntregi, adic int (*)[5], iar a[0] i a[1] sunt adrese de ntregi,
adic int*. Mai exact, expresia a[0] este adresa primei linii din matrice (a
primului tablou de cinci elemente) i este echivalent cu *(a+0), iar expresia
a[1] este adresa celei de-a doua linii din matrice (a celui de-al doilea tablou
de cinci elemente), adic *(a+1). n final, deducem c a[1][2] este
echivalent cu *(*(a+1)+2), ceea ce ilustreaz echivalena operatorului de
indexare i a celui de indirectare.
n privina echivalenei identificatorilor de tablouri i a pointerilor, nu mai putem
fi att de categorici. S pornim de la urmtoarele dou definiii:
int a[ 2 ][ 5 ];
int *b[ 2 ] = {
a[ 0 ] // adica b[ 0 ] = &a[ 0 ][ 0 ]
a[ 1 ] // adica b[ 1 ] = &a[ 1 ][ 0 ]
};
#include <iostream.h>
main( ) {
int a[ 2 ][ 5 ];
int *b[ 2 ] = { a[ 0 ], a[ 1 ] };
return 1;
}
char x[ ] = "algoritm";
char *y = "eficient";
atunci x este adresa unei zone de memorie care conine textul algoritm, iar y
este adresa unei zone de memorie care conine adresa irului eficient.
Expresiile x[1], *(x+1) i expresiile y[1], *(y+1) sunt corecte, valoarea lor
fiind al doilea caracter din irurile algoritm i, respectiv, eficient. n schimb,
dintre cele dou expresii *(++x) i *(++y), doar a doua este corect, deoarece
valoarea lui x nu poate fi modificat.
Prin introducerea claselor i prin posibilitatea de suprancrcare a operatorului
[], echivalena dintre operatorul de indirectare * i cel de indexare [] nu mai este
valabil. Pe baza definiiei
int D = 8192;
// ...
tablou<int> x( D );
dar nu i
tablou<int> c[ 3 ];
tablou<int> x( 25 );
tablou< tablou<int> > d( 3 );
d[ 0 ] = x; // prima linie se initializeaza cu x
d[ 1 ].newsize( 16 ); // a doua linie se redimensioneaza
// a treia linie nu se modifica
*
Diagonala principal este diagonala care unete colul din stnga sus cu cel din dreapta jos.
198 Algoritmi de programare dinamic Capitolul 8
obinem succesiv
0 5 0 5 20 10
50 0 15 5 50 0 15 5
D1 = D2 =
30 35 0 15 30 35 0 15
15 20 5 0 15 20 5 0
0 5 20 10 0 5 15 10
45 0 15 5 20 0 10 5
D3 = D4 =
30 35 0 15 30 35 0 15
15 20 5 0 15 20 5 0
multiplicativ mai mic, fiind probabil mai rapid n practic. Dac folosim
algoritmul Dijkstra-modificat n mod similar, obinem un timp total n
2
O(max(mn, n ) log n), unde m = #M. Dac graful este rar, atunci este preferabil s
aplicm algoritmul Dijkstra-modificat de n ori; dac graful este dens (m n ),
2
A E
B D
E G
F H
*
n aceast seciune vom subnelege c toi arborii de cutare sunt binari.
Seciunea 8.6 Arbori binari optimi de cutare 201
C G
B D F
E H
Dac dorim s gsim arborele optim pentru cheile c 1 < c 2 < < c 5 , cu
probabilitile
Seciunea 8.6 Arbori binari optimi de cutare 203
tim acum cum s calculm numrul minim de comparaii necesare pentru a gsi o
cheie n arborele optim. Mai rmne s construim efectiv arborele optim. n
*
Dac inem cont de mbuntirile propuse de D. E. Knuth (Tratat de programarea
calculatoarelor. Sortare i cutare, Seciunea 6.2.2), acest algoritm de construire a arborilor
optimi de cutare poate fi fcut ptratic.
Seciunea 8.6 Arbori binari optimi de cutare 205
c4
c1 c5
c3
c2
paralel cu tabloul C, vom construi tabloul r, astfel nct r[i, j] s conin valoarea
lui k pentru care este obinut n relaia (*) valoarea minim a lui C[i, j], unde
i < j. Generm un arbore binar, conform urmtoarei metode recursive:
rdcina este etichetat cu (1, n)
dac un vrf este etichetat cu (i, j), i < j, atunci fiul su stng va fi etichetat cu
(i, r[i, j]1) i fiul su drept cu (r[i, j]+1, j)
vrfurile terminale sunt etichetate cu (i, i)
Plecnd de la acest arbore, arborele de cutare optim se obine schimbnd
etichetele (i, j), i < j, n c r[i, j] , iar etichetele (i, i) n c i .
Pentru exemplul precedent, obinem astfel arborele optim din Figura 8.6.
Problema se poate generaliza, acceptnd s cutm i chei care nu se afl n
arbore. Arborele optim de cutare se obine n mod similar.
private:
varf<E> *root; // adresa varfului radacina
int n; // numarul varfurilor din arbore
};
are la baz o clas privat varf<E> prin intermediul creia vom implementa
majoritatea operaiilor efectuate asupra arborilor. Vom cuta s izolm, ori de
cte ori va fi posibil, operaiile direct aplicabile vrfurilor, astfel nct interfaa
dintre cele dou clase s fie foarte clar precizat printr-o serie de operaii
elementare.
Nu vom implementa n aceast seciune arbori binari n toat generalitatea lor, ci
doar arborii de cutare. Obiectivul urmrit n prezentarea listelor a fost structura
de date n sine, mpreun cu procedurile generale de manipulare. n cazul
arborelui de cutare, nu mai este necesar o astfel de generalitate, deoarece vom
implementa direct operaiile specifice. n mare, aceste operaii pot fi mprite n
trei categorii:
Cutri. Localizarea vrfului cu o anumit cheie, a succesorului sau
predecesorului lui, precum i a vrfurilor cu cheile de valoare maxim,
respectiv minim.
Modificri. Arborele se modific prin inserarea sau tergerea unor vrfuri.
Organizri. Arborele nu este construit prin inserarea elementelor, ci global,
stabilind ntr-o singur trecere legturile dintre vrfuri. Frecvent, organizarea
se face conform unor criterii pentru optimizarea cutrilor. Un caz particular al
acestei operaii este reorganizarea arborelui dup o perioad suficient de mare
de utilizare. Este vorba de reconstruirea arborelui ntr-o structur optim, pe
baza statisticilor de utilizare.
Datorit operaiilor de cutare i modificare, elementele de tip E trebuie s fie
comparabile prin operatorii uzuali ==, !=, >. n finalul Seciunii 7.4.1, am artat
c o asemenea pretenie nu este totdeauna justificat. Desigur c, n cazul unor
structuri bazate pe relaia de ordine, aa cum sunt heap-ul i arborele de cutare,
este absolut normal ca elementele s poat fi comparate.
Principalul punct de interes pentru noi este optimizarea, conform algoritmului de
programare dinamic. Nu vom ignora nici cutrile, nici operaiile de modificare
(tratate n Seciunea 8.7.2).
Seciunea 8.7 Arborii binari de cutare ca tip de dat 207
Vom rezolva problema obinerii arborelui optim n cel mai simplu caz posibil (din
punct de vedere al utilizrii, dar nu i n privina programrii): arborele deja
exist i trebuie reorganizat ntr-un arbore de cutare optim. Avnd n vedere
specificul diferit al operaiilor de organizare fa de celelalte operaii efectuate
asupra grafurilor, am considerat util s ncapsulm optimizarea ntr-o clas pe
care o vom numi structur pentru optimizarea arborilor sau, pe scurt, s8a.
Clasa s8a este o clas parametric privat, asociat clasei arbore<E>.
Funcionalitatea ei const n:
i) iniializarea unui tablou cu adresele vrfurilor n ordinea cresctoare a
probabilitilor cheilor
ii) stabilirea de noi legturi ntre vrfuri astfel nct arborele s fie optim.
Principalul motiv pentru care a fost aleas aceast implementare este c sunt
necesare doar operaii modificare a legturilor. Deplasarea unui vrf (de exemplu,
pentru sortare) nseamn nu numai deplasarea cheii, ci i a informaiei asociate.
Cum fiecare din aceste elemente pot fi orict de mari, clasa s8a realizeaz o
economie semnificativ de timp i (mai ales) de memorie.
Pentru optimizarea propriu-zis, am implementat att algoritmul de programare
dinamic, ct i pe cel greedy prezentat n Exerciiul 8.12. Dei algoritmul greedy
nu garanteaz obinerea arborelui optim, el are totui avantajul c este mai
eficient dect algoritmul de programare dinamic din punct de vedere al timpului
de execuie i al memoriei utilizate. Invocarea optimizrii se realizeaz din clasa
arbore<E>, prin secvene de genul
arbore<float> af;
// date membre
tablou<varf<E>*> pvarf; // tabloul adreselor varfurilor
int n; // numarul varfurilor din arbore
n stabilirea valorilor tablourilor pvarf i r se pot distinge foarte clar cele dou
etape ale execuiei constructorului clasei s8a, etape menionate n Seciunea
4.2.1. Este vorba de etapa de iniializare (implementat prin lista de iniializare a
membrilor) i de etapa de atribuire (implementat prin corpul constructorului).
Lista de iniializare asociat constructorului clasei s8a conine parametrul necesar
dimensionrii tabloului pvarf pentru cele n elemente ale arborelui. Cum este ns
iniializat tabloul r care nu apare n lista de iniializare? n astfel de cazuri, se
invoc automat constructorul implicit (apelabil fr nici un argument) al clasei
respective. Pentru clasa tablou<T>, constructorul implicit doar iniializeaz cu 0
datele membre.
Etapa de atribuire a constructorului clasei s8a, implementat prin invocarea
funciei setvarf(), const n parcurgerea arborelui i memorarea adreselor
vrfurilor vizitate n tabloul pvarf. Funcia setvarf() parcurge pentru fiecare
vrf subarborele stng, apoi memoreaz adresa vrfului curent i, n final,
parcurge subarborele drept. Dup cum vom vedea n Exerciiul 9.1, acest mod de
parcurgere are proprietatea c elementele arborelui sunt parcurse n ordine
cresctoare. De fapt, este vorba de o metod de sortare similar quicksort-ului,
vrful rdcin avnd acelai rol ca i elementul pivot din quicksort.
if ( x ) {
setvarf( poz, x->st );
pvarf[ poz++ ] = x;
setvarf( poz, x->dr );
n aceast funcie, x->st, x->dr i x->tata sunt legturile vrfului curent x ctre
fiul stng, ctre cel drept i, respectiv, ctre vrful tat. n plus fa de aceste
legturi, obiectele de tip varf<E> mai conin cheia (informaia) propriu-zis i un
cmp auxiliar pentru probabilitatea vrfului (elementului). n consecin, clasa
varf<E> are urmtoarea structur:
210 Algoritmi de programare dinamic Capitolul 8
private:
varf( const E& v, float f = 0 ): key( v )
{ st = dr = tata = 0; p = f; }
E key; // cheia
float p; // frecventa utilizarii cheii curente
};
Fie trei chei ale cror probabiliti de cutare au fost estimate iniial la 0,18, 0,65,
0,17. S presupunem c se dorete optimizarea arborelui de cutare asociat
acestor chei, att pe baza acestor estimri, ct i folosind rezultatele a 1000 de
cutri de instruire terminate cu succes *. Dac fixm ponderea estimrilor iniiale
n raport cu rezultatele instruirii la 5 / 2, atunci vom iniializa membrul p
(estimarea probabilitii cheii curente) din clasa varf<E> cu valorile
0,18 1000 (5 / 2) = 450
0,65 1000 (5 / 2) = 1625
0,17 1000 (5 / 2) = 425
Apoi, la fiecare cutare terminat cu success, membrul p corespunztor cheii
gsite se incrementeaz cu 1. De exemplu, dac prima cheie a fost gsit n 247
cazuri, a doua n 412 cazuri i a treia n 341 cazuri, atunci valorile lui p folosite
la optimizarea arborelui vor fi 697, 2037 i 766. Suma acestor valori este 3500,
valoare care corespunde celor 1000 de ncercri plus ponderea de 1000
(5 / 2) = 2500 asociat estimrii iniiale. Noile probabiliti, nvate prin
instruire, sunt:
697 / 3500 0,20
2037 / 3500 0,58
766 / 3500 0,22
Pentru verificarea rezultatelor de mai sus, s refacem calculele, lucrnd numai cu
probabiliti. Estimrile iniiale ale probabilitilor sunt 0,18, 0,65 i 0,17. n
urma instruirii, cele trei chei au fost cutate cu probabilitile:
247 / 1000 = 0,247
412 / 1000 = 0,412
697 / 1000 = 0,697
*
n procesul de optimizare pot fi implicate nu numai cutrile terminate cu succes, ci i cele
nereuite. Cutarea cheilor care nu sunt n arbore este tot att de costisitoare ca i cutarea celor
care sunt n arbore. Pentru detalii asupra acestei probleme se poate consulta D. E. Knuth, Tratat
de programarea calculatoarelor. Sortare i cutare, Seciunea 6.2.2.
214 Algoritmi de programare dinamic Capitolul 8
varf<E> *y = x->tata;
while ( y != 0 && x == y->dr )
{ x = y; y = y->tata; }
return y;
}
*
Acest procedeu de estimare a probabilitilor printr-un proces de instruire poate fi formalizat
ntr-un cadru matematic riguros (R. Andonie, A Converse H-Theorem for Inductive Processes,
Computers and Artificial Intelligence, Vol. 9, 1990, No. 2, pp. 159167).
Succesorul unui vrf X este vrful cu cea mai mic cheie mai mare dect cheia vrfului X (vezi i
Exerciiul 8.10).
Seciunea 8.7 Arborii binari de cutare ca tip de dat 215
S remarcm asemnarea dintre funciile C++ de mai sus i funciile analoage din
Exerciiul 8.10.
Pentru a demonstra corectitudinea funciilor _serarch() i _min(), nu avem
dect s ne reamintim c, prin definiie, ntr-un arbore binar de cutare fiecare
vrf K verific relaiile X K i K Y pentru orice vrf X din subarborele stng
i orice vrf Y din subarborele drept.
Demonstrarea corectitudinii funciei _succ() este de asemenea foarte simpl. Fie
K vrful al crui succesor S trebuie determinat. Vrfurile K i S pot fi situate
astfel:
Vrful S este n subarborele drept al vrfului K. Deoarece aici sunt numai
vrfuri Y cu proprietatea K Y (vezi Figura 8.7a) rezult c S este valoarea
minim din acest subarbore. n plus, avnd n vedere procedura pentru
determinarea minimului, vrful S nu are fiul stng.
Vrful K este n subarborele stng al vrfului S. Deoarece fiecare vrf X de aici
verific inegalitatea X S (vezi Figura 8.7b), deducem c maximul din acest
subarbore este chiar K. Dar maximul se determin parcurgnd fiii din dreapta
pn la un vrf fr fiul drept. Deci, vrful K nu are fiul drept, iar S este
primul ascendent din stnga al vrfului K.
n consecin, cele dou situaii se exclud reciproc, deci funcia _succ() este
corect.
K S
4 5
S K
while ( x != 0 ) {
y = x;
if ( k == x->key ) { // cheia deja exista in arbore
x->p += p; // se actualizeaza frecventa
return 0; // se returneaza cod de eroare
}
x = k > x->key? x->dr: x->st;
}
if ( y == 0 ) root = z;
else if ( z->key > y->key ) y->dr = z;
else y->st = z;
Valoarea returnat este true, dac cheia k a putut fi inserat n arbore, sau false,
n cazul n care deja exist n arbore un vrf cu cheia k. Inserarea propriu-zis
const n cutarea cheii k prin intermediul adreselor x i y, y fiind adresa tatlui
lui x. Atunci cnd am terminat procesul de cutare, valoarea lui x devine 0 i noul
vrf se va insera la stnga sau la dreapta lui y, n funcie de relaia dintre cheia k
i cheia lui y.
Procedura de tergere ncepe prin a determina adresa z a vrfului de ters, pe baza
cheii k. Dac procesul de cutare se finalizeaz cu succes, cheia k se va actualiza
(n scopul unor prelucrri ulterioare) cu informaia din vrful z, iar apoi se
demareaz procesul de tergere efectiv a vrfului z. Dac z este un vrf terminal,
nu avem dect s anulm legtura corespunztoare din vrful tat. Chiar i atunci
cnd z are un singur fiu, tergerea este direct. Adresa lui z din vrful tat se
nlocuiete cu adresa fiului lui z. A treia i cea mai complicat situaie apare
Seciunea 8.7 Arborii binari de cutare ca tip de dat 217
E H
A R C R
C H
E N
P
N M P
M P
L
(a) (b)
atunci cnd z este situat undeva n interiorul arborelui, avnd ambele legturi
complete. n acest caz, nu vom mai terge vrful z, ci vrful y, succesorul lui z,
dar nu nainte de a copia coninutul lui y n z. tergerea vrfului y se face
conform unuia din cele dou cazuri de mai sus, deoarece, n mod sigur, y nu are
fiul stng. ntr-adevr, ntr-un arbore de cutare, succesorul unui vrf cu doi fii nu
are fiul stng, iar predecesorul * unui vrf cu doi fii nu are fiul drept (demonstrai
acest lucru!). Pentru ilustrarea celor trei situaii, am ters din arborele din Figura
8.8a vrfurile E (vrf cu doi fii), A (vrf cu un fiu) i L (vrf terminal).
Procedura de tergere se implementeaz astfel:
*
Predecesorul unui vrf X este vrful care are cea mai mare cheie mai mic dect cheia vrfului X.
218 Algoritmi de programare dinamic Capitolul 8
// 4. stergerea propriu-zisa
y->st = y->dr = 0;
delete y;
return 1;
}
#include <iostream.h>
#include "arbore.h"
main( ) {
int n;
cout << "Numarul de varfuri ... "; cin >> n;
g.re_greedy( );
cout << "\n\nArborele Greedy:\n"; g.inord( );
g.re_prodin( );
cout << "Arborele Greedy re-ProgDin:\n"; g.inord( );
return 1;
}
n
g i xi G
i =1
Putei demonstra c prin acest algoritm obinem soluia optim i c aceasta este
de forma x = (1, , 1, x k , 0, , 0), k fiind un indice, 1 k n, astfel nct
0 x k 1. Algoritmul greedy gsete secvena optim de decizii, lund la fiecare
pas cte o decizie care este optim local. Algoritmul este corect, deoarece nici o
decizie din secven nu este eronat. Dac nu considerm timpul necesar sortrii
iniiale a obiectelor, timpul este n ordinul lui n.
S trecem la problema 0/1 a rucsacului. Se observ imediat c tehnica greedy nu
conduce n general la rezultatul dorit. De exemplu, pentru g = (1, 2, 3),
v = (6, 10, 12), G = 5, algoritmul greedy furnizeaz soluia (1, 1, 0), n timp ce
soluia optim este (0, 1, 1). Tehnica greedy nu poate fi aplicat, deoarece este
generat o decizie (x 1 = 1) optim local, nu ns i global. Cu alte cuvinte, la
primul pas, nu avem suficient informaie local pentru a decide asupra valorii lui
x 1 . Strategia greedy exploateaz insuficient principiul optimalitii, considernd
c ntr-o secven optim de decizii fiecare decizie (i nu fiecare subsecven de
decizii, cum procedeaz programarea dinamic) trebuie s fie optim. Problema se
poate rezolva printr-un algoritm de programare dinamic, n aceast situaie
exploatndu-se complet principiul optimalitii. Spre deosebire de problema
continu, nu se cunoate nici un algoritm polinomial pentru problema 0/1 a
rucsacului.
Diferena esenial dintre tehnica greedy i programarea dinamic const n faptul
c metoda greedy genereaz o singur secven de decizii, exploatnd incomplet
principiul optimalitii. n programarea dinamic, se genereaz mai multe
subsecvene de decizii; innd cont de principiul optimalitii, se consider ns
doar subsecvenele optime, combinndu-se acestea n soluia optim final. Cu
toate c numrul total de secvene de decizii este exponenial (dac pentru fiecare
n
din cele n decizii sunt d posibiliti, atunci sunt posibile d secvene de decizii),
algoritmii de programare dinamic sunt de multe ori polinomiali, aceast reducere
222 Algoritmi de programare dinamic Capitolul 8
8.9 Exerciii
8.1 Demonstrai c numrul total de apeluri recursive necesare pentru a-l
n
calcula pe C(n, k) este 2 2.
k
Soluie: Notm cu r(n, k) numrul de apeluri recursive necesare pentru a-l calcula
pe C(n, k). Procedm prin inducie, n funcie de n. Dac n este 0, proprietatea
este adevrat. Presupunem proprietatea adevrat pentru n 1 i demonstrm
pentru n.
Presupunem, pentru nceput, c 0 < k < n. Atunci, avem recurena
r(n, k) = r(n 1, k 1) + r(n 1, k) + 2
Din relaia precedent, obinem
n 1 n 1 n
r(n, k) = 2 2 + 2 2 + 2 = 2 2
k 1 k k
n
Dac k este 0 sau n, atunci r(n, k) = 0 i, deoarece n acest caz avem = 1,
k
rezult c proprietatea este adevrat. Acest rezultat poate fi verificat practic,
rulnd programul din Exerciiul 2.5.
2n
Demonstrai c 4 /(2n+1).
n
8.3
k
8.10 Fie un arbore binar de cutare reprezentat prin adrese, astfel nct vrful i
(adic vrful a crui adres este i) este memorat n patru locaii diferite coninnd
:
KEY[i] = cheia vrfului
ST[i] = adresa fiului stng
DR[i] = adresa fiului drept
TATA[i] = adresa tatlui
(Dac se folosete o implementare prin tablouri paralele, atunci adresele sunt
indici de tablou). Presupunem c variabila root conine adresa rdcinii arborelui
i c o adres este zero, dac i numai dac vrful ctre care se face trimiterea
lipsete. Elaborai algoritmi pentru urmtoarele operaii n arborele de cutare:
i) Determinarea vrfului care conine o cheie v dat. Dac un astfel de vrf nu
exist, se va returna adresa zero.
ii) Determinarea vrfului care conine cheia minim.
iii) Determinarea succesorului unui vrf i dat (succesorul vrfului i este vrful
care are cea mai mic cheie mai mare dect KEY[i]).
Care este eficiena acestor algoritmi?
Soluie:
i) Apelm tree-search(root, v), tree-search fiind funcia:
Seciunea 8.9 Exerciii 225
function tree-search(i, v)
if i = 0 or v = KEY[i] then return i
if v < KEY[i] then return tree-search(ST[i], v)
else return tree-search(DR[i], v)
Iat i o versiune iterativ a acestui algoritm:
function iter-tree-search(i, v)
while i 0 and v KEY[i] do
if i < KEY[i] then i ST[i]
else i DR[i]
return i
ii) Se apeleaz tree-min(root), tree-min fiind funcia:
function tree-min(i)
while ST[i] 0 do i ST[i]
return i
iii) Urmtorul algoritm returneaz succesorul vrfului i:
function tree-succesor(i)
if DR[i] 0 then return tree-min(DR[i])
j TATA[i]
while j 0 and i = DR[ j] do i j
j TATA[ j]
return j
8.11 Gsii o formul explicit pentru T(n), unde T(n) este numrul de arbori
de cutare diferii care se pot construi cu n chei distincte.
Indicaie: Facei legtura cu problema nmulirii nlnuite a matricilor.
V (l , j , X ) = max vi xi
l i j
g i xi X
l i j
i, n general,
V(1, j, X) = max(V(1, j 1, X), V(1, j 1, X g j ) + v j )
Pentru parcurgerea arborilor binari exist trei tehnici de baz. Dac pentru fiecare
vrf din arbore vizitm prima dat vrful respectiv, apoi vrfurile din subarborele
stng i, n final, subarborele drept, nseamn c parcurgem arborele n preordine.
Dac vizitm subarborele stng, vrful respectiv i apoi subarborele drept, atunci
227
228 Parcurgerea arborilor Capitolul 9
parcurgem arborele n inordine, iar dac vizitm prima dat subarborele stng,
apoi cel drept, apoi vrful respectiv, parcurgerea este n postordine. Toate aceste
tehnici parcurg arborele de la stnga spre dreapta. Putem parcurge ns arborele i
de la dreapta spre stnga, obinnd astfel nc trei moduri de parcurgere.
Proprietatea 9.1 Pentru fiecare din aceste ase tehnici de parcurgere, timpul
necesar pentru a explora un arbore binar cu n vrfuri este n (n).
Demonstraie: Fie t(n) timpul necesar pentru parcurgerea unui arbore binar cu n
vrfuri. Putem presupune c exist constanta real pozitiv c, astfel nct t(n) c
pentru 0 n 1. Timpul necesar pentru parcurgerea unui arbore cu n vrfuri,
n > 1, n care un vrf este rdcina, i vrfuri sunt situate n subarborele stng i
n i 1 vrfuri n subarborele drept, este
t(n) c + max {t(i)+t(n i 1) | 0 i n 1}
Vom arta, prin inducie constructiv, c t(n) dn+c, unde d este o alt constant.
Pentru n = 0, proprietatea este adevrat. Prin ipoteza induciei specificate parial,
presupunem c t(i) di+c, pentru orice 0 i < n. Demonstrm c proprietatea este
adevrat i pentru n. Avem
t(n) c+2c+d(n 1) = dn+c+2c d
Lund d 2c, obinem t(n) dn+c. Deci, pentru d suficient de mare, t(n) dn+c,
pentru orice n 0, adic t O(n). Pe de alt parte, t (n), deoarece fiecare din
cele n vrfuri trebuie vizitat. n consecin, t (n).
*
O astfel de implementare poate fi gsit, de exemplu, n E. Horowitz i S. Sahni, Fundamentals of
Computer Algorithms, Seciunea 6.1.1.
Seciunea 9.2 Operaii de parcurgere n clasa arbore 229
Efectul instruciunii delete root ar trebui s fie tergerea tuturor vrfurilor din
arborele cu rdcina root. Pentru a ajunge la acest rezultat, avem nevoie de
implementarea corespunztoare a destructorului clasei varf<E>, destructor
invocat, dup cum se tie, nainte ca operatorul delete s elibereze spaiul alocat.
Forma acestui destructor este foarte simpl:
if ( !x ) return;
_inord( x->st );
Seciunea 9.2 Operaii de parcurgere n clasa arbore 231
cout << x
<< " ( key " << x->key
<< ", f " << x->p
<< ", st " << x->st
<< ", dr " << x->dr
<< ", tata " << x->tata
<< " )";
_inord( x->dr );
}
este exact ceea ce ne trebuie pentru a afia ntreaga structur intern a arborelui.
1 1
2 3 4 2 3 4
5 6 7 8 5 6 7 8
(a) (b)
preord[v]
preord[w] pentru fiecare vrf w pentru care exist o muchie {v, w} n G
care nu are o muchie corespunztoare n A (n Figura 9.1b, o muchie
punctat)
minim[x] pentru fiecare fiu x al lui v n A
3. Punctele de articulare se determin acum astfel:
a. rdcina lui A este un punct de articulare al lui G, dac i numai dac are
mai mult de un fiu;
b. un vrf v diferit de rdcina lui A este un punct de articulare al lui G,
dac i numai dac v are un fiu x, astfel nct minim[x] preord[v].
Alternativa 3a din algoritm rezult imediat, deoarece este evident c rdcina lui
A este un punct de articulare al lui G, dac i numai dac are mai mult de un fiu.
S presupunem acum c v nu este rdcina lui A. Dac x este un fiu al lui v i
minim[x] < preord[v], rezult c exist o succesiune de muchii care l conectez
pe x cu celelalte vrfuri ale grafului, chiar i dup eliminarea lui v. Pe de alt
parte, nu exist nici o succesiune de muchii care s l conecteze pe x cu tatl lui v,
dac minim[x] preord[v]. Se deduce c i alternativa 3b este corect.
n aceast seciune, vom arta cum putem aplica parcurgerea n adncime a unui
graf, ntr-un procedeu de sortare esenial diferit fa de sortrile ntlnite pn
acum.
Seciunea 9.3 Parcurgerea grafurilor n adncime 235
preparat but
cafea cafea
A B E D F
trezire duul mbrcare plecare
Pentru a putea compara aceste dou tehnici de parcurgere, vom da pentru nceput
o versiune nerecursiv pentru procedura ad. Versiunea se bazeaz pe utilizarea
unei stive. Presupunem c avem funcia ftop care returneaz ultimul vrf inserat n
stiv, fr s l tearg. Folosim i funciile push i pop din Seciunea 3.1.1.
procedure iterad(v)
S stiv vid
marca[v] vizitat
push(v, S)
while S nu este vid do
while exist un vrf w adiacent lui ftop(S)
astfel nct marca[w] = nevizitat do
marca[w] vizitat
push(w, S)
pop(S)
Pentru parcurgerea n lime, vom utiliza o coad i funciile insert-queue,
delete-queue din Seciunea 3.1.2. Iat acum algoritmul de parcurgere n lime:
procedure lat(v)
C coad vid
marca[v] vizitat
insert-queue(v, C)
while C nu este vid do
u delete-queue(C)
for fiecare vrf w adiacent lui u do
if marca[w] = nevizitat then marca[w] vizitat
insert-queue(w, C)
Procedurile iterad i lat trebuie apelate din procedura
procedure parcurge(G)
for fiecare v V do marca[v] nevizitat
for fiecare v V do
if marca[v] = nevizitat then {iterad sau lat} (v)
timpul este n: i) (n+m), dac reprezentm graful prin liste de adiacen; ii)
(n ), dac reprezentm graful printr-o matrice de adiacen.
2
delete root;
root = 0; n = 0; // se va crea un nou arbore
f.close( );
return 1;
}
9.6 Backtracking
Backtracking (n traducere aproximativ, cutare cu revenire) este un principiu
fundamental de elaborare a algoritmilor pentru probleme de optimizare, sau de
gsire a unor soluii care ndeplinesc anumite condiii. Algoritmii de tip
backtracking se bazeaz pe o tehnic special de explorare a grafurilor orientate
implicite. Aceste grafuri sunt de obicei arbori, sau, cel puin, nu conin cicluri.
Pentru exemplificare, vom considera o problem clasic: cea a plasrii a opt
regine pe tabla de ah, astfel nct nici una s nu intre n zona controlat de o alta.
O metod simplist de rezolvare este de a ncerca sistematic toate combinaiile
240 Explorri n grafuri Capitolul 9
posibile de plasare a celor opt regine, verificnd de fiecare dat dac nu s-a
obinut o soluie. Deoarece n total exist
64
= 4.426165
. .368
8
procedure perm(i)
if i = n then utilizeaz(T) {T este o nou permutare}
else for j i to n do interschimb T[i] i T[ j]
perm(i+1)
interschimb T[i] i T[ j]
n algoritmul de generare a permutrilor, T[1 .. n] este un tablou global iniializat
cu [1, 2, , n], iar primul apel al procedurii este perm(1). Dac utilizeaz(T)
necesit un timp constant, atunci perm(1) necesit un timp n (n!).
Aceast abordare reduce numrul de configuraii posibile la 8! = 40.320. Dac se
folosete algoritmul perm, atunci pn la prima soluie sunt generate 2830
permutri. Mecanismul de generare a permutrilor este mai complicat dect cel de
generare a vectorilor de opt ntregi ntre 1 i 8. n schimb, verificarea faptului
dac o configuraie este soluie se face mai uor: trebuie doar verificat dac nu
exist dou regine pe aceeai diagonal.
Chiar i cu aceste mbuntiri, nu am reuit nc s eliminm o deficien comun
a algoritmilor de mai sus: verificarea unei configuraii prin if soluie(posibil) se
face doar dup ce toate reginele au fost deja plasate pe tabl. Este clar c se
pierde astfel foarte mult timp.
Vom reui s eliminm aceast deficien aplicnd principiul backtracking. Pentru
nceput, reformulm problema celor opt regine ca o problem de cutare ntr-un
arbore. Spunem c vectorul P[1 .. k] de ntregi ntre 1 i 8 este k-promitor,
pentru 0 k 8, dac zonele controlate de cele k regine plasate n poziiile
(1, P[1]), (2, P[2]), , (k, P[k]) sunt disjuncte. Matematic, un vector P este
k-promitor dac:
P[i] P[ j] {i j, 0, j i}, pentru orice 0 i, j k, i j
Pentru k 1, orice vector P este k-promitor. Soluiile problemei celor opt regine
corespund vectorilor 8-promitori.
Fie V mulimea vectorilor k-promitori, 0 k 8. Definim graful orientat
G = <V, M> astfel: (P, Q) M, dac i numai dac exist un ntreg k, 0 k 8,
astfel nct P este k-promitor, Q este (k+1)-promitor i P[i] = Q[i] pentru
fiecare 0 i k. Acest graf este un arbore cu rdcina n vectorul vid (k = 0).
Vrfurile terminale sunt fie soluii (k = 8), fie vrfuri moarte (k < 8), n care
este imposibil de plasat o regin pe urmtoarea linie fr ca ea s nu intre n zona
controlat de reginele deja plasate. Soluiile problemei celor opt regine se pot
obine prin explorarea acestui arbore. Pentru aceasta, nu este necesar s generm
n mod explicit arborele: vrfurile vor fi generate i abandonate pe parcursul
explorrii. Vom parcurge arborele G n adncime, ceea ce este echivalent aici cu o
parcurgere n preordine, cobornd n arbore numai dac exist anse de a ajunge
la o soluie.
242 Explorri n grafuri Capitolul 9
(regulile sunt aceleai pentru cei doi parteneri) i deterministe (nu exist un factor
aleator).
Pentru a determina o strategie de ctig ntr-un astfel de joc, vom ataa fiecrui
vrf al grafului o etichet care poate fi de ctig, pierdere, sau remiz. Eticheta
corespunde situaiei unui juctor care se afl n poziia respectiv i trebuie s
mute. Presupunem c nici unul din juctori nu greete, fiecare alegnd mereu
mutarea care este pentru el optim. n particular, din anumite poziii ale jocului
nu se poate efectua nici o mutare, astfel de poziii terminale neavnd poziii
succesoare n graf. Etichetele vor fi ataate n mod sistematic astfel:
Etichetele ataate unei poziii terminale depind de jocul n cauz. De obicei,
juctorul care se afl ntr-o poziie terminal a pierdut.
O poziie neterminal este o poziie de ctig, dac cel puin una din poziiile
ei succesoare n graf este o poziie de pierdere.
O poziie neterminal este o poziie de pierdere, dac toate poziiile ei
succesoare n graf sunt poziii de ctig.
Orice poziie care a rmas neetichetat este o poziie de remiz.
Dac jocul este reprezentat printr-un graf finit aciclic, aceast metod eticheteaz
vrfurile n ordine topologic invers.
Vom ilustra aceste idei printr-o variant a jocului nim. Iniial, pe mas se afl cel
puin dou bee de chibrit. Primul juctor ridic cel puin un b, lsnd pe mas
cel puin un b. n continuare, pe rnd, fiecare juctor ridic cel puin un b i
cel mult de dou ori numrul de bee ridicate de ctre partenerul de joc la mutarea
anterioar. Ctig juctorul care ridic ultimul b. Nu exist remize.
O poziie n acest joc este specificat att de numrul de bee de pe tabl, ct i de
numrul maxim de bee care pot fi ridicate la urmtoarea mutare. Vrfurile
grafului asociat jocului sunt perechi <i, j>, 1 j i, indicnd c pot fi ridicate cel
mult j bee din cele i bee de pe mas. Din vrful <i, j> pleac j muchii ctre
vrfurile <i k, min(2k, i k)>, 1 k j. Vrful corespunztor poziiei iniiale
ntr-un joc cu n bee, n 2, este <n, n 1>. Toate vrfurile pentru care a dou
component este zero corespund unor poziii terminale, dar numai vrful <0, 0>
este interesant: vrfurile <i, 0>, pentru i > 0, sunt inaccesibile. n mod similar,
vrfurile <i, j>, cu j impar i j < i 1, sunt inaccesibile. Vrful <0, 0> corespunde
unei poziii de pierdere.
Seciunea 9.7 Grafuri i jocuri 245
<1,1> <2,1>
Figura 9.3 reprezint graful corespunztor jocului cu cinci bee iniiale: vrfurile
albe corespund poziiilor de ctig, vrfurile gri corespund poziiilor de pierdere,
muchiile continue corespund mutrilor prin care se ctig, iar muchiile
punctate corespund mutrilor prin care se pierde. Dintr-o poziie de pierdere nu
pleac nici o muchie continu, aceasta corespunznd faptului c din astfel de
poziii nu exist nici o mutare prin care se poate ctiga.
Se observ c juctorul care are prima mutare ntr-un joc cu dou, trei, sau cinci
bee nu are nici o strategie de ctig, dar are o astfel de strategie ntr-un joc cu
patru bee.
246 Explorri n grafuri Capitolul 9
ahul este, desigur, un joc mult mai complex dect jocul nim. La prima vedere,
graful asociat ahului conine cicluri. Exist ns reglementri ale Federaiei
Internaionale de ah care previn intrarea ntr-un ciclu. De exemplu, se declar
remiz o partid dup 50 de mutri n care nu are loc nici o aciune ireversibil
(mutarea unui pion, sau eliminarea unei piese). Datorit acestor reguli, putem
considera c graful asociat ahului nu are cicluri.
Vom eticheta fiecare vrf ca poziie de ctig pentru Alb, poziie de ctig pentru
Negru, sau remiz. Odat construit, acest graf ne permite s jucm perfect ah,
adic s ctigm mereu, cnd este posibil, i s pierdem doar cnd este
inevitabil. Din nefericire (din fericire pentru juctorii de ah), acest graf conine
attea vrfuri, nct nu poate fi explorat complet nici cu cel mai puternic
calculator existent.
Deoarece o cutare complet n graful asociat jocului de ah este imposibil, nu
putem folosi tehnica programrii dinamice. Se impune atunci, n mod natural,
aplicarea unei tehnici recursive, care s modeleze raionamentul de sus n jos.
Aceast tehnic (numit minimax) este de tip euristic, i nu ne ofer certitudinea
ctigrii unei partide. Ideea de baz este urmtoarea: fiind ntr-o poziie
oarecare, se alege una din cele mai bune mutri posibile, explornd doar o parte a
grafului. Este de fapt o modelare a raionamentului unui juctor uman care
gndete doar cu un mic numr de mutri n avans.
Primul pas este s definim o funcie de evaluare static eval, care atribuie o
anumit valoare fiecrei poziii posibile. n mod ideal, eval(u) va crete atunci
cnd poziia u devine mai favorabil Albului. Aceast funcie trebuie s in cont
de mai muli factori: numrul i calitatea pieselor existente de ambele pri,
controlul centrului tablei, libertatea de micare etc. Trebuie s facem un
compromis ntre acurateea acestei funcii i timpul necesar calculrii ei. Cnd se
aplic unei poziii terminale, funcia de evaluare trebuie s returneze + dac a
ctigat Albul, dac a ctigat Negrul i 0 dac a fost remiz.
Dac funcia de evaluare static ar fi perfect, ar fi foarte uor s determinm care
este cea mai bun mutare dintr-o anumit poziie. S presupunem c este rndul
Albului s mute din poziia u. Cea mai bun mutare este cea care l duce n poziia
v, pentru care
eval(v) = max{eval(w) | w este succesor al lui u}
Aceast poziie se determin astfel:
val
for fiecare w succesor al lui u do
if eval(w) val then val eval(w)
vw
Seciunea 9.7 Grafuri i jocuri 249
function alb(x, n)
if n = 0 or x nu are succesori
then return eval(x)
return max{negru(w, n 1) | w este succesor al lui x}
Acum nelegem de ce aceast tehnic este numit minimax: Negrul ncearc s
minimizeze avantajul pe care l permite Albului, iar Albul ncearc s maximizeze
avantajul pe care l poate obine la fiecare mutare.
Tehnica minimax poate fi mbuntit n mai multe feluri. Astfel, explorarea
anumitor ramuri poate fi abandonat mai curnd, dac din informaia pe care o
deinem asupra lor, deducem c ele nu mai pot influena valoarea vrfurilor
situate la un nivel superior. Acest mbuntire se numete retezare alfa-beta
(alpha-beta pruning) i este exemplificat n Figura 9.4. Presupunnd c valorile
numerice ataate vrfurilor terminale sunt valorile funciei eval calculate n
250 Explorri n grafuri Capitolul 9
Albul a max
Negrul b c min
Albul d e f g h max
5 7
Negrul i j k l ... min
6 2 1 3
B C D
E F G H
probleme despre care nu se tie a priori dac sunt rezolvabile sau nerezolvabile.
Vrful A este un vrf AND (marcm aceasta prin unirea muchiilor care pleac din
A), vrfurile C i D sunt vrfuri OR. S presupunem acum c dorim s aflm dac
problema A este rezolvabil. Deducem succesiv c problemele C, D i A sunt
rezolvabile.
ntr-un arbore oarecare AND/OR, urmtorul algoritm determin dac problema
reprezentat de un vrf oarecare u este rezolvabil sau nu. Un apel sol(u) are ca
efect parcurgerea n postordine a subarborelui cu rdcina n u i returnarea
valorii true, dac i numai dac problema este rezolvabil.
function sol(v)
case
v este terminal: if v este rezolvabil
then return true
else return false
v este un vrf AND: for fiecare vrf w adiacent lui v do
if not sol(w) then return false
return true
v este un vrf OR: for fiecare vrf w adiacent lui v do
if sol(w) then return true
return false
Ca i n cazul retezrii alfa-beta, dac n timpul explorrii se poate deduce c un
vrf este rezolvabil sau nerezolvabil, se abandoneaz explorarea descendenilor
si. Printr-o modificare simpl, algoritmul sol poate afia strategia de rezolvare a
problemei reprezentate de u, adic subproblemele rezolvabile care conduc la
rezolvarea problemei din u.
Cu anumite modificri, algoritmul se poate aplica asupra grafurilor AND/OR
oarecare. Similar cu tehnica backtracking, explorarea se poate face att n
adncime (ca n algoritmul sol), ct i n lime.
252 Explorri n grafuri Capitolul 9
9.9 Exerciii
9.1 ntr-un arbore binar de cutare, care este modul de parcurgere a vrfurilor
pentru a obine lista ordonat cresctor a cheilor?
9.2 Fiecrei expresii aritmetice n care apar numai operatori binari i se poate
ataa n mod natural un arbore binar. Dai exemple de parcurgere n inordine,
preordine i postordine a unui astfel de arbore. Se obin diferite moduri de scriere
a expresiilor aritmetice. Astfel, parcurgerea n postordine genereaz scrierea
postfixat menionat n Seciunea 3.1.1.
9.3 Fie un arbore binar reprezentat prin adrese, astfel nct vrful i (adic
vrful a crui adres este i) este memorat n trei locaii diferite coninnd:
VAL[i] = valoarea vrfului
ST[i] = adresa fiului stng
DR[i] = adresa fiului drept
(Dac se folosete o implementare prin tablouri paralele, atunci adresele sunt
indici de tablou). Presupunem c variabila root conine adresa rdcinii arborelui
i c o adres este zero, dac i numai dac vrful ctre care se face trimiterea
lipsete. Scriei algoritmii de parcurgere n inordine, preordine i postordine a
arborelui. La fiecare consultare afiai valoarea vrfului respectiv.
Soluie: Pentru parcurgerea n inordine apelm inordine(root), inordine fiind
procedura
procedure inordine(i)
if i 0 then
inordine(ST[i])
write VAL[i]
inordine(DR[i])
9.7 ntr-un arbore cu rdcin, elaborai un algoritm care verific pentru dou
vrfuri oarecare v i w, dac w este un descendent al lui v. (Pentru ca problema s
nu devin trivial, presupunem c vrfurile nu conin adresa tatlui).
Indicaie: Orice soluie direct necesit un timp n (n), n cazul cel mai
nefavorabil, unde n este numrul vrfurilor subarborelui cu rdcina n v.
Iat un mod indirect de rezolvare a problemei, care este n principiu avantajos
atunci cnd trebuie s verificm mai multe cazuri (perechi de vrfuri) pentru
acelai arbore. Fie preord[1 .. n] i postord[1 .. n] tablourile care conin ordinea
de parcurgere a vrfurilor n preordine, respectiv n postordine. Pentru oricare
dou vrfuri v i w avem:
preord[v] < preord[w] w este un descendent al lui v,
sau v este la stnga lui w n arbore
postord[v] > postord[w] w este un descendent al lui v,
sau v este la dreapta lui w n arbore
Deci, w este un descendent al lui v, dac i numai dac:
preord[v] < preord[w] i postord[v] > postord[w]
Dup ce calculm valorile preord i postord ntr-un timp n (n), orice caz
particular se poate rezolva ntr-un timp n (1). Acest mod indirect de rezolvare
ilustreaz metoda precondiionrii.
calului, astfel nct fiecare poziie de pe tabl este vizitat exact o dat
(presupunnd c o astfel de secven de mutri exist).
9.16 Modificai algoritmul rec pentru jocul nim, astfel nct s returneze un
ntreg k:
i) k = 0, dac poziia este de pierdere.
ii) 1 k j, dac a lua k bee este o mutare de ctig.
9.17 Jocul lui Grundy seamn foarte mult cu jocul nim. Iniial, pe mas se afl
o singur grmad de n bee. Cei doi juctori au alternativ dreptul la o mutare. O
mutare const din mprirea uneia din grmezile existente n dou grmezi de
mrimi diferite (dac acest lucru nu este posibil, adic dac toate grmezile
constau din unul sau dou bee, juctorul pierde partida). Ca i la nim, remiza este
exclus. Gsii un algoritm care s determine dac o poziie este de ctig sau de
pierdere.
9.19 Dac graful obinut prin reducerea unei probleme are i vrfuri care nu
sunt de tip AND sau de tip OR, artai c prin adugarea unor vrfuri fictive
putem transforma acest graf ntr-un graf AND/OR.
9.20 Modificai algoritmul sol pentru a-l putea aplica grafurilor AND/OR
oarecare.
10. Derivare public,
funcii virtuale
*
Implementarea este preluat din R. Sethi, Programming Languages. Concepts and Constructs,
Seciunea 6.7.
255
256 Derivare public, funcii virtuale Capitolul 10
ciur
contor, i anume 2. Dup aceast prim iteraie, contorul va avea valoarea 3, iar
ntre ciur i contor se va insera prima sit, sit corespunztoare lui 2. Ciurul va
solicita o nou valoare sitei 2 care, la rndul ei, va solicita o nou valoare
contorului. Contorul emite 3, schimbndu-i valoarea la 4, 3 va trece prin sita 2 i
va ajunge la ciur. Imediat, sita 3 se insereaz n lista existent.
Contorul, la solicitarea ciurului, solicitare transmis sitei 3, apoi sitei 2, va
returna n continuare 4. Valoarea 4 nu trece de sita 2, dar sita 2 insist, cci
trebuie s rspund solicitrii primite, astfel nct va primi un 5. Aceast valoare
trece de toate sitele i lista are un nou element. Continund procesul, constatm c
6 se blocheaz la sita 2, 7 trece prin toate sitele (5, 3, 2), iar valorile 8 i 9 sunt
blocate de sitele 2, respectiv 3. La un moment dat, contorul va avea valoarea n,
iar n list vor fi sitele corespunztoare tuturor numerelor prime mai mici dect n.
Pentru implementarea acestui comportament, avem nevoie de o list nlnuit, n
care fiecare element este surs de valori pentru predecesor i i cunoate propria
surs (elementul succesor). Altfel spus, fiecare element are cel puin doi membri:
adresa sursei i funcia care cerne valorile.
class element {
public:
element( element *src ) { sursa = src; }
virtual int cerne( ) { return 0; }
protected:
element *sursa;
};
Acest element este un prototip, deoarece lista conine trei tipuri diferite de
elemente, difereniate prin funcia de cernere:
Seciunea 10.1 Ciurul lui Eratostene 257
private:
int valoare;
};
private:
int factor;
};
Clasa contor este definit complet. Primul element generat (cernut) este stabilit
prin v, parametrul constructorului. Pentru clasa sita, funcia de cernere este mult
mai selectiv. Ea solicit de la surs valori, pn cnd primete o valoare care nu
este multiplu al propriei valori.
int sita::cerne( ) {
while ( 1 ) {
int n = sursa->cerne( );
if ( n % factor ) return n;
}
}
int ciur::cerne( ) {
int n = sursa->cerne( );
sursa = new sita( sursa, n );
return n;
}
Se observ c noua sit este creat i inserat n ciur printr-o singur instruciune:
al crei efect poate fi exprimat astfel: sursa nodului ciur este o nou sita (cu
valoarea n) a crei surs va fi sursa actual a ciurului. O astfel de operaie de
inserare este una dintre cele mai uzuale n lucrul cu liste.
Prin programul urmtor, ciurul descris poate fi pus n funciune pentru
determinarea numerelor prime mai mici dect o anumit valoare.
#include <iostream.h>
#include <stdlib.h>
#include <new.h>
int main( ) {
_new_handler = no_mem;
int max;
cout << "max ... "; cin >> max;
ciur s( &contor( 2 ) );
int prim;
do {
prim = s.cerne( );
cout << prim << ' ';
} while ( prim < max );
cout << '\n';
return 0;
}
nainte de a introduce valori max prea mari, este bine s v asigurai c stiva
programului este suficient de mare i c avei suficient de mult memorie liber
pentru a fi alocat dinamic.
Folosind cunotinele expuse pn acum, aceast ciudat implementare a
algoritmului lui Eratostene nu are cum s funcioneze. Iat cel puin dou motive:
Seciunea 10.1 Ciurul lui Eratostene 259
Prin derivarea public, tipurile ciur, sita i contor sunt subtipuri ale tipului de
baz element. Conversia de la un subtip (tip derivat public) la tipul de baz este
o conversie sigur, bine definit. Membrii tipului de baz vor fi totdeauna corect
iniializai cu valorile membrilor respectivi din subtip, iar valorile membrilor din
subtip, care nu se regsesc n tipul de baz, se vor pierde. Din aceleai motive,
conversia subtip-tip de baz se extinde i asupra pointerilor sau referinelor.
Altfel spus, n orice situaie, un obiect, un pointer sau o referin la un obiect
dintr-un tip de baz, poate fi nlocuit cu un obiect, un pointer sau o referin la un
obiect dintr-un tip derivat public.
Declaraia virtual din funcia cerne() permite implementarea legturilor
dinamice. Prin redefinirea funciei cerne() n fiecare din subtipurile derivate din
element, se permite invocarea difereniat a funciilor cerne() printr-o sintax
unic:
sursa->cerne( )
Dac sursa este de tip pointer la element, atunci, dup cum am precizat mai sus,
oricnd este posibil ca obiectul sursa s fie dintr-un tip derivat din element.
Funcia cerne() fiind virtual n tipul de baz, funcia efectiv invocat nu va fi
cea din tipul de baz, ci cea din tipul actual al obiectului invocator. Acest
mecanism implic stabilirea unei legturi dinamice, n timpul execuiei
programului, ntre tipul actual al obiectului invocator i funcia virtual.
Datorit faptului c definiia din clasa de baz a funciei cerne(), practic nu are
importan, este posibil s o lsm nedefinit:
Consecina acestei aciuni este c nu mai putem defini obiecte de tip element,
clasa putnd servi doar ca baz pentru derivarea unor subtipuri. Astfel de funcii
se numesc virtuale pure, iar clasele respective sunt numite clase abstracte.
*
Ideea acestei structuri este sugerat n A. V. Aho, J. E. Hopcroft i J. D. Ullman, The Design and
Analysis of Computer Algorithms.
Seciunea 10.2 Tablouri iniializate virtual 261
t 50 70 40
p 1 0 2
b 3 0 4
sb
10.2.1 Structura
Dac p[1] nu este o poziie valid n b, adic 0 > p[1] sau p[1] > sb, atunci
a[1] nu a fost modificat i are valoarea implicit.
Dac p[1] este o poziie valid n b, atunci, pentru ca a[1] s nu aib valoarea
implicit, b[p[1]] trebuie s fie 1. Deoarece a[1] nu a fost modificat, nici o
poziie ocupat din b nu are ns valoarea 1. Deci, a[1] are valoarea implicit.
S vedem acum ce se ntmpl pentru un element deja modificat, cum este a[0].
Valoarea lui p[0] corespunde unei poziii ocupate din b, iar b[p[0]] este 0, deci
a[0] nu are valoarea implicit.
Tabloul iniializat virtual cu elemente de tip T, tablouVI<T> (se poate citi chiar
tablou ase), este o clas cu o funcionalitate suficient de bine precizat pentru
a nu pune probleme deosebite la implementare. Vom vedea ulterior c apar totui
anumite probleme, din pcate majore i imposibil de ocolit. Pn atunci, s
stabilim ns structura clasei tablouVI<T>. Fiind, n esen, vorba tot de un
tablou, folosim clasa tablou<T> ca tip public de baz. Altfel spus, tablouVI<T>
este un subtip al tipului tablou<T> i poate fi folosit oricnd n locul acestuia.
Alturi de datele motenite de la tipul de baz, noua clas are nevoie de:
Cele dou tablouri auxiliare p i b.
ntregul sb, contorul locaiilor ocupate din b.
Elementul vi, n care vom memora valoarea implicit a elementelor tabloului.
n privina funciilor membre avem nevoie de:
Un constructor (constructorii nu se motenesc), pentru a dimensiona tablourile
i a fixa valoarea implicit.
O funcie (operator) de iniializare virtual, prin care, n orice moment, s
iniializm tabloul.
Un operator de indexare.
n mare, structura clasei tablouVI<T> este urmtoarea:
tablouVI& operator =( T );
T& operator []( int );
Seciunea 10.2 Tablouri iniializate virtual 263
private:
T vi; // valoarea implicita
template<class T>
T& tablouVI<T>::operator []( int i) {
static T z; // elementul returnat in caz de eroare
// verificarea indicelui i
if ( i < 0 || i >= d ) {
cerr << "\n\ntablouIV -- " << d
<< ": indice eronat: " << i << ".\n\n";
return z;
}
template<class T>
tablouVI<T>& tablouVI<T>::operator =( T v ) {
vi = v; sb = -1;
return *this;
}
template<class T>
tablouVI<T>::tablouVI( int n, T v ):
tablou<T>( n ), vi( v ), p( n ), b( n ), sb( -1 ) {
}
Operaiile de iniializare a obiectelor prin constructori constituie una din cele mai
bine fundamentate pri ale limbajului C++. Pentru clasa tablou<T>, iniializarea
elementelor tabloului este ascuns n constructorul
template<class T>
tablou<T>::tablou( int dim ) {
a = 0; v = 0; d = 0; // valori implicite
if ( dim > 0 ) // verificarea dimensiunii
a = new T [ d = dim ]; // alocarea memoriei
}
~tablou( ) { delete [ ] a; }
care invoc destructorul clasei T (n caz c T are destructor) pentru fiecare obiect
alocat la adresa a. Efectele destructorului asupra obiectelor care nu au fost
niciodat iniializate sunt greu de prevzut. Rezult c prezena destructorului n
clasa T este chiar periculoas, spre deosebire de prezena constructorului care va
genera doar pierderea timpului constant de iniializare.
Continund analiza deficienelor clasei tablouIV<T>, ajungem la banala operaie
de atribuire a[ i ] = vi din operatorul de indexare. Dac tipul T are un operator
de atribuire, atunci acest operator consider obiectul invocator (cel din membrul
drept) deja iniializat i va ncerca s-l distrug n aceeai manier n care
procedeaz i destructorul. n cazul nostru, premisa este contrar: a[ i ] nu este
iniializat, deci nu ne trebuie o operaie de atribuire, ci una de iniializare a
obiectului din locaia a[ i ] cu valoarea vi. Iat un nou argument n favoarea
utilizrii unui tablou de adrese i nu a unui tablou de obiecte.
Fr a mai conta la nota acordat, s observm c operaiile de iniializare i de
atribuire ntre obiecte de tip tablouVI<T> sunt nu numai generatoare de surprize
(neplcute), ci i foarte ineficiente. Surprizele sunt datorate constructorilor i
destructorilor clasei T i au fost analizate mai sus. Ineficiena se datoreaz
faptului c nu este necesar parcurgerea n ntregime a tablourilor implicate n
transfer, ci doar parcurgerea elementelor purttoare de informaie (iniializate).
Cauza acestor probleme este operarea membru cu membru n clasa tablouVI<T>,
prin intermediul constructorului de copiere i al operatorului de atribuire din clasa
tablou<T>.
Concluzia este c tabloul iniializat virtual genereaz o mulime de probleme.
Aceasta, deoarece procesul de iniializare i cel opus, de distrugere, sunt tocmai
elementele imposibil de ocolit n semantica structurilor de tip clas din limbajul
C++. Implementarea prezentat, chiar dac este doar de nota ase, poate fi
266 Derivare public, funcii virtuale Capitolul 10
Derivarea public instituie o relaie special ntre tipul de baz i cel derivat.
Tipul derivat este un subtip al celui de baz, putnd fi astfel folosit oriunde este
folosit i tipul de baz. Aceast flexibilitate se bazeaz pe o conversie standard a
limbajului C++, i anume conversia de la tipul derivat public ctre tipul de baz.
Prin funcionalitatea lui, tabloul iniializat virtual este o particularizare a
tabloului. Decizia de a construi tipul tablouVI<T> ca subtip al tipului tablou<T>
este deci justificat. Simpla derivare public nu este suficient pentru a crea o
veritabil relaie tip-subtip. De exemplu, s considerm urmtorul program pentru
testarea clasei tablouVI<T>.
#include <iostream.h>
#include "tablouVI.h"
main( ) {
cout << "\nTablou (de intregi) initializat virtual."
<< "\nNumarul elementelor, valoarea implicita ... ";
int n, v; cin >> n >> v;
tablouVI<int> x6( n, v );
Acest program este corect, dar valorile afiate nu sunt cele care ar trebui s fie.
Cauza este operatorul de indexare [] din tablou<T>, operator invocat n funcia
Seciunea 10.2 Tablouri iniializate virtual 267
prin intermediul argumentului t. Noi dorim ca, atunci cnd t este de tip
tablouVI<T>, operatorul de indexare [] invocat s fie cel din clasa
tablouVI<T>. De fapt, ceea ce urmrim este o legare (selectare) dinamic, n
timpul rulrii programului, a operatorului [] de tipul actual al obiectului
invocator. Putem obine acest lucru declarnd virtual operatorul de indexare din
clasa tablou<T>:
10.3 Exerciii
10.1 Dac toate elementele unui tablou iniializat virtual au fost modificate,
atunci testarea strii fiecrui element prin tablourile p i b este inutil. Mai mult,
spaiul alocat tablourilor p i b poate fi eliberat.
Modificai operatorul de indexare al clasei tablouVI<T> astfel nct s trateze i
situaia de mai sus.
271
Bibliografie selectiv
273