Salta al contenuto
Note per Studenti Complessità computazionale

Complessità computazionale

In questa pagina 8

Idea

Si misura il numero di operazioni elementari (assegnamenti, confronti, operazioni aritmetiche, accessi per indice) in funzione della dimensione dell'input nn (lunghezza della lista, numero di cifre...). Il tempo in secondi dipende dalla macchina; la crescita al crescere di nn no.

  • Caso peggiore: massimo numero di operazioni tra gli input di dimensione nn (è quello che si dà di solito: è una garanzia).
  • Caso migliore: minimo.
  • Caso medio: media su una distribuzione degli input.

Esempio, ricerca lineare di x in una lista di nn elementi: caso migliore 1 confronto (primo elemento), caso peggiore nn (assente o ultimo).

Notazione O-grande

f(n)=O(g(n))f(n) = O(g(n)) se esistono c>0c > 0 e n0n_0 tali che f(n)≤c⋅g(n)f(n) \le c \cdot g(n) per ogni n≥n0n \ge n_0: ff cresce al più come gg, a meno di costanti.

Regole pratiche:

  • si tiene solo il termine dominante e si tolgono le costanti: 3n2+10n+7=O(n2)3n^2 + 10n + 7 = O(n^2);
  • passi in sequenza: si sommano (vince il maggiore);
  • cicli annidati: si moltiplicano.

Esistono anche Ω\Omega (limite inferiore: cresce almeno come) e Θ\Theta (stessa crescita: sia OO sia Ω\Omega).

Classi di crescita

Classe Nome Esempio
O(1)O(1) costante accesso v[i], append
O(log⁡n)O(\log n) logaritmica ricerca binaria
O(n)O(n) lineare ricerca lineare, somma, massimo
O(nlog⁡n)O(n \log n) quasi lineare merge sort, sorted
O(n2)O(n^2) quadratica selection sort, insertion sort, tutte le coppie
O(2n)O(2^n) esponenziale fib ricorsivo ingenuo, tutti i sottoinsiemi

Con n=106n = 10^6 e 10910^9 operazioni al secondo: nlog⁡nn \log n richiede circa 0,02 s, n2n^2 circa 17 minuti, 2n2^n è impossibile.

Contare i passi

python
for i in range(n):            # n volte
    s += v[i]                 # O(1)
# totale O(n)

for i in range(n):
    for j in range(i + 1, n): # n-1, n-2, ..., 0 volte
        ...                   # totale n(n-1)/2 = O(n^2)

i = n
while i > 1:
    i //= 2                   # i si dimezza: log2(n) iterazioni -> O(log n)

Ricorsione: si scrive l'equazione di ricorrenza del costo T(n)T(n).

  • fattoriale: T(n)=T(n−1)+cT(n) = T(n-1) + c → O(n)O(n).
  • ricerca binaria: T(n)=T(n/2)+cT(n) = T(n/2) + c → O(log⁡n)O(\log n).
  • merge sort: T(n)=2T(n/2)+cnT(n) = 2T(n/2) + cn → O(nlog⁡n)O(n \log n).
  • fib ingenuo: T(n)=T(n−1)+T(n−2)+cT(n) = T(n-1) + T(n-2) + c → esponenziale.

Costi nascosti in Python

Una riga di Python non è sempre O(1)O(1):

Operazione Costo
x in lista, lista.index(x), lista.remove(x), min, max, sum O(n)O(n)
lista.insert(0, x), lista.pop(0) O(n)O(n)
slicing v[a:b], v.copy(), s + t proporzionale alla lunghezza del risultato
x in insieme, d[k], k in d O(1)O(1) in media (vedi Dizionari e insiemi in PythonADT mappa e insieme; dict con chiavi hashable, accesso, get, iterazione, conteggi e raggruppamenti; set e operazioni insiemistiche; tabelle hash e costo O(1) medio; Counter e defaultdict.Dizionari e insiemi in Python →)
sorted(v), v.sort() O(nlog⁡n)O(n \log n)
python
# O(n^2): "in" su una lista dentro un ciclo
dup = any(v[i] in v[i+1:] for i in range(len(v)))
# O(n): insieme
dup = len(set(v)) != len(v)

Misurare i tempi

python
import time
t0 = time.perf_counter()
risultato = funzione(dati)
print(f"{time.perf_counter() - t0:.4f} s")

Raddoppiando nn: un algoritmo lineare raddoppia il tempo, uno quadratico lo quadruplica.

Spazio

Analogamente si misura la memoria aggiuntiva: O(1)O(1) se si usano poche variabili, O(n)O(n) se si crea una copia della lista; la ricorsione usa spazio di stack proporzionale alla profondità.

Errori tipici

  • Considerare O(1)O(1) un in su lista o uno slicing.
  • Confondere caso migliore e caso peggiore.
  • Pensare che le costanti non contino mai: a parità di classe contano, ma per nn grande la classe vince.

Teoria collegata