Appunti per il Modulo di Algoritmi e Struture Dati Guido Fiorino Ultima Versione: 22 marzo 2011 0 Presentazione 0.1 Programma del corso • Un esempio introduttivo • Cenni ai modelli di calcolo e alle metodologie di analisi • Complessità degli algoritmi di ordinamento • Complessità degli algoritmi di selezione e statistiche d’ordine • (polinomi e trasfomata di Fourier) • Tecnica divide et impera • Programmazione dinamica • Tecnica greedy • (Branch and Bound) • Grafi e loro visite • Minimo albero ricoprente • Cammini minimi • (Flusso) 0.2 Materiale Didattico Libro adottato: C. Demetrescu, I. Finocchi, G. Italiano, Algoritmi e strutture dati, McGraw-Hill, 2004. Libri consigliati: T.H. Cormen, C.E. Leiserson, R.L. Rivest, Introduzione agli algoritmi, Jackson Libri, 1999. R. Sedgewick, Algoritmi in C, Addison-Wesley Italia, 1993. Dispense: A. Bertoni, M. Goldwurm, Progetto e analisi di algoritmi, scaricabile dal sito http://homes.dsi.unimi.it/ goldwurm/algo/ 0.3 Esame Uno scritto di 1h30min. in cui si chiedono algoritmi e dimostrazioni di teoremi visti nel corso, problemi in cui si chiede di adattare quanto visto a lezione o dimostrazioni di teoremi non visti a lezione, esercizi che siano applicazione di quanto visto a lezione. 2 0.4 Altro • Ricevimento: contattarmi via email; • questi appunti: http://web-nuovo.dimequant.unimib.it/~guidofiorino/ 3 1 Un esempio introduttivo • nel corso gli algoritmi verranno principalmente scritti in pseudocodice. La codifica in un reale linguaggio di programmazione, C o JAVA, sarà quindi in subordine e servirà solo per concretezza. • Ci occupiamo di progettare algoritmi e di analizzarli matematicamente, cosa diversa dal condurre analisi sperimentali; • L’analisi è preferibile perchè ci da risposte su tutti i casi possibili, cioè permette di predire il comportamento di un algoritmo per ogni possibile dato di ingresso e permette di scegliere tra 2 algoritmi anche senza averli implementati. • Piuttosto che discutere astrattamente il significato delle affermazioni precedenti, per amore di concretezza consideriamo un problema molto semplice. 1.1 Numeri di Fibonacci • Come si espande una popolazione di conigli a partire da una singola coppia sotto le seguenti ipotesi semplificatrici: 1) una coppia di conigli genera una coppia di conigli all’anno; 2) i conigli nel primo anno non si riproducono; 3) i conigli sono immortali; • in base a quanto detto possiamo graficamente rappresentare il numero conigli con un albero; è chiaro che nell’anno t abbiamo tutte le coppie di conigli presenti nell’anno t − 1, infatti nessuno è morto; inoltre tutte le coppie presenti nell’anno t − 2 hanno figliato una coppia di conigli. Quindi se F (n) denota il numero di conigli nell’anno n dell’esperimento abbiamo che F (1) = 1, F (2) = 1, F (n) = F (n − 1) + F (n − 2) per n ≥ 3. Ne segue che F è una funzione definita per casi. E’ una funzione definita per ricorsione. Possiamo domandarci se esista una funzione analitica equivalente. • proviamo a vedere se F (n) ha un andamento esponenziale, cioè se per caso F (n) = an , con a ∈ R. • se F (n) ha andamento esponenziale allora sostituendo nella ricorrenza otteniamo an = an−1 + an−2 2 Portando tutto a primo membro, raccogliendo a fattor comune an−2 otteniamo che deve √ √ essere a − a − 1 = 0. Risolvendo l’equazione otteniamo due soluzioni per a : a1 = 1+2 5 e a2 = 1−2 5 . √ √ • Purtroppo anche se ( 1+2 5 )n e ( 1−2 5 )n hanno il medesimo andamento di F (n) nessuna di loro due è F (n) come si vede ad esempio per n = 2. • Dov’è il problema? Il problema sta nel fatto che le due funzioni risolvono la ricorrenza ma non rispettano il passo base di F (n). 4 • Siccome una qualunque combinazione lineare di funzioni che soddisfano la ricorrenza di fibonacci, soddisfa anch’essa la ricorrenza cerchiamo di ricavare una funzione √ !n √ !n 1− 5 1+ 5 + c2 G(n) = c1 2 2 combinazione lineare delle 2 trovate e che soddisfi anche il passo base, ovvero G(1) = 1, G(2) = 1. Impostiamo il sistema cosi’ da scoprire quanto devono valere c1 , c2 ( √ √ c1 ( 1+2√5 )1 + c2 ( 1−2√5 )1 = 1 c1 ( 1+2 5 )2 + c2 ( 1−2 5 )2 = 1 Questo è un sistema in 2 equazioni e 2 incognite che risolto dà 1 1 c1 = √ , c2 = − √ 5 5 Abbiamo quindi la nostra funzione G(n) = F (n), ovvero abbiamo scoperto l’andamento analitico di F (n): √ !n √ !n ! 1 1+ 5 1− 5 F (n) = √ − 2 2 5 Questa soluzione immediatamente suggerisce un algoritmo molto facile. Il difetto di questa soluzione è che lavora con i reali ma un calcolatore non può rappresentarli con una precisione illimitata. Questo produce errore nei calcoli e quindi un errore nel computo di F (n). Esercizio. provare per credere: scrivete un programma C per l’algoritmo, scegliete le variabili di tipo double o long double. 1.2 Algoritmo ricorsivo • Piuttosto che usare la versione analitica di F (n), usiamo la sua definizione ricorsiva e scriviamo un algoritmo ricorsivo per calcolare F (n) come quello in figura 1.4, pagina 6 (fibonacci2). • Ma quanto tempo ci vuole ad eseguire questo algoritmo in funzione del valore di ingresso? • Prima di tutto stabiliamo cosa è per noi il tempo. Scegliere una grandezza come i secondi non va bene in quanto con il cambiare della tecnologia il medesimo codice eseguito su una macchina nuova impiega di meno. • per noi il tempo impiegato sarà in prima approssimazine il numero di righe di codice eseguite dove assumeremo che ciascuna riga possa essere eseguita sempre con il medesimo tempo e che questo sia costante. 5 • Valutiamo il numero di righe T (n) in funzione di n: – se n = 1 o n = 2 allora T (n) = 1; – se n ≥ 3, allora T (n) = T (n − 1) + T (n − 2) + 2, ovvero il numero di righe eseguite è 2 più il numero di righe richiesto per eseguire la chiamata ricorsiva con parametro n − 1 più il numero di righe richiesto per la chiamata ricorsiva con parametro n − 2. • Si osservi come la ricorrenza assomigli fortemente a quella della funzione F (n) di fibonacci. • Valutiamo T (n): sicuramente vale che T (n) > T (n−2)+T (n−2) = 2T (n−2). Srotolando la ricorsione otteniamo che T (n) > 2T (n − 2) > 22 T (n − 2 ∗ 2) > 23 T (n − 2 ∗ 3) > ... > 2k T (n − 2 ∗ k) > ... fino ad ottenere T(2) se n pari, T (n) > 2T (n − 2) > 22 T (n − 2 ∗ 2) > 23 T (n − 2 ∗ 3) > ... > 2k T (n − 2 ∗ k) > ... fino ad ottenere T(1) se n dispari. Ora, se n pari, quante iterazioni sono necessarie per raggiungere T(2)? Basta porre n − 2 ∗ k = 2 ed otteniamo k = n−2 2 , cioè k è il numero di iterazioni in funzione n−2 di n per arrivare al passo base. Sostituendo k otteniamo che T (n) > 2 2 . Questo ci dice che il numero di righe eseguito è (almeno) esponenziale in funzione di n, con n pari. Per n dispari otteniamo n−1 T (n) > 2 2 . • Possiamo anche limitare superiormente T (n): T (n) < 2T (n − 1) + 2 < 22 T (n − 2) + 2 ∗ 2 < 23 T (n − 3) + 2 ∗ 3 < ... < 2k T (n − k) + 2 ∗ k < ... Questa catena termina quando n − k = 2, ovvero dopo k = n − 2 iterazioni, oppure quando n − k = 1. Nel primo caso, sostituendo n − 2 al posto di k otteniamo T (n) < 2n−2 + 2 ∗ (n − 2) possiamo concludere che il numero di istruzioni è esponenziale rispetto a n. • Il problema dell’algoritmo fibonacci2 è che ricalcola la soluzione al medesimo problema più volte. Questo lo si vede facilmente analizzando l’albero delle chiamate ricorsive: per calcolare F (8) si ricalcola più volte il valore F (4). Albero delle chiamate ricorsive di F(8), pagina 7, Fig 1.5 6 1.3 algoritmo iterativo • L’algoritmo fibonacci3, figura 1.6 pagina 9 riutilizza le risposte a sottoproblemi già risolti senza ricalcolare la risposta e questo fa risparmiare tempo. L’idea è di mantenere una lista i valori F (1), F (2), . . . e di accedervi quando serve. • calcoliamo il numero di righe di codice eseguite in funzione del valore n: le righe 1,2,5 vengono sempre eseguite; • la riga 3 viene eseguita: – 1 volta se n = 1, 2, – n − 1 volte negli altri casi; • il passo 4 viene eseguito: – 0 volte nei casi n = 1, 2, – n − 2 volte altrimenti. • riassumendo: la linea 3 viene eseguita: – n-1 volte per n ≥ 2, – 1 volta se n = 1; la linea 4 viene eseguita: – n − 2 volte se n ≥ 2, – 0 altrimenti. Il numero di linee di codice eseguite, ovvero il tempo di esecuzione in funzione di n è: 4 se n ≤ 1; T (n) = 3 + (n − 2) + (n − 1) = 2n se n ≥ 2 • anche l’occupazione di memoria è un fattore rilevante. L’algoritmo richiede spazio proporzionale a n. • l’algoritmo può essere modificato in fibonacci4 (figura 1.8, pagina 12) per utilizzare spazio costante. Il prezzo che si paga è una maggiore lentezza. 1.4 Notazione asintotica • L’analisi fatta sinora soffre del fatto che contiamo le linee di codice, e quindi il medesimo programma scritto su linee di codice differenti dà valori differenti pur avendo la medesima velocità; • aumentare la velocità di un computer dà tempi differenti, ma l’analisi non cambia dato che sempre lo stesso numero di righe di codice è eseguito. 7 • si può astrarre da questi dettagli mediante la notazione asintotica cioè trascurando le costanti moltiplicative delle funzioni e vedendo come “viaggia” la complessità per n → ∞; • date due funzioni f (n) g(n) da N a N, diremo che f (n) è “O di g(n)” e scriveremo f (n) ∈ O(g(n)) o con abuso di notazione anche f (n) = O(g(n)) se esistono n0 e c > 0 tali che f (n) <= cg(n) per n ≥ n0 , cioè f (n) da un certo punto in poi si comporta come g(n) a meno di una costante. 1.5 Un algoritmo basato su potenze ricorsive 1 1 • Sia A = . Allora 1 0 Lemma 1.1 An−1 = F (n) F (n − 1) , n ≥ 2. F (n − 1) F (n − 2) Cosı̀ possiamo definire l’algoritmo iterativo fibonacci5 (figura 1.10, pagina 14) basato sulla moltiplicazione di matrici. • si vede immediatamente che il tempo di esecuzione è O(n), quindi uguale a finonacci3 e fibonacci4, ma qui la notazione asintotica nasconde le costanti. • fibonacci5 usa spazio di memoria costante. • fibonacci5 può essere ulteriormente migliorato facendo la moltiplicazione di matrici mediante quadrati successivi, basandosi sul fatto che An = An/2 ∗ An/2 , se n pari. • otteniamo cosı̀ fibonacci6 (figura 1.11, pagina 15) il cui tempo di esecuzione è in pratica il tempo speso per la chiamata alla funzione. • Studiamo il tempo impiegato da potenzamatrice in funzione di n: se n = 1 il tempo è una costante, T (1) = K ∈ N. Se n > 1, allora T (n) = T ( n2 ) + K1 . • svolgendo i conti abbiamo che T ( n2 ) = T ( 2n2 ) + K1 , che sostituito in T (n) dà T (n) = T ( 2n2 ) + 2K1 . Procedendo cosı̀ alla i-esima sostituzione abbiamo che T (n) = T ( 2ni ) + iK1 . Ora, 2ni = 1, quando i = lg n, quindi dopo i sostituzioni abbiamo T (n) = T (1) + (lg n)K1 ∈ O(lg n). • il tempo di esecuzione della chiamata ricorsiva per fare la potenza n-esima è logaritmico nel valore di n. 1.6 Il metodo dei quadrati ripetuti L’algoritmo basato su potenze ricorsive usa un metodo noto come quadrati ripetuti. Vale la pena di evidenziarla dato che propone un modo veloce di elevare a potenza un numero o una matrice. b Dovendo fare ab , piuttosto che iterare a ∗ a ∗ a ∗ · · · ∗ a si calcola il valore di c = a 2 e quindi il risultato è 8 ab = c ∗ c oppure ab = c ∗ c ∗ a b b a seconda che b sia pari o dispari. Ovviamente, per calcolare c = a 2 possiamo calcolare d = a 4 e quindi c = d ∗ d oppure c = d ∗ d ∗ a a seconda che 2b sia pari o dispari, idem per d. Ovviamente la base di questo procedimento è che elevare a zero un numero dà come risultato 1. Questo modo di procedere è in stile divide et impera, tipico ad esempio di MergeSort. Oltre all’ovvia versione ricorsiva ne possiamo progettare una iterativa basata sul seguente ragionamento fatto al contrario cioè partendo da zero piuttosto che da b. Consideriamo di avere calcolato la sequenza a0 , a1 , a2 , a4 , a8 , . . . , ax (1) Domanda: quanto deve essere lunga la sequenza e come mettere assieme i valori della sequenza per ottenere ab ? La risposta sta nella rappresentazione di b in base 2. Sappiamo che ogni numero della base 10 può essere espresso in base 2 cioè tramite una sequenza di bit 1bn−1 . . . b0 che inizia per 1 e tali che b = b0 ∗ 20 + b1 ∗ 21 + b2 ∗ 22 · · · + 1 ∗ 2n Cosı̀ 0 +b ab = ab0∗2 1 2 n 1 ∗2 +b2 ∗2 ···+1∗2 0 1 2 n = ab0∗2 ∗ ab1∗2 ∗ ab2∗2 ∗ · · · ∗ a1∗2 i Nota che se un certo bit bi vale zero, allora il fattore vale 1, altrimenti il fattore vale a2 . L’espressione appena data ci dice quali elementi della sequenza (1) devono essere moltiplicati tra di loro e quanto la sequenza deve essere lunga. Quindi l’algoritmo si basa sull’espansione binaria dell’esponente. In Figura 1 è dato lo pseudolinguaggio. Algoritmo potenza(intero base,intero esp)->intero ris=1 pow=base; while (esp > 0) do { if (esp % 2 == 1) then ris=ris*pow pow=pow*pow esp=esp/2 } return ris Figura 1: potenza basata su quadrati ripetuti 9 Il tempo di esecuzione di questo algoritmo è ovviamente analogo a quello in versione ricorsiva, basta osservare che la variabile esp viene divisa per due ad ogni iterazione, analogamente a quanto avviene per l’algoritmo precedente. Esercizio Scrivere l’algoritmo per fare la potenza di una matrice. 1.7 L’algortimo di Euclide per il MCD Si tratta di trovare il più grande divisore d tra due numeri a, b ∈ N. Supponiamo a ≥ b. L’algoritmo di Euclide si basa sulle seguenti osservazioni. Qualsiasi numero d divida sia a che b, in simboli d|a e d|b, deve anche dividere a − b. Infatti, se d|a se d|b allora allora a = q ∗ d, con q ∈ N b = q 0 ∗ d, con q 0 ∈ N cosı̀ a − b = q ∗ d − q 0 ∗ d = (q − q 0 ) ∗ d, quindi d divide a − b. Vale anche che se d|a − b e d|b allora d|a. Infatti se d|a − b se d|b allora allora a − b = q ∗ d, con q ∈ N b = q 0 ∗ d, con q 0 ∈ N cosı̀ (a − b) + b = q ∗ d + q 0 ∗ d = d ∗ (q + q 0 ), quindi d divide a. In base a quanto provato sopra se a ≥ b se a − b ≥ b se a − 2b ≥ b ... mcd(a,b)=mcd(a-b,b) mcd(a-b,b)=mcd(a-2b,b) mcd(a-2b,b)=mcd(a-3b,b) ... Per quanto possiamo andare avanti cosı̀? Per k volte, dove k è tale che 0 ≤ a − kb < b Quindi nell’espressione (2) k altro non è che il quoziente della divisione Abbiamo che mcd(a, b) = mcd(a − kb, b) (2) a b e a − kb è il resto di tale divisione. Adesso basta continuare con lo stesso metodo, cioè dividere b per a − kb e prendere il resto. Si va avanti fin quando non si ottiene resto zero. Infatti per ogni n > 0 vale mcd(n, 0) = n. La Figura 2 presenta l’algoritmo: Circa il fatto che prima o poi si trovi un resto pari a zero non ci sono dubbi. Si osservi infatti che la sequenza dei resti ottenuti dalle divisioni è strettamente decrescente (il resto è sempre strettamente inferiore del divisore). Il tempo dell’algortimo è strettamente dipendente dal numero di volte che vengono ripetute le istruzioni nel ciclo. Facciamo vedere che nella sequenza dei resti r1 , r2 , . . . , ru che vengono calcolati 10 Algoritmo Euclide(intero a,intero b)->intero do r=a%b a=b; b=r while (r <> 0) return a Figura 2: Algoritmo di Euclide durante l’iterazione vale che ri+2 < ri /2 cioè è garantito un dimezzamento ogni due resti. La dimostrazione ha due casi: (i) ri+1 ≤ ri /2, allora dato che ri+2 < ri+1 l’affermazione segue banalmente. (ii) se non vale il punto (i) allora necessariamente ri+1 è compreso nell’intervallo [ r2i + 1, ri − 1]. In base a come procede l’algoritmo, la quantità ri+2 è il resto della divisione tra ri e ri+1 . Il quoziente della divisione è 1, il resto, cioè ri+2 è ri − 1 ∗ ri+1 . Dato che il range di ri+1 è [ r2i + 1, ri − 1] segue che il range di ri+2 è [1, r2i − 1], in ogni caso strettamente inferiore alla metà di ri . In base al risultato appena ottenuto il numero di iterazioni è ovviamente governato da una legge già incontrata, infatti: r0 b r2 < 2 = 2 r2 b r4 < 2 = 22 r4 b r6 < 2 = 23 r6 b r8 < 2 = 24 ... ... ... b ru < u 22 Per avere il valore di u per cui ru = 0, basta sapere per quale u vale che b u = 1, che risolta dà 2 lg b = u 22 Quindi il numero di iterazioni è proporzionale al logaritmo in base 2 del valore di b. Infine vale la pena osservare che ogni qual volta vale il punto (ii) i resti soddisfano la definizione dei numeri di Fibonacci, infatti ri+2 = ri − ri+1 implica ri+2 + ri+1 = ri Quindi se a e b sono due numeri di Fibonacci consecutivi la sequenza di resti ottenuta sempre soddisfa il punto (ii). Quindi il numero di iterazioni dell’algoritmo è quello massimo. Esercizio Un algoritmo alternativo per il mcd(a, b) è quello che prevede, partendo da b di provare tutti i possibili divisori nel range [b, 1]. Implementate questo algoritmo e quello di Euclide ed eseguiteli su numeri di Fibonacci abbastanza grandi e vedrete la differenza nei tempi! 11 Per ora abbiamo considerato come tempo semplicemente il numero di righe che vengono eseguite. In pratica è come affermare che i comandi contenuti negli algoritmi sono eseguiti sempre in tempo costante, indipendentemente dal valore assunto dalle variabili. Questo però potrebbe anche essere un’assunzione troppo forte. Pensiamo alla nostra esperienza: è vero che per calcolare 99 × 99 ci mettiamo tanto quanto per calcolare 9999 × 9999? E ancora, è vero che per calcolare 99 + 99 ci mettiamo tanto quanto 99999 + 99999? La risposta è no, per sommare e moltiplicare ci vuole tanto più tempo quanto più grandi, cioè più lunghi, sono i numeri su cui lavoriamo. Esercizio Assumendo che per sommare due numeri da una cifra ci mettiamo sempre lo stesso tempo (cioè il tempo è costante), quanto tempo occorre per sommare i due numeri an an−1 . . . a0 bn bn−1 . . . b0 con il noto algoritmo della somma? Esercizio Assumendo che per moltiplicare due numeri da una cifra ci mettiamo sempre lo stesso tempo, quanto tempo occorre per moltiplicare i due numeri an an−1 . . . a0 bn bn−1 . . . b0 con il noto algoritmo della moltiplicazione? Nel prossimo paragrafo affrontiamo i problemi che abbiamo aperto in questa introduzione e che come vedremo derivano dal fatto di non aver ancora ben formalizzato matematicamente le varie nozioni per ora introdotte. 12 2 2.1 Modelli di Calcolo e Metodologie di Analisi Un quadro generale Analizzare gli algoritmi significa stabilire come le loro richieste di risorse computazionali aumenta all’aumentare della dimensione dei dati di input. La pratica insegna che le buone performances dei programmi dipendono anche dalla scelta delle strutture dati. Può infatti accadere che a fronte dello stesso metodo, differenti scelte di strutture dati portino a differenti tempi di esecuzione. Per quanto riguarda i problemi, spesso accade che essi abbiano natura discreta, cioè coinvolgano la ricerca di una/la soluzione all’interno di un insieme finito di prossibilità combinatoriali (spazio di ricerca). Ovviamente dato un problema il primo interesse è quello di trovare un algoritmo. Ma immediatamente dopo nasce quello della efficienza computazionale, in particolare efficienza rispetto al tempo di esecuzione, che informalmente possiamo associare a “essere veloci”. Anche l’uso della memoria (spazio computazionale) è un altro aspetto dell’efficienza. Fissiamo le idee sull’efficienza rispetto al tempo di esecuzione. Una definizione concreta di efficienza non può ridursi all’affermazione: “ un algoritmo è efficiente se la sua implementazione và velocemente su dati di input reali”. Tale definizione è troppo vaga ed imprecisa. Può accadere che pessimi algoritmi possano essere veloci quando eseguiti su piccoli casi test e/o su processori potenti. D’altronde buoni metodi risolutivi potrebbero sembrare pessimi a causa di una cattiva implementazione (es. scelta sbagliata delle strutture dati). Inoltre, cos’è un input reale? Infine, la definizione non tiene conto della scalabilità di un algoritmo, cioè di come le performance di un algoritmo si comportano all’aumentare della dimensione dei dati di input. Può accadere che due algoritmi diversi possano avere la medesima velocità quando eseguiti su input di una certa dimensione, ma essere l’uno molto più lento dell’altro quando gli input hanno dimensione 10 volte superiore. Abbiamo quindi bisogno di una nozione di efficienza indipendente dalla piattaforma, indipendente dall’istanza e che permetta di descrivere come varia la richiesta di risorse computazionali al variare della quantità (dimensione, lunghezza) dei dati da elaborare. In pratica, abbiamo bisogno di una visione matematica. La “dimensione dei dati da elaborare” (dimensione dell’istanza di input) è per definizione il numero di simboli di cui sono composti i dati da elaborare. Il consumo di risorse (tempo, spazio, ...) da parte di un algoritmo oggetto di analisi sarà espresso matematicamente mediante una funzione, chiamiamola T , nella dimensione dei dati di input, quindi T ha come dominio i numeri naturali (N). Consideriamo x ∈ N. E’ ovvio che il numero di possibili input diversi di lunghezza x è un numero finito (esempio, se gli input possono essere inseriti usando solo le 26 lettere minuscole dell’alfabeto inglese, allora i possibili input di dimensione 4 sono 264 ). La funzione T associa ad ogni possibile valore di x un valore che rappresenta il consumo di tempo. Dato che fissato x sono molte le istanze di dimensione x, come definiamo T ? 13 Un modo è analizzare l’algoritmo oggetto di studio secondo il caso peggiore (worst case analysis): per ogni possibile valore di x si considerano tutte le istanze di dimensione x ed il tempo associato all’algoritmo, cioè il valore di T (x) è il tempo più alto impiegato. Quindi, l’obiettivo è quello di determinare come varia la funzione T al variare di x. Sebbene sembri troppo penalizzante, catturare l’efficienza pratica di un algoritmo basandosi sul caso peggiore fornisce un limite superiore alle risorse necessarie ad eseguire l’algoritmo qualunque sia l’input di una data dimensione. Sorge adesso la questione: quale andamento deve avere T perchè l’algoritmo oggetto di studio sia considerato efficiente? L’esperienza mostra che tipici problemi di interesse pratico hanno una natura combinatoriale e lo spazio in cui cercare la/una soluzione cresce esponenzialmente rispetto ai dati di input. Buona parte dei problemi possiede un ovvio algoritmo che prevede di analizzare per enumerazione l’intero spazio delle soluzioni. Tale algoritmo però ha tempo esponenziale rispetto alla dimensione dei dati di ingresso. Come esempio si pensi all’ordinamento di n dati: ci sono n! modi di organizzare i dati, ma solo uno ne costituisce l’ordinamento crescente. D’altrone sappiamo che esistono algoritmi che permettono di ordinare facendo n2 o anche solo n log n confronti e scambi. Tra l’altro questi risultati possono essere determinati senza bisogno di implementrare gli algoritmi, ma solo mediante ragionamenti matematici sul metodo. L’analisi di un algoritmo indica anche come possa concretamente essere implementato. Siamo quindi al punto che molti problemi hanno ovvi algoritmi basati sull’analisi dello spazio di ricerca, il quale cresce esponenzialmente nella dimensione dei dati. Tali algoritmi sono in pratica inutilizzabili. Quindi il primo punto è che la definizione di efficienza deve implicare tempi decisamente inferiori rispetto a quelli necessari agli algoritmi a forza bruta. Dato che crescita esponenziale significa che ad un aumento unitario della dimensione dei dati di input corrisponde un aumento moltiplicativo del tempo di esecuzione, un comportamento desiderabile è che all’aumento di un fattore costante k della dimensione dei dati di input corrisponda un aumento delle risorse richiste pari ad un fattore k 0 . Questo è tipico di un andamento polinomiale: T (x) = xd , d ∈ N fissato T (x0 ) = xd0 T (2x0 ) = (2x0 )d = 2d xd0 , dove 2d è il fattore. Si considera efficiente un consumo di risorse con andamento polinomiale (rispetto alla dimensione dei dati, d’ora in poi lo considero implicito). Se per un attimo focalizziamo la nostra attenzione sul tempo di calcolo, si osserva una imperfezione nella nostra definizione: parliamo di tempo di calcolo, ma abbiamo già detto che non samo necessariamente interessati ad una implementazione, ma ad uno studio matematico. Questo significa che tempo (tempo di esecuzione) deve essere definito meglio. Qui non intendiamo il numero di secondi, ma piuttosto il numero di passi di esecuzione elemetari cioè comandi che siano assimilabili a quelli in grado di svolgere una CPU reale: assegnamenti, confronti, incrementi e decrementi sono operazioni elementari. Sotto particolari condizioni che specificheremo meglio tra poco anche le operazioni aritmetiche sono elementari. In verità purchè si descriva l’algoritmo con uno pseudolinguaggio non troppo sofisticato, passo di esecuzione può essere assimilato all’esecuzione di una riga dell’algoritmo. Perchè possiamo evitare di essere molto precisi circa il concetto di passo di esecuzione elementare? La risposta risiede nel fatto che nella sostanza l’andamento della funzione T non viene molto 14 influenzato dalle diverse nozioni, il suo comportamento asintotico non cambia: se rispetto ad un fissato pseudolinguaggio il consumo di tempo ha andamento polinomiale esso lo avrà anche con un differente pseudolinguaggio, purchè ogni istruzione del primo sia traducibile con un numero polinomiale di istruzioni del secondo (la moltiplicazione di polinomi è un polinomio!). Anche per questa ragione le funzioni che descrivono i tempi di esecuzione non sono definite in maniera esatta, ma piuttosto si descrive l’andamento della funzione dando il suo ordine di crescita per mezzo della notazione O. Non ha molta utilità contare esattamente il numero di passi di un algoritmo, infatti le funzioni x2 e x2 + 2x al crescere di x si comportano alla stessa maniera e quindi due algoritmi diversi i cui tempi di esecuzione siano descritti dalle due funzioni date sono considerati avere la stessa velocità. La morale è quindi che nel descrivere il consumo di risorse le costanti ed i fattori moltiplicativi non interessano. In breve: Per poter studiare gli algoritmi abbiamo bisogno di un modello di calcolo. Quello che si usa di solito è la macchina a registri, dove oltre ad un dispositivo di input ed uno di output si ha a disposizione un numero arbitrario di registri ciascuno dei quali può contenere numeri interi o reali di grandezza arbitaria. Queste sono assunzioni non realistiche ma semplificano l’analisi. Infatti non è vero che ciascuna singola operazione abbia il medesimo tempo e sia indipendente dalla grandezza dei dati. Quando si adotta tale criterio si parla di misura di costo uniforme. Il criterio di costo logaritmico tiene conto della dimensione dei dati ed il tempo di ogni singola operazione è misurato rispetto alla dimensione dei dati coinvolti. Siccome ogni intero n ∈ N è rappresentabile in base b con blgb bc + 1 cifre, si parla di costo logaritmico. Ad esempio, dato n quanto costa calcolare 2n , con il seguente algoritmo? x<-2 for i=1 to n do x<-x*2 Secondo il criterio di costo uniforme tempo O(n) in quanto la moltiplicazione costa 1 ed il for è iterato n volte. Ma per il costo logaritmico l’analisi è diversa: all’iterazione i-esima x vale 2i . Il tempo speso per moltiplicare x per 2 è lg 2i dato che la moltiplicazione per due è uno shift verso sx, mentre l’incremento di i costa lg i. Quindi il tempo è n X i + lg i, i=1 ovvero compreso tra n(n+1) 2 e n(n + 1), cioè Θ(n2 ). Esercizio Valutare l’algortimo di Figura 1 secondo il criterio di costo logaritmico. 15 2.2 La notazione asintotica • La notazione asintotica consente di semplificare l’analisi nel senso che possiamo trascurare le costanti ed i termini di ordine inferiore. Considereremo funzioni da N in R+ . Data una funzione f (n) definiamo O(f (n)) = {g(n)|∃c > 0, ∃n0 >= 0 tale che g(n) ≤ c · f (n), ∀n ≥ n0 } Ω(f (n)) = {g(n)|∃c > 0, ∃n0 >= 0 tale che g(n) ≥ c · f (n), ∀n ≥ n0 } Θ(f (n)) = {g(n)|∃c1 > 0, ∃c2 > 0, ∃n0 >= 0 tale che c1 · f (n) ≤ g(n) ≤ c2 · f (n), ∀n ≥ n0 } • Il fatto che g ∈ O(f ) ci dice che g cresce al max come f e da questa è dominata (modulo la costante moltiplicativa); • il fatto che g ∈ Ω(f ) ci dice che g cresce almeno come f e da questa ne è limitata inferiormente (modulo la costante moltiplicativa); 2.3 Metodi di analisi • Un algoritmo è un metodo che forniti dei dati di ingresso produce dei risultati in uscita. Per produrre tali risultati è necessario impiegare delle risorse. • Le risorse più importanti sono il tempo di esecuzione e la memoria necessaria per svolgere i calcoli. • Analizzare un algoritmo che risolve un certo problema significa determinare in funzione di ogni possibile dato di ingresso il numero di passi che compie l’algoritmo o la quantità di memoria usata dall’algoritmo per produrre l’output. • Si tratta quindi di scoprire delle funzioni dall’insieme dei dati di ingresso ai naturali. • Tali funzioni possono essere semplici ed intuitive quando l’insieme dei possibili dati di ingresso è l’insieme dei naturali come nel caso degli algoritmi che risolvono il problema di fibonacci, ma possono essere complicate quando l’insieme dei possibili dati sono sequenze di interi, come nel caso degli algoritmi di ordinamento. • Funzioni di questo tipo sarebbero di difficile interpretazione circa l’uso di risorse computazionali da parte degli algoritmi. • Quello che si preferisce fare è definire delle funzioni di complessità che esprimano l’uso di risorse in funzione della quantità di informazione fornita in input. 2.4 Quantità di Informazione di una Istanza • Per valutare la quantità di informazione fornita in input di definisce prima di tutto la nozione di dimensione di una istanza, la quale a seconda del problema in esame potrà essere il numero di cifre di cui è costituito un intero, il numero di componenti di un vettore. 16 • Nella sua accezione più stringente per dimensione di una istanza dobbiamo intendere il numero di simboli che occorrono per scrivere i dati di input. Quindi, a questo punto valuteremo i tempi di esecuzione di un algoritmo come funzione nella dimensione delle istanze (cioè da interi) a interi. • Ora qui sorge un problema che può essere visto anche analizzando l’esempio di fibonacci: istanze diverse, che danno luogo a tempi di esecuzione diversi hanno la medesima dimensione di istanza, si prenda ad esempio l’istanza n = 120 e n = 999 entrambe di dimensione tre. Come possiamo definire il tempo o lo spazio di calcolo? 17 3 Valutiamo la complessità di alcuni algoritmi visti in precedenza in base alla dimesione dell’istanza Abbiamo visto che l’algoritmo fibonacci2 ha un tempo di esecuzione tale che T (n) > 2 l’argomento per cui si vuole calcolare il valore F (n). n−1 2 , dove n ∈ N è La lunghezza l di n è l = log n, da cui n = 10l , se usiamo la base 10 per scrivere i dati di input. Esprimento T in funzione di l e non di n otteniamo che T (l) > 2 10l −1 2 Questa analisi già ci dice che l’algoritmo ha un tempo super-esponenziale nella dimensione dell’istanza di input. L’unico piccolo difetto che possiamo trovare è di tipo formale: la variabile l è reale e non intera, quindi la funzione T è funzione reale di variabile reale. Per esprimere T come funzione intera di variabile intera dobbiamo arrotondare all’intero superiore log n. A questo punto sono numerosi gli input n che coincidono con dlogne, ciascuno dei quali ha un tempo di esecuzione diverso. Procediamo facendo l’analisi di caso peggiore per il tempo di esecuzione: Tw (l) = max{T (n)|10l−1 ≤ n ≤ 10l − 1} = T (10l − 1) > 2 10l −1 2 Analogamente possiamo procedere nell’analisi dell’algoritmo iterativo fibonacci3, la cui complessità in tempo avevamo stabilito essere T (n) = 2n, dove n è sempre il valore dell’argomento di F . Dal momento che vale la relazione l = log n, in prima approssimazione segue che T (l) = 2 ∗ 10l Se vogliamo esprimere il consumo di risorse mediante una funzione intera di variabile intera allora seguendo il procedimento fatto sopra possiamo condurre l’analisi di caso peggiore e ottenere Tw (l) = max{T (n)|10l−1 ≤ n ≤ 10l − 1} = T (10l − 1) = 2 ∗ (10l − 1) cioè esponenziale in l. Infine l’algoritmo fibonacci5, basato su potenze ricorsive ha una funzione di complessità di tipo logaritmico: T (n) = lg n. A questo punto segue che esprimendo T in funzione della lunghezza l di n abbiamo T (l) = lg 10l = k ∗ l, k ∈ N Quindi il consumo di risorse da parte dell’ultimo algoritmo è di tipo lineare nella lunghezza dell’input. 18 • Analizziamo il problema della ricerca di un elemento in una lista. 3.1 Ricerca Sequenziale • Prendiamo l’algoritmo di ricerca sequenziale (figura 2.2, pagina 30) e valutiamo il numero di confronti che è l’operazione più frequente. Algoritmo RicercaSequenziale(vettore di interi L, intero x)-> {0,1} 1. sia dim il nro di elementi di L; 2. for i=1 to dim do 3. if (L[i] == x) return 1; 4. return 0; Figura 3: Algoritmo di Ricerca Sequenziale • Vogliamo predire il tempo di esecuzione in funzione della quantità di dati presente nella lista, piuttosto che in funzione dell’istanza, cioè dei dati che compaiono nella lista e dell’elemento x da ricercare. • Denotiamo con tempo la funzione che associa ad ogni possibile istanza I il tempo di esecuzione dell’algoritmo su I. Ci sono 3 tipi di analisi: – caso peggiore: fissata la dimensione dell’istanza, quante operazioni al massimo compiamo? Tworst (n) = max(tempo(I)) |I|=n – caso migliore: fissata la dimensione dell’istanza, quante operazioni compiamo nel caso più favorevole? Tbest (n) = min (tempo(I)) |I|=n – caso medio: Tavg (n) = X P rob(I) · tempo(I) |I|=n 3.2 ANALISI DI CASO MEDIO • Facile l’analisi di caso peggiore e migliore, facciamo l’analisi di caso medio: assumiamo che l’elemento x possa trovarsi in una qualsiasi posizione con la medesima probabilità, quindi P rob(pos(x) = i) = 19 1 n Quindi Tavg (n) = n X P rob(pos(x) = i) ∗ i = i=1 n X n+1 1 ∗i= n 2 i=1 se x ∈ L. Se x 6∈ L allora il numero di confronti atteso è n. • Possiamo compiere un’altra analisi di caso medio, assumendo come una distribuzione delle istanze tale che ogni permutazione sia equiprobabile. • Ci sono n! possibili permutazioni di n oggetti. Fissata la posizione i di x ci sono (n − 1)! possibili permutazioni aventi x nel posto i, quindi possiamo scrivere: Pn! 1 Tavg (n) = π=1 n! ∗ (n.ro confronti sulla permutazione π) dove Pn! π=1 è da leggersi come somma su tutte le possibili permutazioni; • per ciascuna delle permutazioni il nro di confronti è una quantità compresa tra 1 e n; riscriviamo la sommatoria raccogliendo i termini rispetto al nro di confronti: Pn (n−1)! Tavg (n) = ∗i i=1 n! = 1 n Pn i=1 i = n+1 2 Nota: il risultato è il tempo atteso sotto l’ipotesi che x appartenga a L. 3.3 Ricerca Binaria Prendiamo lo pseudocodice per la ricerca binaria in Figura 2.4, pagina 34. Algoritmo ricercaBinaria(vettore di interi L, intero x)->{0,1} 1. a=1 2. b=lunghezza di L 3. while (L[(a+b)/2]<>x) do 4. m=(a+b)/2 5. if (L[i]>x) then b=m-1 6. else a=m+1 7. if (a>b) then return 0 8. return 1 Figura 4: Algoritmo di ricerca binaria Consideriamo un array di estremi [a, b], dove b − a + 1 = n. 20 • Caso migliore: x è in posizione a+b 2 ; • Caso peggiore: x viene trovato quando a = b. Dato che dopo un confronto l’array in cui si effettua la ricerca ha dimensione n2 , poi 2n2 e dopo i confronti 2ni , affinchè sia 2ni = 1 deve essere i = log2 n. Quindi Tworst (n) = O(log n). • caso medio: assumiamo che x ∈ L e che possa occupare con la medesima probabilità una qualsiasi delle n posizioni, allora n X 1 ∗ (n.ro confronti per x in posizione pos). Tavg (n) = n pos=1 Per valutare questa quantità facciamo una sorta di ragionamento al contrario: quante posizioni consentono di trovare x con 1 confronto? una, la posizione centrale. E con 2 confronti? 2, le posizioni 1/4 · n e 3/4 · n. E con 3 confronti? 4, le posizioni 1/8 · n, 3/8 · n, 5/8 · n, 7/8 · n. 21 Albero delle posizioni Nro Confronti 1 2 3 posizioni n/2 n/4 n/8 n/8 3/8n 5/8n 7/8n Possiamo andare avanti cosı̀ fino a che i = lg2 n. La costruzione ci dice che per trovare x con i confronti, x può occupare 2i−1 posizioni diverse. Nella sommatoria vi è esattamente un termine per cui il numero di confronti vale 1, vi sono esattamente due termini per cui il numero di confronti vale 2, vi sono esattamente quattro termini per cui il numero di confronti vale 3, vi sono esattamente otto termini per cui il numero di confronti vale 4, etc. Quindi la sommatoria può essere riscritta come Tavg (n) = lg2 n X 1 i=1 n ∗ i ∗ n.ro posizioni che richiedono i confronti lg n 2 1X 1 = i · 2i−1 = lg2 n − 1 + . n i=1 n 22 Si osservi come il tempo medio non si discosti dal tempo peggiore, questo è spiegabile con il fatto che metà degli elementi si comporta come il caso peggiore. 23 4 Statistiche d’Ordine Il problema da risolvere è il seguente: dati n elementi ed un intero k ∈ 1, . . . , n, trovare il k-esimo elemento quando la sequenza è ordinata (k-esimo più piccolo o più grande a seconda dell’ordinamento). La ricerca del mediano avviene quando k = b n2 c. Due algoritmi sono interessanti: • uno randomizzato, basato su partition di quicksort; Partiamo da un problema differente: selezione per piccoli valori di k. La ricerca del minimo può essere fatta con n − 1 confronti, questo bound è ottimale in quanto se lo facessimo con meno non confronteremmo qualche elemento che può essere il minimo. Vogliamo generalizzare l’idea alla ricerca del secondo minimo ed in generale alla ricerca del k-esimo minimo, con k = O( lgnn ). Esaminiamo il semplice algoritmo per la ricerca del secondo minimo, Figura 5.2, pagina 117. Vale quanto segue Lemma 4.1 L’algoritmo secondominimo esegue 2n − 3 confronti nel caso peggiore e n + O(lg n) nel caso medio. Dimostrazione: Il caso peggiore si verifica quando facciamo 2 confronti per ciascuna delle n − 2 iterazioni, e questo avviene quando il vettore è ordinato in maniera decrescente. Per l’analisi di caso medio supponiamo che ogni permutazione del vettore A sia equiprobabile. • Ogni iterazione esegue almeno il primo confronto, ma il secondo è eseguito solo se A[i] è il minimo o il secondo minimo nei primi i valori di A. 24 • Ognuno dei primi i valori può essere uno dei 2 più piccoli con probabilità 2i . • Quindi mediamente il secondo confronto è fatto n X 2 i=3 i = 2 lg n + O(1) Sommando gli n confronti fatti alla riga 5 otteniamo n + O(lg n). Si osservi che 2i viene fuori dalla seguente semplice osservazione: il minimo può trovarsi in una tra le i possibili posizioni, il secondo minimo può trovarsi in una delle restanti i − 1 possibili posizioni quindi le possibili posizioni in cui trovare primo e secondo minimo sono i(i − 1). Ora, qual è la probabilità che il minimo o il secondo minimo si trovino in i ? Sono tutti i casi della forma (i, j) e (j, i), con j ∈ {1, . . . , i−1}, cioè 2(i−1) casi favorevoli sui i(i − 1) casi totali, da cui 2i . A questo punto, possiamo fare di meglio nel caso peggiore e selezionare il secondo minimo più efficientemente? In particolare, possiamo progettare un algoritmo il cui caso peggiore sia uguale a quello di caso medio appena visto? Si, l’idea è quella di suddividere gli n elementi in coppie e vedere chi è il minimo in ciascuna coppia. Tali minimi vengono di nuovo confrontati a coppie tra di loro e cosı̀ via. Colui che resta è il minimo. Ecco un esempio su 44 55 12 42 94 18 06 67 Dov’è il secondo minimo? E’ in quelle coppie in cui compare il minimo. Rifacciamo una ricerca di minimo tra gli elementi che occorrono in tali coppie. Nota che il numero di elementi è lg n. E’ chiaro quindi che abbiamo in mano un metodo per ricercare il secondo minimo con n + O(lg n) confronti nel caso peggiore invece che con 2n. Concludiamo che abbiamo un metodo il cui caso peggiore è uguale a quello del caso medio dell’algoritmo dato sopra. La struttura è nota come albero delle selezioni 25 L’albero delle selezioni altro non è che uno heap di cui ricordiamo la definizione: la sequenza di elementi a1 , . . . , an ha la proprietà di heap, o più brevemente è uno heap, se soddisfa le seguenti proprietà: 1. ai ≤ a2i , se esiste l’elemento a2i , cioè 2i ≤ n; 2. ai ≤ a2i+1 , se esiste l’elemento a2i+1 , cioè 2i + 1 ≤ n; L’idea della ricerca del secondo minimo può essere generalizzata al caso in cui si voglia il k-esimo minimo, con k = O( lgnn ). L’idea applica l’algoritmo heapsort che merita una drata che forniamo qui di seguito. 4.1 HeapSort Probabilmente HeapSort non occuperebbe il posto che occupa tra gli algoritmi di ordinamento se J. Williams non avesse trovato un modo di rappresentare l’albero di selezione mediante una struttura dati di n elementi. Tale struttura dati è nota come heap, la cui definizione è stata fornita poco sopra. Si osservi che in base alle condizioni date, gli elementi oltre la metà della sequenza, cioè an/2+1 , . . . , an non devono soddisfare alcun vincolo rispetto agli altri elementi, quindi già rispettano la proprietà di heap. Inotre, si osservi anche la similitudine tra proprietà di heap e albero delle selezioni. Adesso dobbiamo risolvere il seguente problema: data una sequenza qualsiasi di elementi a1 , . . . , an come la trasformiamo in uno heap? La prima osservazione è che gli elementi della seconda metà sono già uno heap. Se sappiamo trasformare in uno heap una sequenza data ai , . . . , an in cui ai+1 , . . . , an è uno heap, allora siamo a posto, perchè basterà applicare tale algoritmo n2 volte a partire dall’elemento di posto a n2 . Questo algoritmo è facilmente descritto per ricorsione come segue: 26 si confronta ai con a2i e a2i+1 . Se ai è il minimo dei 3 allora ai , . . . , an è uno heap e non c’è nulla che deve essere fatto. In caso contrario si scambia ai con il più piccolo tra a2i e a2i+1 , cosı̀ che adesso ai rispetta la proprietà di heap. Però a seconda del posto dove è avvenuto lo scambio, l’elemento di posto a2i o quello di posto a2i+1 potrebbe non rispettare la propriètà di heap, e quindi occorre procedere ricorsivamente a partire dalla posizione in cui è stato fatto lo scambio. In pratica l’elemento inizialmente in posizione ai deve essere messo al posto giusto in modo che non violi la proprietà di heap, quindi va fatto progressivamente sprofondare all’interno della sequenza fino a che non occupi un posto corretto. L’algoritmo che abbiamo descritto va sotto il nome di setacciamento. Qui di seguito ne diamo una versione ricorsiva in cui a1 , . . . , an è la sequenza, s è l’indice dell’elemento da aggiungere allo heap il quale parte dall’elemento di posto s + 1. setaccio(a1 , . . . , an ,s) SE 2s ≤ n e a2s è più piccolo di as ALLORA min = 2s altrimenti min = s; SE 2s + 1 ≤ n e a2s+1 è più piccolo di amin ALLORA min = 2s + 1 SE min 6= s ALLORA scambia amin con as ; setaccio(a1 , . . . , an ,min) • Al termine della chiamata a setaccio la sequenza as , . . . , an è uno heap se la sequenza as+1 , . . . , an prima della chiamata era uno heap. In Figura 5 ne diamo una possibile implementazione in linguaggio C. • A questo punto possiamo usare la procedura setaccio per ottenere uno heap. Come? Basta iterare la chiamata a setaccio in modo da allungare progressivamente la sequenza di elementi che soddisfano la proprietà di heap. • Ovviamente partiamo dall’elemento di mezzo, dato che la seconda parte già 27 /* n indice dell’ultimo elemento del vettore */ /* ricorda che i vettori partono all’indice 0 */ void setaccio(int a[],int s,int n){ int min,temp; if (2*s+1<=n && a[2*s+1]<=a[s]) min=2*s+1; else min=s; if (2*s+2<=n && a[2*s+2]<=a[min]) min=2*s+2; if (s != min){ temp=a[s]; a[s]=a[min]; a[min]=temp; setaccio(a,min,n); } } Figura 5: Implementazione della funzione setaccio soddisfa la proprietà di essere uno heap. Qui di seguito descriviamo in linguaggio naturale la procedura. faiUnoHeap(a1 , . . . , an ) Per i che varia da n2 a 1: setaccio(a1 , . . . , an ,i) Una implementazione in linguaggio C è fornita in Figura 6. /* n indice dell’ultimo elemento del vettore */ /* ricorda che i vettori partono all’indice 0 */ void faiUnoHeap(int a[],int n){ int i; for (i=(n+1)/2-1;i>=0;i--) setaccio(a,i,n); } Figura 6: Implementazione della funzione faiUnoHeap 28 • Siamo adesso giunti al punto cruciale: come usiamo lo heap per ottenere un vettore ordinato? • Osserviamo che in cima allo heap abbiamo il minimo. Scambiamolo con l’ultimo elemento e consideriamo la sequenza a1 , . . . , an−1 . Essa non è uno heap a causa dello scambio, ma possiamo setacciare a1 e farlo scendere al posto giusto. L’elemento a1 che emerge è il minimo di a1 , . . . , an−1 (cioè il secondo minimo della sequenza originaria). • Procediamo facendo uno scambio tra a1 e an−1 ed un setacciamento, e cosı̀ via fino a arrivare ad una sequenza di un elemento. Gli elementi a1 , . . . , an sono ordinati in maniera descrescente. • Quest’ultima parte costituisce il nucleo di HeapSort che può essere descritto come segue: HeapSort(a1 , . . . , an ) faiUnoHeap(a1 , . . . , an ) Per i che varia da n a 2: scambia a1 con ai setaccio(a1 , . . . , ai−1 , 1) Si veda Figura 7 per una implementazione C. La struttura dell’algoritmo è la seguente: • rendiamo heap il vettore (procedura heapify), e questo è fatto in tempo O(n); • a questo punto cancelliamo il minimo k volte e dopo ogni cancellazione ripristiniamo la proprietà di heap (procedura fixheap(A,N-1)) e questo è fatto in tempo O(lg n). Ricordiamo che uno heap può essere equivalentemente rappresentato come vettore e come albero binario in cui tutti i livelli sono completi al più ad eccezione dell’ultimo, che viene riempito da sinistra a destra. 29 /*n è l’indice dell’ultimo elemento del vettore */ void heapSort(int a[],int n){ int i,temp; faiUnoHeap(a,n); for(i=n;i>=1;i--){ temp=a[0]; a[0]=a[i]; a[i]=temp; setaccio(a,0,i-1); } } Figura 7: Implementazione di heapSort Cosı̀ complessivamente il tempo è O(n + k lg n). L’algoritmo (in pratica HeapSort), è il seguente: Algoritmo Heapselect(array A, intero K)-> elem 1. heapify(A) 2. for i = 1 to k-1 do 3. scambia(a[1],A[N]) 4. fixheap(A,N-1) 5. return A[1] Si osservi che se il k-esimo minimo che si desidera reperire è in posizione pari a k = O( lgnn ), allora il tempo è O(n). Ma se applichiamo tale algoritmo alla ricerca del mediano, ovvero k = d n2 e il tempo è O(n lg n), come un ordinamento. 30 4.2 Calcolo Randomizzato del Mediano L’idea fondamentale è la seguente: • partizioniamo l’array in 3 regioni: A1 , contenente gli elementi più piccoli del perno, A2 con gli elementi uguali al perno, A3 con gli elementi maggiori del perno. • Se gli estremi di A2 includono la posizione che stiamo cercando abbiamo finito, altrimenti procediamo ricorsivamente su A1 oppure A3 a seconda di quella che occupa la posizione che stiamo cercando. • Si tratta di modificare partition di quicksort . La modifica è dettata da ordini di efficienza e semplicità dell’analisi probabilistica (Figura 5.7, pagina 121). • Prima di tutto notiamo che l’algoritmo termina, dato che A1 ed A3 contengono meno elementi di A. • Il caso peggiore si verifica (come con quicksort) quando le partizioni sono sbilanciate. In tal caso l’equazione di ricorrenza è T (n) = O(n) + T (n − 1), T (1) = K ∈ N che ha come soluzione T (n) = O(n2 ). • Facciamo l’analisi probabilistica: preso il perno x e considerata la partizione degli elementi in due insiemi S1 e S2 tali che S1 contiene tutti gli elementi ≤ x e S2 tutti gli elementi ≥ x, il mediano sta sicuramente nell’insieme più grande, la cui dimensione è ≥ n2 . • Quindi, a meno di non restituire il mediano, nel passo ricorsivo dell’algoritmo eliminiamo |A2 | + min(|A3 |, |A1 |) elementi. • Si dimostra che il caso della selezione del mediano, cioè k = d n2 e, è il peggiore, infatti per valori diversi di k, la probabilità di eseguire il passo ricorsivo sulla 31 partizione più piccola aumenta. Quando k 6= d n2 e può accadere di fare il passo ricorsivo su una partizione (quella che contiene la posizione k) il cui numero di elementi è minore di d n2 e, cioè minore della metà degli elementi in A. Questo mai accade se k = d n2 e, cioè se k è la posizione del mediano. Quindi quando si deve stabilire il mediano la ricorsione è sempre fatta su partizioni che contengono più di metà degli elementi. • Ad ogni passo eliminiamo un numero di elementi compreso tra 1 e n2 , tale numero è equiprobabile se il vettore ha tutti elementi diversi e equiprobabile è la scelta tra tutti i possibili x. • Se ogni partizione è equiprobabile allora la chiamata ricorsiva avviene in maniera equiprobabile su array che hanno dimensione compresa tra n2 e n − 1 e la probabilità di incorrere su un array di dimensione i ∈ { n2 , . . . , n − 1} è 1 n−1− n 2 +1 = 2 n • Se l’array ha dimensione n viene partizionato in 3 con n − 1 confronti usando un array di supporto oppure con 2(n − 1) confronti operando in loco. Quindi il numero di confronti atteso è P C(n) = O(n) + n2 n−1 i= n2 C(i), n ≥ 1 C(0) = 0 da cui si dimostra per sostituzione che vale C(n) ≤ tṅ, t ∈ N Base: C(0) = 0 ≤ t · 0; 32 Induzione: Pn−1 C(n) = (n − 1) + n2 i= n C(i), per hp induttiva segue: 2 P n−1 C(n) ≤ (n − 1) + n2 i= n t · i 2 P C(n) ≤ (n − 1) + n2 · t n−1 i= n i 2 C(n) ≤ (n − 1) + 2 n P P n2 −1 · t( n−1 i − i=1 i) i=1 C(n) ≤ (n − 1) + 2 n · t( (n−1)n − 2 (n/2−1)n/2 ) 2 C(n) ≤ (n − 1) + t(3/4n − 1/2) C(n) ≤ n(3/4t + 1) − 1/2t − 1 C(n) ≤ n(3/4t + 1) ≤ tn ⇒ t ≥ 4 • Concludiamo quindi che il numero di confronti atteso è lineare in nella quantità di dati. In Figura 8 forniamo un’implementazione in linguaggio C della procedura Seleziona, che implementa quickselect. int seleziona(int a[],int sx,int dx,int k){ int q; if (sx == dx ) return a[k]; q=partiziona(a,sx,dx); if (k <= q ) return seleziona(a,sx,q,k); else return seleziona(a,q+1,dx,k); } Figura 8: Implementazione della funzione seleziona 33 4.3 Calcolo Deterministico del Mediano • L’algoritmo che presentiamo è deterministico nel senso che l’elemento x su cui fare la partizione è scelto in modo deterministico mediante chiamata ricorsiva. • In questo modo si garantisce che il partizionamento basato su x divida l’array in tre parti tali che le loro dimensioni dipendano da un fattore costante. In particolare, • l’obiettivo è di selezionare x in modo che non disti troppo dal mediano cosı̀ da garantire che ogni chiamata sia, ad esempio, su un array grande al più 34 di quello di input. L’obiettivo è raggiunto applicando l’idea di calcolare il mediano dei mediani, come segue: 1. Se il numero di elementi su cui calcolare il mediano è piccolo si usa un algoritmo di ordinamento, altrimenti si procede come segue: 2. l’insieme degli elementi è frazionato in tanti gruppi da 5. Si ottengono d n5 e gruppi in cui l’ultimo può contenere eventualmente meno di 5 elementi. Abbiamo cosı̀ S1 , . . . , Sd n5 e gruppi; 3. di ogni gruppo, calcoliamo il mediano con un qualsiasi algoritmo. Chiamiamo tali mediani m1 , . . . , md n5 e ; 4. per chiamata ricorsiva troviamo il mediano M dei mediani m1 , . . . , md n5 e ; 5. usiamo l’algoritmo (modificato) di partizionamento di quicksort dove l’elemento pivot x vale M ; 6. procediamo ricorsivamente sulla regione più estesa (o su quella contenente la posizione k di interesse). L’algoritmo descritto sopra può essere schematizzato come segue, dove l e r sono rispettivamente indice dell’elemento più a sinistra e più a destra della sequenza, k è la posizione di interesse all’interno della sequenza di input. 34 medianoDet(a1 , . . . , an , l, r, k)->intero 0. SE r-l inferiore a 10 ALLORA ordina la sequenza al , . . . , ar e restituisci l’elemento di posto k; 1. sia gruppi= (r−l+1) e avanzo= (r − l + 1)%5, cioè gruppi denota 5 il numero di gruppi da 5 elementi in cui possono essere suddivisi gli elementi da al , . . . , ar e avanzo il numero di elementi nell’ultimo gruppo. SE avanzo 6= 0 poniamo totGr=gruppi+1, ALTRIMENTI totGr=gruppi. 2. Per i che varia da 1 a gruppi esegui: 3. sia mi il mediano di a5∗(i−1)+1 , . . . , a5∗(i−1)+5 4. SE avanzo 6= 0 5. ALLORA sia mtotGr il mediano di a5∗gruppi+1 , . . . , a5∗gruppi+avanzo ; 6. M=medianoDet(m1 , . . . , mtotGr , 1, totGr, totGr/2) 7. j=partizionaV2(a1 , . . . , an , l, r, M) 8.SE k <= j ALLORA return medianoDet (a1 , . . . , an , l, j, k) 9.ALTRIMENTI return medianoDet (a1 , . . . , an , j + 1, r, k) Di seguito forniamo una possibile implementazione in linguaggio C. Il passo base è eseguito tramite selectionSort opportunamente adattato. In Figura 11 presentiamo la funzione di partizionamento partizionaV2, variante della usuale funzione di partizionamento di quicksort. La funzione partizionaV2 ha tra i suoi parametri formali il pivot x. Si lascia come esercizio la modifica di questa funzione di partizionamento in modo che il partizionamento divida gli elelementi nelle tre regioni A1 , A2 , e A3 . 35 int medianoDet(int a[],int l,int r,int k){ /* l: indice primo elemento, r: indice ultimo elemento, k: posizione da estrarre, tra gli estremi l e r */ int gruppi, avanzo,totGr; int m[r]; /* vettore dei mediani dei gruppi: m[0] contiene il mediano del primo gruppo etc...*/ int M; int i,j; if ((r-l+1)< 10 ){ /* passo base: con pochi elementi uso un metodo quasiasi */ selectionSort(a,l,r); return a[k]; } gruppi=(r-l+1)/5; avanzo=(r-l+1)%5; if (avanzo != 0) totGr=gruppi+1; else totGr=gruppi; for(i=0;i<gruppi;i++){ /* calcolo del mediano di 5 elementi, uso un metodo qualsiasi */ selectionSort(a,5*i+l , 5*i+4+l); /* osserva +l*/ m[i]=a[5*i+2+l]; } if (avanzo !=0 ){ /* osserva che la variabile i vale gruppi */ /* calcolo del mediano per il gruppo, se esiste, con meno di 5 elementi */ selectionSort(a,5*i+l , 5*i+(avanzo-1)+l); m[i]=a[5*i+(avanzo-1)/2+l]; } M=medianoDet(m,0,i,i/2); /* calcolo del mediano dei mediani */ j=partizionaV2(a,l,r,M); if (k<=j) return medianoDet(a,l,j,k); else return medianoDet(a,j+1,r,k); } Figura 9: Implementazione del Mediano deterministico 36 int indexMin(int a[],int start,int stop){ /* start: indice della prima cella, stop: indice dell’ultima */ int i,idxmin; idxmin=start; for(i=start+1;i<=stop;i++){ if (a[idxmin]>a[i]) idxmin=i; } return idxmin; } void selectionSort(int a[],int start,int stop){ /* start e stop sono gli estremi del vettore a*/ int i,idxMin,help; for(i=start;i<=stop;i++){ idxMin=indexMin(a,i,stop); help=a[i]; a[i]=a[idxMin]; a[idxMin]=help; } } Figura 10: Versione di selectionSort per il calcolo del mediano 37 int partizionaV2(int a[],int sx,int dx, int x){ int i,j,temp; i=sx-1;j=dx+1; while (1){ do i++; while (a[i]<x); do j--; while (a[j]>x); if (i< j){ temp=a[i]; a[i]=a[j]; a[j]=temp;} else return j; } } Figura 11: La funzione partizionaV2, variante di partiziona 38 4.3.1 Analisi di complessità Esiste un metodo che permette di trovare il mediano tra 5 elementi facendo solo 6 confronti (esercizio!), quindi tutti i mediani mi possono essere trovati in 6d n5 e passi. Il calcolo del mediano dei mediani è fatto per chiamata ricorsiva e quindi richiede tempo T (d n5 e) <= T ( n5 + 1). Il vero problema è sapere su quale dimensione dell’array è fatta la chiamata ricorsiva, cioè la dimensione che il partizionamento produce. Il seguente lemma stabilisce che la chiamata ricorsiva viene fatta su una frazione degli elementi di input: Lemma 4.2 La chiamata ricorsiva è effettuata su al più 7 10 n + 3 elementi. Dimostrazione: Ad ogni passo dopo aver partizionato scartiamo gli elementi uguali a M più quelli o più grandi o più piccoli. Supponiamo di scartare quelli più grandi, quanti elementi scartiamo in tutto? Dato che M è mediano degli mi almeno metà di questi è scartata, e quindi almeno n d 21 d n5 ee = d 10 e dato che gli mi sono d n5 e. Ora, ciascun mi è mediano del proprio gruppo Si di 5 elementi quindi è garantito dalla definizione di mediano che almeno altri due elementi di Si siano maggiori di mi e quindi siano scartati. Cosı̀ quei gruppi Si tali che mi ≥ M hanno almeno 3 elementi ≥ M che quindi vengono scartati. I gruppi Si con 5 elementi tali che mi ≥ M sono, per quanto n osservato sopra, d 10 − 1e (-1 è dovuto al fatto che l’ultimo gruppo potrebbe non avere 5 elementi). Cosı̀ è garantito che vengano scartati almeno 3n 10 − 3 elementi. Con un ragionamento assolutamente analogo possiamo dimostrare che il medesimo numero di elementi è scartato se la chiamata ricorsiva scarta quelli più piccoli. Se ne deduce che la chiamata ricorsiva è fatta su 39 7n 10 + 3 elementi. Theorem 4.3 Nel caso peggiore select esegue O(n) confronti. Dimostrazone: ci vuole tempo 6d n5 e < 6( n5 + 1) per il calcolo degli mi ; il mediano dei mediani M è calcolato per chiamata ricorsiva su d n5 e elementi, quindi il tempo è T (d n5 e). Per partizionare l’array di n elementi ci vuole tempo (n − 1). La chiamata ricorsiva per ottenere la soluzione è fatta su 7 impiega tempo T ( 10 n + 3). 7n 10 + 3 elementi, quindi Complessivamente i confronti, ovvero il tempo, sono T (n) ≤ 11 n 7 n + T (d e) + T ( n + 3) + 5 5 5 10 A questo punto per sostituzione si può dimostrare che T (n) ha un andamento lineare, cioè T (n) = Kn, con K ∈ N . In particolare dimostriamo che esiste un valore per K tale che 11 n 7 T (n) ≤ n + T (d e) + T ( n + 3) + 5 ≤ Kn 5 5 10 Se T (n) ha un andamento lineare allora T (n) ≤ 11 n 7 n + K( + 1) + K( n + 3) + 5 ≤ Kn 5 5 10 Trascurando il termine 4K che non dipende da n, possiamo dividere le ultime due disequazioni per n ottenendo 11 K 7 + )+K ≤K 5 5 10 11 9 + K≤K 5 10 Che è soddisfatta per K ≥ 22. Abbiamo quindi dimostrato che l’andamento di T (n) è lineare ed il fattore moltiplcativo di n è 22. 40 5 Tecniche Algoritmiche • La tecnica greedy costruisce la soluzione applicando di volta in volta la scelta localmente più promettente. Molti problemi di ottimizzazione ammettono algoritmi greedy ottimali. 41 6 Tecnica Divide et Impera • La tecnica divide et impera è una tecnica che consiste nel dividere l’istanza del problema da risolvere in sottoistanze le quali vengono risolte ricorsivamente e le cui soluzioni sono ricombinate per ottenere la soluzione dell’istanza. • E’ una tecnica top-down nel senso che si parte dall’istanza generale e si divide in istanze più piccole. • Lo sforzo del progettista consiste nell’individuare sia come dividere il problema sia (ma soprattutto) come ricombinare le soluzioni parziali. 6.1 MergeSort É un algoritmo di ordinamento che ha una chiara struttura ricorsiva: richiama se stesso più volte per trattare istanze più semplici di quella data in input. In questo caso istanza più semplice è sinonimo di sequenza più corta rispetto a quella fornita in input. Data una sequenza a1 , . . . , an , MergeSort opera come segue: 1. divide la sequenza da ordinare in due sottosequenze di n 2 elementi ciascuna; 2. ordina le due sottosequenze ricorsivamente usando MergeSort; 3. fonde le due sottosequenze ordinate per produrre la risposta ordinata. – Si osservi che nel passo 2 la base della ricorsione si ha quando la sequenza da ordinare è lunga 1. – L’operazione chiave di MergeSort è il passo 3, dove si fondono due sequenze ordinate. Nelle Figure 12 e 13 forniamo un’implementazione C degli algoritmi. 42 /* p è l’indice del primo elemento della sequenza, r è l’indice dell’ultimo elemento della sequenza */ void mergesort(int a[],int p,int r){ int q; if (p<r){ q=(p+r)/2; mergesort(a,p,q); mergesort(a,q+1,r); merge(a,p,q,r); } } Figura 12: Implementazione in linguaggio C di mergesort 6.2 Quicksort Anche quicksort ha una chiara struttura dividi et impera. Data una sequenza S di elementi: 1. dividi: scegli un elemento x e usalo per partizionare S in due: A, quella contenente solo gli elementi minori o uguali a x; B, quella contenente solo gli elementi maggiori o uguali a x; 2. impera: procedi ricorsivamente su A come se fosse S, ottenendo A0 ; procedi ricorsivamente su B come se fosse S, ottenendo B 0 ; 3. A0 seguito da B 0 costituisce l’ordinamento di S. Nota che in tal caso la terza fase, quella di ricombinazione delle soluzioni parziali è molto facile. Al contrario la fase di dividi è sofisticata. Si osservi come nel aso di mergeSort la fase di dividi sia banale mentre la fase di ricombinazione è sofisticata. 43 void merge(int a[],int p,int q,int r){ int b[r+1]; /* conterrà il risultato della fusione*/ int i,j,t; /* i punta alla testa della sottosequenza di sx j punta alla testa della sottosequenza di dx t punta alla prima cella libera di b */ i=p;j=q+1;t=1; /* itera il corpo finchè entrambe le sottosequenza hanno elementi*/ while (i<=q && j<=r){ if (a[i]<a[j]) { b[t]=a[i];i++;t++; } else { b[t]=a[j];t++;j++; } } /* copia della coda della sottosequenza non terminata*/ /* nota: solo uno dei due seguenti while parte */ while (i<=q){ b[t]=a[i]; i++; t++; } while (j<=r){ b[t]=a[j]; j++; t++; } /* copia b in a, adesso le due sottosequenze sono fuse in un’unica sequenza ordinata che sta in b */ for(i=1,j=p; i<t; i++,j++){ a[j]=b[i]; } } Figura 13: Implementazione in Linguaggio C della funzione merge 44 6.3 Esercizi – Scrivere una procedura che dato un vettore v ed un elemento x stabilisca se v è partizionato rispetto a x. In caso affermativo deve restituire la posizione di confine tra le due regioni altrimenti -1. – Modificate MergeSort, cosı̀ che divida la sequenza di dati in 3 parti e non due. 45 6.4 Moltiplicazione di interi di grandezza arbitraria • La moltiplicazione di numeri che non stanno in una cella di memoria non può essere considerata a tempo costante. • Presi 2 numeri di n cifre X = xn−1 . . . x0 e Y = yn−1 . . . y0 entrambi di n cifre, la moltiplicazione prende tempo O(n2 ), dove la moltiplicazione di 2 cifre prende tempo costante. • Supponiamo per semplicità che n sia potenza di 2 e suddividiamo ciascun numero in 2 gruppi di n2 cifre: n X = X1 10 2 + X0 n Y = Y1 10 2 + Y0 ovvero, X0 = x n2 −1 . . . x0 , X1 = xn . . . x n2 , Y0 = y n2 −1 . . . y0 , Y1 = yn . . . y n2 . • Cosı̀ n n XY = (X1 10 2 + X0 )(Y1 10 2 + Y0 ) n n = (X1 Y1 10n ) + X1 Y0 10 2 + X0 Y1 10 2 + X0 Y0 n = 10n (X1 Y1 ) + 10 2 (X1 Y0 + X0 Y1 ) + (X0 Y0 ) • Si osservi come le moltiplicazioni tra parentesi siano quelle del doppio prodotto (X1 + X0 )(Y0 + Y1 ) = X1 Y1 + X0 Y1 + X1 Y0 + X0 Y0 , da cui (X1 + X0 )(Y0 + Y1 ) − X1 Y1 − X0 Y0 = X0 Y1 + X1 Y0 Tutto questo ci dice che con i 3 prodotti (X1 + X0 )(Y0 + Y1 ), X1 Y1 e X0 Y0 possiamo calcolare XY come n 10n (X1 Y1 ) + 10 2 ((X1 + X0 )(Y0 + Y1 ) − X1 Y1 − X0 Y0 ) + (X0 Y0 ) dove questi 3 prodotti sono tra numeri aventi al più 46 n 2 + 1 cifre. • Per semplicità nell’analisi seguente consideriamo i numeri su cui si fanno le moltiplicazioni di n2 cifre in quanto l’analisi è asintotica. • L’espressione data sopra dice che il tempo per moltiplicare 2 numeri di n cifre equivale al tempo per fare 3 moltiplicazioni tra numeri di n2 cifre più il tempo di fare somme e shift (le moltiplicazioni per 10) di numeri a n cifre, quindi il tempo è: T (n) = 3T ( n2 ) + K2 · n, n > 1 T (1) = K1 ∈ N • Srotolando la ricorrenza otteniamo T (n) = 3k T (1) + K2 n + K2 n n + · · · + K2 k 2 2 con k = lg2 n, da cui T (n) = 3 lg2 n lg2 n X 1 = O(nlg2 3 ) K1 + K2 n i 2 i=0 Esercizio Scrivere un programma C per fare operazioni aritmetiche con numeri di lunghezza arbitraria. Per cominciare si consideri il seguente frammento di programma: #include<stdio.h> #define LUNGHEZZA 10 /* primo addendo, posizione del’unita’ del primo addendo, secondo addendo, posizione unita’ del secondo addendo, vettore risultato */ int somma(int add1[], int startAdd1, int add2[], int startAdd2, int ris[]); /* vettore e sua dimensione */ void printAdd(int add[],int dim){ int i; for(i=0;i<dim;i++) printf("%d",add[i]); } /* ritorna la posizione delle unita’ */ 47 int leggiAddendo(int add[]){ int i; char cifra; scanf("%c",&cifra); i=0; while (cifra != ’\n’){ add[i]=cifra-’0’; i++; scanf("%c",&cifra); } return --i; } int main(){ int ris[LUNGHEZZA+1],add1[LUNGHEZZA],add2[LUNGHEZZA], i,j, add1Dim,add2Dim; char cifra; printf("Primo addendo:"); add1Dim=leggiAddendo(add1); printf("secondo addendo:"); add2Dim=leggiAddendo(add2); somma(add1,add1Dim,add2,add2Dim,ris); printAdd(ris,LUNGHEZZA); printf("\n"); return 0; } Esercizio 1. Per poter elaborare numeri di grandezza arbitraria è necessario poter fare confronti tra due numeri di lunghezza arbitraria, quindi bisgona ridefinire gli operatori relazionali >, <, =, implementandoli come funzioni; 2. implementare la sottrazione tra interi di grandezza arbitraria; 3. implementare la divisione intera (quoziente e resto) tra numeri di grandezza arbitraria. Dato che la divisione Tra l’altro 48 6.5 Polinomi e Trasformata Veloce di Fourier (FFT) ...To Be Prepared... 49 6.6 La Programmazione Dinamica • Tipicamente, la tecnica della programmazione dinamica si basa sulla filosofia bottom-up. Procediamo dai sottoproblemi più piccoli verso quelli più grandi. • E’ una tecnica utile quando le sottoistanze non sono una indipendente dall’altra ed una stessa sottoistanza dell’istanza originaria può comparire più volte. Questo in pratica significa che analizzando l’albero delle chiamate ricorsive si nota che ci sono due o più chiamate ricorsive aventi con gli stessi parametri attuali; • Le soluzioni delle sottoistanze sono memorizzate in una tabella, cosı̀ qualora la medesima sottoistanza dovesse essere reincontrata sarà sufficiente esaminare l’elemento della tabella che ne contiene la soluzione. • La tecnica algoritmica Programmazione dinamica, prende nome dal fatto che si basa su una tabella (mono o pluridimensionale) la quale memorizza le soluzioni dei sottoproblemi del problema originario e tale tabella viene compilata o meglio programmata dinamicamente, a run-time. • E’ una tecnica bottom up in quanto la tabella è compilata partendo dai sottoproblemi più semplici, cioè dai casi base e passando poi a sottoproblemi più difficili combinando in maniera opportuna le soluzioni dei problemi più semplici per ottenere quelle dei problemi più difficili. • In opposizione, la tecnica top-down affronta l’istanza del problema generale e la divide man mano in sottoistanze più piccole. La tecnica di programmazione dinamica procede come segue: 1. si identificano i sottoproblemi del problema originario e si utilizza una tabella per memorizzare le soluzioni dei sottoproblemi; 2. si definiscono i valori iniziali della tabella relativi ai problemi più semplici; 3. si avanza nella tabella calcolando il valore della soluzione di un sottoproblema 50 (cioè un entry della tabella) in funzione dei valori delle soluzioni dei sottoproblemi già risolti; 4. si restituisce la soluzione del problema originario. 51 6.7 Associatività del prodotto tra matrici • Di seguito ci occupiamo della moltiplicazione di matrici. E’ noto che M1 M2 = M può essere fatto se il numero di colonne di M1 è uguale al numero di righe di M2 e la matrice M ha il numero di righe di M1 ed il numero di colonne di M2 . • Il prodotto di matrici è associativo ma non commutativo. Di seguito considereremo unitario il tempo per fare una moltiplicazione ed una somma. • Date le n matrici M1 M2 . . . Mn dove la matrice i-esima ha dimensione li × li+1 qual è il modo di associarle cosı̀ da minimizzare il numero di operazioni di moltiplicazione? • Il modo di associare le matrici influenza il modo in cui operiamo. Ad esempio, siano M1 (10, 20), M2 (20, 30), M3 (30, 2). • E’ facile verificare che il numero di moltiplicazioni varia di molto a seconda che si faccia M1 (M2 M3 ) o (M1 M2 )M3 . Infatti: M1 ∗ M2 è una matrice di dimensione 10x30 e per calcolare ciascun elemento occorrono 20 moltiplicazioni; (M1 ∗ M2 ) ∗ M3 è una matrice 10x2 e per calcolare ciscun elemento occorrono 30 moltiplicazioni. Segue quindi che il prodotto (M1 ∗ M2 ) ∗ M3 richiede 6600 moltiplicazioni. D’altronde, la stessa matrice può essere calcolata associando M1 ∗ (M2 ∗ M3 ), in tal caso: M2 ∗ M3 ha 20x2 elementi, ciascuno calcolato con 30 moltiplicazioni; M1 ∗ (M2 ∗ M3 ) ha 10x2 elementi, ciascuno calcolato con 20 moltiplicazioni. Segue quindi che il prodotto M1 ∗ (M2 ∗ M3 ) richiede 1600 moltiplicazioni. • questo semplice esempio contiene il succo della strategia: conoscendo il costo delle associazioni (M1 ∗ M2 ) e (M2 ∗ M3 ) possiamo scegliere quella definitiva per la moltiplicazione delle 3 matrici. Nota la nostra scelta è ottima perchè (banalmente) ottime sono le associatività scelte per la moltiplicazione di (M1 ∗ M2 ) e (M2 ∗ M3 ). 52 • Prima di vedere il problema generale, illustriamo l’idea di base, supponendo di dover moltiplicare M1 ∗ M2 ∗ M3 ∗ M4 . • Osserviamo che ci sono diverse associazioni possibili per ottenere la matrice risultato: M1 ∗ (M2 ∗ M3 ∗ M4 ), (M1 ∗ M2 ) ∗ (M3 ∗ M4 ), (M1 ∗ M2 ∗ M3 ) ∗ M4 . • Osserviamo quindi che se conoscessimo il costo della moltiplicazione di M2 ∗M3 ∗ M4 , M1 ∗ M2 , M3 ∗ M4 , M1 ∗ M2 ∗ M3 , potremmo decidere quale associazione ci conviene fare conteggiando per ciascuno dei 3 casi il numero di moltiplicazioni. Ovviamente, decideremmo per l’associatività che produce il numero inferiore di moltiplicazioni; • inoltre se sapessimo anche che i costi delle moltiplicazioni M2 ∗M3 ∗M4 , M1 ∗M2 , M3 ∗ M4 , M1 ∗ M2 ∗ M3 sono ottimi, cioè minimi, potremmo anche affermare che il numero di moltiplicazioni per l’associtività scelta è il minimo possibile. • Ma per fare questa affermazione qualcuno dovrebbe calcolare i costi minimi per la moltiplicazione di M2 ∗ M3 ∗ M4 , M1 ∗ M2 , M3 ∗ M4 , M1 ∗ M2 ∗ M3 . Questo significa, in particolare trovare l’associatività migliore per tali moltiplicazioni di matrici, in particolare per M2 ∗ M3 ∗ M4 e M1 ∗ M2 ∗ M3 , dato che per M1 ∗ M2 , M3 ∗ M4 la scelta è obbligata. • Consideriamo di avere le matrici M1 (10, 2), M2 (2, 5), M3 (5, 20), M4 (20, 50). Le dimensioni sono quindi l1 = 10, l2 = 2, l3 = 5, l4 = 20, l5 = 50 E’ immediato calcolare il costo della moltiplicazione tra 2 matrici: basta moltiplicare tra loro le rispettive dimensioni. Per quanto rigarda la moltiplicazione M1 M2 M3 abbiamo due possibili casi: (M1 M2 )M3 che costa 100+1000; M1 (M2 M3 ) che costa 200+400; Quindi (M1 M2 )M3 è la parentesizzazione migliore, e 600 è il valore inserito in riga 1 colonna 3 ad esprimere il fatto che 600 è il numero minimo di moltiplicazioni da fare per ottere il risultato di M1 M2 M3 . 53 Procediamo analogamente per la moltiplicazione di M2 M3 M4 ottenendo che (M2 M3 )M4 è l’associatività più economica dato che richiede solo 2200 moltiplicazioni contro le 5500 richiesta dall’associatività M2 (M3 M4 ). Per moltiplicare M1 M2 M3 M4 abbiamo 4 possibili modi di associare le matrici, per ciascuno dobbiamo calcolarne il costo. Scopriremo che 3200=2200+1000 è il costo minimo ottenuto associando M1 (M2 M3 M4 ). Possiamo quindi compilare la tabella a 1 2 3 4 da 1 0 100 600 3200 2 0 200 2200 3 0 5000 4 0 Oltre alla matrice dei costi data qui sopra, possiamo compilare la matrice della soluzione, che permetta di ricostruire come fare l’associazione. La matrice soluzione è analoga a quella dei costi: le celle significative sono solo quelle sopra la diagonale principale e se j è il valore che compare alle coordianate (r,c) significa che la moltiplicazione di Mr . . . Mc deve essere fatta con associazione (Mr . . . Mj )(Mj+1 . . . Mc ). Svolgendo l’esempio dato sopra si ottiene la seguente matrice soluzione: a 1 2 3 4 da 1 1 1 1 2 2 3 3 3 4 Quindi da questa matrice deduciamo che M1 M2 M3 M4 devono essere associate come M1 (M2 M3 M4 ) e che M2 M3 M4 devono essere associate come (M2 M3 )M4 • Torniamo al problema con n matrici. Indichiamo con P (i, j) il sottoproblema che consiste nel moltiplicare le matrici Mi . . . Mj . 54 • Quindi il problema originario è P (1, n). Abbiamo che 1. I sottoproblemi che dobbiamo risolvere sono i P (i, j) con i ≤ j; il costo ottimo lo memorizziamo in una matrice C bidimensionale, che risulta una triangolare superiore, nell’entry (i, j); 2. i sottoproblemi del tipo P (i, i), i = 1, . . . , n, sono banali perchè ci sono 0 moltiplicazioni, quindi C[i, i] = 0; 3. la soluzione al problema M1 M2 . . . Mn sta in C[1, n]; • Dobbiamo specificare come calcoliamo il valore di C[i, j] associato al problema Mi . . . Mj in funzione dei sottoproblemi più piccoli già risolti cioè già sapendo come associare prodotti di un numero di matrici inferiori a j−i+1, in particolare sapendo come associare in modo ottimo prodotti di sequenze di matrici del tipo Mi . . . Mr e Mr . . . Mj . Per fare questo dobbiamo tentare tutti i possibili valori di r e vedere quale di questi produce il minimo di C[i, r] + C[r + 1, j] + li lr+1 lj cioè porremo C[i, j] = min C[i, r] + C[r + 1, j] + li lr+1 lj i≤r≤j−1 • Si tratta quindi di compilare in maniera sistematica gli entry della matrice, partendo dai problemi con una matrice (casi base), passando con quei problemi con due matrici, poi a quelli con 3 etc. Questo significa compilare la matrice muovendosi per diagonali a partire da quella principale e poi salendo via via su quelle parallele. • Quello che si ottiene è l’algoritmo in Figura 14 (vedi anche pagina 252), in cui viene anche compilata S, la matrice soluzione. Theorem 6.1 L’algoritmo ordinematrici richiede tempo O(n3 ), dove n è il numero di matrici. 55 Algoritmo OrdineMatrici(l1 ,l2 ,...,ln+1 ) 1. matrice C di n x n interi 2. for i= 1 to n do C[i,i]=0; 3. for d= 1 to (n-1) do 4. for i= 1 to (n-d) do 5. j=d+i; 6. C[i,j]=C[i,i]+C[i+1,j]+li li+1 lj ; S[i,j]=i; 7. for r=i+1 to (j-1) do 8. if C[i,j]>(C[i,r]+C[r+1,j]+li lr+1 lj ) 9. then 10. C[i,j]=C[i,r]+C[r+1,j]+li lr+1 lj 11. S[i,j]=r 12. return C[1,n] Figura 14: Associatività di matrici • Dimostrazione: nella diagonale d, 1 ≤ d ≤ n − 1 ci sono n − d elementi e per ciascuno di essi, che rappresenta un problema di moltiplicazione di d+1 matrici, ci sono d − 1 possibilità (i possibili valori di r sono d − 1). 56 • Otteniamo quindi la seguente serie di sommatorie: T (n) = n−d d+i−1 n−1 X X X d=1 i=1 = n−d n−1 X X 1 r=i d d=1 i=1 = n−1 X d(n − d + 1) d=1 = n−1 X dn − d=1 = n n−1 X d=1 d+ n−1 X 1 d=1 (n − 1)n (n − 1)n − +n 2 2 = O(n3 ) • Si osservi come sia notevolmente più lento un algoritmo di tipo divide et impera in cui presa l’istanza Mi . . . Mj per ogni r = i, . . . , j − 1 faccia una chiamata ricorsiva su Mi . . . Mr e Mr+1 . . . Mj per trovare la partizione ottima e poi decide quale r è il migliore per partizionare Mi . . . Mj . Molti sottoproblemi di Mi . . . Mr e Mr+1 . . . Mj verrebebro risolti più volte! • Si osservi che se dato Mi . . . Mj la partizione ottima è (Mi . . . Mr )(Mr+1 . . . Mj ) allora anche le due partizioni devono essere parentesizzate ottimamente, altrimenti non potrebbe esserlo (Mi . . . Mr )(Mr+1 . . . Mj ). 57 • Questo significa che il problema dell’associatività del prodotto di matrici verifica il principio della sottostruttura ottima: se la soluzione ad un problema è ottima allora anche le soluzioni ai sottoproblemi sono ottime. • Molti problemi riguardanti i grafi ammettono algoritmi progettati secondo la tecnica di programmazione dinamica. Eserczio Progettare e codificare in C un algoritmo che date le dimensioni l1 , . . . , ln+1 di n matrici stampi a video l’associatività ottima ed il costo. Sul nostro esempio introduttivo il programma dovrebbe stampare a video M1((M2M3)M4) come soluzione e 3200 come costo. 58 Stampa di una tabella di associatività di dimensione 6x6. #include<stdio.h> void stampaAssociativita(int s[][6],int i,int j){ if (i==j) printf("A%d",i); else { printf("("); stampaAssociativita(s,i,s[i][j]); stampaAssociativita(s,s[i][j]+1,j); printf(")"); } } /* Main di prova */ int main(void){ /*Inizializza la matrice di programmazione dinamica */ int s[6][6]= { {0,0,0,2,2,2}, {0,1,1,2,2,2}, {0,0,2,2,2,2}, {0,0,0,3,3,4}, {0,0,0,0,4,4}, {0,0,0,0,0,5} }; int i,j; stampaAssociativita(s,0,5); return 0; } 59 60 Knapsack • Il problema dello zaino è il seguente: • si ha a disposizione uno zaino di capacità C nel quale si vogliono mettere degli oggetti, ciascun tipo di oggetto ha un peso ed un valore. • Il problema è di massimizzare il valore posto nello zaino senza superare il peso C che lo zaino in tutto può trasportare. Di ogni tipo di oggetto sono disponibili un numero illimitato di copie. • Per scrivere un algoritmo che risolve il problema osserviamo che se per ciascun tipo di oggetto i conosco il valore della soluzione ottima del problema dello zaino quando la capacità è C − peso(i) (C − peso(i) ≥ 0) allora posso calcolare il valore della soluzione ottima del problema dello zaino con capacità C vedendo per ogni oggetto i qual è quello che aggiunto allo zaino ne massimizza il valore. 61 Knapsack (2) • Più formalmente potremmo dire che date le soluzioni ottime ott(1), . . . , ott(n) ai problemi con zaini di capacità C − peso(1), . . . , C − peso(n) aggiungiamo allo zaino quell’oggetto j tale che ott(j) + valore(j) = maxi=1,...,n (ott(i) + valore(i)). • Una prima soluzione a questa idea potrebbe essere la seguente: zaino(intero capacita, array peso, array valore)-> intero { 1. if capacita <= 0 return 0 2. else { 3. max <- 0; 4. for(i <- 0; i<n; i++) 5. { 6. if ( capacita-peso[i] >= 0 ) { ris=zaino(capacita-peso(i),peso, valore)+valore[i]; 7. if (ris > max ) max=ris } 8. } 9. return max 10.} } 62 Knapsack (3) • Questo algoritmo ricalcola più volte la soluzione al medesimo problema. Cosı̀ il tempo è esponenziale rispetto al valore di capacità e al numero di oggetti. • Una analisi delle chiamate ricorsive dell’algoritmo evidenzia come il medesimo sottoproblema possa essere risolto più volte. • Per ridurre il numero di chiamate dobbiamo memorizzare il valore delle soluzioni ottime mano a mano che le troviamo cosı̀ da evitare delle chiamate inutili. • Siccome i problemi differiscono tra loro per il diverso valore di capacità introduciamo un vettore di C + 1 celle, una cella per ogni possibile valore che la capacità dello zaino può assumere nei sottoproblemi del problema originario. 63 Knapsack (3) zaino2(intero capacita, array peso, array valore, array soluzioneottima)->intero { if (soluzioneottima[capacita] <> -1 ) return soluzioneottima[capacita] else { max=0 for(i <- 1; i <= nroOgg ; i++) { if (capacita-peso[i]>=0) { ris=zaino2(capacita-peso[i],peso,valore, soluzioneottima)+valore[i] if (ris > max) max=ris } } soluzioneottima[capacita]=max return max } } 64 Knapsack (4) • Possiamo apportare un’altra modifica all’algoritmo procedendo bottom-up, cioè cercare di compilare il vettore delle capacità dalle capacità più piccole alle capacità più grandi. • Se abbiamo il problema dello zaino con capacità C e già abbiamo le soluzioni per tutte le capacità più piccole di C non c’è bisogno di fare chiamate ricorsive per risolvere i sottoproblemi per zaini di capacità C − peso(i), per i = 1, . . . , n, basta fare un ciclo su i che va da 1 a n perchè il valore ottimo per il problema C − peso(i) già l’abbiamo. Il passo base del problema è ovviamente per lo zaino di capacità uguale a zero, il cui ottimo è zero. zaino3(capacita, array peso, array valore, array soluzioneottima) { for c <- 1 to capacita do { max <- 0 for i <- 1 to n do if ( c-peso(i)>=0) if (max < soluzioneottima[c-peso[i]]+valore[i]) max <- soluzioneottima[c-peso[i]]+valore[i] soluzioneottima[c]=max; } } 65 Knapsack (5) • Differente è l’idea se abbiamo uno zaino di capacità C ed n tipi di oggetti 1, . . . , n, ciascuno con un peso p(1), . . . , p(n) ed un valore v(1), . . . , v(n), ma di ogni oggetto abbiamo solo una copia, quindi nella soluzione ottima ciascun oggetto o compare, o non compare. • Se l’oggetto 1 compare nella soluzione ottima, allora la soluzione senza l’oggetto 1 è ottima per il problema con uno zaino di capacità C − p(1) e n − 1 tipi di oggetti, 2, . . . , n, ciascuno con un peso p(2), . . . , p(n) ed un valore v(2), . . . , v(n); se l’oggetto 1 non compare nella soluzione ottima allora la soluzione è uguale alla soluzione ottima per il problema con uno zaino di capacità C e n − 1 tipi di oggetti 2, . . . , n, ciascuno con un peso p(2), . . . , p(n) ed un valore v(2), . . . , v(n). • Se il primo oggetto appartiene alla soluzione ottima, allora la soluzione senza tale oggetto è soluzione ottima del problema senza tale oggetto con uno zaino che ha capacità diminuita del peso dell’oggetto; se l’oggetto non appartiene alla soluzione ottima, allora basta risolvere il problema in cui l’oggetto non è presente. • Una prima soluzione che non usa la programmazione dinamica è la seguente: 66 Knapsack (6) zaino4(intero capacita,array peso,array valore,intero lastobj)-> intero { if (capacita <= 0 || lastobj<0) return 0 else { ris1=zaino4(capacita, peso, valore, lastobj-1) ris2=zaino4(capacita-peso[lastobj],peso,valore,lastobj-1) +valore[lastobj] if ris1 > ris2 return ris1 else return ris2 } } • Questa soluzione soffre comunque del fatto che il medesimo problema può essere sottoproblema di più problemi e quindi risolto più volte, come indica una facile analisi delle chiamate ricorsive. • Ciò che distingue un problema dai suoi sottoproblemi sono gli oggetti presenti e la capacità dello zaino. Possiamo quindi utilizzare una matrice che ha tante colonne quanti sono gli oggetti nell’istanza del problema e tante righe quanto è il valore della capacità dello zaino e nella posizione di indice (j, i) l’ottimo del sottoproblema la cui istanza ha i primi i oggetti dell’istanza del problema originario ed uno zaino di capacità j. 67 Knapsack (7) zaino5(intero capacita, array peso, array valore, intero lastobj, matrice soluzioneottima)-> intero { if (capacita <= 0 || lastobj<0) return 0 else { if (soluzioneottima[capacita,lastobj-1]==-1) ris1=zaino5(capacita, peso, valore, lastobj-1, soluzioneottima) else ris1=soluzioneottima[capacita,lastobj-1] if (soluzioneottima[capacita-peso[lastobj],lastobj-1]==-1) ris2=zaino4(capacita-peso[lastobj], peso, valore, lastobj-1, soluzioneottima)+valore[lastobj] else ris2=soluzioneottima[capacita-peso[lastobj], lastobj-1]+valore[lastobj] if (ris1 > ris2) soluzioneottima[capacita,lastobj]=ris1 else soluzioneottima[capacita,lastobj]=ris2 return soluzioneottima[capacita,lastobj]; } } 68 6.8 Longest Common Subsequence (LCS) Data una sequenza di elementi una sua sottosequenza è ottenuta privando la sequenza di un numero qualsiasi di elementi. Ad esempio le parole sono sequenze di lettere e una sottosequenza di “MAGGIO” è “MGG”. Formalmente, date due sequenze X = hx1 , . . . , xn i e Z = hz1 , . . . , zk i diremo che Z è sottosequenza di X se esiste una sequenza di indici di X hi1 , . . . ik i strettamente crescente e tale che per ogni j = 1, . . . , k, Xij = Zj . Date due sequenze X e Y , Z è sottosequenza comune di X e Y se Z è sottosequenza sia di X che si Y . Nel problema di trovare la sottosequenza più lunga ci vengono date due sequenze X = hx1 , . . . , xm i e Y = hy1 , . . . , yn i e dobbiamo trovare la sottosequenza comune più lunga. Vediamo come possiamo risolvere il problema usando la programmazione dinamica. Innanzitutto è immediato vedere che un algoritmo a forza bruta che enumeri tutte le sottosequenze di X e vede se sono anche sottosequenze di Y è impraticabile in quanto dato X = hx1 , . . . , xm i le sue sottosequenze sono tutti i sottoinsiemi di X cioè 2m . Comunque la programmazione dinamica è applicabile se vale il principio della sottostruttura ottima. Dimostriamo tale proprietà nel seguente teorema: 69 Theorem 6.2 Siano X = hx1 , . . . , xm i e Y = hy1 , . . . , yn i due sequenze e Z = hz1 , . . . , zk i una LCS di X e Y . Allora vale quanto segue: 1. se xm = yn allora deve essere zk = xm = yn e hz1 , . . . , zk−1 i è LCS di hx1 , . . . , xm−1 i e hy1 , . . . , yn−1 i; 2. se xm 6= yn allora se zk 6= xm allora Z è LCS di hx1 , . . . , xm−1 i e Y ; 3. se xm 6= yn allora se zk 6= yn allora Z è LCS di X e hy1 , . . . , yn−1 i. Dimostrazione: 1. per hp è xm = yn , allora se per assurdo fosse zk 6= xm ( e quindi zk 6= yn ), allora Z potrebbe essere allungata aggiungendo xm e troveremmo una nuova sottosequenza comune tra X e Y più lunga di Z, contro l’hp del teorema che dice che Z è LCS. Quindi deve essere zk = xm (= yn ). A questo punto hz1 , . . . , zk−1 i è LCS di hx1 , . . . , xm−1 i e hy1 , . . . , yn−1 i perchè se esistesse un LCS W di lunghezza maggiore di k − 1, allora attaccando a W l’elemento xm (= yn ) otterremmo un LCS di lunghezza maggiore di k per X e Y , contro l’hp del th che dice che Z, di lunghezza k, è LCS di X e Y ; 2. per hp è xm 6= yn e zk 6= xm , allora i k elementi comuni a X e Y si trovano in hx1 , . . . , xm−1 i e Y = hy1 , . . . , yn i. D’altronde non può esistere in hx1 , . . . , xm−1 i e Y = hy1 , . . . , yn i una sottosequenza W con più di k elementi, altrimenti essa sarebbe anche sottosequenza di X e Y , quindi Z di k elementi non sarebbe LCS di X e Y . Cosı̀ Z è una LCS di hx1 , . . . , xm−1 i e Y . 3. simmetrico al punto precedente. 70 Il teorema precedente ci dice come analizzare i sottoproblemi per ottenere la soluzione al problema dato: 1. se xm = yn allora cerchiamo un LCS di hx1 , . . . , xm−1 i e hy1 , . . . , yn−1 i e una volta trovata l’allunghiamo con xm ; 2. se xm 6= yn cerchiamo un LCS di hx1 , . . . , xm−1 i e Y , e un LCS di X e hy1 , . . . , yn−1 i, e prendiamo a soluzione migliore la quale è anche quella per X e Y . Esempio Calcoliamo una LCS tra X = hA, B, C, B, D, A, Bi e Y = hB, D, C, A, B, Ai. E’ facile rendersi conto che molti sottoproblemi si sovrappongono, ovvero può capitare di dover ricalcolare più volte una LCS tra medesime sequenze. Ovviamente se una delle due sequenze ha lunghezza 0 allora la LCS ha lunghezza 0. Questo è il caso base. Possiamo introdurre una matrice C[m × n] tale che C[i, j] è il valore della LCS nel caso hx1 , . . . , xi i e hy1 , . . . , yj i. Per quanto detto vale che 1. C[0, t] = 0, per t = 0, . . . , n; 2. C[t, 0] = 0, per t = 0, . . . , m; C[i − 1, j − 1] + 1, se xi = yj 3. se i, j > 0, C[i, j] = max{C[i, j − 1], C[i − 1, j], } altrimenti Quindi un primo algoritmo risolutivo computa la matrice procedendo ricorsivamente top-down. Possiamo risparmiare le chiamate ricorsive e procedere nella compilazione della matrice secondo uno schema bottom up tenendo conto che la prima riga e la prima colonna valgono zero. Vediamo come con il nostro esempio dato sopra. Esercizio Determinare una LCS tra h1, 0, 0, 1, 0, 1, 0, 1i e h0, 1, 0, 1, 1, 0, 1, 1, 0i La Figura 15 mostra dell’algoritmo in versione bottom-up: X ha m + 1 componenti, Y ha n + 1 componenti, C é la matrice calcolata e in C[m, n] si trova LCS(X,Y). Nota: nello pseudolinguaggio consideriamo che le componenti degli array siano indicizzate a partire da 0 e che le lettere che realmente fanno parte della sequenza siano memorizzate a partire dall’indice 1. 71 Algoritmo LCS(array X, array Y, intero m, intero n, matrice C) 1. for i= 1 to m do c[i,0]=0; 2. for i= 0 to n do c[0,j]=0; 3. for i= 1 to m do 4. for j= 1 to n do 5. if (x[i]=y[j]) then c[i,j]=c[i-1,j-1]+1 6. else if (c[i-1,j]>=c[i,j-1]) 7. then c[i,j]=c[i-1,j] 8. else c[i,j]=c[i,j-1] Figura 15: Algoritmo per LCS calcolata in versione bottom-up Un’ulteriore analisi dei valori da porre nelle celle C[i, j], se i, j > 0 mostra che possiamo ottenere la risposta facendo uso di una matrice di sole due righe. Nel seguente algoritmo la matrice C ha due righe, quella di indice 0 e quella di indice 1. Le colonne sono n. Algoritmo LCS2(array X, array Y, intero m, intero n, matrice C) 1. for j= 0 to n do c[0,j]=0; 3. for i= 1 to m do c[i%2,0]=0 4. for j= 1 to n do 5. if (x[i]=y[j]) then c[i%2,j]=c[(i-1)%2,j-1]+1 6. else if (c[(i-1) % 2,j]>=c[i%2,j-]) 7. then c[i%2,j]=c[(i-1)%2,j] 8. else c[i%2,j]=c[i%2,j-1] Figura 16: Algoritmo per LCS ottimizzato Infine si osservi che a patto di complicare ulteriormente l’algoritmo è possibile dare un’implemntazione che utilizzi solo un vettore e 2 variabili. Nei problemi di ottimizzazione determinare il valore ottimo è solo uno degli aspetti. L’altro è determinare una soluzione ottima. Vediamo come determinare una sottosequenza ottima. A tale scopo è necessario tenere in memoria l’intera matrice C. Se applichiamo l’algoritmo illustrato in Figura 15 al problema di trovare la massima lunghezza di una LCS tra hA, B, C, B, D, A, Bi e 72 hB, D, C, A, B, Ai compileremo la seguente matrice: ∅ A B C B D A B ∅ 0 0 0 0 0 0 0 0 B 0 0 1 1 1 1 1 1 D 0 0 1 1 1 2 2 2 C 0 0 1 2 2 2 2 2 A 0 1 1 2 2 2 3 3 B 0 1 2 2 3 3 3 4 A 0 1 2 2 3 3 4 4 Da questa matrice è possibile procedendo a ritroso stabilire una soluzione ottima. Il modo di procedere a ritroso è dettato dal modo che abbiamo impiegato per costruire la matrice. Esercizio Scrivere un programma che date 2 sequenze di caratteri ASCII (tipo char del C) computi la lunghezza di una LCS e stampi una LCS. Esercizio Variante del precedente esercizio: il programma deve stampare l’elenco di tutte le LCS di due sequenze date. 73 7 Grafi Definizione di grafo: un grafo G è una coppia (V, E) dove V è un insieme non vuoto di oggetti e E ⊆ V 2 . Se per ogni (x, y) ∈ E vale (y, x) ∈ E si parla di grafo non orientato, altrimenti il grafo si considera orientato. Dato (x, y) ∈ E diremo che (x, y) incide sui vertici x e y. Per grafi non orientati si dice che x e y sono adiacenti. Un cammino in un grafo G da un vertice x ad un vertice y è una sequenza di vertici (v0 , v1 , . . . , vu ) dove v0 = x, vu = y e (vi−1 , vi ) ∈ E. In tal caso il cammino passa per i vertici v0 , . . . , vu e gli archi (vi−1 , vi ). Il cammino è semplice se i vertici sono distinti. Un cammino che inizia e finisce nello stesso nodo è un ciclo. Il ciclo è semplice se i nodi intermedi del cammino sono tutti distinti tra loro. Un grafo non orientato è connesso se esiste un cammino tra ogni coppia di nodi del grafo. Un grafo orientato è fortemente connesso se esiste un cammino orientato tra ogni coppia di vertici del grafo. 7.1 Come rappresentare i grafi Lista di archi: gli archi del grafo vengono elencati in un vettore o in un lista. Ogni componente del vettore o della lista rappresenta un arco, contiene quindi i (puntatori ai) nodi estremi dell’arco. Talune manipolazioni o algoritmi richiedono la scansione dell’intera lista, compromettendo talvolta l’efficienza. In Figura 17 un esempio di codice che permette di memorizzare gli archi di un grafo come lista. Lista di adiacenza: una delle strutture dati più utilizzata per i grafi. Per ogni nodo vi è una lista che elenca i nodi a lui adiacenti. Si osservi che in tale rappresentazione gli archi sono implicitamente codificati. Efficiente per visite di grafi. In figura 18 un esempio di codice in Linguaggio C che manipola un grafo mediante liste di adiacenza. Liste di incidenza: si elencano gli archi in una struttura, come nel primo caso, ed inoltre per ogni nodo v si mantiene una lista che elenca gli archi incidenti su v. In Figura 19 un esempio di programma C che manipola un grafo mediante liste di incidenza. matrice di adiacenza: matrice M di dimensione |V | × |V |, indicizzata dai nodi del grafo, tale che M [x, y] = 1 se (x, y) ∈ E, M [x, y] = 0 altrimenti. Permette la verifica dell’esistenza di un arco tra coppie di nodi in tempo costante, ma stabilire chi sono i vicini di un nodo comporta un tempo proporzionale a |V |. Rappresentazione utile per calcoli algebrici. Infatti dato che codifica cammini di lunghezza 1 tra i nodi, per trovare nodi a distanza 2 basta moltiplicare la matrice per se stessa, 74 #include<stdio.h> #include<stdlib.h> typedef struct{int Info; } Nodo; typedef struct{int from, to;} Arco; int main(void){ int nroNodi, nroArchi,i; Arco *listaArchi; Nodo *listaNodi; scanf("%d%d",&nroNodi, &nroArchi); listaNodi=malloc(nroNodi*sizeof(Nodo)); listaArchi=malloc(nroArchi*sizeof(Arco)); for(i=0;i<nroArchi;i++) { printf("Nodo di uscita: "); scanf("%d", &listaArchi[i].from); printf("Nodo di entrata: "); scanf("%d", &listaArchi[i].to); } for(i=0;i<nroArchi;i++) { printf("(%d,%d)\n",listaArchi[i].from,listaArchi[i].to); } return 0; } Figura 17: Esempio di Grafo manipolato con lista di archi 75 #include<stdio.h> #include<stdlib.h> typedef struct{int Info, *listaNodiAdiacenti, nroAdiacenti; } Nodo; int main(void){ int nroNodi, i,j; Nodo *listaNodi; scanf("%d",&nroNodi); listaNodi=malloc(nroNodi*sizeof(Nodo)); for(i=0;i<nroNodi;i++) { printf("Nro nodi adiacenti al nodo %d:",i); scanf("%d",&listaNodi[i].nroAdiacenti); listaNodi[i].listaNodiAdiacenti=malloc(listaNodi[i].nroAdiacenti*sizeof(int)); for(j=0; j<listaNodi[i].nroAdiacenti; j++){ printf("Nodo adiacente al nodo %d: ",i); scanf("%d", &listaNodi[i].listaNodiAdiacenti[j]); } } for(i=0;i<nroNodi;i++) { printf("Lista dei nodi adiacenti al nodo %d:\t",i); for(j=0; j<listaNodi[i].nroAdiacenti; j++){ printf("%d; ",listaNodi[i].listaNodiAdiacenti[j]); } printf("\n"); } return 0; } Figura 18: Esempio di Grafo manipolato con liste di adiacenza 76 #include<stdio.h> #include<stdlib.h> typedef struct {int info, *listaDiIncidenza, dimListaDiIncidenza; } Nodo; typedef struct{int from, to;} Arco; int main(void){ int nroNodi, nroArchi,i,j,pos; Arco *listaArchi; Nodo *listaNodi; scanf("%d%d",&nroNodi, &nroArchi); /* Alloca spazio per la lista dei nodi */ listaNodi=malloc(nroNodi*sizeof(Nodo)); /*Inizializza il campo dimListaDiIncidenza a zero */ for(i=0;i<nroNodi;i++) listaNodi[i].dimListaDiIncidenza=0; listaArchi=malloc(nroArchi*sizeof(Arco)); for(i=0;i<nroArchi;i++) { printf("Nodo di uscita: "); scanf("%d", &listaArchi[i].from); /* NOTA BENE: consideriamo grafi orientati */ listaNodi[listaArchi[i].from].dimListaDiIncidenza++; printf("Nodo di entrata: "); scanf("%d", &listaArchi[i].to); } for(i=0;i<nroNodi;i++){ if (listaNodi[i].dimListaDiIncidenza>0) { /* dichiara il vettore che rappresenta la lista di Incidenza */ listaNodi[i].listaDiIncidenza=malloc(listaNodi[i].dimListaDiIncidenza*sizeof(int)); } else listaNodi[i].listaDiIncidenza=NULL; } /* per ogni nodo costruisce la lista di incidenza */ for(i=0;i<nroNodi;i++){ pos=0; for(j=0;j<nroArchi;j++){ if (listaArchi[j].from == i ){ /* se il j-esimo arco parte dal nodo i, allora si memorizza che l’arco j-esimo esce dal nodo i-esimo */ listaNodi[i].listaDiIncidenza[pos]=j; pos++; } } } printf("Lista Archi: \n"); for(i=0;i<nroArchi;i++) printf("(%d,%d)\n",listaArchi[i].from,listaArchi[i].to); printf("Informazioni sui nodi:\n"); for(i=0; i<nroNodi; i++){ printf("Nodo %d ha %d archi uscenti: ", i,listaNodi[i].dimListaDiIncidenza); for(j=0;j<listaNodi[i].dimListaDiIncidenza;j++) printf("%d ", listaNodi[i].listaDiIncidenza[j]); printf("\n"); } return 0; } Figura 19: Grafo manipolato con liste di incidenza 77 e cosi’ via per trovare nodi connessi tra loro con cammini di lunghezza 3, 4 etc. In Figura 20 un esempio di programma C per la gestione di un grafo mediante matrice di adiacenza. matrice di incidenza: matrice M di dimensione |V |x|E|, dove le righe sono indicizzate dai nodi, le colonne dagli archi. Se il grafo orientato contiene un arco (i, j) allora lungo una certa colonna c della matrice che rappresenta l’arco (i, j) varrà che M [i, c] = 1, M [j, c] = −1 e altrove lungo la colonna i valori saranno 0. M [i, c] = 1, denota che l’arco è uscente da i, M [j, c] = −1 denota che l’arco è entrante in j. Per grafi non orientati si usa semplicemente 1 per denotare i nodi su cui l’arco associato alla colonna c incide. In Figura 21 un esempio in linguaggio C. 78 #include<stdio.h> #include<stdlib.h> int main(void){ int nroNodi, nroArchi,*matrice, from, to; scanf("%d%d",&nroNodi,&nroArchi); matrice=malloc(nroNodi*nroNodi*sizeof(char)); /*Inizializzazione della matrice */ for (from=0;from< nroNodi;from++) for(to=0;to<nroNodi;to++) matrice[from*nroNodi+to]=’0’; do { printf("Nodo Uscente:"); scanf("%d",&from); printf("Nodo Entrante:"); scanf("%d",&to); matrice[from*nroNodi+to]=’1’; nroArchi--; } while (nroArchi>0); printf("Matrice di Adiacenza:\n"); for (from=0;from< nroNodi;from++){ for(to=0;to<nroNodi;to++) printf("%c ", matrice[from*nroNodi+to]); printf("\n"); } return 0; } Figura 20: Grafo manipolato mediante matrice di adiacenza 79 #include<stdio.h> #include<stdlib.h> int main(void){ int nroNodi, nroArchi,*matrice, from, to,i; scanf("%d%d",&nroNodi,&nroArchi); matrice=malloc(nroNodi*nroArchi*sizeof(int)); /*Inizializzazione della matrice */ for (from=0;from< nroNodi;from++) for(to=0;to<nroArchi;to++) matrice[from*nroArchi+to]=0; for(i=0;i<nroArchi;i++) { printf("Nodo Uscente:"); scanf("%d",&from); printf("Nodo Entrante:"); scanf("%d",&to); matrice[from*nroArchi+i]=1; matrice[to*nroArchi+i]=-1; } printf("Matrice di Incidenza:\n"); for (from=0;from< nroNodi;from++){ for(to=0;to<nroArchi;to++) printf("%d ", matrice[from*nroArchi+to]); printf("\n"); } return 0; } Figura 21: Grafo manipolato mediante matrice di incidenza 80 7.2 Visite di Grafi Visitare un grafo significa accedere ai nodi di un grafo G = (V, E) dato a partire da un nodo sorgente s∈V. Ci sono due startegie per visitare i nodi di un grafo: visita in ampiezza (breadth-first) e visita in profondità (depth-first). 7.3 Visita in ampiezza Consideriamo un grafo G = (V, E), orientato o meno, ed un suo nodo s ∈ V . Visitare in ampiezza G a partire da s significa accedere a tutti i nodi r1 , . . . , rn raggiungibili direttamente da s. A questo punto per ogni nodo ri appartenenente alla lista di nodi {r1 , . . . , rn } si accede ai nodi direttamente raggiungibili da ri purchè non siano già stati precedentemente raggiunti. Si viene cosı̀ a determinare una seconda lista di nodi {t1 , . . . , tm }: sono quelli raggiungibili da s attraversando due archi. Si procede iterativamente in questa maniera costruendo una nuova lista di nodi a partire dai nodi in {t1 , . . . , tm } sino ad aver raggiunto tutti i nodi del grafo: questo corrisponderà ad ottenere una lista vuota. Vediamo un esempio. Per tenere conto dei nodi già visitati è necessario marcarli, cioè sapere se un certo nodo è già stato visitato precedentemente. Per rendere più chiara la dinamica dell’algoritmo è utile marcare i nodi con i colori nero, grigio e bianco. Inizialmente tutti i vertici sono bianchi, cioè non marcati. Il vertice designato come sorgente è il primo ad essere scoperto e viene marcato di grigio. Tutti i suoi vicini, cioè i vertici raggiungibili da s mediante un arco uscente ad s, che siano bianchi, vengono scoperti, marcati di grigio e accodati ad una lista contenente nodi grigi. A questo punto dato che tutti i vicini di s sono stati scoperti marchiamo s di nero. L’algoritmo procede come su s prendendo il primo nodo grigio dalla lista dei nodi grigi. Quanto abbiamo appena descritto è un modo di esplorare il grafo che fa uso di un’unica lista. I nodi che progressivamente vengono scoperti piuttosto che andare a formare una nuova lista vengono accodati all’unica lista di nodi grigi. La lista di nodi grigi ha quindi un capo ed una coda detti tecnicamente testa della lista e coda della lista. Quando una struttura dati quale la lista dei nodi grigi viene manipolata come illustrato sopra la lista viene detta coda (queue, in inglese), per enfatizzare il fatto che nella struttura i dati vengono inseriti da un estremo (la coda) e vengono prelevati dall’altro (il capo). Quindi i nodi grigi possono essere considerati nodi di frontiera, cioè nodi scoperti ma i cui vicini potrebbero non ancora essere stati esplorati. Si noti che nell’esecuzione dell’ algoritmo un nodo da 81 bianco diventa grigio e non può più tornare bianco. Analogamente un nodo grigio che diventa nero non può più tornare grigio o bianco. Un nodo che diventa nero è un nodo che viene considerato esplorato ed è sinonomo del fatto che che tutti i nodi appartenenti alla sua lista di adiacenza sono stati scoperti, cioè sono diventati grigi. Dato che di un nodo da esplorare interessano i suoi vicini, ne segue che l’algoritmo lavora efficientemente quando il grafo è rappresentato mediante lista di adiacenza. A questo punto, dato che (i) esplorare un nodo significa scandirne la lista di adiacenza, (ii) tale scansione è fatta al più una volta (iii) la somma delle lunghezze delle liste di adiacenza è il numero di archi (iv) inizialmente dobbiamo marcare di bianco tutti i nodi, segue che il tempo è proporzionale alla somma tra numero di nodi e archi di G e quindi lineare nell’ampiezza della rappresentazione a liste di adiacenza di G. Durante l’esplorazione è possibile tenere conto degli archi del grafo che vengono attraversati. Tali archi vanno a costituire un albero detto di breadth-first. Si osservi che in base al modo di procedere la ricerca definisce in modo naturale l’albero dei nodi raggiungibili da s. Per ogni nodo v raggiungibile da s il percorso nell’albero dei nodi corrisponde al percorso più breve da s a v, cioè il percorso contenente il minor numero di archi. Di seguito diamo di visita in profondità in dettaglio. Algoritmo VisitaBFS(grafo G, vertice s)-> albero 1. Rendi tutti i nodi marcati di bianco; 2. Sia T l’albero formato dal solo nodo s; 3. Sia F l’insieme vuoto; 4. marca di grigio il vertice s 5. aggiungi s in coda a F 6. while (F non e’ vuoto ) do 7. estrai da F il primo vertice u; 8. visita il vertice u; 9. for each ( arco (u,v) di G ) do 10. if (v e’ bianco) then 11. marca v di grigio 12 aggiungilo in coda a F; 12. rendi u padre di v in T (cioè metti in T l’arco (u,v)); 13. marca di nero u 14. return T Esercizio Codificare in C l’algoritmo VisitaBFS. In particolare bisogna decidere come tenere conto della marcatura dei nodi. Occorre anche memorizzare gli archi che formano l’albero di breadth-first. Esercizio Estendere l’algoritmo dell’esercizio precedente affichè stampi il percorso di ogni nodo del grafo raggiungibile dalla sorgente; 82 Esercizio Modificare il programma svolto nell’esercizio precedente affinchè di ogni nodo del grafo stampi il percorso. Si osservi che non è detto che tutti i nodi del grafo siano nell’albero breadth-first. Infatti mancano quelli che dalla sorgente non sono raggiungibili. In pratica si tratta di determinare quali siano i nodi che dalla sorgente sono irraggiungibili. 83 7.4 Visita in profondità Consideriamo un grafo G = (V, E) orientato o meno ed un suo nodo s ∈ V . Visitare in profondità G a partire da s significa accedere a tutti i nodi r1 , . . . , rn direttamente raggiungibili da s. A questo punto si marca s di nero (nodo esplorato). Si considera l’ultimo nodo nella lista hr1 , . . . , rn i. Se da rn sono raggiungibili nodi non marcati di nero allora si aggiungono tutti alla lista, si marca rn di nero e si prosegue come illustrato considerando l’ultimo nodo. Se da rn non sono raggiungibili nuovi nodi allora si marca di nero rn , lo si cancella dalla lista. Si cerca a ritroso nella lista il primo nodo che non sia marcato di nero, cancellando i nodi marcati di nero. Se tutti i nodi nella lista sono marcati di nero allora ovviamente la lista diventa vuota e la visita termina, altrimenti trovato un nodo non nero si procede come mostrato per rn . Si osservi come in questo caso la procedura esplori l’ultimo nodo inserito nella lista, cercando di scoprire sempre nuovi nodi. Quando non è più possibile la procedura torna indietro (backtrack) sui propri passi cercando nuovi nodi da scoprire a partire da un nodo precedentemente considerato. Si osservi come la lista dei nodi venga trattata secondo uno schema diverso rispetto allo schema a coda del caso precedente. Qui un unico estremo della lista funge a punto di entrata e uscita. Quando una struttura dati viene manipolata inserendo i dati dallo stesso punto da cui vengono fatti uscire e viceversa si dice che la struttura dati è una pila (stack, in inglese). Dato che lo stack possiede uno stesso estremo sia per l’inserimento che la cancellazione dei dati, segue che il dato che viene cancellato è l’ultimo ad essere entrato, ecco perchè si dice che in uno stack vale il principio del last-in first-out. L’algoritmo descritto sopra può più compattamente essere descritto come procedura ricorsiva (Figura 22). Una procedura iterativa basata su una lista di nodi è data in Figura 23. Si osservi come tale algoritmo formalizzi direttamente la descrizione data a inizio paragrafo. In tale algoritmo possiamo notare che: 1. si fa uso dei colori bianco e nero; 2. il ciclo for in linea 11 si occupa di aggiungere nodi non marcati di nero alla lista; 3. in riga 9 si stabilisce se la lista dei nodi grigi sia o meno estendibile con nuovi nodi non neri. Concludiamo osservando che esiste un algoritmo alternativo a quello di Figura 23. Nella descrizione data a inizio paragrafo si dice: Se da rn sono raggiungibili nodi non marcati di nero allora si aggiungono tutti alla lista, si marca rn di nero... Questo corrisponde alle righe 11-14 dell’algoritmo. La variante che proponiamo consiste nell’aggiungere alla lista solo un successore alla volta. I successori aggiunti sono quelli NON ANCORA 84 Algoritmo visitaDFS(grafo G, vertice s) 1. Sia T l’albero vuoto; 2. Colora di bianco tutti i vertici; 3. VisitaDFSRicorsiva(grafo G, vertice s, albero T); 13. return T Algoritmo VisitaDFSRicorsiva(grafo G, vertice v, albero T) 1. Marca di grigio e visita il vertice v; 2. Per ogni arco (v,w) in G esegui: 3. if (w è marcato bianco) then 4. aggiungi l’arco (v,w) a T 5. VisitaDFSRicorsiva(grafo G, vertice w, albero T) 6. colora di nero v Figura 22: Visita in profondità: algoritmo in versione ricorsiva Algoritmo VisitaDFS(grafo G, vertice s)-> albero 1. Rendi tutti i nodi marcati di bianco; 2. Sia T l’albero formato dal solo nodo s; 3. Sia F l’insieme vuoto; 4. aggiungi s in coda a F 5. while (F non e’ vuoto ) do 6. sia u l’ultimo vertice in F; 7. visita il vertice u; 8. marca il vertice u di nero 9. if (esiste arco (u,v) di G tale che v non è marcato di nero) 10. then 11. for each ( arco (u,v) di G ) do 12. if (v non e’ marcato nero) 13. then 14. aggiungi v in testa a F; 15. sia t l’ultimo nodo in F; 16. aggiungi l’arco (u,t) in T 17. else 18. while (ultimo vertice di F è nero) 19. do estrai ultimo vertice di F 20. return T Figura 23: Visita in profondità: algoritmo in versione iterativa 85 SCOPERTI. Questo significa che a differenza del precedente algoritmo un nodo compare nella lista al massimo una volta. Un nodo cambia la sua marcatura in nero per il fatto di essere nella lista dei nodi. Viene cancellato dalla lista dei nodi quando tutti i suoi vicini sono già stati esplorati, cioè nessuno dei vicini è bianco. Anche questo algoritmo può essere agevolmente descritto con una marcatura a due colori. Algoritmo VisitaDFS(grafo G, vertice s)-> albero 1. Rendi tutti i nodi marcati di bianco; 2. Sia T l’albero vuoto; 3. Sia F l’insieme vuoto; 4. marca come nero il vertice s 5. aggiungi s in coda a F 6. visita s 6. while (F non e’ vuoto ) do 7. sia u l’ultimo vertice in F; 8. marca il vertice u di nero 9. if (esiste arco (u,v) di G tale che v non è marcato di nero) 10. then 11. aggiungi v in testa a F; 12. visita v 13. aggiungi l’arco (u,v) in T 14. else 15. estrai ultimo vertice di F 16. return T Concludiamo con la seguente osservazione. Per quanto riguarda la visita in profondità la nozione di nodo sorgente non è rilevante come nel caso della visita in ampiezza. Quello a cui si è interessati è cominciare l’esplorazione da un nodo bianco, cioè non visitato. Se al termine dell’esplorazione del grafo ci sono nodi bianchi, cioè inespolari, cioè irraggiungibili dal nodo da cui si era partiti, allora l’esplorazione riparte da uno dei nodi bianchi. L’esplorazione quindi si considera terminata quando nel grafo non ci sono più nodi bianchi. Esercizio. Riscrivere gli algoritmi dati per fare una visita in profondità completa. L’effetto è quindi quello di una iterazione della visita in profondità da più nodi sorgenti che porta alla costruzione di un singolo albero per ogni nodo sorgente. Il risultato è che i dati in T rappresentano nel caso generale una serie di alberi, cioè una foresta. 86 7.5 Cammini minimi in grafi pesati: l’algoritmo di Dijkstra Ci sono problemi in cui è necesario associare ad ogni arco un peso o costo. In tal caso si parla di grafi pesati e per cammino di costo minimo non si intende un cammino che minimizza il numero di archi attraversati ma un cammino che minimizza il totale della somma dei costi degli archi attraversati. L’algoritmo di Dijkstra permette di trovare il percorso minimo da un nodo sorgente verso tutti i nodi di un grafo pesato in cui i pesi degli archi siano non negativi. L’algoritmo è una variante della procedura di visita in ampiezza. L’input dell’algoritmo è un grafo pesato ed un nodo sorgente. L’output associa ad ogni nodo v del grafo il costo minimo del cammino dalla sorgente a v e un insieme di archi che determinano un albero che contiene, a partire dalla sorgente, il percorso migliore verso ogni nodo del grafo raggiungibile dalla sorgente. Anche in questo algoritmo marchiamo i nodi come bianchi, grigi o neri. I nodi neri sono i nodi per cui si è già determinata la distanza minima dalla sorgente; i nodi grigi sono quei nodi che sono raggiungibili da un nodo marcato di nero. Questo implica che un nodo grigio è un nodo che appartiene ad una delle liste di adiacenza dei nodi neri e che per un nodo grigio è stato determinato un percorso dalla sorgente ma non è detto che sia ottimo. I nodi bianchi non sono ancora stati scoperti, quindi non fanno parte di alcuna delle liste di adiacenza dei nodi neri. Ciò che distingue l’algoritmo di Dijkstra dall’algoritmo di ricerca in ampiezza è il criterio usato per selezionare il nodo grigio la cui lista di adiacenza deve essere scandita. L’algoritmo associa ad ogni nodo v del grafo una distanza, misurata rispetto al costo degli archi che costituiscono il percorso dalla sorgente s al nodo v. Di seguito con d(v) indicheremo la distanza da s associata a v. Tale distanza inizialmente viene posta a zero per il nodo s e a infinito per tutti gli altri nodi, quindi: d(s) = 0, d(v) = ∞ per ogni nodo v ∈ V \ {s} Questo significa che inizialmente l’algoritmo suppone che tutti i nodi abbiano distanza infinita da s. La sorgente è il primo nodo a diventare grigio, gli altri sono bianchi. Si scandisce la lista di adiacenza di s e si aggiornano le distanze dei nodi che in essa vi compaiono. Tali nodi diventano grigi, s diventa nero. Si osservi come i nodi non raggiunti restino bianchi e con distanza infinita, mentre i nodi grigi e neri, i nodi raggiunti, abbiano una distanza finita. I nodi neri sono i nodi la cui lista di adiacenza è stata scandita e per essi il percorso ottimo da s è stato calcolato. Dei nodi grigi la lista di adiacenza non è stata scandita; si ha un valore per un pecorso a partire da s ma non si è ancora stabilito se sia l’ottimo. La scelta del nodo grigio di cui scandire la lista di adiacenza è fatta secondo il seguente criterio: si sceglie il nodo grigio con distanza minore da s. 87 La lista di adiacenza del nodo grigio viene scandita ed il nodo diventa nero. Osservazione: dato che il nodo selezionato ha distanza minima da s la sua distanza da s non necessita di essere aggiornata. Se ne deduce che i nodi neri sono i nodi di cui non è più necessario verificare la distanza da s perchè già sappiamo essere minima. Per ogni nodo r nella lista di adiacenza del nodo grigio t appena selezionato si eseguono le seguenti operazioni: se k è la distanza tra t e s (cioè d(t) = k) e l’arco (t, r) pesa z allora affermiamo che esiste un percorso da s a r che passa per t di peso d(t) + z. A questo punto la distanza d(r) viene aggiornata col valore d(t) + z se tale valore è inferiore a quello attualmemte presente in d(r). Dopo aver scandito la lista di adiacenza di t si annerisce t e si ripete il procedimento fino a quando ci sono nodi grigi da selezionare. Si noti quindi come durante l’esecuzione, per ogni nodo v del grafo, il valore in d(v) contenga sempre la minor distanza sa s relativamente a quei percorsi finora analizzati, cioè relativamente a quegli archi per ora attraversati. Per quanto riguarda i nodi grigi, si noti anche che il percorso che l’algoritmo trova a partire da s verso di loro è costituito da archi che collegano tra loro nodi neri. Se ne deduce che se un percorso che conduce ad un nodo grigio non è ottimo allora non lo è a causa dell’ultimo arco, cioè quello entrante nel nodo grigio. Riassumendo, per ogni nodo l’algoritmo produce il miglior percorso a partire da s perchè durante la sua esecuzione vengono soddisfatte le due seguenti condizioni: 1. per ogni nodo nero la distanza stabilita è ottima; 2. per ogni nodo non-nero la distanza stabilita è ottima relativamente agli archi attraversati. Di seguito formalizziamo l’algoritmo appena descritto: Algoritmo Dijkstra(Grafo G, vertice s)->albero 1. per ogni vertice v in G poni d(v) a infinito e padre[v]=nil; 2. marca tutti i nodi di bianco; 3. poni d[s]=0 e padre[s]=s; 4. marca s di grigio; 5. finchè ci sono nodi grigi esegui: 6. sia v il nodo grigio con d[v] più piccolo; 7. marca v di nero; 88 8. per ogni vertice x nella lista di adiacenza di v esegui: 9. marca x di grigio 10. se d[x]> d[v]+costo(v,x) 11. allora 12. d[x]=d[v]+costo(v,x) 13. padre[x]=v; 14. return padre Per quanto riguarda l’analisi di complessità in tempo, ad ogni iterazione viene eseguita una ricerca di minimo. Tale ricerca viene effettuata tante volte quanti sono i nodi del grafo. Viene poi scandita la lista di adiacenza del nodo. La lunghezza totale delle liste di adiacenza è il numero di archi del grafo. Tutte le altre operazioni di inizializzazione dipendono anch’esse dal numero di nodi e richiedono complessivamente tempo proporzionale al numero di nodi del grafo. Quindi il tempo della procedura è quadratico nel numero di nodi del grafo. Si osservi che se le distanze minime vengono memorizzate in una struttura dati opportuna (più sofisticata di un vettore lineare come abbiamo supposto noi) il tempo diventa O(|E| + |V | log |V |). 7.6 Raggiungibilità tra ogni coppia di nodi Questa parte contiene le idee di base per sviluppare algoritmi che calcolino il costo mnimo del cammino tra ogni coppia di nodi di un grafo. Il problema di determinare l’esistenza di un qualche percorso tra ogni coppia di nodi di un grafo è chiaramente un problema più semplice rispetto a quello di calcolare un percorso ottimo tra ogni coppia di nodi. Dati due nodi x e y di un grafo G = (V, E), determinare se esiste un cammino tra x e y significa stabilire se esiste una sequenza di nodi x = v0 , v1 , . . . , vn = y tale che (vi , vi+1 ) ∈ E per i = 0, . . . , n − 1. Per risolvere questo tipo di problemi si considera la rappresentazione di grafi mediante matrice di adiacenza. Come vedremo la determinazione dell’esistenza di cammini tra ogni coppia di nodi di un grafo dato corrisponderà ad una elaborazione algebrica della matrice di adiacenza ed in particolare al suo elevamento a potenza. Per renderci conto del ruolo giocato dalla moltiplicazione di matrici analizziamo un problma ancora più semplice: per ogni coppia di nodi x e y di un grafo dato vogliamo determinare l’esistenza di cammini di lunghezza al massimo 2, cioè cammini tali che per raggiungere y a partire da x si debbano attraversare al massimo due archi, transitando quindi, al massimo, per un nodo intermedio. Quanto detto sopra può essere parafrasato dicendo che per ogni coppia di nodi x e y di un grafo dato vogliamo determinare l’esistenza di un cammino da x a y del tipo x, z, y, dove z è nodo intermedio, cioè tale che (x, z) e (z, y) sono archi del grafo dato. 89 Quindi, affinchè da x sia raggiungibile y con un cammino di lunghezza al più 2 deve accadere che nel grafo dato esiste l’arco (x, y) oppure esiste un nodo z tale che (x, z), (z, y) ∈ E. Quindi se v1 , . . . , vn sono gli archi del grafo deve valere che (x, v1 ), (v1 , y) ∈ E, oppure (x, v2 ), (v2 , y) ∈ E, oppure ... (x, vn ), (vn , y) ∈ E Vediamo cosa questo significa in termini di matrice di adiacenza su un esempio conceto. Per comodità in Figura 24 riportiamo 2 copie della matrice di adiacenza a da 1 2 3 4 5 6 1 2 3 4 5 6 0 1 0 1 0 0 0 1 1 0 1 1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 0 a da 1 2 3 4 5 6 Figura 24: matrice di adiacenza di un grafo orientato Dal nodo 3 c’è un cammino di lunghezza 2 al nodo 1 sse • c’è un arco dal nodo 3 al nodo 1 oppure • c’è un arco dal nodo 3 al nodo 2 e dal nodo 2 al nodo 1, oppure • c’è un arco dal nodo 3 al nodo 3 e dal nodo 3 al nodo 1, oppure • c’è un arco dal nodo 3 al nodo 4 e dal nodo 4 al nodo 1, oppure • c’è un arco dal nodo 3 al nodo 5 e dal nodo 5 al nodo 1, oppure 90 1 2 3 4 5 6 0 1 0 1 0 0 0 1 1 0 1 1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 0 Questo corrisponde a moltiplicare la terza riga con la prima colonna della matrice di adiacenza secondo la nozione di prodotto riga per colonna. Quindi è sufficiente che uno degli addendi del prodotto riga per colonna valga 1 affinchè ci sia un cammino di lunghezza 2. La generalizzazione è immediata: elevare al quadrato la matrice di adiacenza produce come risultato una matrice tale che la cella di coordinate (i, j) vale 1 sse esiste un cammino di lunghezza minore o uguale a 2 tra il nodo i ed il nodo j. Ne segue che elevando alla k-esima potenza la matrice di adiacenza M di un grafo dato si ottiene la matrice M k tale che M k [i][j] = 1 sse essste un cammino da i a j di lunghezza minore o uguale a k. Se ci sono n nodi, allora essite un cammino da un nodo x ad un nodo y di un grafo dato G purchè M n [i][j] = 1. Quindi per determinare la raggiungibilità tra qualsiasi coppia di nodi di un grafo G è sufficiente calcolare la potenza n-esima della sua matrice di adiacenza M . Ovviamente la matrice M n può a sua volta essere interpretata come la matrice di adiacenza di un nuovo grafo, usualmente denotato con G∗ , detto la chiusura transitiva di G: il grafo G∗ contiene l’arco (x, y) sse in G esiste un cammino da x a y. 91 8 Array e Allocazione della Memoria Il modo più frequente di dichiarare un array è attraverso una allocazione statica della memoria, esempio: int x[20][30]; In questo caso le dimensioni di x sono costanti, decise a priori dal programmatore e non dipendono dai dati di input. D’altronde per taluni problemi può essere opportuno poter dichiarare gli array cosı̀ che la loro dimensione dipenda dai dati di input. In tal caso si parla di allocazione dinamica della memoria dato che il numero di celle di cui è composto l’array non è noto a priori al programmatore e per differenti esecuzioni del programma l’array può assumere dimensioni differenti. Versioni recenti di compilatori C mettono a disposizione questa caratteristica. E’ quindi corretto il seguente frammento di programma: int x; scanf("%d",&x); int v[x][x*x]; in cui le dimensioni di v dipendono dalla variabile x. Si osservi come non sia possibile determinare a priori le dimensioni dell’array, le quali potranno variare ad ogni esecuzione del frammento di codice. Si osservi anche come diventi quindi possibile mischiare dichiarazioni di variabili e istruzioni. Dato che gli array possono essere usati come parametri attuali di funzioni ed essendoci in tal caso piccole differenze tra array monodimensionali e pluridimensionali trattiamo i due casi separatamente. Gli array monodimensionali (vettori) sono un caso particolare di quelli pluridimensionali. Per quanto riguarda i vettori allocati dinamicamente non ci sono differenze rispetto al loro utilizzo come prametri formali nelle funzioni. Consideriamo il frammento int x; scanf("%d",&x); int v[x]; f(v,x); La funzione f definita come: void f(int v[],int x){ ..... } 92 è perfettamente adatta a trattare ogni vettore di interi indipendentemente dalla lunghezza del parametro attuale che gli viene passato. Si osservi che anche void f(int v[50],int x){ ..... } va bene, dato che la costante 50 non viene considerata in fase di compilazione e neppure di esecuzione. Deduciamo che non vi è alcun cambiamento nel trattamento dei vettori allocati dinamicamente. Per quanto riguarda le matrici bi o pluridimensionali dei cambiamenti ci sono. Di un parametro formale di tipo matrice occorre dichiarare la dimensione di tutte le componenti ad eccezione della prima. Quindi se una funzione ha come parametro formale una matrice bidimensionale occorre dichiarare da quante colonne è composta: void f(int v[][50],int x){ ..... } Questa funzione può essere chiamata passando una matrice di qualsivoglia righe ma con 50 colonne. La necessità di conoscere il numero di colonne del parametro formale è legato a come una matrice è realmente memorizzata: essa è un vettore di elementi memorizzati per righe. Quindi per sapere dove, ad esempio, inizia il primo elemento della riga di indice 2 occorre sapere quante sono le colonne della matrice. Quindi per sapere dove è memorizzato l’elemento alle coordinate (i, j) della matrice basta applicare la formula i * numero di colonne della matrice + j Quindi, tornando all’esempio, l’assegnamento void f(int v[][50],int x){ .... z= v[3][8] .... } prevede di applicare la formula 3 ∗ 50 + 8 per ritrovare l’elemento posto logicamente alle coordinate (3, 8). Attenzione che se il parametro attuale corrispondente a v non è una matrice con 50 colonne, il programma verrà comunque compilato ma in fase di esecuzione non si comporterà come ci si aspetta. Esercizio. Come prova di quanto detto sopra, scrivete una funzione che debba stampare a video l’elemento di posto (5,9) di una matrice con 50 colonne. Fate 2 chiamate a f: la prima passando 93 come parametro attuale una matrice con 50 colonne, la seconda passando una matrice di 20 colonne. Vedrete che in questo secondo caso non viene stampato l’elemento che ci si aspetta. Per quanto detto dovendo, ad esempio, stampare a video la diagonale principale di una matrice quadrata, non sapremmo come scrivere un’unica funzione. Abbiamo il problema di rendere parametrico il numero di colonne. Abbiamo alcuni modi diversi per raggiungere questo obiettivo. Il primo modo è quello di scrivere una intestazione come segue: void f(int x,int v[][x]) in cui il numero di colonne non è più una costante ma una variabile. In tal caso il numero di colonne del parametro formale v è funzione del primo parametro formale x. E’ un tipo di scrittura che non tutti i compilatori accettano. L’esempio di Figura 25 mostra la soluzione per il problema della stampa della diagonale di 2 matrici aventi numero di colonne l’uno diverso dall’altra. Il secondo modo è quello di usare le variabili globali. Una variabile globale è una variabile che a differenza di quelle locali dichiarate nel corpo delle funzioni, sono visibili a tutte le funzioni. Esse vengono poste prima delle funzioni. Nell’esempio di Figura 26 la variabile nrocol è globale. Questo metodo comunque non rappresenta un buono stile di programmazione. Il modo migliore è fare riferimento al fatto che una matrice altro non è che un vettore le cui righe sono disposte una dietro l’altra. Come programmatori trattiamo quindi il parametro formale matrice come un vettore, associando le coordiante di riga e colonna alla posizione del vettore mediante la formula data precedentemente. La relativa implementazione è in Figura 27. Questa versione può dare dei warning dal momento che il compilatore rileva che il parametro formale è unidimensionale mentre quelli attuali sono bidimensionali, ma il programma viene eseguito correttamente. Infine l’intestazione void f(int v[], int dimcol) può essere sostituita equivalentemente con void f(int *v, int dimcol){ Per quanto riguarda l’allocazione dinamica di vettori e matrici, il modo più consueto di farlo è attraverso la funzione malloc. Il frammento di codice ..... int *z,*k; scanf("%d%d", &x,&y); z=malloc(sizeof(int)*x*x); k=malloc(sizeof(int)*y*y); ha lo stesso effetto della dichiarazione di z e k come matrici, cioè come sequenza di un certo numero di elementi. 94 Scopo della funzione malloc, il cui prototipo è in stdlib.h (header che deve quindi essere incluso nel programma), è riservare spazio in memoria. Essa riserva un numero di byte pari al valore del parametro attuale con cui è invocata. Avendo bisogno di x*x interi il numero di byte complessivo che deve essere allocato è x*x moltiplicato per il numero di byte occupato da un intero. Il comando sizeof restituisce la spazio occupato dal suo argomento. Di fatto al termine della chiamata z e k hanno la stessa natura di due vettori dichiarati dinamicamente come int z[x*x],k[y*y]; La funzione malloc restituisce un indirizzo di memoria. L’indirizzo restituito è quello dove comincia l’area di memoria riservata, cioè dove comincia il vettore di interi. L’informazione restituita consente di accedere all’area di memoria riservata e quindi l’indirizzo viene memorizzato in una variabile puntatore. A questo punto per accedere alle diverse celle del’area di memoria si utilizza la stessa notazione usata per le variabili di tipo vettore: si usano le parentesi quadrate. Il seguente è un esempio di allocazione dinamica di un vettore di strutture. #include<stdio.h> #include<stdlib.h> typedef struct{int x;double y; float z;} Tripletta; void f(Tripletta *v, int dim){ int i; for(i=0;i<dim;i++) printf("%d, %f, %f\n",v[i].x, v[i].y, v[i].z); } int main(void){ int nroElementi,i; Tripletta *T; scanf("%d",&nroElementi); /*T = malloc(sizeof(Tripletta)*nroElementi);*/ T = calloc(nroElementi, sizeof(Tripletta)); for(i=0;i<nroElementi;i++){ T[i].x=i; T[i].y=0.5; T[i].z=1.5; } f(T,nroElementi); 95 return 0; } 96 #include<stdio.h> void f(int x,int v[][x]){ int i; for(i=0;i<x;i++) printf("%d;\n ",v[i][i]); } int main(void){ int e,x,y,i,j; scanf("%d%d",&x,&y); int z[x][x]; int k[y][y]; e=0; for(i=0;i<x;i++) for(j=0;j<x;j++) { z[i][j]=e; printf("z[%d][%d]=%d;\n",i,j,z[i][j]); e++; } f(x,z); for(i=0;i<y;i++) for(j=0;j<y;j++) { k[i][j]=e; printf("k[%d][%d]=%d;\n",i,j,k[i][j]); e++; } f(y,k); return 0; } Figura 25: Stampa della diagonale Versione 1 97 #include<stdio.h> int nrocol; void f(int v[][nrocol]){ int i; for(i=0;i<nrocol;i++) printf("%d;\n ",v[i][i]); } int main(void){ int e,x,y,i,j; scanf("%d%d",&x,&y); int z[x][x]; int k[y][y]; e=0; for(i=0;i<x;i++) for(j=0;j<x;j++) { z[i][j]=e; printf("z[%d][%d]=%d;\n",i,j,z[i][j]); e++; } nrocol=x; f(z); for(i=0;i<y;i++) for(j=0;j<y;j++) { k[i][j]=e; printf("k[%d][%d]=%d;\n",i,j,k[i][j]); e++; } nrocol=y; f(k); return 0; } Figura 26: Stampa delle diagonali: uso delle variabili globali 98 #include<stdio.h> void f(int v[], int dimcol){ int i; for(i=0;i<dimcol;i++) printf("%d;\n ",v[i*dimcol+i]); } int main(void){ int e,x,y,i,j; scanf("%d%d",&x,&y); int z[x][x]; int k[y][y]; e=0; for(i=0;i<x;i++) for(j=0;j<x;j++) { z[i][j]=e; printf("z[%d][%d]=%d;\n",i,j,z[i][j]); e++; } f(z,x); for(i=0;i<y;i++) for(j=0;j<y;j++) { k[i][j]=e; printf("k[%d][%d]=%d;\n",i,j,k[i][j]); e++; } f(k,y); return 0; } Figura 27: Stampa della diagonale: versione con array 99 9 Nota su sprintf La funzione sprintf è simile a printf. Mentre printf stampa a video, sprintf stampa “dentro una stringa” cioè ciò che dovrebbe essere emesso a video (printf) viene emesso in una variabile di tipo vettore di caratteri. Esso un esempio banale: #include<stdio.h> int main(void){ char v[20]; int i; /*inizializzazione di v*/ for(i=0;i<10;i++) v[i]=’\0’; printf("Salve Mondo\n"); sprintf(v,"Salve Mondo\n"); printf("%s",v); printf(v); return 0; } L’esempio mette in evidenza un’altra caratteristica di printf e cioè che il parametro attuale a primo argomento può essere una variabile di tipo stringa. Infatti, vale la pena ricordare che il prototipo di printf è: int printf(char *format, ...); Quello di sprintf è int sprintf(char *str, char *format, ...); A questo punto diventa agevole rendere parametrico l’output di printf, come nel seguente esempio: #include<stdio.h> int main(void){ char v[20]; int i,dim,x; /*inizializzazione di v*/ for(i=0;i<10;i++) v[i]=’\0’; scanf("%d%d",&x,&dim); 100 sprintf(v,"%c%dd",’%’,dim); printf("stringa di formato:%s\n",v); printf(v,x); return 0; } 10 Le Funzioni C per la Generazione di Numeri Casuali Per evitare di dover inserire a mano dati di input, può essere comodo generare casualmente dei numeri. • la funzione C long int random(void) restituisce un numero casuale compreso tra 0 e RAND MAX; • la funzione C void srandom(unsigned int seed) inizializza la sequenza casuale. Il modo più comune di farlo è per mezzo della chiamata alla funzione time t time(time t *t) che restituisce il tempo trascorso dal 1 gennaio 1970, misurato in secondi. • Tipicamente la chiamata srandom(time(NULL)); inizializza il generatore di numeri casuali. • Le funzioni random() e srandom() sono nella libreria stdlib.h, mentre time() è compresa nella libreria time.h, occore quindi ricordarsi di includerle. In Figura 28 vi è un programma che genera e stampa a video 1000 numeri casuali. 101 #include <stdlib.h> #include<time.h> #include<stdio.h> int main(void){ int a; srandom(time(NULL)); for(a=0;a<1000;a++) printf("%d\n", random()); return 0; } Figura 28: Esempio di Generazione di Numeri Casuali 102