Appunti di Algoritmi e Struture Dati per il Corso di Laurea

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