Obiettivo: disegnare “buoni” algoritmi e “buone” strutture dati per “risolvere” problemi. Algoritmo: procedimento passo passo per eseguire un compito. • Un programma è una particolare implementazione di un algoritmo, non è un algoritmo • Vi sono sempre molti algoritmi diversi per ogni problema dato Struttura Dati : modo sistematico di organizzare i dati e accedere ad essi. algoritmo e organizzazione dati validità semantica efficienza Risolve il problema dato? codifica È un “buon” algoritmo? Classificare come buoni avere modi precisi per analizzarli Misure naturale di bontà: • spazio utilizzato • tempo di run degli algoritmi e delle operazioni sulle strutture dati Qual è il modo corretto per misurare il tempo di run? Siamo interessati a determinare la dipendenza del tempo di calcolo dalla dimensione dell’input. In generale il tempo aumenta con la dimensione dell’input. Una tabella rivelatrice Se un algoritmo è eseguito in tempo T(n), dove n è la dimensione dell’istanza in input, quanto tempo richiede risolvere un’istanza del problema di dimensione n se un’istanza di dimensione 1 richiede 1 µs (10-6 s)? n=10 n=20 n=50 n=100 n=1000 n lgn 33 µs 86 µs 282 µs 664 µs 10 ms n 2 100 µs 400 µs 3 ms 10 ms 1s n 5 100 ms 3s 5 min 3 hr 32 yr 2 n 1ms 1s 36 yr T(n) n! 4s 77147yr 1051 yr 4x1016yr 10287 yr 10144 yr 102554 yr 10-4 x 2n T e m p o 105 104 di c a l c o l o 10-6 x 2n 1 giorno 1 ora 103 102 10-2 x n3 1 minuto 10 -4 x n3 10 1 secondo 1 5 10 -1 10 15 20 25 30 35 Dimensione dell’istanza Algorithmics versus Hardware 40 Un’altra tabella rivelatrice Se un algoritmo è eseguito in tempo T(n), qual è la massima istanza del problema che può essere risolta in un minuto ? T(n) Il computer più veloce del mondo nel 1990 n lgn 2 n 5 n n 2 n! Un computer 1000 volte piu’ veloce 1.5 x 103 miliardi 1 x 106 miliardi 8 milioni 260 milioni 570 2300 46 56 16 18 In quale modo stimiamo il tempo di run? Approccio empirico (a posteriori) Approccio teorico (a priori) Per sviluppare la metodologia dobbiamo precisare: 1. 2. 3. 4. linguaggio per descrivere gli algoritmi modello computazionale d’esecuzione metrica per misurare il tempo di run modo per caratterizzare i tempi di run anche per gli algoritmi ricorsivi 1. Il linguaggio di disegno: Pseudo-codice assegnazione: ← i ← j ← k equivale alla seq.: j ← k; i ← j espressioni: simboli matematici standard per espressioni numeriche e booleane commento: { } dichiarazione di metodo: nome (param 1, param 2, ...) chiamata di un metodo: nome (param 1, param 2, ...) ritorno da un metodo: return valore dati composti: - i-esimo elemento array A: A[i] - A[i . . j] ≡ <A[i], A[i+1], . . . , A[j]> se i ≤ j sequenza vuota se i > j - i dati composti sono organizzati in oggetti, che sono strutturati in attributi o campi: ad es. length[A] una variabile che rappresenta un oggetto è trattata come puntatore un puntatore che non si riferisce a nessun oggetto: nil parametri alle procedure passati per valore per gli oggetti una copia del puntatore struttura di blocco: spaziatura costrutti iterativi e condizionali : PASCAL-like if condizione then azioni (else azioni) while condizione do azioni repeat azioni until condizione for variabile-incremento do azioni 2. Il modello computazionale • • • • • • • assegnazione di un valore ad una variabile chiamata di un metodo eseguire un’operazione aritmetica confronto di due numeri indicizzazione di un elemento in un array riferimento a un oggetto rientro da un metodo Assunzione implicita: il numero di operazioni primitive è proporzionale al tempo di esecuzione dell’algoritmo Questo approccio dà origine al modello computazionale chiamato Random Access Machine (RAM): • CPU connessa a un banco di celle di memoria • ogni cella memorizza una parola (un numero, una stringa, un indirizzo . . ., in generale il valore di un tipo base) • la CPU accede ad una cella di memoria arbitraria con una operazione primitiva La quantità di tempo (e di spazio) consumato dall’esecuzione di un programma RAM su un dato input può essere determinato essenzialmente usando due criteri: Criterio di costo uniforme: l’esecuzione di un’istruzione richiede un tempo indipendente dalla “grandezza” degli operandi Criterio di costo logaritmico: il tempo di calcolo richiesto da un’istruzione dipende dal numero di bit necessari a rappresentare gli operandi 3. Misurare il tempo di run: Contare le operazioni primitive Massimo (A, n) current-max ← A[0] for i ← 1 to n-1 do if current-max < A[i] then current-max ← A[i] return current-max 2 1+n 4 (n-1) / 6 (n-1) 1 Numero di operazioni primitive: t (n) = minimo 2 + 1 + n + 4(n-1) + 1 = 5n massimo 2 + 1 + n + 6(n-1) + 1 = 7n - 2 tem po di run 5 ms tempo nel caso peggiore 3 ms tempo nel caso migliore 1 ms A B C D E istanze in input F G Analisi del caso medio tempo d’esecuzione dell’algoritmo espresso come media dei tempi per tutti i possibili input conoscere la distribuzione di probabilità sull’insieme degli input: un compito non semplice Analisi del caso peggiore • non richiede teoria probabilità • caso peggiore quasi sempre facile da identificare • se un algoritmo si comporta bene nel caso peggiore, si comporta bene su ogni input 4. Come esprimere il tempo d’esecuzione degli algoritmi ricorsivi ? Equazione di ricorrenza: una funzione che esprime il tempo di esecuzione sull’input di dimensione n in funzione del tempo su input di dimensione inferiore. Massimo-ricorsivo (A, n) if n = 1 then return A[0] return max (Massimo-ricorsivo (A, n-1), A[n-1]) T(n) = 3 T(n-1) + k T(n) = k (n-1) + 3 se n = 1 altrimenti Moltiplicazione per “somme successive” x1 x2 45 19 moltiplicazione (x1, x2) y ← x1 44 19 prod ← 0 43 19 while y > 0 do .. .. . . prod ← prod + x2 4 19 y ←y-1 3 19 return prod 2 19 1 19 855 Moltiplicazione “alla russa” x1 45 22 11 5 2 x2 19 38 76 152 304 1 608 19 --76 152 --608 855 molt-russa (x1, x2) y1 ← x1 y2 ← x2 prod ← 0 while y1 > 0 do if y1 is odd then prod ← prod + y2 y1 ← y1 div 2 y2 ← y2 + y2 return prod moltiplicazione (x1, x2) y ← x1 prod ← 0 while y > 0 do prod ← prod + x2 x1 ← x1 - 1 return prod nel while molt-russa (x1, x2) y1 ← x1 y2 ← x2 prod ← 0 while y1 > 0 do if y1 is odd then prod ← prod + y2 y1 ← y1 div 2 y2 ← y2 + y2 return prod moltiplicazione molt-russa 2m assegnazioni m somme m decrementi m+1 confronti 3 lg m assegnazioni lg m divisioni 2 lg m somme 2 lg m + 1 confronti 5m+1 + 3 operazioni 8 lg m+1 + 4 operazioni f(m) = 5m + 4 g(m) = 8 lg m + 5 60 54 50 49 numero di operazioni 44 40 39 34 29 30 27,46 19 25,68 23,58 21 17,68 14 13 9 10 31,58 29 24 20 30,36 5 4 0 0 1 2 3 4 5 6 moltiplicatore 7 8 9 10 11 Ulteriore astrazione : tasso di crescita o ordine di grandezza del tempo di esecuzione. • ogni passo nello pseudo-codice (e ogni statement in un linguaggio ad alto livello) corrisponde a un piccolo numero di operazioni primitive che non dipendono dalla dimensione dell’input • basta considerare il termine principale perchè i termini di ordine inferiore non sono significativi per n grande . L’ordine di grandezza del tempo di esecuzione fornisce una semplice caratterizzazione dell’efficienza e consente di confrontare algoritmi alternativi. Efficienza asintotica degli algoritmi: come cresce il tempo di esecuzione con il crescere al limite della dimensione delle istanze in input Notazione asintotica Consideriamo funzioni dai naturali ai numeri reali non negativi Notazione O: O (g(n)) e’ l’insieme di tutte le funzioni f(n) per cui esistono due costanti positive c ed n0 tali che f(n) ≤ c • g(n) per tutti gli n ≥ n0 c g(n) f(n) tem po di run n0 f(n) ∈ O (g(n)) n Notazione Ω: Ω(g(n)) e’ l’insieme di tutte le funzioni f(n) per cui esistono due costanti positive c ed n0 tali che f(n) ≥ c • g(n) per tutti gli n ≥ n0 f(n) tem po di run c g(n) n0 f(n) ∈ Ω (g(n)) n Notazione Θ: Θ(g(n)) e’ l’insieme di tutte le funzioni f(n) per cui esistono tre costanti positive c1, c2 ed n0 tali che c1• g(n) ≤ f(n) ≤ c2 • g(n) per tutti gli n ≥ n 0 c2 g(n) f(n) tem po di run c1 g(n) n0 f(n) ∈ Θ (g(n)) n Proprietà Transitiva: f(n) = Θ (g(n)) e g(n) = Θ (h(n)) f(n) = O (g(n)) e g(n) = O (h(n)) f(n) = Ω (g(n)) e g(n) = Ω (h(n)) Riflessiva: Simmetrica: f(n) = Θ (h(n)) f(n) = O (h(n)) f(n) = Ω (h(n)) f(n) = Θ (f(n)) f(n) = O (f(n)) f(n) = Ω (f(n)) f(n) = Θ (g(n)) Simmetrica trasposta: f(n) = O (g(n)) g(n) = Θ (f(n)) g(n) = Ω (f(n)) • d(n) = O(f(n)) a . d(n) = O(f(n)), per ogni costante a > 0 • d(n) = O(f(n)) & e(n) = O(g(n)) d(n) + e(n) = O(f(n) + g(n)) • d(n) = O(f(n)) & e(n) = O(g(n)) d(n) . e(n) = O(f(n) . g(n)) • f(n) funzione polinomiale di grado d: f(n) = a0 + a1n + . . . + adnd f(n) = O(nd) Complessità O(log n) logaritmica O(n) lineare O(n2) quadratica O(nk) (k ≥ 1) polinomiale O(an) (a > 1) esponenziale trattabili non trattabili Funzioni ordinate per velocità di crescita n log n n n n log n n2 nn32 2n 2 1 1,41 2 2 4 8 4 4 2 2 4 8 16 64 16 8 3 2,83 8 24 64 512 256 16 4 4 16 64 256 4.096 65.536 32 5 5,66 32 160 1.024 32.768 4.294.967.296 64 6 8 64 384 4.096 262.144 1,84 x 1019 128 7 11,31 128 896 16.384 2.097.152 3,40 x 1038 256 8 16 256 2.048 65.536 16.777.216 1,15 x 1077 512 9 22,63 512 4.608 262.144 134.217.728 1,34 x 10154 1.024 10 32 1.024 10.240 1.048.576 1.073.741.824 1,79 x 10308 10 10 9 9 8 8 9 8 8 8 7 7 6 6 5 5 4,75 4 4 4 3,17 3 2,81 3 2,32 2 1 logn radn n n logn n^2 n^3 2^n 2 1,73 1,41 1,58 1 2 3 2,58 2,24 2,45 2,65 3,58 3,32 3,46 3 2,83 3,16 3,32 3,46 2 0 2 3 4 5 6 7 8 9 10 11 12 60 logn radn n n logn n^2 n^3 2^n 50 40 30 20 10 0 2 3 4 5 6 7 8 9 10 11 12 4500 logn radn n n logn n^2 n^3 2^n 4000 3500 3000 2500 2000 1500 1000 500 0 2 3 4 5 6 7 8 9 10 11 12 Problema: ordinamento di numeri. Input: una sequenza di n numeri <a1, a2,…,an>. Output: una permutazione <a1’, a2’,…,an’> della sequenza di input tale che a1’≤ a2’ ≤ … ≤ an’. 1 2 3 4 5 3 5 1 8 2 key = 5 1 2 3 4 5 3 5 1 8 2 3 5 5 8 2 key = 1 1 3 5 8 2 1 2 3 4 3 3 5 8 2 5 1 3 5 8 2 1 2 key = 8 3 4 5 1 3 5 8 2 key = 2 1 3 5 8 8 1 3 5 5 8 1 3 3 5 8 1 2 3 5 8 Insertion-sort (A) 1 for j ← 2 to length[A] do 2 key ← A[j] 3 {inserisci A[j] nella sequenza A[1. .j-1] num. volte 1 n n-1 spostando a destra gli elementi > di A[j]} i ← j-1 while i > 0 and A[i] > key do A[i+1] ← A[i] i ← i-1 A[i+1] ← key 4 5 6 7 8 n-1 Σ tj (j=2..n) Σ(tj-1) (j=2..n) Σ(tj-1) ( j=2..n) n-1 T(n) = 1 + a n + b (n-1) + c Σ tj + d Σ(tj-1) = = (a + b) n + (1- b) + c Σ tj + d Σ (tj-1) Situazione migliore: A e’ ordinato T(n) è una funzione lineare di n. Situazione peggiore: A è ordinato in ordine inverso T(n) è una funzione quadratica di n. Situazione media: ogni permutazione e’ ugualmente probabile T(n) è una funzione quadratica di n. Problema: valutazione di polinomi. Input: una sequenza di n+1 numeri reali A = <a0, a1,…,an> e una variabile x. Output: il valore del polinomio di grado n: P(x) = a0 + a1x+ … + anxn. Poly-eval (A, x, n) 1 y←1 2 result ← A[0] 3 for i ← 1 to n do 4 y←y•x 5 result ← result +A[i] • y 6 return result {y = xi} L’algoritmo esegue 2 n moltiplicazioni n somme e 2n assegnazioni. Ma si può fare meglio La regola di Horner: P(x) = a0 + x (a1+ … + x (an-1+ x an))…) Horner (A, x, n) 1 result ← A[n] 2 for i ← n - 1 downto 0 do 3 result ← result • x + A[i] 4 return result L’algoritmo esegue n somme, n moltiplicazioni e n assegnazioni. Questo algoritmo è sicuramente migliore L’analisi asintotica non distingue però tra i due algoritmi: per entrambi si ottiene Θ(n) Poly-eval (A, x, n) 1 y←1 2 result ← A[0] 3 for i ← 1 to n do 4 y←y•x 5 result ← result +A[i] • y 6 return result fuori dal for confronti nel for Poly-eval 5 n+1 8n Poly-eval: 9 n + 6 Horner: 7n+5 Horner (A, x, n) 1 result ← A[n] 2 for i ← n - 1 downto 0 do 3 result ← result • x + A[i] 4 return result Horner 4 n+1 6n 1200 T(n) = 9 n + 6 1005 1000 T(n) = 7 n + 5 915 825 800 735 782 712 645 600 642 555 572 465 400 432 285 82 12 1 362 292 195 200 0 502 375 222 152 105 15 11 21 31 41 51 61 71 81 91 101 111