Sunteți pe pagina 1din 6

2.

Tehnici de programare

2.1. Algoritmi. Analiza algoritmilor


E cunoscut faptul că algoritmul reprezintă o succesiune finită de operaţii (instrucţiuni,
comenzi) cunoscute, care fiind executate într-o ordine bine stabilită, furnizează soluţia unei
probleme. Amintim, de asemenea, că unul şi acelaşi algoritm poate fi descris prin diverse
metode: scheme logice, formule matematice, texte scrise într-un limbaj de comunicare între
oameni, cu ajutorul limbajelor de programare.

2.1.1. Noţiune de complexitate a algoritmilor


Simultan cu dezvoltarea tehnicii de calcul se elaborează un număr enorm de diverşi algoritmi
în diferite domenii aplicative şi, firesc, se atrage o atenţie deosebită eficienţii (performanţei) lor.
Întrucât resursele de memorie şi a timpului de lucru ale calculatorului sunt limitate, nu este
suficient să cunoaştem algoritmul de rezolvare a problemei. Se impune astfel a găsi o măsură a
gradului de eficienţă a algoritmilor, numită complexitate.
Doi parametri caracterizează complexitatea unui algoritm:
• Spaţiul de memorie necesar pentru stocarea datelor pe care le prelucrează algoritmul,
reprezentând componenta spaţială a complexităţii.
• Timpul necesar pentru execuţia tuturor prelucrărilor specificate în algoritm, reprezentând
componenta temporală a complexităţii.

Analiza acestor parametri de eficienţă a algoritmilor este cunoscută în literatura de specialitate


sub numele de analiza complexităţii algoritmilor. Această analiză este utilă pentru a stabili dacă
un algoritm utilizează un volum acceptabil de resurse pentru rezolvarea problemei respective. În
caz contrar, algoritmul nu este considerat eficient şi nu poate fi aplicat în practică. Analiza
complexităţii este utilizată şi în compararea algoritmilor cu scopul de a-l alege pe cel mai eficient
dintre cele disponibile.

2.1.2. Dimensiunea unei probleme. Complexitatea spaţială.


În majoritatea algoritmilor volumul resurselor necesare depinde de dimensiunea problemei de
rezolvat. Aceasta este determinată, de regulă, de un număr n ce caracterizează cantitatea datelor de
intrare ale algoritmului.
Exemple:
1) în problemele de prelucrare a tablourilor unidimensionale dimensiunea problemei poate fi
considerată ca fiind numărul n de componente din tablou;
2) în problemele de prelucrare a tablourilor bidimensionale dimensiunea problemei de-
asemenea este numărul de componente, dar adesea este util de exprimat această valoare prin
numărul de linii şi de coloane din tablou: n∙m;

Înainte de a preciza zonele de memorare a variabilelor declarate într –un program C++, vom
examina posibilitatea de redirecţionare a alocării variabilelor.
Pe parcursul studierii modulului ”Programarea procedurală” am precizat secţiunile
(c1asele) de memorare a variabilelor globale (segmentul de date) şi a variabilelor locale (stiva).
Acestea sunt clasele de mernorare implicite. Dacă dorim, putem specifica în mod explicit clasa de
memorare a unei variabile (fie ea locală sau globală), poziţionând înaintea declaraţiei variabilei
cuvântul-cheie static care reprezintă un specificator de clasă de memorare.
În cazul variabiIelor locale, specificatorul static indică faptul că variabila nu este memorată
pe stivă, ci în segmentul de date. Prin urmare, ea este initializată automat cu 0 şi are zona de
memorie alocată de la prima execuţie a blocului în care este declarată pănă la finisarea execuţiei
programului. Domeniul de vizibilitate nu se modifică (variabila rămâne vizibilă numai în interiorul
blocului în care este declarată).
Avantajele utilizării variabilelor locale statice sunt multiple: se alocă o singură dată, nu la
fiecare apel al funcţiei; îşi păstrează valorile între două apeluri consecutive ale funcţiei; domeniul
de vizibilitate fiind local, nu există riscuri ca valoarea variabilei să fie alterată de alte funcţii ale
programului, ca în cazul variabilelor globale; dacă spaţiul disponibil pe stivă devine o problemă,
reprezintă o soluţie de alocare a unor variabile locale.
În cazul variabilelor globale, indică faptul că variabila poate fi utilizată numai în cadrul
fişierului sursă în care este declarată (deci nu mai are legatură externă, nu mai poate fi utilizată în
alte fişiere sursă ale programului). Cu alte cuvinte,variabila devine locală fişierului sursă în care
este declarată.

Exemplu
void f ()
{ static int x=2;
x++i; cout << x << endl;}
Funcţia f() conţine variabila locală statică x. La primul apel al funcţiei, variabila x este
iniţializată cu valoarea 2, apoi valoarea variabilei este incrementată şi se va afişa pe ecran
valoarea 3. La al doilea apel al funcţiei f() , variabila x, fiind statică, nu mai este nici alocată, nici
iniţializată, ci îşi păstrează valoarea de la apelul precedent (3). Această valoare va fi
incrementată, apoi se va afişa pe ecran valoarea 4.
Deci la fiecare apel al funcţiei f() se va afisa o altă valoare. Dacă nu am fi declarat variabila
x utilizând specificatorul static, funcţia ar fi afişat întotdeauna valoarea 3.

În general, necesarul de memorie al unui program, scris într-un limbaj de nivel înalt, depinde
atât de tipurile variabilelor utilizate cât şi de mediul de implementare a limbajului, de modul de
gestionare a memoriei interne a calculatorului.

În procesul derulării unui program C++, spaţiul de memorie internă este divizat în căteva
secţiuni, dar le menţionăm pe trei dintre cele mai esenţiale:
─ segmentul date, destinat alocării variabilelor globale ( aceste variabile sunt declarate în
exteriorul oricărei funcţii C++), precum şi alocării variabilelor locale statice;
─ stiva, destinată alocării parametrilor actuali, variabilelor locale dinamice (acele care nu sunt
specificate prin static), şi a adreselor de revenire pe parcursul execuţiei subprogramelor
C++. Apelul unui subprogram implică depunerea datelor respective în stivă, iar ieşirea din
subprogam –eliminarea lor din stivă. Accentuăm că în cazul parametrilor variabilă în stivă se
depune numai adresa variabilei din programul (/subprogramul) apelant, iar în cazul
parametrului valoare în stivă va fi depusă o copie a datelor din lista parametrilor actuali.
─ heap-ul, utilizat pentru alocarea variabilelor dinamice. Aceste variabile sunt create şi
eventual distruse cu ajutorul subprogramelor standard new şi, respectiv delete.
Prin urmare, estimarea necesarului de memorie presupune evaluarea următoarelor
caracteristici ale unui program:
Vd(n) –volumul de memorie ocupat de variabilele globale şi a celor locale statice în
segmentul date;
Vs(n) –volumul de memorie ocupat de parametrii actuali şi de variabilele locale dinamice în
stivă; Vh(n) –volumul de memorie ocupat de variabilele dinamice în heap.

Progresele tehnologice fac ca importanţa criteriului spaţiu de memorie utilizat să scadă,


prioritar devenind criteriul timp. În cele ce urmează ne vom concentra în analiza complexităţii
temporale a algoritmilor, discutând despre necesarul de memorie numai atunci când acest lucru
devine important.

2.1.3. Complexitatea temporală


Vom nota cu T(n) timpul de execuţie al unui algoritm destinat rezolvării unei probleme de
dimensiune n. Pentru a estima T(n) trebuie de stabilit mai întâi o unitate de măsură. Evident,
unităţile obişnuite de măsură a timpului (secunde ş.a.m.d.) aici nu se potrivesc –unul şi acelaşi
program pentru aceleaşi date de intrare, la diferite calculatoare va avea, în general, diferite durate
de execuţie.
Timpul real de execuţie a unui algoritm este constituit de mărimea T(n) = Q(n)∙τ, unde
Q(n) este numărul de operaţii elementare executate, iar τ –durata medie de execuţie a unei aşa
operaţii. Sunt considerate operaţii elementare operaţiile aritmetice (adunarea, scăderea,
înmulţirea, împărţirea) şi cele logice (negaţia, conjuncţia şi disjuncţia), comparaţiile, saltul
necondiţionat, atribuirea şi indexarea componentelor unui tablou. De regulă, timpul necesar
introducerii valorilor de intrare şi extragerii rezultatelor este ignorat, deci nu se include în T(n).
Valoarea concretă τ a unităţii de măsură a timpului , depinde de mediul de programare,
capacitatea de prelucrare a calculatorului utilizat şi este de ordinul 10-9...10-7 secunde.

S-a constatat că prin raţionamente teoretice deseori este foarte dificil de determinat o formulă
(expresie analitică) exactă pentru Q(n). Din acest motiv se caută o limită superioară a numărului
de operaţii cerut de algoritm, analizându-se doar cazurile cele mai defavorabile. Cazul cel mai
defavorabil corespunde instanţelor pentru care numărul de operaţii efectuate este maxim. Se
procedează astfel întrucât:
1) cunoscând timpul de execuţie în cazul cel mai defavorabil, suntem siguri că pentru orice
date de intrare durata execuţiei nu va fi mai mare;
2) timpul de execuţie în mediu deseori este destul de aproape de cel în cazul cel mai
defavorabil.

2.1.4. Ordin de complexitate. Clase de algoritmi.


Anterior s-a menţionat că complexitatea temporală a algoritmilor se caracterizează prin timpul
de execuţie T(n) sau numărul de operaţii elementare Q(n). Întrucât calculatoarele moderne au o
viteză de calcul foarte mare, problema determinării mărimii Q(n) se pune doar pentru valori mari
ale lui n. În consecinţă, în formulele ce exprimă mărimea Q(n), prezintă interes doar termenul
dominant, adică acel termen d(n), care tinde cel mai repede la infinit pentru n→∞. De exemplu,
pentru formula
Q(n)=17n2+1000n+2000,
termenul dominant d(n) =17n2. Evident, pentru valorile mari ale lui n numărul de operaţii
elementare Q(n)=17n2.
Deci, pentru a aprecia eficienţa unui algoritm, nu este necesară cunoaşterea expresiei exacte a
lui Q(n), dar modul în care acesta creşte odată cu creşterea lui n. O măsură utilă în acest sens
este ordinul de complexitate a algoritmului, redat prin notaţia O(d(n)) care se citeşte ”algoritm
cu timpul de execuţie d(n)” sau, mai pe scurt, ”algoritm de ordinul d(n)”.

Observaţie: Ca regulă generală: când determinăm ordinul de creştere, ignorăm termenii cu


creştere mai înceată şi ignorăm constanta din faţa termenului dominant.

Exemple:
1) Dacă Q(n) = an + b (a > 0), atunci termenul dominant din această formulă este d(n) = an.
În acest caz ordinul de complexitate al algoritmului este O(an) = O(n), adică algoritmul
respectiv este liniar.
2) Dacă Q(n) = an2 + bn + c (a > 0), atunci d(n) = an2, iar O(an2) = O(n2), motiv pentru care
spunem că este un ordin pătratic de complexitate.

Un algoritm este mai eficient decât altul dacă ordinul de complexitate al primului algoritm este
mai mic decât al celui de-al doilea. Relaţia între ordinele de complexitate are semnificaţie doar
pentru dimensiuni mari ale problemei. De exemplu, dacă considerăm Q1(n)=10n+10 şi Q2(n) =
n2, atunci se observă că Q1(n) > Q2(n) pentru n ≤ 10, deşi ordinul de creştere al lui Q1 este
evident mai mic decât al lui Q2. Prin urmare, un algoritm mai eficient decât altul reprezintă
varianta cea mai bună doar în cazul problemelor de dimensiuni mari. Majoritatea
algoritmilor întâlniţi în practică se încadrează în una dintre clasele menţionate în tabelul 2.1.
Ordin de complexitate Tipul algoritmului
O(n) Algoritm liniar.
O(nk), k-natural Algoritm polinomial. Dacă k=2, algoritmul este pătratic, iar dacă k=3,
algoritmul este cubic.
O(kn), k-natural Algoritm exponenţial. De exemplu, O(2n), O(3n), etc. Algoritmul de
tip O(n!) este tot de tip exponenţial deoarece:1×2×3×...×n>2n-1.
O(logn) Algoritm logaritmic.
O(n·logn) Algoritm liniar logaritmic.
Tabelul 2.1: Clase de algoritmi

Funcţiile (logn, n, n·logn, n2 şi n3), nominalizate în acest tabel, au o viteză mică de


creştere iar algoritmii, timpul de lucru al căror se estimează prin aceste funcţii, se consideră
rapizi. Vitezele de creştere ale funcţiilor exponenţiale (inclusiv, factorialului) uneori se
caracterizează ca <<explozibile>>, iar algoritmii respectivi sunt neeficienţi.
Pentru o analiză comparativă a vitezelor de creştere ale funcţiilor în studiu se poate folosi
tabelul 2.2.
n logn n·logn n2 n3 2n n!
10 3 33 102 103 103 105
102 7 664 104 106 1030 1094
103 10 9966 106 109 10301 103435
104 13 132877 108 1012 103010 1019335
105 17 1660964 1010 1015 1030103 10243338
106 20 19931569 1012 1018 10301030 102933369
Tabelul 2.2: Valorile rotungite ale termenilor dominanţi

Conform tabelului 2.2 avem: 1< logn < n < n·logn < n2 < n3 < 2n < n!
Pentru estimarea complexităţii algoritmilor deobicei se folosesc următoarele două reguli:
Regula sumei. Fie un algoritm este divizat în două părţi, ce nu au operaţii comune. Dacă
ordinul de complexitate a unei părţi este O(f(n)) iar a celei de a doua O(g(n)), atunci
complexitatea lor sumară se redă prin O(max( f(n), g(n))).
Regula produsului. Dacă o parte a algoritmului este de ordinul O(f(n)) şi se execută de g(n)
ori, atunci complexitatea lor totală se redă prin O( f(n) ∙ g(n)).

2.1.5. Operaţia de bază, cheia studiului complexităţii

Pentru a estima timpul de calcul ar trebui să inventariem toate instrucţiunile programului şi să


ştim de câte ori se execută fiecare din ele (în funcţie de n). Mai mult, ar trebui să cunoaştem cât
durează execuţia fiecărui tip de instrucţiune.

Observaţie. Nu întotdeauna putem şti de câte ori se execută o instrucţiune, chiar dacă
cunoaştem valoarea lui n. De exemplu, pentru calculul maximului elementelor unui vector cu
componente numere intregi, nu putem şti de câte ori se execută atribuirea max=A[i]. Cu toate
acestea, putem considera că există o proporţionalitate între valoarea n şi numărul de execuţii.
Ţinând cont de această observaţie vom proceda astfel:
Se alege o operaţie numită operaţie de bază, şi se determină de câte ori se execută aceasta.
Cerinţa pentru operaţia de bază este ca aceasta să se afle în cel mai interior corp de ciclu şi a
cărei contorizare permite estimarea în avans, înainte de lansarea în execuţie, a timpului de
execuţie a programului corespunzător. De exemplu, pentru problema aflării maximului,
formulată mai sus, operaţia de bază va fi comparaţia (fiind dat n se fac n-1 comparaţii pentru
calculul maximului). Deci algoritmul respectiv este liniar, adică ordinul de complexitate se redă
prin O(n).
Astfel ordinul de complexitate este determinat de structurile repetitive care se execută cu
mulţimea de
valori pentru datelele de intrare. În cazul structurilor repetitive imbricate, ordinul de
complexitate este dat de produsul dintre numerele de repetiţii ale fiecărei structuri repetitive.

Structura repetitivă Numărul de execuţii ale Tipul


corpului structurii (f(n)) algoritmului
for (i =1; i<= n; i = i+k) {...} f(n) = n/k O(n/k) = O(n) Liniar
for (i =1; i<= n; i = i*k) {...} n/k =1
f(n)
f(n)=logkn Logaritmic
O(logkn) = O(logn)
for (i =n; i>= 1; i = i/k) {...} kf(n) = n O(logkn) = O(logn) Logaritmic
for (i =1; i<=n; i= i++) f(n) = n*( logkn) O (n*logkn)= O(n*logn) Liniar
for (j =1; j<=n; j= j*k) {...} logaritmic
for (i=1; i<=n; i++) f(n) =1+2+3+...+n = (n*(n+1))/2 Pătratic
for (j=1; j<=n; j++) {...} O(f(n)) = O(n2)
Exemplul_1. Căutarea secvenţială într-un tablou liniar:
Fie un tablou A cu n componente numere întregi. Se pune problema de a stabili dacă o
valoare dată x se găseşte sau nu printre elementele tabloului A.
Secvenţa de program:
i=0;
while (i<n && A[i]!=x) i=i+1;
if(i<=n-1) cout<<"Exista";
else cout<<"Nu exista";

Operaţia de bază este incrementarea ( i=i+1;) , cel mai defavorabil caz este acel în care
numărul x nu se află în tablou (ciclul se va executa de n ori), deci ordinul de complexitate este
O(n).

Exemplul_2. Sortarea unui tablou numeric A[n] prin metoda interschimbării:


Secvenţa de program:
for (i=1; i<=n-1; i++)
for (j=i+1; j<=n; j++)
if (A[j] < A[i]) // Interschimbarea
{ x= A[i];
A[i]=A[j]; A[j]=x; }

Notăm cu Q(n) numărul de paşi executaţi de algoritm. Operaţiile de bază sunt: comparaţia
A[i] cu A[j] şi interschimbarea A[i] cu A[j], situate în ciclul interior. Ciclul exterior se execută
de n−1 ori. La primul pas al ciclului exterior se vor executa n−1 paşi în ciclul interior. La al
doilea pas al ciclului exterior vor fi n−2 paşi în ciclul interior, apoi n − 3 , n −4 paşi , etc. Deci,

Q[ n] = (n -1)+( n -2)+...+2+1= n(n-1)/2,


termenul dominant d(n) = n2/2, ordinul de complexitate se redă prin O(n2), deci algoritmul este
pătratic.

Exemplul_3 ( Algoritmul Euclid). Cel mai mare număr natural, care divide fără rest
numerele naturale a şi b, se numeşte cel mai mare divizor comun al numerelor a şi b.
Exemplu: CMMDC(70,105) =35.
Versiunea modernă a algoritmului Euclid se bazează pe proprietatea
CMMDC(a,b) = CMMDC(a % b, b), dacă a >b şi constă în înlocuirea consecutivă a celui mai
mare dintre numerele a şi b prin restul de la împărţirea celui mai mare la cel mai mic dintre
aceste numere; împărţirea întreagă se va continua până când restul respectiv nu se va egala cu
zero.
Exemplu: CMMDC(147, 462) = CMMDC(147, 462 % 147) = CMMDC(147, 21)=
=CMMDC(147 % 21, 21)=CMMDC(0,21)=21.

Următoarea funcţie calculează şi returnează CMMDC(a, b), conform algoritmului descris:


int CMMDC(int a, int b) // Restrictie: cel putin unul dntre numerele a ,b este diferit de zero)
{ while (a!=0 && b!=0)
if (a>=b) a=a % b; else b=b % a;
return (a+b); }

În ipoteza că ambele numere (a şi b) sunt diferite de zero, se poate de convins în faptul că


după două execuţii a corpului ciclului cel mai mic dintre numerele date la sigur se va micşora
mai mult decât de două ori, deci ciclul se va executa de m=log (min (a,b)) ori. Dacă convenim să
includem operaţia % în lista operaţiilor elementare de bază, complexitatea temporală a
algoritmului în studiu se va reda prin O(m), adică algoritmul este logaritmic.

2.1.6. Exerciţii şi probleme


1. Indicaţi termenii dominanţi:
a) 12n+7;
b) 6n2 +100n +36;
c) 15n3+1000n2-28n+6000;
d) 2000 n3+2n+13;
e) 2n + 3n +17 n4 +1000n.

2. Se consideră un algoritm format din k cicluri imbricate:


for(i1=1; i1<=n; i1++)
for(i2=1; i2<=n; i2++)
...
for(ik=1; ik<=n; ik++) F();
Numărul de operaţii elementate QF efectuate în subprogramul F() este o mărime constantă.
Estimaţi complexitatea temporală a algoritmului.

3. Determinaţi ordinul de complexitate temporală al algoritmilor de sortare a unui tablou


unidimensional A[n] cu elemente numere întregi prin metodele: 1) selecţiei; 2) inserţiei; 3)
distribuţiei.

4. Se consideră mulţimea A formată din n numere întregi. Determinaţi dacă există cel puţin o
submulţime B, B ⊆ A, suma elementelor căreia este egală cu m. De exemplu, pentru A={-3, 1, 5,
9 } şi m=7, o astfel de mulţime există şi anume B={-3, 1, 9 }.
Estimaţi complexitatea temporală a algoritmului elaborat.

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