Appunti di Algoritmi e Struture Dati per il Corso di Laurea Specialistica in ECOSTI Guido Fiorino 0 Presentazione 0.1 Programma del corso • Un esempio introduttivo • Cenni ai modelli di calcolo e alle metodologie di analisi • Algoritmi di ordinamento Bucketsort e Radixsort • Selezione e statistiche d’ordine • Tecnica divide et impera • Programmazione dinamica • (Tecnica greedy) • 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. Un orale facoltativo che farà media con lo scritto. 2 1 Un esempio introduttivo nel corso gli algoritmi NON verranno scritti in C o in JAVA o in qualche linguagglio di programmazione reale ma in pseudocodice; ci occupiamo di progettare algoritmi e di analizzarli matematicamente, cosa diversa che 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; 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 a√n−2 otteniamo = 0. √ che deve essere a −a−1 √ 1+ 5 1− 5 1+ 5 n Risolvendo l’equazione otteniamo due soluzioni per a : a1 = 2 e a2 = 2 . Purtroppo anche se ( 2 ) √ 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). 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 G(n) = c1 + c2 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 3 Abbiamo quindi la nostra funzione G(n) = F (n), ovvero abbiamo scoperto l’andamento analitico di F (n): √ !n ! √ !n 1− 5 1+ 5 1 − 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). 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. 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 di n per arrivare n−2 al passo base. Sostituendo k otteniamo che T (n) > 2 2 . Questo ci dice che il numero di righe eseguito è n−1 (almeno) esponenziale in funzione di n, con n pari. Per n dispari otteniamo 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 < ... 4 Questa catena termina quando n − k = 2, ovvero dopo k = n − 2 iterazioni. Sostituendo 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) 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. 5 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. 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 Sia A = 1 1 . 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, n = 1, quando i = lg n, quindi dopo i sostituzioni abbiamo T (n) = T (1) + (lg n)K1 ∈ O(lg n). il tempo di 2i esecuzione della chiamata ricorsiva per fare la potenza n-esima è logaritmico nel valore di n. 6 2 Modelli di Calcolo 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 comunque tale criterio sia adottato 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 dlgb be 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 2.1 n(n+1) 2 e n(n + 1), cioè Θ(n2 ). 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.2 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 7 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.2.1 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. 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 dalla dimensione delle istanze (cioè da interi) a reali positivi. 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? Piuttosto che riprendere fibonacci in cui il tempo di esecuzione è in maniera naturale espresso da interi ad interi in quanto l’input è un singolo numero intero, usiamo il problema della ricerca di un elemento in una lista. 2.2.2 Ricerca Sequenziale Prendiamo l’algoritmo di ricerca sequenziale (figura 2.2, pagina 30) e valutiamo il numero di confronti che è l’operazione più frequente. 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. 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 |I|=n 8 P rob(I) · tempo(I) 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) = Quindi Tavg (n) = n X 1 n P rob(pos(x) = i) ∗ i = i=1 n X n+1 1 ∗i= n 2 i=1 se x ∈ Lista. Se x 6∈ Lista 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 π) 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: Tavg (n) = = 2.2.3 i=1 (n−1)! n! Pn = Pn 1 n i=1 i ∗i n+1 2 Ricerca Binaria Prendiamo lo pseudocodice per la ricerca binaria in Figura 2.4, pagina 34. Consideriamo un array di estremi [a, b], dove b − a + 1 = n. • 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 ∈ Lista e che possa occupare con la medesima probabilità qualsiasi posizione, allora n X 1 Tavg (n) = ∗ (n.ro confronti per x in posizione pos). 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. Possiamo andare avanti 9 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. Quindi la sommatoria può essere riscritta come lg2 n Tavg (n) = X 1 ∗ i ∗ n.ro posizioni che richiedono i confronti n i=1 lg2 n = 1 X 1 i · 2i−1 = lg2 n − 1 + . n n i=1 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. 10 3 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; • uno deterministico. 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, in Figura 5.2, pagina 117. Vale quanto segue Lemma 3.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. 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? 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. Dov’è il secondo 11 minimo? E’ in quelle coppie in cui compare il minimo. Rifacciamo una ricerca di minimo tra gli elementi che occorrono in tali coppie. Questa idea può essere generalizzata al caso in cui k = O( lgnn ). Volendo il k-esimo massimo procediamo come segue: rendiamo heap il vettore, e questo è fatto in tempo O(n). A questo punto cancelliamo il massimo k volte e dopo ogni cancellazione ripristiniamo la proprietà di heap, e questo è fatto in tempo O(lg n). Cosı̀ complessivamente il tempo è O(n + k lg n). L’algoritmo è 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 qualora fosse k = O( lgnn ) 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. 3.1 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 (Figura 5.7, pagina 121). La modifica è dettata da ordini di efficienza e semplicità dell’analisi probabilistica. 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) 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 partizione più piccola aumenta. Quando k 6= d n2 e si può ottenere una partizione di dimensione più grande di k ma minore di d n2 e su cui si effettua la ricorsione, cosa che mai accade se k = d n2 e. 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 12 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(i), n ≥ 1 C(n) = O(n) + n2 n−1 i= n 2 C(0) = 0 da cui si dimostra per sostituzione che vale C(n) ≤ tṅ, t ∈ N Base: C(0) = 0 ≤ t · 0; Induzione: C(n) = (n − 1) + ≤ (n − 1) + ≤ (n − 1) + n−1 2X C(i) ≤ per hp induttiva n n 2 n i= 2 n−1 X t·i i= n 2 n−1 X 2 ·t n i i= n 2 n −1 n−1 2 X X 2 i) C(n) ≤ (n − 1) + · t( i− n i=1 i=1 2 (n − 1)n (n/2 − 1)n/2 ≤ (n − 1) + · t( − ) n 2 2 ≤ (n − 1) + t(3/4n − 1/2) ≤ n(3/4t + 1) − 1/2t − 1 ≤ n(3/4t + 1) ≤ tn, per t ≥ 4. 3.2 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 divide l’array in due parti tali che il mediano può essere cercato (ricorsivamente) su una frazione dell’array grande al più la metà. L’idea è 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’idea si basa sul calcolo del mediano dei mediani: 13 1. 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; 2. di ogni gruppo calcoliamo il mediano; facile e veloce perchè ci sono solo 5 elementi in ogni gruppo. Chiamiamo tali mediani m1 , . . . , md n5 e ; 3. per chiamata ricorsiva trova il mediano M dei mediani m1 , . . . , md n5 e ; 4. usa l’algoritmo quickselect dove l’elemento di partizionamento x vale M . L’algoritmo è il seguente (Figura 5.10, pagina 125). Algoritmo select(array A, intero k)->elem 1. if (|A|<=10) then 2. ordina A 3. return k-esimo elemento 4. partiziona A in dn/5e sottoinsiemi di (max) 5 elementi 5. for i= 1 to dn/5e do mi mediano di Si 6. M=select({mi |i ∈ [1, dn/5e]}, dn/10e) 7. partiziona A prodotto rispetto a M calcolando: 8. A1 = {y ∈ A|y < M } 9. A2 = {y ∈ A|y = M } 10. A3 = {y ∈ A|y > M } 11.if (k ≤ |A1 |) then return select (A1 , k) 12.else if (k > |A1 | + |A2 |) 13. then return select (A3 , k − |A1 | − |A2 |) 14.else return M E’ facile dimostrare che 6 confronti sono sufficienti per trovare il mediano tra 5 elementi, 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). Il vero problema è sapere su quale dimensione dell’array è fatta la chiamata ricorsiva, cioè la dimensione che il partizionamento produce. Nel seguente lemma contiamo gli elementi esclusi: Lemma 3.2 La chiamata ricorsiva al passo 4 è 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 n M è mediano degli mi almeno metà di questi è scartata, e quindi almeno d 12 d n5 ee = d 10 e dato che gli mi sono n d 5 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 n − 1e (-1 è dovuto al fatto che l’ultimo gruppo potrebbe che mi ≥ M sono, per quanto osservato sopra, d 10 14 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 7n 10 + 3 elementi. Theorem 3.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 7n 10 + 3 elementi, quindi 7 impiega tempo T ( 10 n + 3). 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) ≤ Kn − 4K. 15 4 4.1 Ordinamento Bucketsort Consideriamo di voler ordinare n numeri dell’intervallo [1, . . . , n], e che i numeri possano ripetersi. Allora basta usare un vettore ausiliario di n componenti in cui contiamo le frequenze e poi riscrivere il vettore dei dati sulla base delle frequenze di ciascun intero (vedi algoritmo integersort, figura 4.20, pagina 103). Tale algoritmo impiega un tempo lineare rispetto al numero di elementi. La dimostrazione è immediata: la linea 3 consiste nel contare le frequenze ed il tempo è O(n). Dato che la somma dei valori in Y è n, complessivamente il ciclo while viene eseguito n volte(basta osservare che l’operazione di decremento deve essere effuttuata esattamente n volte. Quindi si ottiene complessivamente O(n). L’algoritmo è immediatamente adattabile al caso in cui l’intervallo degli interi sia [1, . . . , k]. In tal caso il tempo sarà O(n+k), che resta una quantità lineare se k è dell’ordine di n. Ma se k fosse molto grande, ad esempio nc , con c > 1, l’algoritmo avrebbe complessità O(nc ), che se c ≥ 2 significa avere prestazioni peggiori degli algoritmi di ordinamento standard. Vedremo la soluzione più avanti. Se non bisogna ordinare numeri ma record di dati rispetto ad un dato numerico, es. anno di nascita, mese di nascita, possiamo modificare l’algoritmo integersort in bucketsort come segue: algoritmo bucketsort(array X, intero k) 1. sia n la dimensione di X 2. sia Y una array di k liste inizialmente vuote 3. for i=1 to n do 4. appendi X[i] alla lista Y[X[i].key] 5. poni il risultato della concatenazione delle k liste Y[1],...,Y[k] in X piuttosto che usare dei contatori usiamo delle liste, dove in ogni lista vengono messi quei record aventi la medesima chiave di ordinamento. Dopo aver costruito le liste basta concatenarle con un tempo che è O(n + k). Si osservi che gli algoritmi descritti NON operano in loco, ovvero hanno bisogno di memoria aggiuntiva oltre a quella dei dati, una caratteristica che non hanno gli algoritmi basati su confronti. Una caratteristica di bucketsort utile da osservare è la sua stabilità: 2 elementi x[i], x[j] con la medesima chiave e tali che i < j, si troveranno in 2 nuove posizioni x[i0 ], x[j 0 ], tali che i0 < j 0 . 4.2 Radixsort Se k è un numero grande, molto più grande di n, possiamo considerare le cifre che compongono i numeri e applicare bucketsort a ciscuna cifra dei numeri, partendo dalla cifra meno significativa. Radixsort può essere espresso come segue : 16 algoritmo radixsort (array A, intero nrocifre) 1. d=0; 2. while (d < nrocifre) 3. usa bucketsort per ordinare gli elementi di A rispetto alla cifra di posto d; 4. d++ Se il valore massimo che una chiave può assumere è k, allora blgb kc + 1 è al più la quantità di cifre di cui sono composti i numeri da ordinare, dove b rappresenta la base in cui sono espressi i numeri da ordinare; ad esempio, se i numeri da ordinare sono espressi in base 10, il valore massimo che compare nel vettore da ordinare è 9999, sappiamo che tutti i numeri sono composti da al più 4 cifre. Quindi lgb k passate di bucketsort sono sufficienti, dove ciascuna passata occupa tempo O(n). Considerare ciascuna cifra non ci consente di sfruttare bene bucketsort, che comunque impiega tempo almeno n, anche se k piccolo. Allora la cosa migliore da fare è scegliere un k pari a n per ciascuna passata di bucketsort, ovvero non scegliere k = 10 (se i numeri fossero rappresentati in base 10, cioè con le cifre 0,1,2,. . . ,9) per 1 passata di bucketsort, ma k uguale a n, tanto il tempo resta sempre O(n), ma possiamo risparmiare passate. Questo significa dire che piuttosto che prendere una cifra ne prendiamo lg10 n = |n|. Cosı̀ se k = nc , ovvero |k| = c lg10 n, invocando bucketsort considerando lg10 n cifre faremo c invocazioni, ed il tempo resterà O(n). 17 5 Tecniche Algoritmiche Sono tre le principali tecniche per la progettazione di algoritmi. La tecnica divide et impera, 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. La programmazione dinamica, che si basa sulla filosofia bottom-up. Si procede 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. 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 greedy, che costruisce la soluzione applicando di volta in volta la scelta localmente più promettente. Molti problemi di ottimizzazione ammettono algoritmi greedy ottimali. 5.1 Tecnica Divide et Impera Lo sforzo del progettista consiste nell’individuare sia come dividere il problema sia (ma soprattutto) come ricombinare le soluzioni parziali. Prendiamo l’esempio della 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ù n 2 18 + 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 lg2 n T (n) = 3lg2 n K1 + K2 n X 1 = O(nlg2 3 ) 2i i=0 5.2 La Programmazione Dinamica 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 (cioè un entry della tabella) in funzione dei valori delle soluzioni dei sottoproblemi già risolti; 4. si restituisce la soluzione del problema originario. 5.2.1 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 19 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 . Torniamo al problema con n matrici. Indichiamo con P (i, j) il sottoproblema che consiste nel moltiplicare le matrici Mi . . . Mj . 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 di pagina 252 riportato qui di seguito. 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 7. for r=i+1 to (j-1) do 8. C[i,j]=min{C[i,j],C[i,r]+C[r+1,j]+li lr+1 lj 9. return C[1,n] 20 Theorem 5.1 L’algoritmo ordinematrici richiede tempo O(n3 ), dove n è il numero di matrici. Dimostrazione: nella diagonale d, 1 ≤ d ≤ n − 2 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). Otteniamo quindi la seguente serie di sommatorie: T (n) = n−1 X n−d X d+i−1 X d=1 i=1 = n−1 X X n−d 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 = 1, . . . , 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 ). 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. 5.3 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. 21 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. 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.} } 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. 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++) 22 { 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 } } 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; } } 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. 23 Una prima soluzione che non usa la programmazione dinamica è la seguente: 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. 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]; } } 24 5.4 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: Theorem 5.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 e 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. 25 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 . E’ facile rendersi conto che molti sottoproblemi si sovrappongono. 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. 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 X ha m + 1 componenti,Y ha n + 1 componenti, C é la matrice calcolata e in C[m, n] si trova LCS(X,Y). 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-]) 7. then c[i,j]=c[i-1,j] 8. else c[i,j]=c[i,j-1] 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 26 4. 5. 6. 7. 8. c[i%2,0]=0 for j= 1 to n do if (x[i]=y[j]) then c[i%2,j]=c[(i-1)%2,j-1]+1 else if (c[(i-1) % 2,j]>=c[i%2,j-]) then c[i%2,j]=c[(i-1)%2,j] else c[i%2,j]=c[i%2,j-1] Invece di una matrice possiamo usare un vettore e 2 variabili... 27 6 Grafi 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. 6.1 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. • 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. • 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. • matrice di adiacenza: matrice M di dimensione |V |x|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, e cosi’ via per trovare nodi connessi tra loro con cammini di lunghezza 3, 4 etc. • matrice di incidenza: matrice M di dimensione |V |x|E|, dove le righe sono indicizzate dai nodi, le colonne dagli archi. M [i, j] = 1 se l’arco indicizzato da j incide sul nodo indicizzato da i. Per grafi orientati si possono usare +1 e −1 per distinguere nodo sorgenete e nodo destinazione dell’arco in questione. 6.2 Visite di Grafi Visitare un grafo significa seguirne sistematicamente gli archi in modo da visitarne i nodi. Sia G = (V, E) un grafo. Le seguenti sono carattristiche comuni per tutte le visite dei grafi: 28 • Le visite costruiscono un albero, che indicheremo con T , dei nodi visitati. I nodi di T sono un sottoinsieme di V . • vi è un insieme di nodi F ⊆ nei nodi di T che viene detto frangia di T. Tale frangia sono in pratica le foglie di T . • Gli algoritmi di visita si distinguono per la politica che adottano nel considerare i nodi inseriti/estratti dalla frangia (l’ordine di visita dei nodi). 6.2.1 Visita generica Quando visitiamo un grafo dobiamo prestare attenzione a non cadere in cicli infiniti a causa del fatto che visitiamo più volte lo stesso nodo. Il problema viene evitato introducendo un bit di marcatura. Riportiamo l’algoritmo di pagina 275. Algoritmo Visita Generica(grafo G, vertice s)-> albero 1. Rendi tutti i nodi non marcati; 2. Sia T l’albero formato dal solo nodo s; 3. Sia F l’insieme vuoto; 4. marca il vertice s e aggiungi s a F 5. while (F non e’ vuoto ) do 6. estrai da F un qualsiasi vertice u; 7. visita il vertice u; 8. for each ( arco (u,v) di G ) do 9. if (v non e’ marcato) then 10. marca v e aggiungilo a F; 11. rendi u padre di v in T; 12. else <eventuali operazioni dipendenti dallo specifico tipo di visita> 13. return T Prima di tutto l’algoritmo termina perchè la prima volta che un nodo viene inserito in F viene marcato e solo i nodi non marcati venmgono inseriti in F. Il tempo di marcatura complessivamente è lineare nel numero di nodi del grafo, considerando che una singola marcatura costi tempo costante; Per ogni nodo estratto dalla frangia F occorre generare tutti i suoi vicini. L’efficienza di questa operazione dipende dalla rappresentazione scelta. Si osservi come : • nel caso di rappresentazione con liste di adiacenza o incidenza il tempo dipenda complessivamente dal numero di archi del grafo; • nel caso di rappresenzazione mediante lista di archi, per ogni nodo del grafo gli archi del grafo vengano scanditi tutti; 29 • nel caso di rappresentazione mediante matrice di adiacenza, per ogni nodo la riga nella matrice corrispondente debba essere scandita interamente. 6.2.2 Visita in ampiezza Consideriamo un grafo G = (V, E) non orientato. Visitare in ampiezza un grafo G a partire da un nodo sorgente s significa adattare la visita generica in modo tale che la frangia F in cui vengono messi/estratti i nodi sia gestita come una struttura dati coda. L’albero di visita T che si ottiene (detto BFS da breadth-first search) ha la proprietà che ogni suo nodo è il più vicino possibile alla radice. Riportiamo l’algoritmo a pagina 280 Algoritmo VisitaBFS(grafo G, vertice s)-> albero 1. Rendi tutti i nodi non marcati; 2. Sia T l’albero formato dal solo nodo s; 3. Sia F l’insieme vuoto; 4. marca 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 non e’ marcato) then 11. marca v 12 aggiungilo in coda a F; 12. rendi u padre di v in T; 13. return T 6.2.3 Visita in profondità Consideriamo un grafo G = (V, E) non orientato. Visitare in profondità un grafo G a partire da un nodo sorgente s significa adattare la visita generica in modo tale che la frangia F in cui vengono messi/estratti i nodi sia gestita come una struttura dati pila (stack). L’albero di visita T che si ottiene è detto DFS da depth-first search. La visita in profondità è un adattamento della visita in preordine di alberi binari. Qui bisogna tener conto del fatto che ci possono essere cicli tra nodi e che un nodo può avere più successori. Vediamo due versioni: una ricorsiva ed una iterativa. Riportiamo l’algoritmo a pagina 283. Algoritmo VisitaDFSRicorsiva(grafo G, vertice v, albero T) 1. Marca e visita il vertice v; 2. Per ogni arco (v,w) in G esegui: 3. if (w non è marcato) then 4. aggiungi l’arco (v,w) a T 30 5. VisitaDFSRicorsiva(grafo G, vertice w, albero T) Algoritmo visitaDFS(grafo G, vertice s) 6. Sia T l’albero vuoto; 7. VisitaDFSRicorsiva(grafo G, vertice v, albero T); 13. return T Di seguito diamo la versione iterativa: Algoritmo VisitaDFS(grafo G, vertice s)-> albero 1. Rendi tutti i nodi non marcati; 2. Sia T l’albero formato dal solo nodo s; 3. Sia F l’insieme vuoto; 4. marca 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 non e’ marcato) then 11. marca v 12 aggiungilo in testa a F; 12. rendi u padre di v in T; 13. return T 31