Complexitate Computațională și Notația Asimptotică
Complexitate Computațională și Notația Asimptotică¶
De ce ne interesează complexitatea?¶
Imaginează-ți că ai doi algoritmi care rezolvă aceeași problemă. Pe un input de 100 de elemente, ambii rulează instant. Pe 10.000 de elemente, primul termină în 0.1 secunde, al doilea în 10 minute. Pe 1.000.000 de elemente, primul termină în 1 secundă, al doilea ar dura 11 zile.
Complexitatea nu este despre cât de rapid rulează un program pe un anumit computer — ci despre cum crește timpul de execuție pe măsură ce inputul devine mai mare. Este diferența dintre un algoritm care scalează și unul care nu.
Analiza Asimptotică¶
Analiza asimptotică ignoră constantele și termenii mici, concentrându-se pe comportamentul dominant pentru inputuri mari.
De ce ignorăm constantele? Pentru că:
- Un computer de 2x mai rapid reduce orice algoritm cu un factor constant
- Optimizările de compilator schimbă constantele fără a schimba algoritmul
- Ceea ce contează este rata de creștere, nu valoarea exactă
Notația Big-O (marginea superioară)¶
$f(n) = O(g(n))$ înseamnă că $f$ crește cel mult la fel de repede ca $g$:
$$\exists \; c > 0, \; n_0 \geq 0 \;\text{ a.î. } \; f(n) \leq c \cdot g(n), \quad \forall \; n \geq n_0$$
Exemplu concret: Fie $f(n) = 3n^2 + 7n + 15$. Afirmăm că $f(n) = O(n^2)$.
Demonstrație: Alegem $c = 25$ și $n_0 = 1$. Atunci $3n^2 + 7n + 15 \leq 3n^2 + 7n^2 + 15n^2 = 25n^2$ pentru $n \geq 1$. ✓
Notația Omega (marginea inferioară)¶
$f(n) = \Omega(g(n))$ înseamnă că $f$ crește cel puțin la fel de repede ca $g$:
$$\exists \; c > 0, \; n_0 \geq 0 \;\text{ a.î. } \; f(n) \geq c \cdot g(n), \quad \forall \; n \geq n_0$$
Notația Theta (margine strânsă)¶
$f(n) = \Theta(g(n))$ înseamnă că $f$ crește exact la fel de repede ca $g$:
$$f(n) = O(g(n)) \;\text{ și }\; f(n) = \Omega(g(n))$$
Big-O spune „cel mult atât", Omega spune „cel puțin atât", Theta spune „exact atât". În practică, când cineva zice „algoritmul e $O(n \log n)$", de obicei înseamnă $\Theta(n \log n)$.
Clase de Complexitate¶
| Complexitate | Numele | $n = 10$ | $n = 1000$ | $n = 10^6$ | Exemplu |
|---|---|---|---|---|---|
| $O(1)$ | Constantă | 1 | 1 | 1 | Acces array pe index |
| $O(\log n)$ | Logaritmică | 3 | 10 | 20 | Căutare binară |
| $O(n)$ | Liniară | 10 | 1000 | $10^6$ | Parcurgere array |
| $O(n \log n)$ | Liniaritmic | 33 | 10.000 | $2 \cdot 10^7$ | Merge Sort |
| $O(n^2)$ | Pătratică | 100 | $10^6$ | $10^{12}$ | Bubble Sort |
| $O(2^n)$ | Exponențială | 1024 | $10^{301}$ | ∞ | Subseturi (brute force) |
| $O(n!)$ | Factorială | 3.6M | ∞ | ∞ | Permutări (brute force) |
Regulă practică: Un computer modern execută ~$10^8$ operații/secundă. Dacă $n = 10^6$, un algoritm $O(n^2)$ ar lua ~$10^{12} / 10^8 = 10.000$ secunde ≈ 2.7 ore. Un algoritm $O(n \log n)$ ar lua ~$0.2$ secunde.
Cum calculăm complexitatea¶
Regula 1: Bucle simple¶
Numărul de iterații determină complexitatea:
# O(n) — o singură buclă
for i in range(n):
print(i)
# O(n²) — două bucle imbricate, fiecare O(n)
for i in range(n):
for j in range(n):
print(i, j)
# O(n³) — trei bucle imbricate
for i in range(n):
for j in range(n):
for k in range(n):
print(i, j, k)
Regula 2: Bucle consecutive se adună¶
# O(n) + O(n²) = O(n²)
for i in range(n): # O(n)
print(i)
for i in range(n): # O(n²)
for j in range(n):
print(i, j)
Se păstrează doar termenul dominant: $O(n) + O(n^2) = O(n^2)$.
Regula 3: Bucle imbricate se înmulțesc¶
# O(n) × O(m) = O(n·m)
for i in range(n):
for j in range(m):
print(i, j)
Regula 4: Înjumătățirea → logaritm¶
Când la fiecare pas înjumătățim inputul, complexitatea este $O(\log n)$:
# O(log n)
i = n
while i > 0:
print(i)
i = i // 2
De câte ori putem înjumătăți $n$ până ajungem la 1? De exact $\log_2 n$ ori.
Regula 5: Recursivitate¶
Pentru funcții recursive, scriem o relație de recurență și o rezolvăm.
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
$T(n) = T(n-1) + O(1) \implies T(n) = O(n)$
def fibonacci_naive(n):
if n <= 1:
return n
return fibonacci_naive(n-1) + fibonacci_naive(n-2)
$T(n) = T(n-1) + T(n-2) + O(1) \implies T(n) = O(2^n)$ — exponențial! Extrem de ineficient.
Exemplu Detaliat: Căutarea Binară¶
Căutarea binară este unul dintre cei mai fundamentali algoritmi. Funcționează pe un array sortat și la fiecare pas elimină jumătate din candidați.
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = left + (right - left) // 2 # evităm overflow
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1 # target e în jumătatea dreaptă
else:
right = mid - 1 # target e în jumătatea stângă
return -1 # nu a fost găsit
De ce left + (right - left) // 2 și nu (left + right) // 2? Pentru a evita overflow-ul în limbaje cu întregi de dimensiune fixă. Dacă left și right sunt ambele aproape de valoarea maximă a tipului int, suma lor ar depăși limita.
Analiză: La fiecare iterație, intervalul de căutare se înjumătățește:
- Start: $n$ elemente
- După 1 pas: $n/2$
- După 2 pași: $n/4$
- După $k$ pași: $n/2^k$
- Se oprește când $n/2^k = 1$, deci $k = \log_2 n$
Complexitate: $O(\log n)$ timp, $O(1)$ spațiu.
Variante utile ale căutării binare¶
def lower_bound(arr, target):
"""Primul index i cu arr[i] >= target (sau len(arr) dacă nu există)"""
left, right = 0, len(arr)
while left < right:
mid = left + (right - left) // 2
if arr[mid] < target:
left = mid + 1
else:
right = mid
return left
def upper_bound(arr, target):
"""Primul index i cu arr[i] > target"""
left, right = 0, len(arr)
while left < right:
mid = left + (right - left) // 2
if arr[mid] <= target:
left = mid + 1
else:
right = mid
return left
Aceste variante sunt esențiale: lower_bound și upper_bound permit numărarea aparițiilor unui element într-un array sortat în $O(\log n)$:
def count_occurrences(arr, target):
return upper_bound(arr, target) - lower_bound(arr, target)
Complexitatea Spațiu¶
Până acum am discutat despre timp. Dar și memoria contează.
# O(1) spațiu — doar variabile simple
def sum_array(arr):
total = 0
for x in arr:
total += x
return total
# O(n) spațiu — creăm o copie
def reverse_copy(arr):
return arr[::-1]
# O(n) spațiu din cauza stivei de recursivitate
def sum_recursive(arr, i=0):
if i == len(arr):
return 0
return arr[i] + sum_recursive(arr, i + 1)
Atenție la recursivitate! Chiar dacă nu alocăm structuri noi, fiecare apel recursiv ocupă spațiu pe stivă. O recursivitate de adâncime $n$ consumă $O(n)$ memorie.
Teorema Master¶
Pentru recurențe de forma $T(n) = a \cdot T(n/b) + O(n^c)$:
- Dacă $c < \log_b a$: $T(n) = O(n^{\log_b a})$
- Dacă $c = \log_b a$: $T(n) = O(n^c \log n)$
- Dacă $c > \log_b a$: $T(n) = O(n^c)$
Merge Sort: $T(n) = 2T(n/2) + O(n)$ → $a=2, b=2, c=1$ → $\log_2 2 = 1 = c$ → Cazul 2: $O(n \log n)$
Căutare Binară: $T(n) = T(n/2) + O(1)$ → $a=1, b=2, c=0$ → $\log_2 1 = 0 = c$ → Cazul 2: $O(\log n)$
Complexitate Best, Average, Worst Case¶
Un algoritm poate avea complexități diferite în funcție de input:
- Best case — cel mai favorabil input (rar relevant)
- Average case — media peste toate inputurile posibile
- Worst case — cel mai defavorabil input (cel mai des analizat)
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1
- Best case: $O(1)$ — elementul e primul
- Average case: $O(n/2) = O(n)$
- Worst case: $O(n)$ — elementul nu există
De obicei analizăm worst case pentru că oferă garanții.
Exerciții¶
-
Determinați complexitatea timp și spațiu:
python def mystery(n): if n <= 1: return 1 return mystery(n // 2) + mystery(n // 2) -
Un algoritm are complexitatea $T(n) = 2T(n/2) + n^2$. Ce complexitate are conform Teoremei Master?
-
Implementați căutarea binară recursiv și analizați diferența de spațiu față de varianta iterativă
-
Dat un array sortat de $n$ elemente, găsiți prima poziție unde
arr[i] == iîn $O(\log n)$