Orario: Martedì 1013 Giovedì Lab.Martedì 1416 RicevimentoGio.16 aula a
Problemi, Algoritmi e Strutture Dati
Problema: Ordinamento Input: una sequenza di n numeri Output: i numeri ricevuti in input ordinati dal più piccolo al più grande ovvero: dove a π (i) ≤ a π (i+1) π è una opportuna permutazione degli indici 1,…,n
Idea per ordinare… Ad ogni passo ho una sottosequenza ordinata in cui inserisco un nuovo elemento dell’input: ordinati elemento da inserire ordinati Non necessariamente ordinati
Insertion Sort Insertion-sort(A) 1.for j=2 to length(A) 2.do key = A[j] 3. {insert A[j] in A[1,…,j-1]} 4. i = j-1 5. while i>0 and A[i]>key 6.do A[i+1] = A[i] 7. i=i-1 8. A[i+1] = key
Esempio: A = {5,2,4,6,1,3} j= j= j= j= j= j=
Ordinamento Insertion Sort A[1,…,n] = vettore i,j,key = variabili Numeri ordinati n numeri
Problema Algoritmo Strutture Dati OutputInput
Strutture dati usate da Insertion-Sort DATI: Insieme di numeri: S OPERAZIONI: read(i) size(S) modify(i,x)
Insertion Sort Insertion-sort(A) 1.for j=2 to size(A) 2.do key = read(j) 3. {insert A[j] in A[1,…,j-1]} 4. i = j-1 5. while i>0 and read(i)>key 6.do modify(i+1,read(i)) 7. i=i-1 8. modify(i+1,key)
Strutture Dati Astratte DATI + OPERAZIONI “Che cosa” DATI OP 1 OP 2 OP n ……………
Esempio di ADS Dati = insieme S di numeri OP 1 = estrai il minimo OP 2 = estrai il massimo OP 3 = restituisci la taglia di S OP 4 = inserisci un nuovo numero in S
Insertion sort ADS = DS = Insieme S di numeri + Read, Size, Modify S=“A[1,…,n]” (vettore) Read(i)=“A[i]” Modify(i,x)=“A[i]=x”
ADS = che cosa vogliamo ? DS = come lo implementiamo ?
Quando una struttura dati è “buona” ? Una DS è buona quando non usa troppe risorse. Risorse Tempo Spazio di memoria Numero di processori …
Dimensione del problema Le risorse usate (tempo, spazio) si misurano in funzione della dimensione dell’istanza del problema che vogliamo risolvere. Esempio: se ordiniamo n numeri la taglia dell’input sarà n. se moltiplichiamo due matrici nxn sarà n 2. Etc… Il tempo e lo spazio saranno funzioni in n ( TIME(n), SPACE(n) )
Analisi di Insertion Sort Dati: A= Dimensione di A = nc 1 Read(i) impiega tempo c 2 Modify(i,x) impiega tempo c 3 NOTA: c 1, c 2, c 3 sono indipendenti da n e vengono perciò dette costanti
Insertion Sort: analisi del costo computazionale t j numero di elementi maggiori di A[j] dipende dai dati in input caso ottimo: t j = 0 caso pessimo: t j = j-1 caso medio: t j = (j-1)/2 tjtj A[j]
Insertion Sort: analisi del costo computazionale Insertion-sort(A) 1.for j=2 to length(A) 2.do key = A[j] 3. i = j-1 4. while i>0 and A[i]>key 5.do A[i+1] = A[i] 6. i=i-1 7. A[i+1] = key C1nC1n C 2 n-1 C 3 n-1 C 4 ∑(t j +1) C 5 ∑t j C 6 ∑t j C 7 n-1 j=2 n n n
T(n) = c 1 n + c 2 (n-1) + c 3 (n-1) + c 4 ∑(t j +1) + (c 5 +c 6 ) ∑t j + c 7 (n-1) caso ottimo: t j = 0 T(n) = an + b; lineare caso pessimo: t j = j-1 T(n) = an 2 + bn + c; quadratico caso medio: t j = (j-1)/2Esercizio!!!
Procedimento di astrazione Costo in microsecondi Costanti c i Costanti a, b, c an 2 + bn + c Ordini di grandezza: quadratico
Analisi Asintotica
Analisi asintotica Obiettivo: semplificare l’analisi del tempo di esecuzione di un algoritmo prescindendo dai dettagli implementativi o di altro genere. Classificare le funzioni in base al loro comportamento asintotico. Astrazione: come il tempo di esecuzione cresce in funzione della taglia dell’input asintoticamente. Asintoticamente non significa per tutti gli input. Esempio: input di piccole dimensioni.
“O grande” Limite superiore asintotico f(n) = O(g(n)), se esistono due costanti c e n 0, t.c. f(n) cg(n) per n n 0 f(n) e g(n) sono funzioni non negative Notazione usata ad esempio nell’analisi del costo computazionale nel caso pessimo
Limite inferiore asintotico f(n) = (g(n)) se esistono due costanti c e n 0, t.c. cg(n) f(n) per n n 0 Usato per: tempo di esecuzione nel caso ottimo; limiti inferiori di complessità; Esempio: il limite inferiore per la ricerca in array non ordinati è (n). “ grande”
Eliminiamo termini di ordine inferiore e costanti. 50 n log n è O(n log n) 7n - 3 è O(n) 8n 2 log n + 5n 2 + n è O(n 2 log n) Nota: anche se (50 n log n) è O(n 5 ), ci aspettiamo di avere approssimazioni migliori !!!
“tight bound” = approssimazione stretta. f(n) = (g(n)) se esistono c 1, c 2, e n 0, t.c. c 1 g(n) f(n) c 2 g(n) per n n 0 f(n) = (g(n)) se e solo se f(n) = O(g(n)) e f(n) = (g(n)) O(f(n)) è spesso usato erroneamente al posto di (f(n)) “”“”
c 2 g(n) c 1 g(n) f(n) Taglia dell’input = n Tempo di esecuzione n0n0 f(n) = (g(n))
”o piccolo” e ” piccolo” f(n) = o(g(n)) Analogo stretto di O grande Per ogni c, esiste n 0, t.c. f(n) cg(n) per n n 0 Usato per confrontare tempi di esecuzione. Se f(n)=o(g(n)) diciamo che g(n) domina f(n). f(n)= (g(n)) analogo stretto di grande.
Notazione Asintotica Analogie con i numeri reali f(n) = O(g(n))f g f(n) = (g(n))f g f(n) = (g(n))f = g f(n) = o(g(n))f < g f(n) = (g(n))f > g Abuso di notazione: f(n) = O(g(n)) Versione corretta: f(n) appartiene a O(g(n))
Non tutte le funzioni si possono confrontare !!! nn 1+sen(n) ??? 1+sen(n) oscilla tra 0 e 2
Esempi M = numero grande, es: ; = numero piccolo, es: 0, ; Mn = (n ) Log(n) = o(n) [Log(n)] M = o(n ) n M =o(2 n ) 2 nM = o(n!) Mn! = o(n n )
Limiti e notazione asintotica f(n)/g(n) ---> c allora f(n) = (g(n)) f(n)/g(n) ---> 0 allora f(n) = o(g(n)) f(n)/g(n) ---> ∞ allora f(n) = (g(n))
Limiti e notazione asintotica log[f(n)] = o(log[g(n)])allora f(n) = o(g(n)) log[f(n)] = (log[g(n)])non è detto che f(n) = o(g(n)) esempio: log[n] = (log[n 2 ]) ma n = o(n 2 )
Tecniche Algoritmiche: Divide et Impera
Divide et Impera Divide et impera: Dividi: Se l’istanza del problema da risolvere è troppo “complicata” per essere risolta direttamente, dividila in due o più “parti” Risolvi ricorsivamente: Usa la stessa tecnica divide et impera per risolvere le singole parti (sottoproblemi) Combina: Combina le soluzioni trovate per i sottoproblemi in una soluzione per il problema originario.
MergeSort: Algoritmo Dividi: se S contiene almeno due elementi (un solo elemento è banalmente già ordinato), rimuovi tutti gli elementi da S e inseriscili in due vettori, S 1 e S 2, ognuno dei quali contiene circa la metà degli elementi di S. (S 1 contiene i primi n/2 elementi e S 2 contiene i rimanenti n/2 elementi). Risolvi ricorsivamente: ordina gli elementi in S 1 e S 2 usando MergeSort (ricorsione). Combina: metti insieme gli elementi di S 1 e S 2 ottenendo un unico vettore S ordinato (merge)
Mergesort: esempio
Merge Sort: Algoritmo Merge-sort(A,p,r) if p < r then q p+r)/2 Merge-sort(A,p,q) Merge-sort(A,q+1,r) Merge(A,p,q,r) Rimuovi il più piccolo dei due elementi affioranti in A[p..q] e A[q+1..r] e inseriscilo nel vettore in costruzione. Continua fino a che i due vettori sono svuotati. Copia il risultato in A[p..r].
Merge … e così via
Equazioni ricorsive: un esempio semplice T(n) = 1 se n = 1 T(n/2) + 1 se n > 1 Come si risolve ???
Equazioni ricorsive Risultati e tempi di esecuzione di algoritmi ricorsivi possono essere descritti usando equazioni ricorsive Un equazione ricorsiva esprime il valore di f(n) come combinazione di f(n 1 ),...,f(n k ) dove n i < n. Esempio: Merge Sort
Metodo iteratvo T(n) = T(n/2) + 1 T(n/4) T(n/8) T(n/n) k Ci fermiamo quando 2 k =n k chiamate ricorsive
Dobbiamo valutare k. sappiamo che 2 k = n, quindi log 2 ( 2 k ) = log 2 (n), ovvero k = log 2 (n)
Induzione Dobbiamo dimostrare che una affermazione è vera per ogni n≥0 Teorema. Se 1. affermazione(0) è vera. 2. affermazione(n-1) vera implica affermazione(n) vera. Allora affermazione(n) vera per ogni n ≥ 0
Dimostrazione per induzione: esempio n i=1 i = n(n+1)/2 affermazione(n): 1 i=1 i = 1(1+1)/2 = 1 OK affermazione(1): affermazione(n-1) “implica” affermazione(n): n-1 i=1 i = (n-1)(n)/2 n i=1 i = n(n+1)/2 implica
Dimostrazione per induzione: esempio n i=1 i =...ma n-1 i=1 i + n = (n-1)(n)/2 + n = n(n+1)/2 L’uguaglianza tra questi due termini non è altro che affermazione(n-1) e quindi la assumiamo vera per ipotesi induttiva.
Metodo di sostituzione Primo passo: Ci buttiamo a “indovinare” una possibile soluzione: T(n) ≤ clog 2 (n) Secondo passo: la verifichiamo per induzione come segue: Assumiamo che T(n’) ≤ clog 2 (n’) per n’ < n e dimostriamo che T(n) ≤ clog 2 (n) c è una costante (indipendente da n) che determineremo strada facendo…
T(n) = T(n/2) + 1 ≤ clog 2 (n/2) + 1 = clog 2 (n) - clog 2 (2) + 1 = clog 2 (n) - c + 1 se c ≥ 1 allora ≤ clog 2 (n) Ipotesi induttiva !!!
Equazioni ricorsive: un esempio più complicato T(n) = (1) se n = 1 2T(n/2) + (n) se n > 1 Soluzione T(n) = (n log(n))
Albero di ricorsione Cn + 2T(n/2) C(n/2) + 2T(n/4) C(n/4) + 2T(n/8) = c n = c n + …… + = n(log(n)) (1) …… = c n Il fattore log(n) deriva dal fatto che l’albero ha un altezza log(n)
“Master Method” T(n) = aT(n/b) + f(n) a 1, b > 1, f(n) > 0 Poniamo x = log b a f(n) = O(n x- ) con >0 allora T(n) = (n x ) f(n) = (n x ) allora T(n) = (n x log(n)) f(n) = (n x+ ) con >0 af(n/b) ≤ cf(n) con c n 0
… Merge sort T(n) = (n log(n)) Insertion sortMerge sort Worst case Average case Best case (n 2 ) (n log(n)) (n) (n log(n))
Perchè ordinare è importante … velocizza molto la ricerca !!! Binary-search(A,x) i=0 j=length(A)-1 while i<j do k= (i+j)/2 if A[k]=x then return true if A[k]>x then j=k-1 if A[k]<x then i=k+1 if A[i]=x then return true else return false
Analisi di Binary search Poniamo D(t)=j-i. D(t) è l’ampiezza del vettore sul quale ancora dobbiamo eseguire la ricerca dopo t confronti. Abbiamo D(0) = n-1 ……… D(t+1) = D(t)/2 Usciamo dal while quando D(t)<2 … ovvero se t ≥ log 2 n. Quindi T(n) = (log 2 n)
Priority Queue (Code a Priorità) Dati: un insieme di elementi, ognuno dei quali ha una chiave (un intero per esempio). Operazioni: inserimento, trova il massimo, estrazione del massimo (massima chiave). Applicazioni delle PQ: Job scheduling Event-driven simulations
Implementazione (facile) usando vettori Prima soluzione: vettore ordinato. Ricerca massimo: (1) operazioni estrazione massimo: (1) operazioni inserimento: (n) operazioni Seconda soluzione vettore non ordinato. Ricerca massimo: (n) operazioni estrazione massimo: (n) operazioni inserimento: (1) operazioni Si può fare meglio ???
Grafi e Alberi G=(V,E) V={1,2,3,4,5,6,7,8} E={(1,2),(1,3),(1,4),(3,4),(6,7),(7,8)} {1,3,4,1} è un ciclo. Un grafo senza cicli è aciclico.
Un albero è un grafo aciclico con un numero di nodi uguale al numero di archi più uno ( |V|=|E|+1 ) Albero Foresta
r x qy w Radice r è la radice x è il padre di y y è un figlio di x x e q sono avi di w w e q sono discendenti di x q è fratello di y Foglie h(a) altezza del nodo a: h(x)=1 h(y)=h(q)=2 h(w)=3
Heap A={ 128, 64, 72, 8, 7, 12, 30, 1, 6, 3 } A(6) = 12
Heap: definizione formale Un Heap è un albero binario quasi completo. Quasi significa che possono mancare alcune foglie consecutive a partire dall’ultima foglia di destra. Per ogni nodo i: Value(i) ≤ Value(Parent(i)) Nota 1: il massimo si trova nella radice Nota 2: non c’è nessuna relazione tra il valore di un nodo e quello di un suo fratello
Memorizzazione di un heap in un vettore
Memorizzazione di un heap in un vettore Radice posizione 1 Per ogni nodo in posizione i: left-child(i) posizione 2i right-child(i) posizione 2i+1 parent(i) i/2
i AB HeapsHeap i2i2i+1 parte del vettore già heapizzato elemento da aggiungere al sotto heap (verde) 4i4i +38i8i +7
i AB IDEA: facciamo scendere il nodo i nell’albero fino a trovare la sua posizione. ? AB i
Heapify(A,i) l=left(i) r=right(i) if l≤heap-size(A) and A[l]>A[i] then largest=l else largest=i if r≤heap-size(A) and A[r]>A[largest] then largest=r if largest i then Exchange(A[i],A[largest]) Heapify(A,largest)
Heapify: costo computazionale Caso pessimo: il nodo si sposta fino ad arrivare alle foglie. Heapify impiega tempo costante ad ogni livello per sistemare A[i], A[left(i)] e A[right(i)]. Esegue aggiustamenti locali al massimo height(i) volte dove height(i) = O(log(n))
Build-heap(A) heap-size(A)=length(A) for i= length(A)/2 downto 1 do heapify(A,i) Analisi “approssimativa”: ogni chiamata a heapify costa O(log(n)). Chiamiamo heapify O(n) volte, quindi build-heap = O(nlog(n)) Domanda (esercizio): build-heap = (nlog(n)) ?
PQ implementate con Heap Extract-max(A) if heap-size(A)<1 then “error” max=A[1] A[1]=A[heapsize(A)] heapsize(A)=heapsize(A)-1 Heapify(A,1) return max O(log(n))
PQ implementate con Heap max = max = ?? max = Heapify( )
PQ implementate con Heap Insert(A,x) heap-size(A)=heap-size(A)+1 i=heap-size(A) while i>1 and A[parent(i)]<x do A[i]=A[parent(i)] i=parent(i) A[i]=x O(log(n))
Heap Sort: l’idea. Heap Heapify Heap Heapify... avanti così...
Heap Sort Heap-Sort(A) build-heap(A) for i=length(A) downto 2 do exchange(A[1],A[i]) heap-size(A)=heap-size(A)-1 heapify(A,1) O(nlog(n)) È un metodo “in place”
Quicksort: l’idea Dividi: Dividi il vettore in due parti non vuote. Conquista: ordina le due parti ricorsivamente Combina: fondi le due parti ottenendo un vettore ordinato. A={10,5,41,3,6,9,12,26} mergesort quicksort A metà A 1 ={10,5,41,3} A 2 ={6,9,12,26} Intorno a un Pivot, es 12 A 1 ={10,5,3,6,9,12} A 2 ={41,26} Dividi
Quicksort Quicksort(A,p,r) if p<r then q=partition(A,p,r) Quicksort(A,p,q) Quicksort(A,q+1,r) Nota: Mergesort lavora dopo la ricorsione Quicksort lavora prima della ricorsione Partition è cruciale !!!
ij i i i i i j j j j j A(p,r) < 5≥ 5 Pivot (n) in place
Analisi di QS nel caso ottimo Caso ottimo: partizioni bilanciate T(n) = 2T(n/2) + (n) quindi: T(n) = (nlog(n))
Analisi di QS nel caso pessimo Caso pessimo: partizioni sbilanciate T(n) = T(n-1) + (n) quindi: T(n) = (n 2 ) ricorsione partition
Analisi di QS nel caso non buono ! 90% 10% T(n) ???
Albero di ricorsione n 1/10 n9/10 n 1/100 n9/100 n 81/100 n n + (n log(n)) < n 81/1000 n729/1000 n log 10 n log 10/9 n
Analisi del caso medio di QS: una intuizione. Caso medio: a volte facciamo una buona partition a volte no... buona partition: cattiva partition
Caso medio le buone e le cattive partition si alternano... cattiva 1n-1 1(n-1)/2 dopo una cattiva e una buona partizione in successione siamo più o meno nella situazione in cui la cattiva partizione non è stata fatta ! buona
QS: distribuzione degli input Abbiamo assunto implicitamente che tutte le sequenze di numeri da ordinare fossero equiprobabili. Se ciò non fosse vero potremmo avere costi computazionali più alti. Possiamo “rendere gli input equiprobabili” ? mischiamo la sequenza casualmente prima di ordinare Scegliamo il pivot a caso. come procediamo
QS “randomizzato” QSR una una versione randomizzata della procedura Partition. Randomized-partition(A,p,r) i=random(p,r) exchange(A[p],A[i]) return partition(A,p,r) Un algoritmo randomizzato non ha un input pessimo, bensì ha una sequenza di scelte pessime di pivot.
Insertion sort Merge sort Heap sort Quick sort Caso pessimo n2n2 n log(n) n2n2 Caso medio n2n2 n log(n) Caso ottimo nn log(n) = in place
È possibile ordinare in meno di n log(n) ??? ovvero in o(n log(n))
Limite inferiore di complessità Insertion-sort Merge-sort Heap-sort Quick-sort “Comparison-sort” algoritmi basati su confronti Questi metodi calcolano una soluzione che dipende esclusivamentedall’esito di confronti fra numeri TEOREMA (Lower Bound per algoritmi Comparison-sort): Qualsiasi algoritmo “comparison-sort” deve effettuare nel caso pessimo (n log(n)) confronti per ordinare una sequenza di n numeri.
lower bound per comparison sort IDEA: con n numeri ho n! possibili ordinamenti. Possiamo scegliere quello giusto tramite una sequenza di confronti. ≤> >> ≤ ≤ Ogni nodo rappresenta un confronto.
Esempio: n=3 {a 1,a 2,a 3 } a 1 :a 2 a 2 :a 3 a 1 :a 3 a 1,a 2,a 3 a 1 :a 3 a2,a1,a3a 2 :a 3 ≤> >> ≤ ≤ Ogni nodo bianco rappresenta un confronto. Ogni nodo rosso rappresenta una possibile soluzione. a 1,a 3,a 2 a 3,a 1,a 2 >≤ a 2,a 3,a 1 a 3,a 2,a 1 >≤ albero dei confronti
3! = 6 = numero di foglie dell’albero dei confronti. ogni (cammino dalla radice ad una) foglia rappresenta un ordinamento ci sono n! ordinamenti. quanto deve essere alto un albero binario per avere n! foglie ??? un albero binario alto h ha al massimo 2 h foglie dobbiamo avere 2 h ≥ n! Formula di Stirling: n! > (n/e) n e= h ≥ log[(n/e) n ] = nlog(n) - nlog(e) = (nlog(n))
Il caso pessimo di un qualsiasi algoritmo comparison-sort eseguito su una sequenza di n numeri è dato dall’altezza dell’albero di decisione associato a quell’algoritmo. MA Un albero binario con n! foglie (ordinamenti) ha un altezza (nlog(n)) QUINDI qualsiasi algoritmo comparison-sort, nel caso pessimo, esegue (nlog(n)) confronti.
Counting sort: come ordinare in tempo lineare (!?!?) Ipotesi di lavoro: I numeri da ordinare appartengono all’intervallo [1,k] Risultato: counting sort ha un costo computazionale O(n + k) Se k=O(n) allora counting sort ha un costo computazionale O(n) e quindi “batte” tutti i comparison sort
Counting sort: un esempio A = {3,6,4,1,3,4,1,4} C’ = {2,0,2,3,0,1} C’’ = {2,2,4,7,7,8}. C’[3]=2 perché il numero 3 è contenuto 2 volte in A C’’[4]=7 perché ci sono 7 numeri minori o uguali a 4
Algoritmi di ordinamento stabili A B C D E F C E D A B F
Algoritmi di ordinamento NON stabili A B C D E F E C D A B F
Algoritmi di ordinamento stabili Un algoritmo di ordinamento è stabile se: Se A[i] = A[j] e i < j allora A[i] compare nell’ordinamento prima di A[j] ESERCIZIO: dimostrare che counting sort è stabile.
Counting-sort(A,B,k) 1.for i=1 to k do C[i]=0 2.for j=1 to length(A) do C[A[j]]=C[A[j]]+1 3.for i=2 to k do C[i]=C[i]+C[i-1] 4.for j=length(A) downto 1 do 5.B[C[A[j]]]=A[j] 6.C[A[j]]=C[A[j]]-1 1.costa (k) 2.costa (n) 3.costa (k) 4.costa (n) Quindi Counting sort = (n + k)
Radix sort vettore ordinato
Radix sort Radix-sort(A,d) for i=1 to d do usa un “stable sort” per ordinare A sulla cifra iesima Ogni cifra è compresa tra 1 e k. Usiamo counting sort (stabile). Costo computazionale: d (n+k) = (nd+dk). Counting sort non lavora in place !
Bucket sort Ipotesi: i numeri da ordinare sono uniformemente distribuiti nell’intervallo [0,1), ovvero Ci si aspetta che nell’intervallo [x,x+ ) ci siano tanti numeri quanti in [y,y+ ) per qualunque x,y,
Bucket-sort(A) n=length(A) for i=1 to n do inserisci A[i] nella lista B[ nA[i] ] for i=0 to n-1 do ordina la lista B[i] usando insertion-sort Concatena le liste B[0],...,B[n-1] NOTA: nA[i] restituisce il “bucket” dove inserire A[i]
Variabile Aleatoria Discreta: variabile che può assumere un numero finito di valori con una certa distribuzione di probabilità. Esempio 1: X = (Testa, Croce) Pr(X=Testa) = Pr(X=Croce) = 1/2 Esempio 2: Y = (1,2,3,4) Pr(Y=1) = Pr(Y=2) = 1/3, Pr(Y=3) = Pr(Y=3) = 1/6.
Media di una VAD E[Y]= 1·1/3 + 2·1/3 + 3·1/6 + 4·1/6 = 13/6 Valori possibili di Y Probabilità
Vogliamo calcolare il costo computazionale medio di Bucket Sort. Bucket Sort usa Insertion Sort sulle singole liste. Assumendo che la lunghezza media di ogni lista sia n i, il costo della singola applicazione di Insertion Sort è (n i ) 2 (nel caso pessimo !!!) Dobbiamo quindi valutare E[(n i ) 2 ]. NOTA: E[X 2 ] diversa da E[X] 2
n i = variabile aleatoria = numero di elementi nel bucket i Pr(x ---> B i ) = 1/n Distribuzione binomiale Pr(n i = k) = ( ) (1/n) k (1-1/n) n-k E[n i ] = n 1/n = 1 E[n i 2 ] = Var[n i ] + E 2 [n i ] = 2 - 1/n = (1) E[n i 2 ] = costo computazionale di insertion sort Costo computazionale di bucket sort = O(n) nknk
Selezione: esempio minimo massimo quarto elemento nell’ordinamento Input Input ordinato
Selezione Ordinamento (nlog(n)) minimo/massimo (n) Selezione ???? Selezione: Calcolare l’iesimo elemento nell’ordinamento.
Selezione Input: un insieme A di n numeri distinti e un numero i tra 1 e n Output: l’elemento x in A maggiore di esattamente i-1 altri numeri in A Soluzione banale: ordino gli elementi di A e prendo l’iesimo elemento nell’ordinamento. (nlog(n)) (analisi del caso pessimo)
A[1,...,n] 1 n q Supponiamo che A[i] ≤ A[j] per ogni 1 ≤ i ≤ q e ogni q < j ≤ n Domanda: il k-esimo elemento nell’ordinamento sta in L o in R ? LR Risposta: Facile. Se k ≤ q ---> L. Altrimenti R.
Selezione in tempo medio lineare Rand-select(A,p,r,i) if p=r then return A[p] q=rand-partition(A,p,r) k=q-p+1 if i≤k then return Rand-select(A,p,q,i) else return Rand-select(A,q+1,r,i-k) caso pessimo (n 2 ) caso medio (n) (senza dimostrazione)
Selezione in tempo lineare nel caso pessimo IDEA: dobbiamo progettare un buon algoritmo di partition n (1- )n Nota: basta che sia maggiore di zero e indipendente da n !!!
Select(i) 1.Dividi i numeri in input in gruppi da 5 elementi ciascuno. 2.Ordina ogni gruppo (qualsiasi metodo va bene). Trova il mediano in ciascun gruppo. 3.Usa Select ricorsivamente per trovare il mediano dei mediani. Lo chiamiamo x. 4.Partiziona il vettore in ingresso usando x ottenendo due vettori A e B di lunghezza k e n-k. 5.Se i≤k allora Select(i) sul vettore A altrimenti Select(i-k) sul vettore B.
5 Mediano della terza colonna n/5 = M Calcoliamo il mediano di M usando Select ricorsivamente !!!
Supponiamo di aver riordinato le colonne a seconda del valore del loro mediano. = mediano dei mediani. Maggiori o uguali di Minori o uguali di
più o meno 3 n/10 Se partizioniamo intorno a lasciamo almeno (circa) 3n/10 elementi da una parte e almeno (circa) 3n/10 elementi dall’altra !!! OK
Select: costo computazionale T(n) = (1)se n < c (n) + T(n/5) + T(7n/10 ) se n ≥ c Costo per ordinare le colonne Costo per calcolare il mediano dei mediani Costo per la chiamata ricorsiva di select T(n) ≤ k n, k costante opportuna Dim: Esercizio
Strutture Dati Elementari: Pile e Code
Pile (Stacks) Dati: un insieme S di elementi. Operazioni: PUSH, POP PUSH: inserisce un elemento in S POP: restituisce l’ultimo elemento inserito e lo rimuove da S Politica: Last-In-First-Out (LIFO)
Pila PUSH…
Pila POP…
Code (Queues) Dati: un insieme S di elementi. Operazioni: ENQUEUE, DEQUEUE ENQUEUE : inserisce un elemento in S DEQUEUE : restituisce l’elemento da più tempo presente (il più vecchio) e lo rimuove da S Politica: First-In-First-Out (FIFO)
Coda ENQUEUE…
Coda DEQUEUE…
Implementazione di Pile con Vettori STACK-EMPTY(S) If top[S] = 0 then return TRUE else return FALSE PUSH(S,x) top[S] = top[S] + 1 S[top[S]] = x POP(S) if STACK-EMPTY(S) then “error” else top[S] = top[S] - 1 return S[top[S] + 1]
Implementazione di code con Vettori ENQUEUE(Q,x) Q[tail[Q]] = x if tail[Q] = length[Q] then tail[Q] = 1 else tail[Q] = tail[Q] + 1 DEQUEUE(Q,x) x = Q[head[Q]] if head[Q] = length[Q] then head[Q] = 1 else head[Q] = head[Q] + 1
Problemi con i vettori Vettori Semplici, Veloci ma Bisogna specificare la lunghezza staticamente Legge di Murphy Se usi un vettore di lunghezza n = doppio di ciò che ti serve, domani avrai bisogno di un vettore lungo n+1 Esiste una struttura dati più flessibile?
Linked Lists Dato 1nextDato 2nextDato 3--- Head[L] Elemento della lista: Dato + puntatore all’ elemento successivo nella lista
Doubly Linked Lists Dato Head[L] next Dato 2prevnext Dato 3prev---- Elemento della lista: Dato + puntatore al predecessore + puntatore al successore
Ricerca e Inserimento LIST-SEARCH(L,k) x = head[L] while x nil and key[x] k do x = next[x] return x LIST-INSERT(L,x) next[x] = head[L] if head[L] nil then prev[head[L]] = x head[L] = x prev[x] = nil
Cancellazione LIST-DELETE(L,k) x = LIST-SEARCH[L,k] if prev[x] nil then next[prev[x]] = next[x] else head[L] = next[x] if next[x] nil then prev[next[x]] = prev[x]
Costi Computazionali InserimentoLIST-INSERT 1) CancellazioneLIST-DELETE 1) RicercaLIST-SEARCH n)
Sentinelle ---prev nil[L] next Lista vuota usando le sentinelle
Sentinelle xlast nil[L] first Dato 1prevnext sentinella Dato 2prevnext Dato 3prevnext Dato 4prevnext
Cancellazione usando le sentinelle LIST-DELETE(L,k) x = LIST-SEARCH[L,k] if prev[x] nil then next[prev[x]] = next[x] else head[L] = next[x] if next[x] nil then prev[next[x]] = prev[x] LIST-DELETE-SENTINEL(L,k) x = LIST-SEARCH[L,k] next[prev[x]] = next[x] prev[next[x]] = prev[x]
Esercizio: usare le sentinelle per SEARCH e INSERT
Alberi binari rappresentati usando liste Padre Figlio sinistro Filgio destro
Alberi generali rappresentati usando liste Padre Primo figlio Primo fratello --- Lista dei fratelli
Costo computazionale delle operazioni sulle liste Singly linked non ordinata Singly linked ordinata Doubly linked non ordinata Doubly linked ordinata Ricerca Inserimento Cancellazione Successore Predecessore Massimo
Tabelle Hash
Elemento 1 Elemento 12 Elemento 2 Elemento 41 Elemento i 1 |S| Vettore V Ogni elemento ha una chiave K i tale che 0 < K i < n+1 Insieme S
Tabella con indirizzamento diretto i iElemento i-esimo j jElemento j-esimo Posizione nel vettore = valore della chiave
Tabella con indirizzamento diretto Search(V,k) return V[k] Insert(V,x) V[key[x]] = x Delete(V,x) V[key[x]] = nil Costo computazionale = (1)
Problemi… Supponiamo che solo una parte S’ dello spazio S delle chiavi sia utilizzata/attiva. Cosa succede quando |S’| << |S| ? Si spreca spazio di memoria !!! Soluzioni?
Problemi… S’ S = Spazio sprecato
Una soluzione … Possiamo ridurre l’occupazione di spazio da (|S|) a (|S’|) Usando LINKED LISTS !!! PROBLEMA (non finiscono mai): Inserimento, Cancellazione e Ricerca costano (|S’|) invece di (1).
Vero Problema: compromesso tra TEMPO e SPAZIO Usando Hash tables possiamo raggiungere: Tempo di accesso: (1) Spazio di memoria: (|S’|) Ma … in media e non nel caso pessimo !
IDEA … i iElemento i-esimo h(i) iElemento i-esimo h = funzione hash
Funzione hash h restituisce un numero intero da 1 a M. Usiamo una tabella con M posizioni x viene memorizzato in posizione h(key[x])
Proprietà per una “buona” h Deterministica ma deve sembrare “random” in modo da minimizzare le collisioni. x e y generano una collisione se x ≠ y e h(x) = h(y) h deve minimizzare il numero di collisioni
kiki kjkj kiki kjkj -- h(k i )=h(k j ) Risoluzione di collisioni con “chaining”
Chained-hash-insert(T,x) Inserisci x in testa alla lista: T[h(key[x])] Chained-hash-search(T,k) Ricerca l’elemento con chiave k nella lista: T[h(k)] Chained-hash-delete(T,x) cancella x dalla lista: T[h(key[x])]
Chaining: analisi Load factor =n/m, n = numero di elementi memorizzati in T m = dimensione di T Caso pessimo: tutte le n chiavi finiscono nella stessa posizione. Ricerca = (n) Caso medio: Simple uniform hashing: Pr(h(k)=i) = Pr(h(k)=j)
Simple uniform hashing: un esempio m= (1/2) 2 (1/8) 3 (1/8) 5 (1/16) 6 (1/8) 4 (1/16) U= in rosso è indicata la probabilità che una certa chiave debba essere inserita nella tabella h NON UNIFORME !!! PERCHE’ ???
Simple uniform hashing: un esempio m= (1/2) 2 (1/8) 3 (1/8) 5 (1/16) 6 (1/8) 4 (1/16) U= in rosso è indicata la probabilità che una certa chiave debba essere inserita nella tabella h UNIFORME !!! PERCHE’ ???
Simple uniform hashing Una funzione hash si dice uniforme quando rende uniforme il riempimento della tabella. Non quando la distribuzione delle chiavi è uniforme !!!
Teorema: Ipotesi: collisioni gestite con chaining simple uniform hashing caso medio Tesi: una ricerca ha costo computazionale (1+ )
Dimostrazione: Caso di ricerca senza successo. Load factor è la lunghezza media di una catena. In una ricerca senza successo il numero di elementi esaminati è uguale alla lunghezza media delle catene. Calcolare h() costa 1.
Dimostrazione: Caso di ricerca con successo. Assumiamo di inserire elementi in testa alla catena. Simple uniform hashing numero medio di elementi in una catena dopo i inserimenti = i/m l’elemento j ci si aspetta che venga inserito nella posizione 1 +(j-1)/m all’interno di una catena.
Un elemento generico finirà in media nella posizione data dalla formula: 1/n ( 1+ (i-1)/m ) = 1/n (n + [n(n+1)]/[2m] - n/m) = = 1 + /2 - 1/(2m) = = (1+ ) i=1 n
Supponiamo che n=O(m). Ovvero che il numero di elementi inseriti nella tabella sia proporzionale alla dimensione della tabella. Abbiamo: = n/m = O(m)/m = O(1) In questo caso la ricerca impiega tempo costante !!! Cosa succede se gli elementi vengono inseriti all’inizio delle liste ?
Riepiloghiamo... Se usiamo doubly linked lists per le catene e se inseriamo i nuovi elementi in testa alle liste abbiamo Ricerca Cancellazione Inserimento O(1) operazioni in media
Funzioni hash: progettazione Pr(k) = probabilità della chiave k S j ={ k U tali che h(k)=j } Vogliamo uniform hashing ovvero Pr(k) = 1/m (m=dimensione della tabella) kSjkSj
Esempio U = { x R : 0≤x<1 } x preso a caso da U. Definiamo h(x)= xm Dimostrare per esercizio che h() è una buona hash function. (suggerimento: definire S j esplicitamente)
Se Pr() è sconosciuta Usiamo euristiche IDEA: h deve dipendere da tutti i bit di k deve essere indipendente da eventuali pattern che possono essere presenti nelle chiavi
Supponiamo per semplicità che le chiavi siano numeri naturali. Metodo della divisione h(k) = k mod m Esempio: m=12, k=100, h(100) = 100 mod 12 = 4 Per controllare se uno ha scelto un buon m è consigliabile usare un “benchmark” reale.
Metodo della moltiplicazione h(k) = m(kA mod m) Esempio: A = (5 1/2 -1)/2 = , k = , m = h(123456) = conti conti conti = 41
Risoluzione collisioni: open addressing h( ,0) =1 h( ,0) =1h( ,1) =2 h(,0) =2h(,1) =4 h( ,0) =2h( ,1) =4h( ,2) =5
Open addressing Nessun puntatore: spazio risparmiato! ≤ 1 sempre. Nessuna lista per gestire le collisioni Hash function più complessa. deve essere una permutazione di
h(k,i) = posizione della tabella in cui inserire la chiave k quando tutte le posizioni h(k,0),..., h(k,i-1) sono già occupate.
Open addressing: uniform hashing Se gestiamo le collisioni con il metodo open addressing, la funzione hash restituisce una permutazione degli indici. Invece di simple uniform hashing parliamo di uniform hashing. Uniform hashing: tutte le permutazioni devono apparire con la stessa probabilità
Open addressing: inserimento Hash-insert(T,k) i=0 repeat j=h(k,i) if T[j]=nil then T[j]=k return j else i=i+1 until i=m error “hash table overflow”
Open addressing: ricerca Hash-search(T,k) i=0 repeat j=h(k,i) if T[j]=k then return j else i=i+1 until (T[j]=nil) or (i=m) return nil
Open addressing: cancellazione Hash-delete(T,k) i=Hash-search(T,k) if i nil then T[i]=nil NON FUNZIONA
h(6,0)=0 h(6,1)=1 h(6,2)=2 h(6,3)=3 Inseriamo Cancelliamo ricerchiamo 6 Risposta: 6 non c’è
Esercizio: Modificare Hash-search e Hash-delete per risolvere il problema illustrato nel lucido precedente. 3 7 D Suggerimento: usare un carattere con il quale contrassegnare gli elementi cancellati.
Open addressing: linear probing Sia h’ una funzione hash “ordinaria”. Definiamo h(k,i)=(h’(k) + i) mod m Esempio di linear probing: m=5, k=3, h’(3)=4 h(k,0) = 4 h(k,1) = 5 h(k,2) = 0 h(k,3) = 1 h(k,4) = 2 h(k,5) = 3 = probe
Linear probing: primary clustering Tempo medio di accesso per una ricerca senza successo: 1.5 Perche’? Tempo medio di accesso per una ricerca senza successo: 2.5 Perche’? Clustering primario
Usando linear probing il clustering primario si forma con alta probabilità. Pr = (i+1)/m i slot pieni i+1 slot pieni Pr = 1/m i slot vuoti
Quadratic probing h(k,i) = (h’(k) + c 1 i + c 2 i 2 ) mod m con c 2 0 Cosa si può dire sul clustering primario ?
Double hashing h(k,i) = (h 1 (k) + ih 2 (k)) mod m Cosa succede se MCD(m,h 2 (k)) = d > 1 ??? Quante permutazioni distinte produce il double hashing ???
Open addressing: ricerca Teorema: Data una hash table con open addressing e load factor = n/m < 1 la lunghezza media di una “probe” in una ricerca senza successo è 1/(1- ). (Ipotesi: uniform hashing)
1/(1- ) = m = (m-1)/m (valore massimo di ) 1/(1- ) = m/(m-1) = 1/m (valore minimo di ) 1/(1- ) = 2 = 1/2
Dimostrazione: IDEA: cosa succede quando facciamo una ricerca senza successo ??? Empty X = lunghezza probe = quante volte devo calcolare h(k,i) prima di trovare uno slot vuoto = elemento non trovato Dobbiamo valutare E[X] = media di X
Lemma: X variabile aleatoria discreta X= 0 --> p > p 1 i --> p i E[X] = i=0 ∞ ip i i=0 ∞ iPr(X=i) i=0 ∞ i ( Pr(X≥i) - Pr(X≥i+1) ) i=1 ∞ Pr(X ≥i) = == ESERCIZIO !!!
E[X] = i=1 ∞ (n/m) i i=1 ∞ ii ∞ Pr(X≥i) ≤ ≤ Costo per la ricerca: 1 + E[X] = 1 + i=1 ∞ i = 1 + + 2 + / (1- )
Open addressing: inserimento Teorema: Data una hash table con open addressing e load factor = n/m < 1, la lunghezza media di una “probe” è 1/(1- ). (Ipotesi: uniform hashing)
Dimostrazione: Nota: deve essere < 1. Per inserire un elemento abbiamo bisogno di determinare la posizione nella tabella dove inserirlo. Ricerca: costo 1/(1- ). Per inserire nella tabella nella posizione appena determinata: (1).
Alberi Binari di Ricerca
Alberi di ricerca binari ≤ >
Alberi di ricerca binari ≤ >
BST: definizione formale Sia x un nodo dell’albero: Se y è un nodo nel sottoalbero sinistro di x allora key[y]≤key[x] Se y è un nodo nel sottoalbero destro di x allora key[y]>key[x] Nota che un BST può essere molto sbilanciato !!! bilanciato sbilanciato
Inorder-tree-walk(x) if x ≠ nil then Inorder-tree-walk(left[x]) print key[x] Inorder-tree-walk(right[x]) ORDINAMENTO
Ricerca ricerchiamo il 16 (h) confronti h=altezza albero
Ricerca Tree-search(x,k) if x=nil or k=key[x] then return x if k<key[x] then return Tree-search(left[x],k) else return Tree-search(right[x],k) Esercizio: dimostrare che il costo computazionale di Tree-search è (h)
Successore 2017 x ha il figlio destro. successore(x)=minimo nel sottoalbero di destra Dimostrazione: Esercizio.
Successore 2017 x non ha il figlio destro. successore(x) = il più basso avo di x il cui figlio sinistro è avo di x Dimostrazione: Esercizio.
Operazioni su BST Ricerca Minimo Massimo Predecessore Successore (h) confronti
Inserimento
Cancellazione 3 casi: x non ha figli: elimina x x ha un figlio: x ha 2 figli: Lemma: il successore di x sta nel sotto albero destro e ha al massimo 1 figlio. Dimostrazione: esercizio.
eliminiamo successore di 15
ha preso il posto di 15
Cancellazione di un nodo x con 2 figli: 1. sia y = successore di x. y ha un solo figlio (al massimo) 2. sostituisci x con y. 3. rimuovi y.
Problema Tutte le operazioni su BST hanno un costo lineare nell’altezza dell’albero. Purtroppo, quando l’albero è sbilanciato, h = n-1 Le operazioni hanno un costo lineare invece che logaritmico come speravamo !!! Soluzione ???
Soluzione Introduciamo alcune proprietà addizionali sui BST per mantenerli bilanciati. Paghiamo in termini di una maggiore complessità delle operazioni dinamiche sull’albero. Tali operazioni devono infatti preservare le proprietà introdotte per mantenere il bilanciamento.
Red-Black Trees
Red Black Trees = BST + alcune proprietà aggiuntive
Proprietà A ogni nodo è rosso o nero
Proprietà B ogni foglia è nera (ne aggiungiamo un livello fittiziamente)
Proprietà C un nodo rosso ha figli neri
Proprietà D tutti i cammini da un nodo x alle foglie ha lo stesso numero di nodi neri nodi neri
Idea di base Proprietà D: se un RB tree non ha nodi rossi è completo. Possiamo immaginarci un RB tree come un albero nero completo a cui abbiamo aggiunto “non troppi” nodi rossi (Proprietà C). Ciò rende l’albero “quasi bilanciato”
Black-height di un RB tree bh(x) = numero di nodi neri (senza contare x) nel cammino da x a una foglia) bh(root) = black-height dell’albero
bh(x)
Teorema: Un RBT con n nodi interni è alto al massimo 2log 2 (n+1) Lemma: Il numero di nodi interni di un sotto albero radicato in x è maggiore o uguale a 2 bh(x) -1
Dimostrazione del lemma. Per induzione sull’altezza di x. CASO BASE: h(x)=0. Se h(x)=0 allora bh(x)=0 inoltre x è una foglia quindi il numero di nodi interni è 0. INDUZIONE: h(x)>0. x ha 2 figli: L e R. Abbiamo 2 casi: L è rosso: bh(L) = bh(x) L è nero: bh(L) = bh(x) - 1 R viene trattato in modo analogo
Visto che h(L) < h(x) e h(R) < h(x) applichiamo l’ipotesi induttiva. Numero di nodi interni dell’albero radicato in L maggioreo o uguale a 2 bh(L) - 1. Stesso discorso per R. Inoltre 2 bh(L) -1 2 bh(x) e 2 bh(R) - 1 2 bh(x) Quindi: numero di nodi interni dell’albero radicato in x maggiore o uguale a 2 bh(x) bh(x) che è uguale a 2 bh(x) - 1.
Dimostrazione del teorema. Sia h l’altezza dell’albero. Qualsiasi cammino dalla radice --> foglia contiene almeno metà nodi neri. Il cammino radice --> foglia che determina l’altezza dell’albero contiene almeno h/2 nodi neri. bh(T) = bh(root) h/2 Lemma --> n 2 bh(root) - 1 quindi: n 2 bh(root) - 1 2 h/ concludiamo: log 2 (n+1) log 2 (2 h/2 ) = h/2.
Rotazioni Operazioni di ristrutturazione locale dell’albero che mantengono soddisfatte le proprietà A,B,C,D Y X A CB Y X AC B destra sinistra esercizio: scrivere il pseudo codice per le rotazioni
Inserimento Idea: inseriamo x ---> T color[x]=red qualche rotazione + ricolorazione nodo inserito ---> ---> !!!!!! il figlio di un nodo rosso deve essere nero
p
p rotazione sinistra --->
p 8 rotazione destra --->
FINE...
metodo generale: zio(x) è rosso C A x B D p yz ws rosso up A C x B p yz ws D
metodo generale: zio(x) è rosso C B x A D p y zws rosso up B C x A p y zws D
metodo generale: zio(x) è nero C A x B B p yz nero left(A) x A p y z D D C
... e poi... B right(C) x A p y z D C C x A y zD B
Ci sono un certo numero di casi analoghi riconducibili a quelli esaminati: esercizio.
Cancellazione di un nodo da un RB-tree Come nel caso dei BST, possiamo sempre assumere di eliminare un nodo che ha al massimo un figlio. Infatti se dobbiamo cancellare un nodo x con due figli, spostiamo la chiave del successore y di x in x e poi rimuoviamo y dall’albero. Il successore di un nodo con due figli ha sempre al massimo un figlio.
Cancellazione di un nodo con ≤ 1 figlio Sia x il nodo da cancellare. Sia y il figlio di x e sia z il padre di x. 1. rimuoviamo x collegando z con y. 2. se x era rosso allora y e z sono neri e non dobbiamo fare altro. 3. se x era nero e y è rosso allora coloriamo y di nero. Se x era nero e y è nero allora ricoloriamo y con un colore nero “doppio”. Dopodichè svolgiamo alcune altre operazione descritte nel seguito.
Esempio z y x z y z y x z y ricolora doppio nero che va poi ridistribuito su un nodo rosso annerendolo. z y x
Cancellazione di un nodo con ≤ 1 figlio Idea: cercare di far salire il doppio nero nell’albero fino a trovare un nodo rosso sul quale scaricare una parte del nero del nodo “doppio nero” e riottenere una colorazione legale. Per far ciò operiamo operazioni di ristrutturazioni locali dell’albero e ricolorazioni che possono propagare il doppio nero due livelli più in alto
Caso 1: fratello nero con almeno un figlio rosso x y s t x y s t x y s t rotazione sinistra rotazione derstra e poi sinistra ab x ys t ab
Caso 2: fratello nero con figli neri x y s x y s x y s x y s
Caso 3: fratello rosso x y s ab x y s a b rotazione sinistra
Programmazione Dinamica
Divide et impera: si suddivide il problema in sotto problemi indipendenti, si calcola ricorsivamente una soluzione per i sottoproblemi e poi si fondono le soluzioni così trovate per calcolare la soluzione globale per il problema originale. Programmazione dinamica: simile all’approccio divide et impera, ma in questo caso si tiene traccia (in una tabella) delle soluzioni dei sottoproblemi perchè può capitare di dover risolvere il medesimo sottoproblema per più di una volta.
Prodotto di una sequenza di matrici Dobbiamo calcolare A=A 1 A 2 A m dove A i sono matrici di opportune dimensioni (righe x colonne). In che ordine conviene effettuare le moltiplicazioni ?
Moltiplicazioni di matrici Matrix-multiply(A,B) if columns(A) rows(B) then “error” else for i=1 to rows(A) do for j=1 to columns(B) do C[i,j]=0 for k=1 to columns(A) do C[i,j]=C[i,j]+A[i,k]B[k,j] (rows(A) columns(B) columns(C)) moltiplicazioni.
Esempio: A=MNQ M = 10 righe, 100 colonne N = 100 righe, 5 colonne Q = 5 righe, 50 colonne Primo metodo A=((MN)Q). Numero di moltiplicazioni: per calcolare A’=MN per calcolare A=A’Q Totale 7500 Secondo metodo A=(M(NQ)). Numero di moltiplicazioni: per calcolare A’=NQ per calcolare A=MA’ Totale 75000
Numero di possibili parentesizzazioni Il numero di possibili parentesizzazioni P(n) può essere ottenuto come segue: P(n) = 1 n=1 n>1 P(k)P(n-k) k=1 n-1 numero di parentesizzazioni delle prime k matrici numero di parentesizzazioni delle altre n-k matrici
P(n) risulta essere (4 n /n 3/2 ) Quindi esponenziale in n. L’algoritmo di enumerazione non può essere usato !!! Osservazione chiave: Supponiamo che la soluzione ottima sia ottenuta 1. moltiplicando le prime k matrici tra loro in qualche modo 2. moltiplicando le altre n-k matrici tra loro in qualche modo 3. moltiplicando le due matrici ottenute ai primi due passi Le parentesizzazioni dei passi 1 e 2 sono ottime
Soluzione ricorsiva m[i,j] = costo minimo per moltiplicare le matrici A i,...,A j m[i,j] = 0 i=j i<j min{ m[i,k] + m[k+1,j] + p i-1 p k p j } i≤k<j
Costo della soluzione ricorsiva Esercizio: Determinare il costo computazionale dell’algoritmo ricorsivo progettato a partire dall’equazione ricorsiva del lucido precedente. Esponenziale o polinomiale ???
Numero di sottoproblemi Quanti sottoproblemi abbiamo? uno per ogni coppia di indici i,j nel range 1,...n ovvero (n 2 ) (pochi !). Quindi l’algoritmo ricorsivo deve risolvere più volte lo stesso sottoproblema altrimenti non si spiega il costo esponenziale !
m= A1A1 A2A2 A3A3 A4A4 A5A5 A6A6 A 1 30x35 A 2 35x15 A 3 15x5 A 4 5x10 A 5 10x20 A 6 20x25
s = s[i,j] contiene l’indice ottmo per spezzare la moltiplicazione A i A j in due: A i A s[i,j] e A s[i,j] +1 A j
Matrix-chain-order(p) n=length(p)-1 for i=1 to n do m[i,i]=0 for l=2 to n do for i=1 to n-l+1 do j=i+l-1 m[i,j]=∞ for k=i to j-1 do q=m[i,k]+m[k+1,j]+p i-1 p k p j if q<m[i,j] then m[i,j]=q s[i,j]=k return m,s Pseudo codice
Matrix-chain-multiply(A,s,i,j) if j>i then X = Matrix-chain-multiply(A,s,i,s[i,j]) Y = Matrix-chain-multiply(A,s,s[i,j]+1,j) return Matrix-multiply(X,Y) else return A i Parentesizzazione ottima dell’esempio = ((A 1 (A 2 A 3 ))((A 4 A 5 )A 6 )) Costo computazionale: Matrix-chain-order tempo (n 3 )spazio (n 2 ) Matrix-chain-multiply tempo esercizio spazio esercizio Pseudo codice
Passi fondamentali della programmazione dinamica 1.Caratterizzazione della struttura di una soluzione ottima 2.Definizione ricorsiva del valore di una soluzione ottima 3.Calcolo del valore di una soluzione ottima con strategia bottom-up 4.Costruzione di una soluzione ottima a partire dalle informazioni già calcolate.
nell’esempio della moltiplicazione di matrici Una parentesizzazione ottima associata a una lista A 1,A 2,...,A m di matrici da moltiplicare può essere sempre suddivisa in due parentesizzazioni ottime associate alle liste A 1,...,A k e A k+1,...,A m per un opportuno valore di k. 2.Una parentesizzazione ottima costa quanto la somma dei costi delle due sotto parentesizzazioni ottime più il costo dovuto alla moltiplicazione delle matrici associate alle due sotto parentesizzazioni 3.vedi come procedere con la matrice m nei lucidi precedenti 4.vedi come procedere con la matrice s nei lucidi precedenti
Caratteristiche del problema per applicare la programmazione dinamica Sottostruttura ottima. Una soluzione ottima per il problema contiene al suo interno le soluzioni ottime dei sottoproblemi Sottoproblemi comuni. Un problema di ottimizzazione ha sottoproblemi comuni quando un algoritmo ricorsivo richiede di risolvere più di una volta lo stesso sottoproblema
Versione ricorsiva con memorizzazione dei risultati parziali in una tabella Mem-matrix-chain(p) n=length(p)-1 for i=1 to n do for j=1 to n do m[i,j]=∞ return Lookup-chain(p,1,n) Lookup-chain(p,i,j) if m[i,j]<∞ then return m[i,j] if i=j then m[i,j]=0 else for k=1 to j-1 do q=Lookup-chain(p,i,k)+ Lookupchain(p,k+1,j) + p i-1 p k p j if q<m[i,j] then m[i,j]=q return m[i,j]
Esercizi Determinare il costo computazionale di: Mem-matrix-chain ??? Lookup-chain ???
Sottosequenza comune più lunga Problema di ottimizzazione. Possiamo applicare la programmazione dinamica ?? X = ABCDBDAB Y = BDCABA Z = BCBA è LCS(X,Y)
Sotto struttura ottima Siano X= e Y= Sia Z= una qualunque LCS di X e Y. 1.Se x m =y n, e z k =x m =y n allora Z k-1 è LCS di X m-1 e Y n-1 2.Se x m y n, e z k x m allora Z k-1 è LCS di X m-1 e Y 3.Se x m y n, e z k y n allora Z k-1 è LCS di X e Y n-1
B D C A B A A B C B D A B
LCS-length(X,Y) m=length(X) n=length(Y) for i=1 to m do c[i,0]=0 for j=1 to n do c[0,j]=0 for i=1 to m do for j=1 to n do if x i =y j then c[i,j]=c[i-1,j-1] + 1 b[i,j]= else if c[i-1,j]≥c[i,j-1] then c[i,j]=c[i,j-1] b[i,j]= else c[i,j]=c[i,j-1] b[i,j]= return b,c
Costruzione di una LCS Print-LCS(b,X,i,j) if i=0 or j=0 then return if b[i,j] = then Print-LCS(b,X,i-1,j-1) print x i else if b[i,j] = then Print-LCS(b,X,i-1,j) else Print-LCS(b,X,i,j-1)
Algoritmi Greedy
Selezione di attività S={1,...,n} insieme di attività. Ogni attività ha un tempo di inizio s i e un tempo di fine f i. Problema: Selezionare un sottoinsieme S’ di attività in modo tale che: 1.se i e j appartengono a S’ allora: s i ≥f j oppure s j ≥f i. 2.La cardinalita di S’ è massimizzato.
Attività ordinate in base a f i
Pseudocodice Greedy-activity-selector(s,f) n=length(s) A={1} j=1 for i=2 to n do if s i ≥f j then A=A {i} j=i return A (n)
Dimostrazione di correttezza Assumiamo che le attività siano ordinate per tempi di fine in modo crescente. Dimostriamo che esiste una soluzione ottima che contiene l’attività 1 che è quella che termina per prima (con il tempo di fine più piccolo). Supponiamo per assurdo che esista una soluzione S’’migliore di S’ (S’ contiene l’attività 1 ed è stata costruita con l’algoritmo greedy). Assumiamo che sia S’ che S’’ siano ordinate per tempi di fine crescenti. S’={1,.....} e S’’={k,......} dove 1 k. Sia T=S’’-{k} {1}. Non è difficile dimostrare (esercizio) che T è una soluzione ottima e che ovviamente contiene l’attività 1. Adesso eliminiamo da S tutte le attività che sono incompatibili con l’attività 1 e ripetiamo la dimostrazione da capo.
A volte gli algoritmi greedy non trovano la soluzione ottima... Problema del commesso viaggiatore. Dato un insieme di n città occorre trovare la strada più breve per visitarle tutte una ed una sola volta e tornare alla città di partenza. Nessun algoritmo greedy può funzionare...
Algoritmo greedy: parto dalla città 1 e poi procedo visitando la città più vicina non ancora visitata. Quando tutte le città sono state visitate torno alla città Soluzione ottima Soluzione con algoritmo greedy
Non sempre una soluzione di ottimo “globale” si ottiene facendo una serie di scelte “localmente” ottime. Per il problema del commesso viaggiatore nessuna politica decisionale greedy fornisce una soluzione finale ottima.
Proprietà della scelta greedy. Ad ogni passo l’algoritmo compie una scelta in base ad una certa politica ed alle scelte compiute fino a quel momento. Cosi facendo ci si riduce ad un sottoproblema di dimensioni più piccole. Ad ogni passo si calcola un pezzo della soluzione. Sottostruttura ottima. Come nel caso della programmazione dinamica, anche per applicare un algoritmo greedy occorre che la soluzione ottima contenga le soluzioni ottime dei sottoproblemi.
Programmazione dinamica Vs Algoritmi greedy Non sempre è possibile risolvere un problema con programmazione dinamica o con algoritmi greedy (commesso viaggiatore). Non sempre i problemi che soddisfano la proprietà della sottostruttura ottima possono essere risolti con entrambi i metodi. Ci sono problemi che non possono essere risolti con algoritmi greedy ma possono essere risolti con programmazione dinamica Se un problema può essere risolto con algoritmi greedy è inutile scomodare la programmazione dinamica.
Problema dello zaino: 2 versioni Knapsack 0-1: Un ladro durante una rapina si trova davanti a n oggetti. Ogni oggetto ha un valore v i e un peso w i (numeri interi). Il ladro ha uno zaino che può contenere fino a W (numero intero) chilogrammi di refurtiva. Il ladro deve scegliere quali oggetti rubare per massimizzare il valore complessivo degli oggetti rubati. Knapsack: In questo caso il ladro può anche prendere una parte frazionaria degli oggetti. Non è costretto a “prendere o lasciare” un oggetto, può decidere di prenderne un pezzo grande a suo piacimento. Nota: Knapsack è una generalizzazione di Knapsack 0-1
Proprietà della sottostruttura ottima Entrambe le versioni del knapsack soddisfano tale proprietà. Supponiamo infatti che il ladro possa rubare refurtiva avente peso W’ non maggiore di W di valore massimo V. Se togliamo dallo zaino l’oggetto j otteniamo la soluzione ottima del sottoproblema in cui lo zaino può contenere al massimo W-w j chilogrammi ottenuti mettendo insieme oggetti da un insieme di n-1 (abbiamo eliminato l’oggetto j).
Knapsack: soluzione greedy Idea: il ladro prende la quantità più grande possibile dell’oggetto i tale per cui v i /w i (valore per unità di peso) è massimo. Dopodiche’, se nello zaino c’è ancora posto, ripete l’operazione. Esercizio. Dimostrare che l’algoritmo è greedy ed è corretto.
Knapsack: soluzione greedy € 60 € 100 € € al chilo 5 € al chilo 4 € al chilo Zaino da 50 chili Soluzione ottima di knapsack con algoritmo greedy € = 2/3 di 30
Knapsack 0-1: nessuna soluzione greedy € 60 € 100 € € al chilo 5 € al chilo 4 € al chilo Soluzione ottima di knapsack 0-1 Zaino da 50 chili 3020 € 220 Soluzione di knapsack 0-1 scegliendo per primo l’oggetto da 6 € al chilo (valore per unità di peso massimo) €
Knapsack 0-1: Soluzione con programmazione dinamica Prima di decidere se inserire nello zaino l’oggetto i bisogna controllare il valore della sottosoluzione ottima di due altri sottoproblemi su n-1 oggetti: il sottoproblema nel quale l’oggetto i è inserito nello zaino e lo zaino ha capienza W-w i il sottoproblema nel quale l’oggetto i non è inserito nello zaino e lo zaino ha ancora peso W Esercizio: risolvere knapsack 0-1 con la programmazione dinamica