Teoria della Calcolabilità non vediamo problema di base: si può fare con un algoritmo/programma ?? possibile / impossibile finito / infinito possibile = si può fare in un tempo finito usando quantità finita di memoria ................. usando una quantità finita di risorse esempi: trovare tutti i numeri primi minori di N possibile trovare tutti i numeri primi impossibile calcolare le prime N cifre decimali di possibile calcolare tutte le cifre decimali di impossibile decidere se una formula con and, or, not è sempre vera possibile idem per formule che usano anche impossibile obiettivo primario precisare i concetti di: algoritmo funzione calcolabile con un algoritmo problema risolubile con un algoritmo 1 Teoria della Complessità problema di base: sapendo che si può fare quanto è difficile/complesso, quanto costa farlo ?? obiettivo primario precisare i concetti di: 1) complessità/costo di un algoritmo o programma 2) difficoltà intrinseca (o costo intrinseco) del calcolo di una funzione o della risoluzione di un problema. Vedremo solo punto 1) Inoltre, non vedremo la teoria della complessità, ma soltanto complessità computazionale concreta Qui programma = algoritmo = programma in un linguaggio di programmazione vero descrizione in linguaggio comodo ma preciso della soluzione del problema 2 A che serve lo studio della complessità ? Un problema tante soluzioni Come scegliere ? Almeno tre criteri: 1) semplicità, chiarezza ..... 2) generalità, riusabilità .... 3) efficienza: tempo di esecuzione quantità di memoria necessaria quantità di traffico generato su rete ....... Però diverse esigenze 1) minimizzare 2) minimizzare 3) minimizzare anche in contrasto: tempi /costi di sviluppo del programma costi di manutenzione del programma costi di esecuzione del programma 1) programma usa e getta 3) parte critica di sistema operativo, foglio elettronico,..... 2) ................. tutti gli altri casi studio complessità algoritmi / programmi strumento per stimare costi di esecuzione anche tecniche di benchmarking (tests su input significativi) di analisi del profilo però NON vediamo 3 Misure di complessità per algoritmi e programmi piú chiaro sui programmi .......... Misure statiche o strutturali: lunghezza del programma quante righe di codice annidamento di cicli, procedure,.... . si guarda la complessità della struttura del programma interesse teorico non le consideriamo e ci limitiamo a ......... Misure dinamiche o computazionali misurare quantità di risorse utilizzate dal programma in esecuzione Risorse diversi tipi ( tempo di CPU, quantita` di RAM, quantità di memoria secondaria,.................) ci limitiamo al tempo 4 Misurare il tempo di esecuzione ......... Esempio: programma P per ordinare successione di interi Tempo di esecuzione di P su input i1, ...., in dipende almeno da: 1) n meglio 100 che 100 000 2) la successione i1, ...., in quasi ordinata molto "disordinata" ........ 3) algoritmo astratto (il metodo di ordinamento scelto), sia A 4) linguaggio di programmazione usato 5) il sistema su cui si esegue il programma hardware processore, bus, ... software sistema operativo, compilatore,... Il programmatore può agire su 3) un po' su 4) forse su 5) Rispetto a 4) e 5), il fattore 3) è “a monte” se A non va Quindi: NON si rimedia con linguaggio / macchina analisi di complessità solo il fattore 3), in funzione di 1) e 2) la prima analisi è cosi'; permette di concludere, ad es, che un metodo di ordinamento è migliore/peggiore di un altro. In situazioni critiche (es. sistemi real-time, dove tempo è parte della correttezza) si passa a analisi che tiene conto di 4) e 5); ad esempio, codificato l’algoritmo come programma, si valutano prestazioni reali tramite benchmark 5 NOI: solo 3) in funzione di 1) e 2) nell’esempio, invece di (*) tempo di esecuzione di P su input i1, ...., in considereremo (**) tempo di esecuzione di A su input i1, ...., in problema: tempo in (*) tempo in (**) può essere tempo fisico, no in (**) non si misura il tempo, ma qualcos’altro In effetti, (**) diviene: (***) numero di passi necessari per eseguire A su i1, ...., in Però ora bisogna capire cosa sono i passi !! facile a livello di linguaggio macchina (JUMP, LOAD...) ma noi ragioniamo a livello di pseudo-codice .... o si cerca di capire grosso modo ..... o si usano ordini di grandezza: proporzionale ad n2 invece di = 75 n2 - 33 n + 58 Base: istruzione semplice num costante di passi le costanti sono tutte uguali in ordini di grandezza 6 Funzioni complessità Es. ordinare una successione finita di numeri programma P algoritmo astratto A INPUT_per_A insieme degli input su cui A termina Per esprimere “tempo” (il costo) di esecuzione di A sull’input i1, ...., in espresso come “numero di passi di calcolo” possiamo usare funzione TiA : INPUT_per_A N funzione non facile da calcolare, anche in modo approssimato n numeri distinti ===> n! input diversi input diversi ===> costi diversi ( da n a n2 ) Inoltre interessa: quanto costa ordinare n numeri (qualunque) funzione: TA : N N t.c. TA (n) = costo di ordinare n numeri. Come definirla ? caso peggiore: TA (n) = max { TiA (i) | i successione di n numeri } caso migliore: TA (n) = min { ....... } caso medio: TA (n) = media { ....... } 7 Come ottenere TA se non si conosce Per caso peggiore TiA ? fissato n: si comincia a ragionare su generico input di dimensione n appena diventa necessario, si cerca input i di lunghezza n, che sia pessimo ....... Per caso migliore .................. input ottimo Tutto questo è facilitato dal fatto che non si cerca di calcolare esattamente TA , ma solo di valutarne l’ordine di grandezza. Nel seguito considereremo solo il caso peggiore Qui esempio di InsertionSort : vedere dispense 8 Ordini di grandezza: O, e Consideriamo funzioni da N in R (l’insieme dei reali) definite e non-negative da un certo punto in poi Nel seguito: es f, g, .... sono funzioni di questo tipo f (n) = 5 log 2 (n) - 10 f(0) indefinito f(1) negativo ...................... f(n) definito e non negativo per n ≥ 4 Def. O(g) = { f | esistono per ogni n ≥ n0 : t.c. es g t.c. ed n0 N, f(n) ≤ c g(n) } g(n) = 3 n2 + 10 n - 5 O(g) contiene f t.c. f(n) = 300 n2 h t.c. h(n) = 100000000 n + 500000 k t.c. k(n) = 5 n log 2 (n) + 10 n se f O(g) si dice che oppure che f O(g) c R, c > 0, f cresce al piú come g g è un limite superiore per f generalizza f ≤ g [ f(n) ≤ g(n), per ogni n ] 9 O(g) = { f | esistono t.c. c R, c > 0, per ogni n ≥ n0 : ed n0 N, f(n) ≤ c g(n) } c ed n0 dipendono da f possiamo sempre prendere c razionale, di solito c > 1 si poteva anche scrivere: c f(n) ≤ g(n) ( con c < 1 ) (g) = def { f | esistono c R, c > 0, ed n0 N, t.c. per ogni n ≥ n0 : g(n) ≤ c f(n) } (g) = def O(g) (g). se f O(g) si dice che oppure che f cresce al piú come g g è un limite superiore per f se f (g) si dice che oppure che f cresce almeno come g g è un limite inferiore per f f (g) f cresce come g f stesso ordine di grandezza di g si dice che oppure che 10 O(g) = { f | f O(g) cn0 t.c. n ≥ n0 : generalizza f ≤ g (g) = { f | cn0 t.c. f(n) ≤ c g(n) } [ f(n) ≤ g(n), per ogni n ] n ≥ n0 : g(n) ≤ c f(n) } f (g) generalizza f ≥ g [ f(n) ≥ g(n), per ogni n ] (g) = def O(g) (g) f (g) generalizza f = g Proprietà di base f O(f) e analogamente per e f X(g) e g X(h) ===> f X(h) transitività f O(g) sse g (f) f (g) sse g (f) sse (g) = { f | c, d > 0 ed n0 riflessività X = O, , (g) = (f) t.c. n ≥ n0 : c g(n) ≤ f(n) ≤ d g(n) } Collegamento con il concetto di limite....... 11 Notazione semplificata Invece di si scrive Es. invece di si scrive Logaritmi : f (g) per O, dove g è tale che f(n) (exp) g(n) = exp [ anche f(n) = (exp) ] TA (g), dove g è t.c. g(n) = 2n TA (n) (2n) TA(n) (log n) senza specificare base qui le costanti “non contano” logax = c logbx dove c = loga b supponiamo a, b, x t.c i log siano definiti 12 Esempi (usando la notazione semplificata). Supponiamo che i coefficienti, le basi dei logaritmi,.... siano tali da rispettare le proprietà di definitezza e non-negatività Inoltre: k, a, b, .... sono costanti k è intero pk(n) è un polinomio di grado k a coefficienti reali pk(n) = ak nk + ak-1 nk-1 + ... + a1 n + a0 (1) = (a) per ogni a strettamente positiva pk(n) O( nk ) pk(n) (nk) quindi: se ak > 0 pk(n) (nk) (log n)k O(n) per ogni k ≥ 0 log nk (log n) log(n5 an) se ak > 0 infatti log nk = k log n = log n5 + log an = 5 log n + n log a (n) 13 sempre: pk(n) = ak nk + ak-1 nk-1 + ... + a1 n + a0 pk(n) O(an) an O(bn) per ogni k ed ogni a > 1 e an (bn) se 1 < a < b f(n) = n2 quando n è pari n3 quando n è dispari allora f(n) O(n3) (n2) e non si può precisare meglio 14 O( ), ( ) e ( ) e funzioni complessità. A algoritmo TA : N N (es ricerca binaria in un array ordinato) TA(n) = a * parte_intera(log 2 n) + b Calcolare esattamente a e b non è facile ..... ci basta capire che a > 0 e quindi il logaritmo compare davvero. Se non conosciamo a e b inutile tirarsele dietro è anche seccante scrivere “parte_intera” Quindi, semplicemente: TA (g) , dove g è tale che oppure: g(n) = log2 (n) TA (n) ( log n ) Risultato semplice però info sufficientemente precisa su come cresce TA(n) al crescere di n. permette di distinguere ricerca binaria da algoritmo ovvio S di ricerca sequenziale: TS(n) = c n + d , c d costanti, c >0 cioe`: TS (n) ( n ) 15 Semplicità e precisione con O T (n) = 18 n3 log n+ 5 n2 Esempio Semplicita` : uso O( ), ( ) e ( ) per semplificare quindi: T(n) (27 n3 log n + 44 n2 -21 n + 33 log n ) NO anche se giusto T(n) ( n3 log n ) Precisione: SI uso O( ), ( ) e ( ) per avere info significative su come cresce la complessità al crescere della dimensione quindi T(n) (log n ) NO T(n) (n27 ) NO anche se giusto Però anche n1.88 (n2) n1.88 (n) SI SI Sempre per precisione cerchiamo stime in ( ) ripiegando su O( ) ed ( ) quando non ci riusciamo 16 Ancora sulla def di O( ) ( ) ( ) Consideriamo funzioni da N in R (l’insieme dei reali) definite e non-negative da un certo punto in poi c R, c > 0, O(g) = { f | esistono t.c. per ogni n ≥ n0 : ed n0 N, f(n) ≤ c g(n) } TA(n) = a * parte_intera(log 2 n) + b R e non N per eliminare “parte intera” e simili ruolo di n0 trascurare casi in cui funzione indefinita trascurare casi iniziali (input di piccole dimensioni) costante c permette di semplificare trascurando costanti moltiplicative termini di grado inferiore Analogo per ( ) e ( ) 17 Proprietà della somma e “del massimo” Sia f+g la funzione tale che f+g(n) = f(n)+g(n) max{f,g} la funzione tale che max{f,g}(n) = max{f(n), g(n)} allora fj O(gj) ====> f1+f2 O(g1 + g2) fj (gj) ====> f1+f2 (g1 + g2) f+g ( max{f,g} ) Quindi: 3 n log n + 1000 n ( n log n ) Classi notevoli di funzioni (1) funzioni costanti notare che (1) ≠ (0) O(n) funzioni al piu' lineari O(n) O( n2 ) O( n3 ) .... funzioni (al piú) polinomiali le altre sovrapolinomiali esponenziali o sbrigativamente 18 Significato delle stime con ordini di grandezza. 1 problema 3 algoritmi per risolverlo: A1 T1(n) (n) A2 T2(n) (n) A3 T3(n) (n). complessità-tempo (caso peggiore) • Per n piccolo ad es. poca differenza per n > n=3 se solo input "piccoli" analisi piú fini ( contare davvero il numero di passi • n testing ) Per n sempre piú grande comportamento asintotico delle funzioni T differenze sempre maggiori costanti e dei termini di ordine inferiore non contano Il primo algoritmo è migliore degli altri (nel caso peggiore; nel caso medio ......... ) 19 Supponiamo T1(n) = n T2(n) = n tempo per "un passo" T3(n) = n 1s = 10-6 sec T(n) input di dim = 10 input di dim = 60 A1 n = 10-5 secondi = 6 * 10-5 secondi A2 n = 0.1 secondi ≈ 13 minuti A3 n ≈ 10-3 secondi ≈ 366 secoli Per vedere che algoritmo astratto è fattore principale: con nuova macchina 1000 volte piú veloce da Inoltre 366 secoli ci sono algoritmi in a 36 anni ........... 22n o peggio 20 Dimensione dell’input ( o del problema ) Problema: trovare uno o piú parametri che caratterizzano la dimensione degli input di A. Per un vero programma: input <----> stringa dimensione <----> lunghezza Ma vvogliamo rimanere a livello di algoritmo " astratto " vicini al problema quindi: numero di elementi da ordinare invece di lunghezza della stringa che li codifica (dipende dalla base, dai separatori,....) Conseguenza: non abbiamo una definizione di dimensione dell’input per ogni problema dobbiamo capire .......... Per fortuna, di solito è abbastanza facile ....... negli esercizi verra` detto Nota: i parametri sono sempre in N. 21 Altro aspetto : individuazione dell’input ! Algoritmo A sotto forma di procedura o funzione: l’input corrisponde ai parametri attuali o ad alcuni di essi ma anche a delle variabili globali....... Algoritmo di merge sort ===> procedura MS con tre parametri: A di tipo array [1..n] of integer inf e sup di tipo integer per ordinare AAA si chiama MS(AAA, 1, n) l’input corrisponde ad AAA Algoritmo di insertion sort ===> procedura con un parametro A di tipo array ==> input come sopra senza parametri, riferendosi ad un array A globale l’input corrisponde ad A Nei casi dubbi ....... ignorare come viene presentato A e riferirsi direttamente al problema. 22 Regole empiriche per il calcolo di complessità Idea di base exp ed istruzioni “elementari” : abbiamo idea del costo exp e istruzioni non elementari : si possono tradurre in una successione di istruzioni elementari. Scriviamo: 1) tempo(....) per abbreviare “tempo necessario a valutare/ eseguire .......” Espressioni tempo(exp) = costante eccetto: exp con chiamate a funzioni exp con operazioni su blocchi di dati (es. aa+bb con aa, bb arrays) tempo(exp con chiamate a funzioni) = tempi(chiamate delle funzioni) tempo(exp con operazioni su blocchi di dati) = tempi(operazioni “semplici”) es:tempo(aa + bb) = (i = inf,..., sup) tempo(aa[i] + bb[i]) se aa, bb : array [inf .. sup] of ....... 23 2) Assegnazione, return, espressioni-istruzioni stile C tempo(x exp) = tempo(exp) tempo(aa[ exp’ ] exp) = tempo(exp) + tempo(exp’) tempo(rr.campo) exp) = tempo(exp) rr è un record ma: tempo(aa bb) = (i = inf,..., sup) tempo(aa[i] bb[i]) se aa, bb : array [inf .. sup] of ....... tempo( return(exp) ) = tempo (exp) tempo( exp; ) = tempo (exp) questo per lo stile C 3) Input/output Qui le cose si complicano. A voler essere precisi: tempo( scrivi(exp) ) tempo ( leggi (x) ) ??? perche` I/O = tempo(exp) + ??? = ??? genera chiamate al sistema operativo .... Per semplificarci la vita, nei conti useremo ??? = costante 24 4) Successione di istruzioni tempo( istr_1; .... ; istr_k) = tempo(istr_1) + .... + tempo(istr_k) poichè interessano gli ordini di grandezza si può semplificare prendendo max { tempo(istr_i) } 5) Istruzioni condizionali Se T = tempo( if cond then blocco_1 else blocco_2) Tc = tempo(cond) T_i = tempo(blocco_i) per i=1, 2 allora: Tc + min { T_1, T_2 } ≤ T ≤ Tc + max{ T_1, T_2 } Con la filosofia del caso peggiore: T = Tc + max{ T_1, T_2 }. Quindi: tempo( if cond then blocco_1) = Tc + T_1 25 6) Istruzione while tempo( while cond do blocco ) = Tc_last + (i = 1...max) ( Tc_i + Tb_i ) dove: max = numero di volte che si ripete il ciclo Tb_i = tempo del blocco alla i-ma iterazione Tc_i = tempo della condizione alla i-ma iterazione Tc_last = tempo della condizione l’ultima volta Problema stimare capire max Tb_i Tc_i al variare di i (*) spesso non si riesce a farlo con precisione si approssima usando ordini di grandezza. (*) i conta le iterazione, non necessariamente compare nel while Repeat: del tutto analogo a while. 26 Istruzioni “per”, “for”. Il modo più sicuro fino a quando non si è acquistata abbastanza pratica, è di tradurle usando un while, o un diagramma per k = e_inf, for k := È equivalente a: e_inf+1, ...., e_inf to e_sup : e_sup blocco do blocco sup e_sup k e_inf while k ≤ sup do { blocco ; k++ } il costo (k = inf ... sup) ( t_blocco_k) for( exp_1; exp_2; exp_3) <blocco> È equivalente a: exp_1 ; while exp_2 do stile C { <blocco> exp_3 ; } 27 Regole empiriche per Procedure non ricorsive Il costo da valutare è quello della chiamata dichiaraz: procedura P (parf_1, ..., parf_k) chiamata P (para_1, ...., para_k); tempo ( P (para_1, ....) ) = { blocco } tempo (passaggio dei parametri) + tempo( blocco ) tempo( blocco ) si calcola applicando le regole che stiamo descrivendo; se contiene delle chiamate a procedura/funzione, il loro costo si calcola a parte, separatamente,.... e poi si somma tutto. Poichè non c’è ricorsione, prima o poi si arriva ad eliminare tutte le chiamate tempo (passaggio dei parametri) parf_i parametro OUT o IN-OUT calcolare indirizzo di para_i e “passarlo alla proc”. tempo costante, ma attenzione a para_i = aa[ exp, exp’ ], con aa array comunque simile ad assegnazione. parf_i parametro IN come assegnazione parf_i para_i Funzioni non ricorsive analogo (all'interno di una espressione) Procedure / funzioni ricorsive : non vediamo 28 Altre istruzioni in analogia a quanto visto; ad esempio: switch/case: si traduce in una cascata di if then else, oppure si ragiona direttamente ...... si valuta questo, poi si confronta, poi ...... break: e` un JUMP, quindi costo costante new / malloc / calloc / free / dispose: come per input/output: per semplicita` a costo costante istruzioni ad alto livello tipo: “ordina l’array A in modo crescente” “test: le due stringhe sono uguali ?” capire come si traducono usando istruzioni standard fare i conti sulla traduzione. Dichiarazioni di solito si ignorano, per due motivi: costo costante, anche nel caso di array molto grande, a meno che l’implementazione non preveda una inizializzazione automatica in quasi tutti gli algoritmi sensati, il costo delle istruzioni è dominante. 29 Preprocessing, Compilazione, Linking,.... quindi anche #include #define i costi legati a queste fasi vengono ignorati perche` dipendono dal sistema e dall’ambiente di programmazione precedono l’esecuzione Ricordiamo che: nella realtà, si fanno i conti solo su algoritmi che, tradotti in programmi, verranno usati molto (si compila "una volta sola" .......) il conto di complessità “tempo” ha per scopo stimare ordine di grandezza del tempo di esecuzione del codice oggetto altro costo, molto rilevante che è ignorato: il costo di sviluppo Esercizi: fare i conti di complessità per gli algoritmi sulle dispense ignorare i limiti sul numero di elementi tipo n < MAX in altre parole, mettere MAX = valore molto grande ..... NOTAZIONE: nel seguito aa : array [ inf ... sup ] of ... inf, sup "variabili" 30 Conti “ad occhio” Approssimazioni successive Per evitare errori che nascono da conti oppure troppo superficiali troppo dettagliati fare un conto ad occhio, semplificando .... fare un conto più preciso, ma senza perdersi troppo in dettagli confrontare il risultato col precedente; se non quadra capire dove si è sbagliato (in 1 o in 2 ?) e correggere fare un conto ancora più preciso confrontare il risultato col precedente; se non quadra capire dove si è sbagliato e correggere eccetera ........ Anche: iniziare con stime grossolane che permettono solo di concludere in O(...) oppure in (...) raffinare poco alla volta fino a concludere, se possibile, con stima in (...). 31 Operazioni dominanti 1o esempio: vedere il file op_dom.pdf 2o esempio: visita BFS di un albero Riscriviamo, senza commentarlo, uno degli algoritmi visti (dispense, parte seconda) procedura bfs_2_ter ( tt : albero -- parametro IN) var aux : coda_di_alberi tx : albero; i : integer { aux empty( ) in_coda(tt, aux) while not is_empty(aux) do { tx testa(aux) resto(aux) visita ( root(tx) ) per i = 1,…..,n_figli_root(tx) : in_coda( sottoalb_root(tx, i) , aux ) } } Vogliamo calcolare la complessità di una generica chiamata bfs_2_ter ( tttt ) dove tttt è un generico albero con k nodi; vogliamo la complessità in funzione proprio di k. Per farlo dobbiamo conoscere la complessità delle operazioni sulla coda [empty(), testa,....] e la complessità della visita di un nodo [ procedura visita(...) ]. Le operazioni sulla coda, se implementate bene, sono tutte a costo costante (e supponiamo sia cosi'); per comodità, supponiamo a costo costante anche la visita di un nodo. Allora, nell'algoritmo di sopra, tutto è a costo costante (passaggio del parametro, valutazione espressioni, assegnazioni,....) tranne il while e l'istruzione "per". Se l'albero ha nodi con apertura limitata a priori, allora il "per" è a costo costante, altrimenti non è facile capire guardando il codice. Non è nemmeno facile capire, guardando il codice, quante volte si esegue il corpo del while. Però è abbastanza facile capire qual'è l'operazione dominante: devo trovare l'istruzione "semplice" che si esegue "più di tutte le altre"; quindi devo cercare l'istruzione "più interna" : questa è: in_coda( sottoalb_root(tx, i) , aux ) infatti è interna al "per" che è dentro il while. Questa istruzione ha un costo costante; il probleme è capire quante volte si esegue. 32 Questo è un esempio dove guardare il codice confonde solo le idee. La soluzione è ritornare all'idea intuitiva: si tratta di visitare, in ampiezza, l'albero, cioè: visitare ciascun nodo (una ed una sola volta) procedendo per livelli. Poichè i nodi sono in corrispondenza biunivoca con i sottoalberi, ogni sottoalbero entra in coda una ed una sola volta, dunque l'operazione si ripete k volte. Conclusione: la chiamata bfs_2_ter ( tttt ) ha complessità in ( k ) A lezione, avevo preso come dominante l'istruzione visita ( root(tx) ) Questo non è corretto, anche se poi in effetti funziona. Quanto segue non è stato visto a lezione, però può essere utile ........ 33 Operazioni dominanti def A algoritmo op, op1, op2 " operazioni " ===================== op è dominante in A o per A se TA (n) ( Num(n) * c_op) • n • Num(n) dimensione dell'input (anche una tupla) numero di volte che si esegue op (caso peggiore) per input di dimensione • c_op n è il costo di una singola op ====================== op1, op2 sono dominanti in A o per A se TA (n) ( Num1(n) * c_op1 + Num2(n) * c_op2 ) La def. si può estendere a k operazioni dominanti. ========================================= Uso per calcolare costo di A : • trovare operazioni dominanti • stimare il loro costo • stimare numero di volte che vengono eseguite in genere è facile basta l'ordine di grandezza 34 Ricerca di un elemento xx in un array aa operazione dominante confronto tra xx e aa[j] Algoritmi di ordinamento di array visti e che vediamo: operazioni dominanti: confronti tra elementi copiature di elementi Ordini di grandezza e buon senso Gli ordini di grandezza non devono farci trascurare le cose ovvie. quello che è inutile resta inutile quello che è stupido resta stupido, anche se “in ordini di grandezza non fa differenza”. 35