APPUNTI DI ALGORITMI I
Esercitazioni 1 e 2
Tiziana Calamoneri
Dip. Di Informatica
Univ. Di Roma “La Sapienza”
Per eventuali commenti o errori, scrivere a [email protected]
COMPLESSITA’ ASINTOTICA
L’ordine di grandezza del tempo di esecuzione di un algoritmo dà una misura
dell’efficienza dell’algoritmo stesso, consentendo di confrontare algoritmi diversi che
risolvono lo stesso problema. Tutto ciò ha senso quando la dimensione dell’input è
sufficientemente grande, e questo è il motivo per cui si parla di complessità
temporale asintotica.
Prima di parlare della complessità di un algoritmo, introduciamo alcune definizioni:
Notazione O (limite asintotico superiore)
Date due funzioni f(n), g(n)  0, si dice che f(n) è un O(g(n)) se esistono due costanti
c ed n0 tali che 0  f(n)  c g(n) per ogni n  n0.
Vedi figura 2.1.b pag 22 del Cormen, Leiserson, Rivest.
Esempio 1.
Sia f(n)=3n+3.
f(n) è un O(n2) in quanto cn2  3n+3 per ogni n se c  6. Ma f(n) è anche O(n), infatti
cn  3n+3 per ogni n se c  6, oppure per ogni n  3 se c  2.
Esempio 2.
Sia f(n)= n2+4n.
f(n) è un O(n2) in quanto cn2  n2+4n per ogni n se c  5 oppure per c>1 ed n0 4/(c1).
Esempio 3.
Sia f(n) un polinomio di grado m: f(n)= i=0m aini, am>0, f(n) è un O(nm).
Per dimostralo, procediamo per induzione su m:
casi base: se m=0 allora f(n)=a0, che è un O(1), se c  a0 per qualunque n; se m=1 allora
f(n)=a0+a1 n, che è O(n) se c a0+a1.
Ipotesi induttiva: f(n)= i=0m-1 aini è un O(n m-1) se c i=0m-1 ai.
Passo induttivo: dobbiamo dimostrare che h(n)= i=0m aini è un O(n m), cioè che c’ nm
 i=0m aini se c’ i=0m ai. Per ipotesi induttiva, h(n) si può scrivere come f(n)+am nm
e c nm-1  f(n) se c  i=0m-1 ai. Ci chiediamo se sia h(n)  c’nm; questa disuguaglianza è
equivalente a: f(n)+am nm  (c+am)nm, che è a sua volta equivalente a: f(n)  c nm, che
è vera poiché, per ipotesi induttiva, f(n)  c nm-1 c nm.
Notazione  (limite asintotico inferiore)
Date due funzioni f(n), g(n)  0, si dice che f(n) è un (g(n)) se esistono due costanti
c ed n0 tali che f(n)  c g(n) per ogni n  n0.
Vedi figura 2.1.c pag 22 del Cormen, Leiserson, Rivest.
Esempio 4.
Sia f(n)= 2n2+3.
f(n) è un  (n) perché 2n2 +3  cn se c=1 e qualunque n. Tuttavia f(n) è anche un O(n2) in
quanto 2n2+3  c n2 per ogni n se c  2.
Esempio 5.
Sia f(n) un polinomio di grado m: f(n)= i=0m aini, am>0, f(n) è un (nm).
Dimostrazione per esercizio.
Abbiamo visto che, in entrambe le notazioni affronate, per ogni funzione f(n), è
possibile trovare più funzioni g(n). Tuttavia, poiché la notazione asintotica ci serve
per stimare al meglio la complessità di un algoritmo, vorremmo trovare – tra tutte le
possibili funzioni g(n) – quella che più si avvicina ad f(n). Per questo cerchiamo la
più piccola g(n) per determinare O e la più grande per determinare . La definizione
che segue formalizza questo concetto intuitivo.
Notazione  (limite asintotico stretto)
Date due funzioni f(n), g(n)  0, si dice che f(n) è un (g(n)) se esistono tre costanti
c1 , c2 ed n0 tali che c1 g(n)  f(n)  c2 g(n) per ogni n  n0.
In altre parole, f(n) è un (g(n)) se è contemporaneamente O(g(n)) e (g(n)).
Vedi figura 2.1.a pag 22 del Cormen, Leiserson, Rivest.
Esempio 6.
Sia f(n)= 3n+3. f(n) è un (n) ponendo, ad esempio, c1=4, c2=3 ed n0=3.
Esempio 7.
Sia f(n) un polinomio di grado m: f(n)= i=0m aini, am>0. f(n) è un (nm).
Dimostrazione per esercizio.
Algebra della notazione asintotica
Per semplificare il calcolo della complessità degli algoritmi, possiamo derivare delle
semplici regole.
1a. Per ogni k>0 e per ogni f(n)  0, se f(n) è un O(g(n)), allora k f(n) è un O(g(n)).
2a. Per ogni f(n), d(n) >0, se f(n) è un O(g(n)) e d(n) è un O(h(n)), allora f(n)+d(n) è
un O(g(n)+d(n))=O(max(g(n), h(n)).
3a. Per ogni f(n), d(n) >0, se f(n) è un O(g(n)) e d(n) è un O(h(n)), allora f(n)d(n) è un
O(g(n)h(n)).
Dimostrazione.
1a. Per ipotesi, f(n) è un O(g(n)) quindi esistono due costanti c ed n0 tale che f(n) 
cg(n) per ogni n  n0. Segue che k f(n)  k c g(n). Questo prova che, prendendo kc
come nuova costante c’ e mantenendo lo stesso n0, k f(n) è un O(g(n)).
2a. Se f(n) è un O(g(n)) e d(n) è un O(h(n)), allora esistono quattro costanti c’ e c”,
n0’ ed n0” tali che f(n)  c’ g(n) per ogni n  n0‘ e d(n)  c” h(n) per ogni n  n0”.
Allora f(n)+d(n)  c’g(n)+ c”h(n)  max(c’,c”)(g(n)+h(n)) per ogni n 
max(n0‘,n0”). Da ciò segue che f(n)+d(n) è un O(g(n)+d(n)). Infine,
max(c’,c”)(g(n)+h(n))  2(max(c’,c”) max(g(n),h(n)). Ne segue che f(n)+d(n) è
un O(max(g(n),d(n)).
3a. Se f(n) è un O(g(n)) e d(n) è un O(h(n)), allora esistono quattro costanti c’ e c”,
n0’ ed n0” tali che f(n)  c’ g(n) per ogni n  n0‘ e d(n)  c” h(n) per ogni n  n0”.
Allora, f(n) d(n)  c’g(n) c”h(n)  max(c’,c”)(g(n)h(n)) per ogni n  max(n0‘,n0”).
Si può quindi concludere che f(n)d(n) è un O(g(n)h(n)).
In modo analogo, è possibile dimostrare anche le seguenti regole:
1b. Per ogni k>0 e per ogni f(n)  0, se f(n) è un (g(n)), allora k f(n) è un (g(n)).
2b. Per ogni f(n), d(n) >0, se f(n) è un (g(n)) e d(n) è un (h(n)), allora f(n)+d(n) è
un (g(n)+d(n))=  (max(g(n), h(n)).
3b. Per ogni f(n), d(n) >0, se f(n) è un (g(n)) e d(n) è un (h(n)), allora f(n)d(n) è
un (g(n)h(n)).
1c. Per ogni k>0 e per ogni f(n)  0, se f(n) è un (g(n)), allora k f(n) è un (g(n)).
2c. Per ogni f(n), d(n) >0, se f(n) è un (g(n)) e d(n) è un (h(n)), allora f(n)+d(n) è
un (g(n)+d(n))= (max(g(n), h(n)).
3c. Per ogni f(n), d(n) >0, se f(n) è un (g(n)) e d(n) è un (h(n)), allora f(n)d(n) è un
(g(n)h(n)).
Molto informalmente, le regole 1. si possono riformulare dicendo che le costanti
moltiplicative si possono ignorare, le regole 2. e 3. sono equivalenti a dire che le
notazioni asintotiche commutano con le operazioni di somma e prodotto.
Esempio 8.
Trovare una stima asintotica stretta per f(n) = 3n 2n +4n4.
3n 2n +4n4 = (n) (2n)+(n4)= (n 2n)+(n4)= (n2n).
Esempio 9.
Trovare una stima asintotica stretta per f(n) = 2n+1.
2n+1=2 2n=(2n).
Esempio 10.
Trovare una stima asintotica stretta per f(n) = 22n.
22n = (22n).
Questo ci permette di osservare che le costanti si possono ignorare SOLO se non
sono all’esponente.
COMPLESSITA’ DEGLI ALGORITMI
Applichiamo quanto detto per calcolare la complessità computazionale degli
algoritmi come funzione della dimensione dell’input, cioè – informalmente – della
quantità di dati che l’algoritmo richiede in input. Se l’input è un vettore di interi di
lunghezza n, allora n è la dimensione dell’input, se l’input è una matrice n  m, allora
n  m è la dimensione dell’input, se l’input è un albero con n nodi, allora n è la
dimensione dell’input, e così via.
Diamo ora delle semplici regole generali:
 La complessità di un algoritmo è pari alla somma delle complessità delle
istruzioni che lo compongono.
 Le operazioni elementari (confronto, assegnazione, lettura di una variabile,
stampa di una variabile) hanno complessità =(1). Questa assunzione non è
totalmente corretta, considerato che ogni variabile occupa un numero di byte
proporzionale al suo valore e sarebbe quindi più sensato pensare che la
complessità di un’operazione come quella di lettura/scrittura di una variabile
sia proporzionale al logaritmo del suo valore; tuttavia è da tener presente che la
complessità computazionale va calcolata per valori molto grandi dell’input e
quindi, come vedremo nel seguito, questa assunzione non fa variare
sensibilmente il risultato.
 L’istruzione IF (condizione) THEN istruzione1 ELSE istruzione2 ha
complessità pari al massimo delle complessità di istruzione1 ed istruzione2, se
si può assumere che la complessità di condizione sia costante.
 Le iterazioni (cicli WHILE e REPEAT) hanno complessità pari alla massima
complessità delle istruzioni all’interno del ciclo moltiplicata per il numero di
volte per cui quel ciclo viene ripetuto. Questa regola produce un risultato utile
se tutte le iterazioni del ciclo hanno la stessa complessità e se è possibile
stimare in modo abbastanza preciso il numero di iterazioni.
Consideriamo ora alcuni esempi per chiarire quanto detto.
Esempio 11.
Calcolo del massimo in un vettore disordinato di dimensione n.
Funzione Trova_Max(A: vettore)
1. Max := A[1];
2. FOR i=2 TO n DO
3.
IF A[i] > Max
4.
THEN Max:=A[i];
5. stampa Max.
Le istruzioni 1. e 5. hanno complessità (1). La condizione dell’IF ha complessità
costante, quindi la complessità delle istruzioni 3. e 4. nel loro complesso è (1). Il
ciclo FOR dell’istruzione 2. viene eseguito esattamente n-1 volte. Quindi, detta T(n)
la complessità computazionale dell’algoritmo, si può dire che:
T(n) =(1) + (n-1) (1)+(1) = (n).
Esempio 12.
Ordinamento di un vettore disordinato di dimensione n tramite l’algoritmo Bubble
Sort.
Funzione Bubble(A: vettore)
1. FOR i=1 TO n-1 DO
2.
FOR j=1 TO n-1 DO
3.
IF (A[j+1]<A[j])
4.
THEN scambia(A[j], A[j+1]);
5. RETURN A.
L’istruzione IF THEN ha complessità (1). Il ciclo FOR dell’istruzione 2. che
contiene l’IF viene ripetuto n-1 volte. Il ciclo dell’istruzione 1. che comprende al suo
interno le istruzioni dalla 2. alla 4. viene ripetuto anch’esso n-1 volte. Infine,
l’istruzione 5. ha complessità (n), pari alla dimensione del vettore restituito. Quindi:
T(n)=(n-1)((n-1) (1))+ (n)= (n2).
L’algoritmo di Bubble Sort può essere ottimizzato al modo seguente, tenendo conto
del fatto che all’iterazione i-esima l’elemento in posizione n-i+1 si trova nella sua
posizione definitiva:
Funzione Bubble2(A: vettore)
1. FOR i=1 TO n-1 DO
2.
FOR j=1 TO n-i DO
3.
IF (A[j+1]<A[j])
4.
THEN scambia(A[j], A[j+1]);
5. RETURN A.
L’analisi è identica alla precedente, salvo l’istruzione 2. la cui complessità possiamo
valutare in due modi diversi. Un primo modo consiste nell’osservare che il ciclo
dell’istruzione 2. viene ripetuto un numero di volte che non è mai superiore ad n-1. In
tal caso si ha: T(n)  (n-1)((n-1) (1))+ (n)=  (n2) da cui segue che T(n)=O(n2),
che è un risultato più debole di quello che vorremmo ottenere. L’altro modo consiste
nel valutare esattamente il numero di iterazioni, che è n-i. Il problema che si pone ora
è che le istruzioni all’interno del ciclo 1. (ripetuto n-1 volte) hanno una complessità
che dipende dal parametro i del ciclo, non è pertanto più possibile moltiplicare la
complessità delle istruzioni all’interno del ciclo per il numero di iterazioni. Bisogna,
invece, sommare il contributo di ciascuna iterazione, dipendentemente dal parametro
i, cioè: T(n)= i=1n-1 ((n-i) (1))+ (n)= (1) i=1n-1(n-i) + (n)= (1)(n(n-1))/2) +
(n)=  (n2).
Deduciamo che il tentativo di ottimizzare l’algoritmo non ha prodotto un
miglioramento in termini di complessità asintotica. Infatti, la complessità si è circa
dimezzata. Ciò ci permette di fare alcune considerazioni sul corretto utilizzo della
complessità asintotica, che non calcola il tempo esatto impiegato dall’algoritmo
studiato, ma piuttosto ci permette di avere un’idea di quanto velocemente cresca il
tempo di esecuzione rispetto alla crescita della dimensione dell’input.
Concludiamo questo argomento con un esempio di tempo di esecuzione di un
algoritmo, al variare della sua complessità asintotica.
Supponiamo che questo algoritmo lavori su un vettore la cui dimensione sia n=10000
e che ogni singola operazione venga eseguita in 1/10000 sec. Allora:
O(n)
 1 sec
2
O(n )
 104 sec  2,8 ore
O(n log n) 
12 sec.