Impara Algoritmi e complessità da zero
1Che cosa misura la complessità
Un algoritmo è un elenco finito di passi precisi che risolve un problema, come ordinare una lista o trovare un nome in un elenco telefonico.
Due algoritmi possono risolvere lo stesso problema ma impiegare tempi molto diversi. La complessità descrive come il lavoro cresce al crescere dell’input.
La dimensione dell’input si chiama n: il numero di elementi da ordinare, la lunghezza di una lista, il numero di nodi di un grafo.
Pensa di cercare un nome in un elenco telefonico di 1000 pagine. Leggere ogni pagina richiede fino a 1000 passi. Aprirlo a metà e dimezzare ogni volta richiede circa 10 passi.
La complessità temporale conta i passi. La complessità spaziale conta la memoria aggiuntiva. Entrambe si scrivono come funzione di n.
Di solito si considera il caso peggiore: il lavoro massimo su tutti gli input di dimensione n. A volte si chiede invece il caso medio o il caso migliore.
Autoverifica Un algoritmo il cui lavoro cresce come n² impiega 1 secondo per n = 1000. Circa quanto impiega per n = 2000?
- 1 secondo
- 2 secondi
- 4 secondi
- 8 secondi
Risposta: 4 secondi. Raddoppiare n moltiplica n² per 2² = 4, quindi il tempo passa da circa 1 a circa 4 secondi. 2 secondi sarebbe la risposta per una crescita lineare, e 8 per n³.
2Notazione O grande e classi di crescita
La notazione O grande (Big O) conserva solo ciò che conta per n grande. Si eliminano i fattori costanti e si tiene solo il termine che cresce più in fretta.
3n² + 5n + 2 ⇒ O(n²)
Per n = 1000 il termine n² vale 3 000 000, mentre 5n è solo 5000. I termini più piccoli cambiano appena il totale.
Queste classi compaiono continuamente, dalla crescita più lenta alla più veloce:
- O(1), costante: leggere un elemento di un array tramite il suo indice.
- O(log n), logaritmica: la ricerca binaria, che dimezza il problema a ogni passo.
- O(n), lineare: un solo passaggio su tutti gli elementi, come trovare il valore massimo.
- O(n log n): buoni algoritmi di ordinamento come il merge sort.
- O(n²), quadratica: due cicli annidati sugli elementi, come il bubble sort.
- O(2ⁿ), esponenziale: provare ogni sottoinsieme. Diventa rapidamente inutilizzabile.
O grande è un limite superiore: il lavoro cresce al più così in fretta. Ω (Omega grande) è un limite inferiore, e Θ (Theta) è un limite stretto, sia superiore sia inferiore.
La base di un logaritmo non conta nella notazione O grande, perché i logaritmi con basi diverse differiscono solo per un fattore costante.
Autoverifica Qual è l’O grande di 4n + 2n² + 100?
- O(100)
- O(2n²)
- O(n)
- O(n²)
Risposta: O(n²). Per n grande il termine n² domina, e il fattore costante 2 si elimina. O(n) tiene il termine sbagliato, e O(2n²) dimentica di eliminare la costante.
3Ricerca, ordinamento e ricorsione
Alcuni algoritmi ricorrono in quasi ogni corso. Impara le loro complessità come dati di fatto.
- La ricerca lineare controlla gli elementi uno a uno: O(n). La ricerca binaria richiede un array ordinato e dimezza l’intervallo a ogni passo: O(log n).
- Il bubble sort, l’insertion sort e il selection sort confrontano e scambiano elementi in cicli annidati: O(n²). L’insertion sort è O(n) su un array già ordinato.
- Il merge sort divide l’array in metà, le ordina e le fonde: O(n log n) in ogni caso, con O(n) di memoria aggiuntiva.
- Il quicksort divide attorno a un pivot: O(n log n) in media, ma O(n²) nel caso peggiore, per esempio un array ordinato con il primo elemento come pivot.
- Nessun ordinamento basato solo su confronti può fare meglio di Ω(n log n) nel caso peggiore. Il counting sort è più veloce perché non confronta.
Una funzione ricorsiva richiama se stessa su un input più piccolo finché raggiunge un caso base a cui risponde direttamente. Il suo tempo di esecuzione si scrive come una relazione di ricorrenza:
- T(n) = T(n/2) + O(1) dà O(log n), come la ricerca binaria.
- T(n) = 2T(n/2) + O(n) dà O(n log n), come il merge sort.
- T(n) = T(n − 1) + O(n) dà O(n²).
- T(n) = 2T(n − 1) + O(1) dà O(2ⁿ).
Vale la pena conoscere tre idee di progettazione. Divide et impera divide, risolve e combina. Il metodo greedy (goloso) sceglie a ogni passo l’opzione localmente migliore. La programmazione dinamica memorizza le risposte ai sottoproblemi sovrapposti così che vengano calcolate una sola volta.
Autoverifica Qual è la complessità temporale nel caso peggiore del quicksort?
- O(log n)
- O(n log n)
- O(n)
- O(n²)
Risposta: O(n²). Con pivot sbagliati ogni divisione lascia n − 1 elementi da un lato, il che dà O(n²). O(n log n) è il suo caso medio, e il caso peggiore del merge sort.
4Il metodo passo dopo passo
Per trovare la complessità temporale di un frammento di codice:
- Decidi che cos’è n: la dimensione dell’input.
- Conta quante volte viene eseguito ogni ciclo. Un ciclo che somma 1 a ogni passo viene eseguito circa n volte. Un ciclo che raddoppia o dimezza viene eseguito circa log₂ n volte.
- I cicli uno dentro l’altro si moltiplicano. I cicli uno dopo l’altro si sommano.
- Per una funzione ricorsiva, scrivi la ricorrenza: quante chiamate, su quale dimensione, più il lavoro per chiamata.
- Tieni il termine che cresce più in fretta ed elimina le costanti.
- Controlla con un numero piccolo, per esempio n = 8 o n = 1024.
Per una domanda teorica, nomina prima l’algoritmo o l’idea che descrive, poi richiama la sua complessità e le sue condizioni (input ordinato, pesi non negativi, sottoproblemi sovrapposti).
5Un esempio svolto
Segui ogni passaggio. Prova a prevedere il successivo prima di aprirlo.
Esempio svoltoQual è la complessità temporale di questo codice? for i = 1 to n: (j = 1; while j < n: j = j × 2)
1n è il numero fino al quale vanno i cicli.
2i va da 1 a n con passo 1, quindi il ciclo esterno viene eseguito n volte.
3j assume i valori 1, 2, 4, 8, … e si ferma quando raggiunge n. Dopo k giri j = 2k, quindi il ciclo si ferma dopo circa log₂ n giri.
4Il ciclo interno sta dentro il ciclo esterno, quindi i conteggi si moltiplicano.
n · log₂ n ⇒ O(n log n)
5Per n = 1024 il ciclo interno viene eseguito log₂ 1024 = 10 volte, quindi il corpo viene eseguito 1024 · 10 = 10 240 volte. È molto meno di n² = 1 048 576.
6Errori comuni
- Moltiplicare cicli che vengono eseguiti uno dopo l’altro. n + n = 2n è ancora O(n).
- Tenere le costanti, come in O(2n) o O(n/2). Entrambe sono O(n).
- Contare un ciclo che raddoppia come n giri. Viene eseguito circa log₂ n volte.
- Usare la ricerca binaria su dati non ordinati, o su una lista concatenata dove il punto centrale non si raggiunge in un solo passo.
- Dare il caso medio del quicksort quando viene chiesto il caso peggiore.
- Pensare che O grande significhi “esattamente”. O(n²) è un limite superiore; Θ(n²) è la crescita esatta.
Autoverifica Una funzione esegue un ciclo di n iterazioni e poi, separatamente, un altro ciclo di n iterazioni. Qual è la sua complessità temporale?
- O(2n)
- O(log n)
- O(n)
- O(n²)
Risposta: O(n). I cicli uno dopo l’altro si sommano: n + n = 2n, e la costante si elimina, quindi O(n). O(n²) sarebbe corretto solo se un ciclo fosse dentro l’altro.
7Come sono fatte le domande d’esame
- Facile: termini e fatti chiave. La complessità della ricerca binaria, del merge sort o dell’accesso a un array, il significato di O, Ω e Θ, che cos’è la ricorsione.
- Medio: applicare i concetti. La complessità di un frammento di codice, una ricorrenza da risolvere, come cambia il tempo quando n raddoppia, quale algoritmo si adatta a un compito.
- Difficile: casi e analisi. Il teorema master, i casi peggiori del quicksort, greedy contro programmazione dinamica, e le classi P, NP e NP-completo.
P contiene i problemi risolvibili in tempo polinomiale. NP contiene i problemi le cui soluzioni possono essere verificate in tempo polinomiale. Ogni problema in P è in NP; se P = NP è ancora sconosciuto.
Impara passo dopo passo nell’appImpara passo dopo passo nell’app