Orario: Martedì Giovedì Lab. Martedì Ricevimento aula a Gio. 10 11 14 14 13 13 16 16 16 Problemi, Algoritmi e Strutture Dati Problema: Ordinamento Input: una sequenza di n numeri <a1,a2,…,an> Output: i numeri ricevuti in input ordinati dal più piccolo al più grande ovvero: <aπ(1),aπ(2),…,aπ(n)> 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 ordinati elemento da inserire Non necessariamente 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=2 j=3 j=4 j=5 j=6 j=7 5 2 2 2 1 1 2 5 4 4 2 2 4 4 5 5 4 3 6 6 6 6 5 4 1 1 1 1 6 5 3 3 3 3 3 6 Ordinamento n numeri Insertion Sort A[1,…,n] = vettore i,j,key = variabili Numeri ordinati Problema Input Algoritmo Strutture Dati Output 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 OP1 OP2 …………… OPn Esempio di ADS Dati = insieme S di numeri OP1 = estrai il minimo OP2 = estrai il massimo OP3 = restituisci la taglia di S OP4 = inserisci un nuovo numero in S Insertion sort ADS = Insieme S di numeri + Read, Size, Modify DS = 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à n2. Etc… Il tempo e lo spazio saranno funzioni in n (TIME(n), SPACE(n)) Analisi di Insertion Sort Dati: A=<a1,a2,…,an> Dimensione di A = n•c1 Read(i) impiega tempo c2 Modify(i,x) impiega tempo c3 NOTA: c1, c2, c3 sono indipendenti da n e vengono perciò dette costanti Insertion Sort: analisi del costo computazionale tj A[j] tj numero di elementi maggiori di A[j] dipende dai dati in input caso ottimo: tj = 0 caso pessimo: tj = j-1 caso medio: tj = (j-1)/2 Insertion Sort: analisi del costo computazionale Insertion-sort(A) 1. for j=2 to length(A) C1 n 2. do key = A[j] C2 n-1 3. i = j-1 C3 n-1 4. while i>0 and A[i]>key C4 ∑(tj+1) C5 ∑tj C6 ∑tj C7 n-1 5. 6. 7. do A[i+1] = A[i] i=i-1 A[i+1] = key n j=2 n j=2 n j=2 T(n) = c1n + c2 (n-1) + c3 (n-1) + c4 ∑(tj+1) + (c5+c6) ∑tj + c7 (n-1) caso ottimo: tj = 0 caso pessimo: tj = j-1 caso medio: tj = (j-1)/2 T(n) = an + b; lineare T(n) = an2 + bn + c; quadratico Esercizio!!! Procedimento di astrazione Costo in microsecondi Costanti ci Costanti a, b, c an2 + 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 n0, t.c. f(n) cg(n) per n n0 f(n) e g(n) sono funzioni non negative Notazione usata ad esempio nell’analisi del costo computazionale nel caso pessimo “W grande” Limite inferiore asintotico f(n) = W(g(n)) se esistono due costanti c e n0, t.c. cg(n) f(n) per n n0 Usato per: tempo di esecuzione nel caso ottimo; limiti inferiori di complessità; Esempio: il limite inferiore per la ricerca in array non ordinati è W(n). Eliminiamo termini di ordine inferiore e costanti. 50 n log n è O(n log n) 7n - 3 è O(n) 8n2 log n + 5n2 + n è O(n2 log n) Nota: anche se (50 n log n) è O(n5), ci aspettiamo di avere approssimazioni migliori !!! “” “tight bound” = approssimazione stretta. f(n) = (g(n)) se esistono c1, c2, e n0, t.c. c1g(n) f(n) c2g(n) per n n0 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)) W f(n) = (g(n)) c2 g(n) f(n) c1 g(n) Taglia dell’input = n n0 ”o piccolo” e ” piccolo” f(n) = o(g(n)) Analogo stretto di O grande Per ogni c, esiste n0 , t.c. f(n) cg(n) per n n0 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 W grande. Notazione Asintotica Analogie con i numeri reali f(n) f(n) f(n) f(n) = O(g(n)) = W(g(n)) = (g(n)) = o(g(n)) f(n) = (g(n)) f f f= f< g g g g 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 !!! n n1+sen(n) ??? 1+sen(n) oscilla tra 0 e 2 Esempi M = numero grande, es: 10000000; = numero piccolo, es: 0,0000001; Mn = (n) Log(n) = o(n) [Log(n)]M = o(n) nM =o(2n) 2nM = o(n!) Mn! = o(nn) 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 esempio: log[n] = (log[n2]) ma n = o(n2) f(n) = o(g(n)) 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, S1 e S2, ognuno dei quali contiene circa la metà degli elementi di S. (S1 contiene i primi n/2 elementi e S2 contiene i rimanenti n/2 elementi). Risolvi ricorsivamente: ordina gli elementi in S1 e S2 usando MergeSort (ricorsione). Combina: metti insieme gli elementi di S1 e S2 ottenendo un unico vettore S ordinato (merge) Mergesort: esempio 24 10 21 36 24 10 36 87 39 83 58 21 39 83 58 87 24 10 24 36 36 10 87 83 21 39 21 58 39 83 58 24 36 10 87 24 36 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) 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 1 12 27 36 38 47 54 1 23 25 32 68 96 12 23 … e così via Equazioni ricorsive: un esempio semplice 1 se n = 1 T(n/2) + 1 se n > 1 T(n) = 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(n1),...,f(nk) dove ni < n. Esempio: Merge Sort Metodo iteratvo T(n) = T(n/2) + 1 T(n/4) +1 + 1 T(n/8)+1 +1 + 1 ..................... T(n/n)+1 ......................... + 1 1 + 1 ......................... + 1 k Ci fermiamo quando 2k=n Dobbiamo valutare k. sappiamo che 2k = n, quindi log2( 2k ) = log2(n), ovvero k = log2(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 affermazione(n): i i=1 = n(n+1)/2 1 affermazione(1): i i=1 = 1(1+1)/2 = 1 affermazione(n-1) “implica” n-1 i = (n-1)(n)/2 i=1 OK affermazione(n): n implica i i=1 = n(n+1)/2 Dimostrazione per induzione: esempio n-1 i + n = (n-1)(n)/2 + n = i = i=1 n(n+1)/2 i=1 n ...ma 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) ≤ clog2(n) Secondo passo: la verifichiamo per induzione come segue: Assumiamo che T(n’) ≤ clog2(n’) per n’ < n e dimostriamo che T(n) ≤ clog2(n) c è una costante (indipendente da n) che determineremo strada facendo… T(n) = T(n/2) + 1 ≤ clog2(n/2) + 1 = clog2(n) - clog2(2) = clog2(n) - c + 1 se c ≥ 1 allora ≤ clog2(n) Ipotesi induttiva !!! + 1 Equazioni ricorsive: un esempio più complicato (1) se n = 1 2T(n/2) + (n) se n > 1 T(n) = 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) …… (1) (1) C(n/2) + 2T(n/4) C(n/4) + 2T(n/8) …… …… C(n/4) + 2T(n/8) …… …… …… …… C(n/4) + 2T(n/8) =cn + =cn + =cn + …… (1) (1) =cn = n(log(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 = logba f(n) = O(nx-) con >0 allora T(n) = (nx) f(n) = (nx) allora T(n) = (nx log(n)) f(n) = W(nx+) con >0 af(n/b) ≤ cf(n) con c<1 per tutti gli n>n0 allora T(n) = (f(n)) … Merge sort T(n) = (n log(n)) Insertion sort Merge sort Worst case (n2) (n log(n)) Average case (n2) (n log(n)) (n) (n log(n)) Best case 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 ≥ log2n. Quindi T(n) = (log2n) 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) {1,3,4,1} è un ciclo. Un grafo senza cicli è aciclico. V={1,2,3,4,5,6,7,8} E={(1,2),(1,3),(1,4),(3,4),(6,7),(7,8)} 1 4 6 2 7 3 5 8 Un albero è un grafo aciclico con un numero di nodi uguale al numero di archi più uno ( |V|=|E|+1 ) 1 Albero 3 4 7 5 3 8 6 2 1 4 6 2 Foresta 5 7 8 Radice r h(a) altezza del nodo a: h(x)=1 h(y)=h(q)=2 h(w)=3 x y q w Foglie 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 Heap 128 72 64 8 1 7 6 12 30 3 A={ 128, 64, 72, 8, 1 2 3 4 A(6) = 12 7, 12, 30, 1, 5 6 7 8 6, 9 3 } 10 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 128 72 64 8 1 7 6 3 12 30 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 Heaps Heap A i 2i 2i+1 B 4i 4i +3 8i 8i +7 parte del vettore già heapizzato elemento da aggiungere al sotto heap (verde) IDEA: facciamo scendere il nodo i nell’albero fino a trovare la sua posizione. ? i A B A Bi Heapify(A,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 largesti 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) 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 A metà A1={10,5,41,3} A2={6,9,12,26} quicksort Intorno a un Pivot, es 12 A1={10,5,3,6,9,12} A2={41,26} 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 !!! A(p,r) i Pivot 5 3 2 6 4 1 3 7 i j i j j 5 3 2 6 4 1 3 7 3 3 2 6 4 1 5 7 i j i j 3 3 2 6 4 1 5 7 3 3 2 1 4 6 5 7 j i 3 3 2 1 4 6 5 7 <5 ≥5 (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) ricorsione quindi: T(n) = (n2) partition Analisi di QS nel caso... ... non buono ! 90% 10% T(n) ??? Albero di ricorsione n + n 1/10 n 1/100 n log10n 9/10 n 9/100 n 9/100 n 81/100 n 81/1000 n 729/1000 n n + n + <n log10/9n (n log(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... 1 1 cattiva n-1 (n-1)/2 (n-1)/2 buona dopo una cattiva e una buona partizione in successione siamo più o meno nella situazione in cui la cattiva partizione non è stata fatta ! 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” ? come procediamo mischiamo la sequenza casualmente prima di ordinare Scegliamo il pivot a caso. 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 n2 n log(n) n log(n) n2 Caso medio n2 n log(n) n log(n) n log(n) Caso ottimo n n log(n) n log(n) n 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 W(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: a1:a2 ≤ a2:a3 ≤ a1,a2,a3 ≤ a1,a3,a2 n=3 > a1:a3 {a1,a2,a3} > ≤ > a3,a1,a2 albero dei confronti a1:a3 a2,a1,a3 ≤ a2,a3,a1 > a2:a3 > a3,a2,a1 Ogni nodo bianco rappresenta un confronto. Ogni nodo rosso rappresenta una possibile soluzione. 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 2h foglie dobbiamo avere 2h ≥ n! Formula di Stirling: n! > (n/e)n e=2.17... h ≥ log[(n/e)n] = nlog(n) - nlog(e) = W(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 W(nlog(n)) QUINDI qualsiasi algoritmo comparison-sort, nel caso pessimo, esegue W(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’[3]=2 perché il numero 3 è contenuto 2 volte in A C’ = {2,0,2,3,0,1} C’’[4]=7 perché ci sono 7 numeri minori o uguali a 4 C’’ = {2,2,4,7,7,8}. Algoritmi di ordinamento stabili 4 A 2 C 7 B 2 E 2 C 3 D 3 D 4 A 2 E 7 B 7 F 7 F Algoritmi di ordinamento NON stabili 4 A 2 E 7 B 2 C 2 C 3 D 3 D 4 A 2 E 7 B 7 F 7 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. 2. 3. 4. costa (k) costa (n) costa (k) costa (n) Quindi Counting sort = (n + k) Radix sort 310 638 237 272 182 926 310 272 182 926 237 638 310 926 237 638 272 182 182 237 272 310 638 926 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, 78 0 17 1 12 17 39 2 21 23 26 3 39 62 4 -- 68 5 -- 21 6 12 23 -- 78 8 -- 26 -- 62 7 -- 68 -- -- -- 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 Probabilità E[Y]= 1·1/3 + 2·1/3 + 3·1/6 + 4·1/6 = 13/6 Valori possibili di Y 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 ni , il costo della singola applicazione di Insertion Sort è (ni )2 (nel caso pessimo !!!) Dobbiamo quindi valutare E[(ni )2]. NOTA: E[X2] diversa da E[X]2 ni = variabile aleatoria = numero di elementi nel bucket i Pr(x ---> Bi) = 1/n Distribuzione binomiale Pr(ni = k) = ( n ) (1/n)k (1-1/n)n-k E[ni] = n 1/n =k 1 E[ni2] = Var[ni] + E2[ni] = 2 - 1/n =(1) E[ni2] = costo computazionale di insertion sort Costo computazionale di bucket sort = O(n) Selezione: esempio 12 3 8 1 7 6 100 91 Input 1 3 6 7 8 12 91 100 Input ordinato massimo minimo quarto elemento nell’ordinamento Selezione Selezione: Calcolare l’iesimo elemento nell’ordinamento. Ordinamento (nlog(n)) minimo/massimo (n) Selezione ???? 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 L R q n 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 ? 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 (n2) 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. 2. 3. 4. 5. Dividi i numeri in input in gruppi da 5 elementi ciascuno. Ordina ogni gruppo (qualsiasi metodo va bene). Trova il mediano in ciascun gruppo. Usa Select ricorsivamente per trovare il mediano dei mediani. Lo chiamiamo x. Partiziona il vettore in ingresso usando x ottenendo due vettori A e B di lunghezza k e n-k. Se i≤k allora Select(i) sul vettore A altrimenti Select(i-k) sul vettore B. n/5 5 Mediano della terza colonna Calcoliamo il mediano di M usando Select ricorsivamente !!! =M Supponiamo di aver riordinato le colonne a seconda del valore del loro mediano. Minori o uguali di = mediano dei mediani. Maggiori o uguali di più o meno 3 n/10 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 (1) T(n) = (n) + T(n/5) + T(7n/10 ) T(n) ≤ k n, k costante opportuna Dim: Esercizio se n < c se n ≥ c Costo per ordinare le colonne Costo per calcolare il mediano dei mediani Costo per la chiamata ricorsiva di select 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) PUSH(S,x) top[S] = top[S] + 1 If top[S] = 0 S[top[S]] = x then return TRUE else return FALSE 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]] if tail[Q] then else = x = length[Q] tail[Q] = 1 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 ma Semplici, Veloci 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 1 Head[L] next Dato 2 next Dato 3 --- Elemento della lista: Dato + puntatore all’ elemento successivo nella lista Doubly Linked Lists Dato 1 Head[L] Dato 2 ---- prev next next Elemento della lista: Dato + puntatore al predecessore + puntatore al successore Dato 3 prev ---- 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 Inserimento LIST-INSERT 1) Cancellazione LIST-DELETE 1) Ricerca LIST-SEARCH n) Sentinelle Lista vuota usando le sentinelle --- nil[L] prev next Sentinelle nil[L] x last first Dato 1 prev next Dato 2 prev next Dato 3 prev next Dato 4 prev next sentinella 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 Ricerca Inserimento Cancellazione Successore Predecessore Massimo Singly linked ordinata Doubly linked non ordinata Doubly linked ordinata Tabelle Hash Ogni elemento ha una chiave Ki tale che 0 < Ki < n+1 1 Insieme S Elemento 1 Elemento 2 Vettore V Elemento i Elemento 12 Elemento 41 |S| Tabella con indirizzamento diretto Posizione nel vettore = valore della chiave i i Elemento i-esimo j j Elemento j-esimo 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 … h = funzione hash i i Elemento i-esimo h(i) i Elemento i-esimo 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 h(ki)=h(kj) Risoluzione di collisioni con “chaining” ki ki kj kj -- 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 U= 1 (1/2) 2 (1/8) 3 (1/8) 4 (1/16) 5 (1/16) 6 (1/8) 1 h m=2 2 NON UNIFORME !!! PERCHE’ ??? in rosso è indicata la probabilità che una certa chiave debba essere inserita nella tabella Simple uniform hashing: un esempio U= 1 (1/2) 2 (1/8) 3 (1/8) 4 (1/16) 5 (1/16) 6 (1/8) 1 h m=2 2 UNIFORME !!! PERCHE’ ??? in rosso è indicata la probabilità che una certa chiave debba essere inserita nella tabella 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 n (1+ (i-1)/m) = 1/n (n + [n(n+1)]/[2m] - n/m) = = 1 + /2 - 1/(2m) = i=1 = (1+) 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 Sj={ k U tali che h(k)=j } Vogliamo uniform hashing ovvero Pr(k) = 1/m kSj (m=dimensione della tabella) 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 Sj 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 = (51/2-1)/2 = 0.618..., k = 123456, m = 10000 h(123456) = conti conti conti = 41 Risoluzione collisioni: open addressing h(,0) h(,0) h(,2) h(,0) =4 h(,0) h(,1) h(,1) h(,1) =2 =5 =1 1 2 3 4 5 Open addressing Nessun puntatore: spazio risparmiato! ≤ 1 sempre. Nessuna lista per gestire le collisioni Hash function più complessa. <h(k,0), h(k,1), ... , h(k,m-1)> deve essere una permutazione di <1, ... , m> 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 <1, ... ,m>. 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 inil then T[i]=nil NON FUNZIONA 0 3 1 7 2 8 3 0 1 2 3 3 7 8 6 0 3 1 7 2 3 6 Inseriamo 6 h(6,0)=0 h(6,1)=1 h(6,2)=2 h(6,3)=3 Cancelliamo 8 ricerchiamo 6 Risposta: 6 non c’è Esercizio: Modificare Hash-search e Hash-delete per risolvere il problema illustrato nel lucido precedente. Suggerimento: usare un carattere con il quale contrassegnare gli elementi cancellati. 0 1 2 3 3 7 D 6 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) h(k,1) h(k,2) h(k,3) h(k,4) h(k,5) = = = = = = 4 5 0 1 2 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à. i slot pieni Pr = (i+1)/m i slot vuoti Pr = 1/m i+1 slot pieni Quadratic probing h(k,i) = (h’(k) + c1i + c2i2) mod m con c20 Cosa si può dire sul clustering primario ? Double hashing h(k,i) = (h1(k) + ih2(k)) mod m Cosa succede se MCD(m,h2(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) = (m-1)/m 1/(1- ) = m (valore massimo di ) = 1/m 1/(1- ) = m/(m-1) (valore minimo di ) = 1/2 1/(1- ) = 2 Dimostrazione: IDEA: cosa succede quando facciamo una ricerca senza successo ??? X = lunghezza probe = quante volte devo calcolare h(k,i) prima di trovare uno slot vuoto = elemento non trovato Empty Dobbiamo valutare E[X] = media di X Lemma: 0 --> p0 X variabile aleatoria discreta E[X] = = = ∞ i=0 X= ∞ ∞ i(Pr(X≥i) - Pr(X≥i+1)) i=0 .......... i --> pi .......... ipi iPr(X=i) i=0 1 --> p1 ESERCIZIO !!! = ∞ Pr(X ≥i) i=1 E[X] = ≤ ≤ ∞ Pr(X≥i) i=1 ∞ i=1 ∞ i=1 (n/m)i Costo per la ricerca: 1 + E[X] = i 1+ ∞ i=1 i = 1 + + 2 + 3 + .......... 1 / (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 8 5 18 6 15 9 17 16 Alberi di ricerca binari 8 5 18 6 15 9 17 16 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]) 8 5 18 7 6 15 9 ORDINAMENTO 17 16 Ricerca ricerchiamo il 16 8 5 18 7 6 15 9 17 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 15 6 3 2 18 7 4 17 13 9 20 x ha il figlio destro. successore(x)=minimo nel sottoalbero di destra Dimostrazione: Esercizio. Successore 15 6 3 2 18 7 4 17 13 9 20 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 5 15 6 3 2 18 7 4 17 13 5 9 20 99 99 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 15 6 3 2 18 7 4 17 20 13 9 successore di 15 17 6 3 2 18 7 4 20 13 9 17 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 26 17 41 14 21 10 7 3 16 12 15 19 30 23 20 28 47 38 35 39 Proprietà A ogni nodo è rosso o nero 26 17 41 14 21 10 7 3 16 12 15 19 30 23 20 28 47 38 35 39 Proprietà B ogni foglia è nera (ne aggiungiamo un livello fittiziamente) 26 17 41 14 21 10 7 3 16 12 15 19 30 23 20 28 47 38 35 39 Proprietà C un nodo rosso ha figli neri 26 17 41 14 21 10 7 3 16 12 15 19 30 23 20 28 47 38 35 39 Proprietà D tutti i cammini da un nodo x alle foglie ha lo stesso numero di nodi neri 26 17 41 14 21 10 7 16 12 15 19 30 23 20 28 47 38 35 39 3 4 nodi neri 4 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) 3 26 3 2 3 2 21 16 12 41 14 10 7 17 15 19 23 20 30 28 47 38 35 39 Teorema: Un RBT con n nodi interni è alto al massimo 2log2(n+1) Lemma: Il numero di nodi interni di un sotto albero radicato in x è maggiore o uguale a 2bh(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 2bh(L) - 1. Stesso discorso per R. Inoltre 2bh(L)-1 2bh(x)-1 - 1 e 2bh(R) - 1 2bh(x)-1 - 1 Quindi: numero di nodi interni dell’albero radicato in x maggiore o uguale a 2bh(x)-1 - 1 + 2bh(x)-1 - 1 + 1 che è uguale a 2bh(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 2bh(root) - 1 quindi: n 2bh(root) - 1 2h/2 - 1. concludiamo: log2(n+1) log2(2h/2 ) = h/2. Rotazioni Operazioni di ristrutturazione locale dell’albero che mantengono soddisfatte le proprietà A,B,C,D esercizio: scrivere il pseudo codice per le rotazioni Y A X B C X B destra sinistra Y C A Inserimento Idea: inseriamo x ---> T color[x]=red qualche rotazione + ricolorazione 41 30 28 38 35 nodo inserito ---> 32 47 39 ---> !!!!!! il figlio di un nodo rosso deve essere nero 11 2 1 p 15 7 5 4 14 8 11 rotazione sinistra ---> 2 1 p 7 5 4 14 8 15 rotazione destra ---> 11 7 2 p 14 8 1 5 4 15 7 2 11 1 5 4 FINE... 8 14 15 metodo generale: zio(x) è rosso p C A x A D up w z C D p B y rosso s x B y w z s metodo generale: zio(x) è rosso p C p rosso B C D B D up A x z y w s A x z y w s metodo generale: zio(x) è nero p C p A nero C D B D left(A) x B y A z x z y ... e poi ... C p B A x D z y B right(C) A x C y z D 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 z z x x y y z z x y y doppio nero che va poi ridistribuito su un nodo rosso annerendolo. y ricolora 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 y x s rotazione sinistra y s t x t y x t s rotazione derstra e poi sinistra t y s x a a b b Caso 2: fratello nero con figli neri y y x s x y x s y s x s Caso 3: fratello rosso y x s rotazione sinistra s y b x a b a Programmazione Dinamica 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=A1•A2• ••• •Am dove Ai 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: 10•100•5 per calcolare A’=MN 10•15•50 per calcolare A=A’Q Totale 7500 Secondo metodo A=(M(NQ)). Numero di moltiplicazioni: 100•5•50 per calcolare A’=NQ 10•100•50 per calcolare A=MA’ Totale 75000 Numero di possibili parentesizzazioni Il numero di possibili parentesizzazioni P(n) può essere ottenuto come segue: 1 P(n) = n-1 P(k)P(n-k) k=1 numero di parentesizzazioni delle prime k matrici n=1 n>1 numero di parentesizzazioni delle altre n-k matrici P(n) risulta essere W(4n/n3/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 Ai,...,Aj 0 m[i,j] = min{ m[i,k] + m[k+1,j] + pi-1pkpj } i≤k<j i=j i<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 (n2) (pochi !). Quindi l’algoritmo ricorsivo deve risolvere più volte lo stesso sottoproblema altrimenti non si spiega il costo esponenziale ! A1 A2 A3 A4 A5 A6 30x35 35x15 15x5 5x10 10x20 20x25 m= A1 A2 A3 A4 A5 A6 s= s[i,j] contiene l’indice ottmo per spezzare la moltiplicazione Ai•••Aj in due: Ai•••As[i,j] e As[i,j] +1•••Aj Pseudo codice 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]+pi-1pkpj 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 Ai Parentesizzazione ottima dell’esempio = ((A1(A2A3))((A4A5)A6)) Costo computazionale: Matrix-chain-order Matrix-chain-multiply tempo (n3) tempo esercizio spazio (n2) spazio esercizio Passi fondamentali della programmazione dinamica 1. 2. 3. 4. Caratterizzazione della struttura di una soluzione ottima Definizione ricorsiva del valore di una soluzione ottima Calcolo del valore di una soluzione ottima con strategia bottom-up Costruzione di una soluzione ottima a partire dalle informazioni già calcolate. nell’esempio della moltiplicazione di matrici..... 1. 2. 3. 4. Una parentesizzazione ottima associata a una lista A1,A2,...,Am di matrici da moltiplicare può essere sempre suddivisa in due parentesizzazioni ottime associate alle liste A1,...,Ak e Ak+1,...,Am per un opportuno valore di k. 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 vedi come procedere con la matrice m nei lucidi precedenti 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) + pi-1pkpj 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 X = ABCDBDAB Y = BDCABA Z = BCBA è LCS(X,Y) Problema di ottimizzazione. Possiamo applicare la programmazione dinamica ?? Sotto struttura ottima Siano X=<x1,...,xm> e Y=<y1,...,yn> Sia Z=<z1,...,zk> una qualunque LCS di X e Y. 1. 2. 3. Se xm=yn, e zk=xm=yn allora Zk-1 è LCS di Xm-1 e Yn-1 Se xmyn, e zk xm allora Zk-1 è LCS di Xm-1 e Y Se xmyn, e zk yn allora Zk-1 è LCS di X e Yn-1 0 0 1 A 2 B 3 C 4 B 5 D 6 A 7 B 1 2 3 4 5 6 B D C A B A 0 0 0 0 0 0 0 0 0 0 0 1 1 1 0 1 1 1 1 2 2 0 1 1 2 2 2 2 0 1 1 2 2 3 3 0 1 2 2 2 3 3 0 1 2 2 3 3 4 0 1 2 2 3 4 4 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 xi=yj 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 xi 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 si e un tempo di fine fi. Problema: Selezionare un sottoinsieme S’ di attività in modo tale che: 1. se i e j appartengono a S’ allora: si≥fj oppure sj≥fi. 2. La cardinalita di S’ è massimizzato. 11 10 9 8 7 6 5 4 3 2 1 0 1 2 3 4 5 6 7 8 9 10 11 12 Attività ordinate in base a fi 13 14 11 10 9 8 7 6 5 4 3 2 1 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Pseudocodice Greedy-activity-selector(s,f) n=length(s) A={1} j=1 for i=2 to n do if si≥fj 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 1k. 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à 1. 2 4 1 Soluzione con algoritmo greedy 3 2 4 1 3 Soluzione ottima 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 vi e un peso wi (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-wj 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 vi/wi (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 € 120 6 € al chilo 10 Zaino da 50 chili 5 € al chilo 20 30 4 € al chilo Soluzione ottima di knapsack con algoritmo greedy 10 20 20 = 2/3 di 30 € 240 Knapsack 0-1: nessuna soluzione greedy € 60 € 100 € 120 6 € al chilo 10 Zaino da 50 chili 5 € al chilo 20 30 4 € al chilo Soluzione ottima di knapsack 0-1 30 20 € 220 Soluzione di knapsack 0-1 scegliendo per primo l’oggetto da 6 € al chilo (valore per unità di peso massimo) 10 30 € 180 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-wi 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