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.