Proiectarea algoritmilor – complexitate
Analiza eficienței algoritmilor prin studiul complexității temporale și spațiale.
Bacalaureat
Ce măsurăm: complexitatea timp și complexitatea spațiu
Eficiența unui algoritm se judecă după două resurse:
- complexitatea timp — câte operații elementare (comparații, atribuiri, operații aritmetice) execută algoritmul, exprimate ca funcție de dimensiunea datelor de intrare n;
- complexitatea spațiu — câtă memorie suplimentară folosește, dincolo de datele de intrare.
De ce numărăm operații și nu secunde: timpul în secunde depinde de calculator, limbaj și compilator, pe când numărul de operații caracterizează algoritmul însuși. Această justificare se cere ca atare la teorie.
Pentru același algoritm, numărul de operații poate diferi la aceeași dimensiune n, în funcție de datele concrete; de aceea se disting:
- cazul favorabil — datele cele mai convenabile (la căutarea secvențială: elementul e chiar primul);
- cazul defavorabil — datele cele mai rele (elementul lipsește — se parcurge tot vectorul);
- cazul mediu — media pe toate intrările posibile.
Dacă nu se precizează altfel, complexitatea unui algoritm înseamnă comportarea în cazul defavorabil — garanția pe care o putem promite indiferent de date.
Există adesea un compromis timp-memorie: poți accelera un algoritm consumând memorie suplimentară (vectori de frecvență, tabele de programare dinamică, memoizare) sau poți economisi memorie cu prețul unor recalculări. Recunoașterea acestui compromis — de exemplu, de ce sortarea prin numărare e rapidă dar cere un vector auxiliar cât plaja valorilor — este exact genul de justificare punctată la subiectul de analiză.
Notația O — ordinul de complexitate
Notația O (O mare) exprimă ordinul de creștere al numărului de operații când n devine mare, ignorând constantele multiplicative și termenii de rang inferior: un algoritm cu 3n² + 20n + 7 operații este O(n²), pentru că la n mare termenul n² domină totul.
Ierarhia claselor uzuale, de la cea mai bună la cea mai slabă — trebuie știută pe de rost:
- O(1) — timp constant: accesul la un element de tablou prin indice;
- O(log n) — logaritmic: căutarea binară; dublarea lui n adaugă o singură operație;
- O(n) — liniar: parcurgerea unui vector, căutarea secvențială;
- O(n log n) — liniaritmic: MergeSort, HeapSort — pragul optim pentru sortările prin comparații;
- O(n²) — pătratic: două cicluri imbricate pe n, sortările elementare;
- O(2^n), O(n!) — exponențial, factorial: generarea submulțimilor, respectiv a permutărilor prin backtracking; impracticabile chiar pentru n de câteva zeci.
Cum citești complexitatea direct din structura codului:
- un ciclu de la 1 la n → O(n); două cicluri imbricate pe n → O(n²); trei → O(n³);
- două cicluri succesive (neimbricate) → O(n) + O(n) = tot O(n) — imbricarea, nu numărul ciclurilor, dă produsul; aceasta e confuzia clasică;
- un ciclu în care variabila se dublează sau se înjumătățește la fiecare pas (i = i * 2, n = n / 2) → O(log n);
- descompunerea unui număr în cifre → O(numărul de cifre) = O(log n).
Intuiția pe numere, bună la verificări rapide: pentru n = 1.000.000, log2(n) este aproximativ 20, n log n este aproximativ 20 de milioane (fezabil), iar n² este 10^12 (nefezabil pe timpul unui test).
Compararea algoritmilor: aceeași problemă, costuri diferite
Aceeași problemă admite adesea algoritmi cu ordine de complexitate radical diferite — iar subiectele cer explicit varianta eficientă.
Căutarea într-un vector: secvențială — O(n), merge pe orice vector; binară — O(log n), dar cere obligatoriu vector sortat. Aplicarea căutării binare pe vector nesortat dă rezultate greșite — condiția de aplicabilitate face parte din răspuns.
Verificarea primalității: testarea divizorilor până la n-1 este O(n); testarea doar până la radical din n este O(sqrt(n)) — argumentul: dacă n = a·b și a <= b, atunci a <= sqrt(n), deci orice număr compus are un divizor cel mult egal cu radicalul său.
Suma unui subșir de întrebări repetate: recalcularea fiecărei sume este O(n) per întrebare; cu sume parțiale precalculate (s[i] = suma primelor i elemente), fiecare întrebare costă O(1) — exemplu tipic de compromis timp-memorie.
Fibonacci: recursiv pur — exponențial; cu memorare sau iterativ — O(n).
Cel mai mare divizor comun: scăderi repetate — O(max(a, b)) în cazul defavorabil; algoritmul lui Euclid cu împărțiri — O(log(min(a, b))), pentru că restul cel puțin se înjumătățește la fiecare două împărțiri.
Regula practică pentru probleme de concurs și pentru subiectul III de BAC: estimezi n din enunț și alegi clasa de complexitate care încape. Pentru n de ordinul 10^5–10^6, algoritmii O(n²) sunt de regulă prea lenți; țintește O(n log n) sau O(n). Justificarea eficienței — de ce soluția ta nu parcurge de mai multe ori datele — este parte din punctaj, nu ornament.
Complexitatea algoritmilor de sortare
Sortările elementare, toate O(n²) în cazul defavorabil:
- selecție: la pasul i se caută minimul din zona nesortată și se aduce pe poziția i; face mereu același număr de comparații — aproximativ n²/2 — indiferent de date, dar cel mult n-1 interschimbări; complexitatea nu depinde de ordinea inițială;
- inserție: fiecare element nou se inserează la locul lui în zona deja sortată, deplasând elementele mai mari; pe vector deja sortat face doar n-1 comparații — O(n), cazul favorabil; pe vector sortat invers — O(n²);
- interschimbare (bubble sort): compară și schimbă perechi vecine în treceri repetate, până la o trecere fără nicio interschimbare; cu acest test de oprire, cazul favorabil (vector sortat) este O(n).
Sortările eficiente, O(n log n):
- MergeSort: Divide et Impera; garantat O(n log n) în orice caz, dar cere O(n) memorie suplimentară pentru interclasare; este sortare stabilă (păstrează ordinea relativă a elementelor egale);
- QuickSort: partiționare în jurul pivotului; O(n log n) în medie, dar O(n²) în cazul defavorabil (pivot mereu extrem — de exemplu vector deja sortat cu pivot primul element); în practică e printre cele mai rapide;
- HeapSort: construiește un heap și extrage repetat maximul; garantat O(n log n), memorie suplimentară O(1).
Rezultat teoretic cerut la grile: orice sortare bazată pe comparații face în cazul defavorabil cel puțin n log n comparații — nu există sortare prin comparații O(n).
Ocolirea acestei limite: sortarea prin numărare (count sort) nu compară elemente, ci numără aparițiile fiecărei valori într-un vector de frecvență — O(n + V), unde V este plaja valorilor; excelentă când valorile sunt mici (note, cifre, vârste), impracticabilă când plaja e uriașă. Condiția de aplicabilitate face parte din răspuns.
De reținut
- complexitate timp
- numărul de operații elementare executate de un algoritm, exprimat ca funcție de dimensiunea datelor de intrare; implicit se raportează cazul defavorabil
- complexitate spațiu
- cantitatea de memorie suplimentară folosită de algoritm, dincolo de spațiul ocupat de datele de intrare
- notația O
- notație care exprimă ordinul de creștere al timpului de execuție pentru date mari, ignorând constantele multiplicative și termenii de rang inferior
- O(log n)
- complexitate logaritmică, specifică algoritmilor care înjumătățesc la fiecare pas dimensiunea problemei — exemplul canonic fiind căutarea binară în vector sortat
- O(n log n)
- complexitatea sortărilor eficiente (MergeSort, HeapSort) și limita teoretică inferioară pentru orice sortare bazată pe comparații, în cazul defavorabil
- caz favorabil / caz defavorabil
- datele de intrare pentru care algoritmul execută cele mai puține, respectiv cele mai multe operații la aceeași dimensiune n
- sortare prin selecție
- sortare O(n²) care la fiecare pas aduce minimul zonei nesortate pe prima poziție a acesteia; numărul de comparații nu depinde de ordinea inițială a datelor
- sortare prin inserție
- sortare care plasează fiecare element la locul potrivit în zona deja sortată; O(n²) în cazul defavorabil, dar O(n) pe un vector deja sortat
- QuickSort
- sortare prin partiționare în jurul unui pivot, O(n log n) în medie și O(n²) în cazul defavorabil, când pivotul cade mereu pe o valoare extremă
- sortare prin numărare
- sortare fără comparații, care numără aparițiile fiecărei valori într-un vector de frecvență; complexitate O(n + V), aplicabilă doar când plaja valorilor V este rezonabil de mică
Greșeli frecvente
Greșit: Două cicluri succesive (neimbricate), fiecare de la 1 la n, sunt declarate O(n²)
Corect: Ciclurile succesive se adună, nu se înmulțesc: O(n) + O(n) = O(n); doar ciclurile imbricate dau produsul complexităților
Greșit: Se afirmă că un algoritm cu 5n + 100 operații este mai slab decât unul cu n² operații pentru că are constante mari
Corect: Ordinul de creștere decide pentru n mare: O(n) bate O(n²) de la un prag încolo, indiferent de constante; la n = 1000, 5n + 100 înseamnă circa 5.100 de operații, iar n² un milion
Greșit: Aplicarea căutării binare pe un vector nesortat
Corect: Căutarea binară funcționează numai pe vectori sortați — eliminarea unei jumătăți se bazează pe ordine; pe vector nesortat rezultatul este pur și simplu greșit, nu doar lent
Greșit: Se afirmă că QuickSort este întotdeauna O(n log n)
Corect: QuickSort este O(n log n) doar în medie; în cazul defavorabil (pivot mereu extrem, de exemplu pe vector deja sortat cu pivotul primul element) degradează la O(n²) — garanția O(n log n) în orice caz o dau MergeSort și HeapSort
Greșit: Se caută o sortare prin comparații cu complexitate O(n) pentru cazul general
Corect: Orice sortare bazată pe comparații necesită cel puțin n log n comparații în cazul defavorabil; timpul liniar se obține doar renunțând la comparații, ca la sortarea prin numărare, și doar când plaja valorilor o permite
Test — 6 întrebări ca la examen
1. Un algoritm execută exact 2n² + 30n + 1000 de operații pentru o intrare de dimensiune n. Ordinul său de complexitate este:
- O(n)
- O(n²)
- O(n³)
- O(2^n)
Vezi răspunsul
O(n²). Notația O reține doar termenul dominant, fără constante: 2n² domină pe 30n și pe 1000 pentru n mare, deci O(n²). Cine alege O(n) s-a lăsat înșelat de coeficientul mic al lui n² față de constanta 1000 — la n mare raportul se inversează categoric.
2. Care este complexitatea secvenței: un ciclu for de la 1 la n, urmat (nu imbricat) de alt ciclu for de la 1 la n?
- O(n²), pentru că sunt două cicluri
- O(2^n)
- O(n), pentru că ciclurile succesive se adună
- O(n log n)
Vezi răspunsul
O(n), pentru că ciclurile succesive se adună. Ciclurile executate unul după altul își adună costurile: n + n = 2n, adică O(n). Prima variantă e capcana standard — produsul apare doar la cicluri imbricate, unde pentru fiecare pas al primului se execută integral al doilea.
3. Un ciclu de forma: i = n; cât timp i > 1 execută i = i / 2; are complexitatea:
- O(n)
- O(n / 2)
- O(log n)
- O(1)
Vezi răspunsul
O(log n). Variabila se înjumătățește la fiecare pas, deci numărul de pași este numărul de înjumătățiri până la 1, adică aproximativ log2(n). Varianta O(n/2) tentează prin împărțirea la 2 din cod, dar constanta 1/2 nu schimbă ordinul — iar aici nu n se reduce liniar, ci exponențial de repede.
4. Care sortare garantează complexitatea O(n log n) inclusiv în cazul defavorabil?
- QuickSort
- sortarea prin inserție
- MergeSort
- bubble sort cu test de oprire
Vezi răspunsul
MergeSort. MergeSort împarte mereu vectorul în jumătăți egale, deci costul n log n nu depinde de date. QuickSort este distractorul cel mai puternic — excelent în medie, dar degradează la O(n²) când pivotul cade repetat pe extreme; inserția și bubble sort sunt O(n²) în cazul defavorabil.
5. Pe un vector deja sortat crescător, sortarea prin inserție execută:
- aproximativ n²/2 comparații, ca în orice caz
- n-1 comparații — cazul său favorabil, O(n)
- 0 comparații, pentru că vectorul e sortat
- n log n comparații
Vezi răspunsul
n-1 comparații — cazul său favorabil, O(n). Fiecare element nou se compară o singură dată cu vecinul din stânga, constată că e la locul lui și se oprește — n-1 comparații în total. Zero comparații e imposibil: algoritmul nu are de unde ști că vectorul e sortat fără să verifice; prima variantă descrie sortarea prin selecție, insensibilă la ordinea inițială.
6. Trebuie sortate crescător un milion de numere naturale, toate din intervalul [1, 100]. Cea mai eficientă abordare este:
- QuickSort, fiind cea mai rapidă sortare generală
- sortarea prin numărare, cu un vector de frecvență de 100 de poziții
- sortarea prin selecție, având puține interschimbări
- căutarea binară aplicată repetat
Vezi răspunsul
sortarea prin numărare, cu un vector de frecvență de 100 de poziții. Plaja valorilor este minusculă față de n, deci numărarea aparițiilor dă O(n + 100), practic liniar — sub orice sortare prin comparații, care nu poate coborî sub n log n. QuickSort tentează ca reflex de „cea mai bună sortare”, dar aici informația suplimentară din enunț (valori mici) schimbă instrumentul optim; căutarea binară nici măcar nu este o metodă de sortare.
Deschide varianta interactivă — cu AI care îți explică