Concetti di base di complessità degli algoritmi 1 Problemi, algoritmi, programmi • • • • Problema: il compito da svolgere – quali output vogliamo ottenere a fronte di certi input – cioè quale funzione vogliamo realizzare Algoritmo: i passi (il processo) da seguire per risolvere un problema – un algoritmo prende gli input in ingresso ad un problema e li trasforma in opportuni output Come al solito, un problema può essere risolto da tanti algoritmi Un algoritmo è una sequenza di operazioni concrete – deve essere eseguibile da una “macchina” • Un algoritmo deve essere corretto – deve calcolare la funzione giusta – sappiamo che determinare la correttezza di un algoritmo è un problema indecidibile... – ... questo però non vuole dire che non si possa fare niente per cercare di capire se un algoritmo è corretto o no • Un algoritmo può essere descritto in diversi linguaggi – se usiamo un linguaggio di programmazione (C, C++, Java, C#, ecc.) abbiamo un programma • Come linguaggio noi usiamo... lo pseudocodice – non è un vero linguaggio di programmazione, ma ci assomiglia molto – facile da tradurre in codice di un linguaggio di programmazione quale C o Java (o Python) – il particolare linguaggio di programmazione con cui un algoritmo è implementato è, dal punto di vista della complessità, un po' come l'hardware: cambia solo le costanti moltiplicative 2 pseudocodice • assegnamento: i := j – assegnamento multiplo: i := j := e • applicato da destra a sinistra • cioè è la stessa cosa che scrivere j := e; i := j • • • while, for, if-then-else come in C // inizia un commento, che termina alla fine della riga la struttura a blocchi è data dalla indentazione while i > 0 and A[i] > key while (i > 0 and A[i] > key) { A[i + 1] := A[i] i := i − 1 A[i + 1] := key A[i + 1] := A[i] = i := i − 1 } A[i + 1] := key • • Le variabili sono locali alla procedura Agli elementi degli array si accede come in C – A[j] è l'elemento di indice j dell'array A – il primo elemento può avere un indice diverso da 0 • C'è una nozione di sottoarray – A[i..j] è il sottoarray che inizia dall'elemento i-esimo e termina all'elemento j-esimo • e.g. A[1..5] è il sottoarray con i primi 5 elemento dell'array A 3 pseudocodice (2) • • Dati composti sono organizzati in oggetti Gli oggetti hanno degli attributi (detti anche campi) – per indicare il valore di un attributo attr di un oggetto x, scriviamo x.attr – gli array rappresentano dati composti, quindi sono oggetti • ogni array ha un attributo length, che contiene la lunghezza dell'array – • A.length è la lunghezza dell'array A Una variabile che corrisponde ad un oggetto (es. un array) è un puntatore all'oggetto – molto simile ai puntatori in C e, sopratutto, al concetto di reference in Java – per esempio, se abbiamo due variabili x and y, e x punta ad un oggetto con un attributo f, dopo le seguenti istruzioni y := x x.f := 3 si ha che x.f = y.f = 3, in quanto, grazie all'assegnamento y := x, x e y puntano allo stesso oggetto • Un puntatore che non fa riferimento ad alcun oggetto ha valore NIL • I parametri sono passati per valore – la procedura invocata riceve una copia dei parametri passati – se una procedura PROC ha un parametro x e dentro a PROC il parametro x riceve il valore di y (x := y), la modifica non è visibile al di fuori della procedura (per esempio al chiamante) • Quando un oggetto è passato come parametro, ciò che viene passato è il puntatore all'oggetto – degli attributi non viene fatta una copia, e modifiche a questi sono visibili al chiamante – se x è un parametro che è un oggetto con attributo f, gli effetti dell'assegnamento x.f:=3 (il fatto che l'attributo f valga 3) sono visibili al di fuori della procedura 4 – questo è il funzionamento di Java... Modello di computazione • • • Quale è la “macchina” sulla quale vengono eseguiti gli algoritmi scritti in pseudocodice? La macchina RAM! Assunzione di base: ogni istruzione semplice di pseudocodice è tradotta in un numero finito di istruzioni RAM – per esempio x := y diventa, se ax e ay sono gli l'indirizzi in memoria delle variabili x e y (ax e ay sono delle costanti): LOAD ay STORE ax • Da ora in poi adottiamo il criterio di costo costante – adatto per gli algoritmi che scriveremo, che non manipoleranno mai numeri né richiederanno quantità di memoria molto più grandi della dimensione dei dati in ingresso • • • In conseguenza di ciò abbiamo che ogni istruzione i di pseudocodice viene eseguita in un tempo costante ci Grazie a questa assunzione, da adesso in poi possiamo “dimenticarci” che il modello computazionale dello pseudocodice è la macchina RAM Inoltre, da ora in poi ci concentriamo sulla complessità temporale, più che su quella spaziale 5 Per chi volesse divertirsi .. • … A fare girare un po’ di codice (non tutto!) • Usando un linguaggio molto simile a quello adottato dal testo: Javascript • (ma anche il C va benissimo!) • http://home.deib.polimi.it/mandriol/Didattica/ MaterialeAlgPrincipi/MaterialeJavascript/Java Script-API.pdf • http://home.deib.polimi.it/pradella/ex.js 6 Primo esempio di problema/algoritmo • Problema: ordinamento – Input: una sequenza A di n numeri a1, a2, ... an – Output: una permutazione b1, b2, ... bn della sequenza di input tale che b1 b2 ... bn • Algoritmo: insertion sort INSERTION-SORT(A) 1 for j := 2 to A.length 2 key := A[j] 3 //Inserisce A[j] nella sequenza ordinata A[1..j−1] 4 i := j − 1 5 while i > 0 and A[i] > key 6 A[i + 1] := A[i] 7 i := i − 1 8 A[i + 1] := key 7 Costo di esecuzione per INSERTION-SORT INSERTION-SORT(A) costo 1 for j := 2 to A.length c1 c2 2 key := A[j] 3 //Inserisce A[j] nella sequenza A[1..j−1] 4 i := j − 1 5 while i > 0 and A[i] > key 6 A[i + 1] := A[i] 7 i := i − 1 8 A[i + 1] := key 0 c4 c5 c6 c7 c8 numero di volte n n-1 n-1 n-1 ∑ j= 2 t j n t j 1 t j 1 n-1 • Note: – n = A.length = dimensione dei dati in ingresso – t2, t3 ... tn = numero di volte che la condizione del ciclo while viene eseguita quando j = 2, 3, ... n • Tempo di esecuzione di INSERTION-SORT: T n = c1n + c2 n 1+ c4 n 1+ c5 t j + c6 t j 1+ c7 t j 1+ c8 n 1 • • Se l'array A è già ordinato, t2 = ... = tn = 1 – T(n) = an+b, cioè T(n) = (n) • questo è il caso ottimo Se A è ordinato, ma in ordine decrescente, t2=2, t3=3, ... tn=n – T(n) = an2+bn+c, cioè T(n) = (n2) • questo è il caso pessimo 8 Un classico problema: l'ordinamento • • • • L'ordinamento degli elementi di una sequenza è un esempio classico di problema risolto mediante algoritmi C'è un gran numero di algoritmi di ordinamento disponibili: insertion sort, bubblesort, quicksort, merge sort, counting sort, ... Ne abbiamo appena visto uno: insertion sort Abbiamo visto che nel caso pessimo TINSERTION-SORT(n) è (n2) – possiamo anche scrivere che TINSERTION-SORT(n) = O(n2) (usando la notazione O, senza specificare “nel caso pessimo”), in quanto il limite superiore (che è raggiunto nel caso pessimo) è una funzione in (n2) – è anche TINSERTION-SORT(n)=(n), in quanto il limite inferiore (raggiunto nel caso ottimo) è (n) • Possiamo fare di meglio? – possiamo cioè scrivere un algoritmo con un limite superiore migliore? • Sì! 9 Merge sort • Idea dell'algoritmo: – se l'array da ordinare ha meno di 2 elementi, è ordinato per definizione – altrimenti: • si divide l'array in 2 sottoarray, ognuno con la metà degli elementi di quello originario • si ordinano i 2 sottoarray ri-applicando l'algoritmo • si fondono (merge) i 2 sottoarray (che ora sono ordinati) • MERGE-SORT è un algoritmo ricorsivo • Un esempio di funzionamento: 42 16 28 36 26 78 84 8 42 16 28 36 26 78 84 8 42 16 28 36 26 78 84 8 16 42 28 36 26 78 8 84 16 28 36 42 8 26 78 84 8 16 26 28 36 42 78 84 10 pseudocodice di MERGE-SORT MERGE-SORT(A, p, r) 1 if p < r 2 q := ⎣(p + r)/2⎦ 3 MERGE-SORT(A, p, q) 4 MERGE-SORT(A, q+1, r) 5 MERGE(A, p, q, r) • Per ordinare un array A = A[1], A[2], ... A[n] invochiamo MERGE-SORT(A, 1, A.length) • MERGE-SORT adotta una tecnica algoritmica classica: divide et ìmpera Se il problema da risolvere è grosso: • – dividilo in problemi più piccoli della stessa natura – risolvi (domina) i problemi più piccoli – combina le soluzioni • Dopo un po' che dividiamo il problema in altri più piccoli, ad un certo punto arriviamo ad ottenere problemi “piccoli a sufficienza” per poterli risolvere senza dividerli ulteriormente – è una tecnica naturalmente ricorsiva in quanto, per risolvere i “problemi più piccoli”, applichiamo lo stesso algoritmo del problema più grosso • Per completare l'algoritmo dobbiamo definire un sottoalgoritmo MERGE che "combina" le soluzioni dei problemi più piccoli 11 Fusione (merge) di sottoarray ordinati • Definizione del problema (input/output) – Input: 2 array ordinati A[p..q] e A[q+1..r] di un array A – Output: l'array ordinato A[p..r] ottenuto dalla fusione degli elementi dei 2 array iniziali • Idea dell'algoritmo: 1. si va all'inizio dei 2 sottoarray 2. si prende il minimo dei 2 elementi correnti 3. si inserisce tale minimo all’inizio dell'array da restituire 4. si avanza di uno nell'array da cui si è preso il minimo 5. si ripete dal passo 2 • pseudocodice: MERGE (A, p, q, r) 1 n1 := q – p + 1 2 n2 := r – q 3 crea (alloca) 2 nuovi array L[1..n1+1] e R[1..n2+1] 4 for i := 1 to n1 5 L[i] := A[p + i - 1] 6 for j := 1 to n2 7 R[j] := A[q + j] 8 L[n1 + 1] := ∞ 9 R[n2 + 1] := ∞ 10 i := 1 11 j := 1 12 for k := p to r 13 if L[i] ≤ R[j] 14 A[k] := L[i] 15 i := i + 1 16 else A[k] := R[j] 17 j := j + 1 12 Analisi dell'algoritmo MERGE • • • • Nell'algoritmo MERGE prima si copiano gli elementi dei 2 sottoarray A[p..q] e A[q+1..r] in 2 array temporanei L e R, quindi si fondono L e R in A[p..r] Escamotage: per non dover controllare se L e R sono vuoti si usa una “sentinella”, un valore particolare (), più grande di ogni possibile valore, messo in fondo agli array (linee 8-9) Dimensione dei dati in input: n = r – p + 1 L'algoritmo è fatto di 3 cicli for: – 2 cicli di inizializzazione (l. 4-7), per assegnare i valori a L e R • il primo è eseguito n1 volte, il secondo n2 volte, con (n1) = (q-p+1) = (n/2) = (n) (n2) = (r-q) = (n/2) = (n) – • • si poteva giungere allo stesso risultato notando che n1 + n2 = n, quindi (n1 + n2) = (n) Il ciclo principale (l. 12-17) è eseguito n volte, e ogni linea ha costo costante In totale TMERGE(n) = (n) MERGE (A, p, q, r) costo 1 n1 := q – p + 1 c 2 n2 := r – q c 3 //crea 2 nuovi array L[1..n1+1] e R[1..n2+1](n) 4 for i := 1 to n1 (n1) per tutto il ciclo 5 L[i] := A[p + i - 1] 6 for j := 1 to n2 (n2) = (n/2) = (n) 7 R[j] := A[q + j] 8 L[n1 + 1] := ∞ c 9 R[n2 + 1] := ∞ c 10 i := 1 c 11 j := 1 c 12 for k := p to r (n) per il ciclo 13 if L[i] ≤ R[j] c 14 A[k] := L[i] c 15 i := i + 1 c 16 else A[k] := R[j] c 17 j := j + 1 c 13 Più in generale: Complessità di un algoritmo divide et impera • In generale, un algoritmo divide et impera ha le caratteristiche seguenti: – si divide il problema in sottoproblemi, ognuno di dimensione 1/b di quello originale – se il sottoproblema ha dimensione n piccola a sufficienza (n<c, con c una costante caratteristica del problema), esso può essere risolto in tempo costante (cioè (1)) – indichiamo con D(n) il costo di dividere il problema, e C(n) il costo di ricombinare le soluzioni dei sottoproblemi – T(n) è il costo per risolvere il problema totale • Possiamo esprimere il costo T(n) tramite la seguente equazione di ricorrenza (o ricorrenza): Tn = Θ1 se n < c Dn + aTn / b + Cn altrimenti • Ricorrenza per l'algoritmo MERGE-SORT: a = b = c = 2, D(n) = (1), C(n) = (n) Tn = Θ1 se n < 2 2T n / 2 + Θn altrimenti – in realtà dovrebbe essere T(n/2) + T(n/2) invece di 2T(n/2), ma l'approssimazione non influisce sul comportamento asintotico della funzione T(n) • Come risolviamo le ricorrenze? Vedremo tra poco... ... per ora: 14 Complessità di MERGE-SORT • Riscriviamo la ricorrenza di MERGE-SORT: Tn = c 2T n / 2+ cn • se n < 2 altrimenti Possiamo disegnare l'albero di ricorsione (consideriamo per semplicità il caso in cui la lunghezza n dell'array è una potenza di 2) cn cn cn/2 log 2 n cn/4 cn cn/2 cn/4 cn/4 cn/4 c c c c c c c c c c c c c cn cn Totale: cnlog n + cn • Sommando i costi dei vari livelli otteniamo T(n) = cn log(n) + cn, cioè TMERGE-SORT(n) = (n log(n)) 15 Un inciso: MERGE-SORT non ricorsivo • • • La complessità spaziale della versione ricorsiva di MERGESORT è TMERGE-SORT(n) = (n log(n)) Perché? (Come tutti gli algoritmi) MERGE-SORT può essere codificato anche in versione non ricorsiva: 42 16 28 36 26 78 84 8 F1 16 42 28 36 26 78 8 84 F2 16 28 36 42 8 26 78 84 F1 8 16 26 28 36 42 78 84 F2 • La complessità spaziale della versione ricorsiva di MERGE-SORT è SMERGE-SORT(n) = (n log(n)) • La complessità spaziale della versione non ricorsiva di MERGE-SORT è • SMERGE-SORT(n) = (n) • La codifica della versione non ricorsiva di MERGE-SORT è …un po’ più complicata 16 Risoluzione di ricorrenze (1) • In generale la funzione di complessità viene ricavata mediante equazioni che mettono in relazione (sotto)programmi e relative (sotto) complessità: • P • {P1; for i := 1 to n {P2}} • T = T1 + n.T2 • Quando P è ricorsivo T dipende da se stessa • Tre tecniche principali: – sostituzione – albero di ricorsione – teorema dell'esperto (master theorem) • Metodo della sostituzione: – formulare un'ipotesi di soluzione – sostituire la soluzione nella ricorrenza, e dimostrare (per induzione) che è in effetti una soluzione 17 Risoluzione di ricorrenze (2) • Metodo della sostituzione: • Congetturo e verifico • Però se cerco O(n) o (n) … semplifico ma … con cautela • (disequazioni invece di equazioni) • Esempio: cerchiamo un limite superiore per la seguente T(n): T(n) = 2T(n/2) + n – supponiamo T(n) = O(n log2(n)) – dobbiamo mostrare che T(n) ≤ cn log2(n) per una opportuna costante c>0 – supponiamo che ciò valga per T(n/2), cioè T(n/2) ≤ cn/2 log2(n/2) – allora, sostituendo in T(n) abbiamo T(n) ≤ 2cn/2 log2(n/2) + n ≤ cn log2(n/2) + n = = cn log2(n) -cn log2(2) + n = cn log2(n) -cn + n ≤ cn log2(n) • basta che c ≥ 1 – dobbiamo però mostrare che la disuguaglianza vale per n = 1 (condizione al contorno); supponiamo che sia T(1) = 1, allora T(1) =1 ≤ c.1.log2(1) = 0? No! – però T(n) ≤ cn log2(n) deve valere solo da un certo n0 in poi, che possiamo scegliere arbitrariamente; prendiamo n0 = 2, e notiamo che, se T(1) = 1, allora, dalla ricorrenza, T(2) = 4 e T(3) = 5 • inoltre, per n > 3 la ricorrenza non dipende più dal problematico T(1) – ci basta determinare una costante c tale che T(2) = 4 ≤ c.2. log2(2) e T(3) = 5 ≤ c3 log2(3) – per ciò basta prendere c ≥ 2 – Le costanti contano! 18 Osservazioni sul metodo di sostituzione (1) • Consideriamo il seguente caso: T(n) = T(n/2)+T(n/2)+1 • Proviamo a vedere se T(n) = O(n): ipotizziamo T(n) ≤ c.n: – T(n) ≤ cn/2+cn/2+1 = cn+1 – basta prendere c=1 e siamo a posto? – No! perché non abbiamo dimostrato la forma esatta della disuguaglianza! • Dall’ipotesi T(n/2) ≤ c (n/2) abbiamo dedotto solo T(n) ≤ c.n +1: • L’induzione non è dimostrata! • Potremmo prendere un limite più alto, e dimostrare che T(n) è O(n2) (cosa che è vera), ma in effetti si può anche dimostrare che T(n) = O(n), dobbiamo solo avere un'accortezza: • Mostriamo che T(n) ≤ cn-b, con b un'opportuna costante – se fosse così, allora T(n)= O(n) – T(n) ≤ cn/2-b+cn/2-b+1 = cn-2b+1 ≤ cn - b • basta prendere b 1 • Attenzione però: – T(n) = 2T(n/2) + n è O(n)? – Ipotesi: T(n) ≤ cn; ricavo – T(n) ≤ cn + n = O(n), quindi C.V.D.? – No! – Dobbiamo mostrare la forma esatta della disuguaglianza, e (c+1)n non è ≤ cn 19 Osservazioni sul metodo di sostituzione (2) • Altro esempio: T n = 2T n + log 2 n – poniamo m = log2(n), quindi n = 2m, otteniamo – T(2m) = 2T(2m/2)+m – Ponendo S(m) = T(2m) abbiamo S(m) = 2S(m/2) + m quindi S(m) = O(m log2(m)) – Quindi, sostituendo all'indietro: – T(n) = O(log2(n) log2log2(n)) 20 Metodo dell'albero di ricorsione • • • Un metodo non molto preciso, ma utile per fare una congettura da verificare poi con il metodo di sostituzione Idea: a partire dalla ricorrenza, sviluppiamo l'albero delle chiamate, indicando per ogni chiamata la sua complessità Esempio: T(n) = T(n/3) + T(2n/3)+O(n) – Prima chiamata: cn T n / 3 – Espandiamo: T 2n / 3 cn cn /3 T n / 9 2cn/3 T 2n / 9 T 2n / 9 T 4n / 9 – fino in fondo: cn cn cn /3 log3/ 2 n cn/9 cn 2cn/3 2cn/9 2cn/9 4cn/9 cn – Se l'albero fosse completo, sommando i costi livello per livello, a ogni livello avremmo un costo cn, ed il numero di livelli k sarebbe tale che n(2/3)k=1, cioè k = log3/2n ... 21 Albero di ricorsione (2) • ... però l'albero non è completo – il ramo più a destra è sì tale che alla fine n(2/3)k=1, ma quello più a sinistra è tale che n(1/3)k'=1, cioè k' = log3n • non fa niente, per ora ci accontentiamo di una congettura anche abbastanza grossolana, tanto poi la andiamo a controllare con il metodo di sostituzione • In definitiva, abbiamo la congettura che T(n)=O(n log2n) – verifichiamola, mostrando che T(n) ≤ dn log2n: – T(n) ≤ (d/3)n log2(n/3)+(2d/3)n log2(2n/3)+cn = (d/3)n log2n –(d/3)n log23+ (2d/3)n log2n –(2/3)dn log23 +(2d/3)n+cn = dn log2n -dn log23+(2d/3)n+cn = dn log2n -dn(log23-2/3)+cn ≤ dn log2n se d>c/(log23-2/3) 22 Albero di ricorsione (3) • Congetturiamo (e verifichiamo) anche che T(n)=(n log2n), cioè che T(n) ≥ kn log2n – T(n) ≥ (k/3)n log2(n/3)+(2k/3)n log2(2n/3)+cn = kn log2n -kn log23+(2k/3)n+cn = kn log2n -kn(log23-2/3)+cn ≥ kn log2n se 0<k<c/(log23-2/3) • Notiamo che in entrambe le sostituzioni sopra c'è anche da considerare il caso base per determinare se opportuni d e k esistono – lasciato per casa come esercizio... • Quindi in effetti T(n)=(n log2n) 23 Teorema dell'esperto (Master Theorem) (1) • Data la ricorrenza: • T(n) = aT(n/b) + f(n) (in cui a 1, b >1, e n/b è o n/b o n/b) 1. se f(n) = O(nlogba-) per qualche >0, allora T(n) = (nlogba) 2. se f(n) = (nlogba), allora T(n) = (nlogbalog(n)) 3. se f(n) = (nlogba+) per qualche >0, e a.f(n/b) c.f(n) per qualche c < 1 e per tutti gli n grandi a sufficienza, allora T(n) = (f(n)) 24 Teorema dell'esperto (Master Theorem) (2) • Alcune osservazioni... • La soluzione è data dal più grande tra nlogba e f(n) – se nlogba è il più grande, T(n) è (nlogba) – se f(n) è il più grande, T(n) è (f(n)) – se sono nella stessa classe secondo la relazione , T(n) è (f(n)log(n)) • “Più grande” o “più piccolo” in effetti è "polinomialmente più grande" e "polinomialmente più piccolo" – n è polinomialmente più piccolo di n2 – n log(n) è polinomialmente più grande di n½ • Il teorema dell'esperto non copre tutti i casi! – se una delle due funzioni è più grande, ma non polinomialmente più grande... – n log(n) è più grande di n, ma non polinomialmente più grande • Applichiamo il teorema dell'esperto a MERGE-SORT: – T(n) = 2T(n/2) + (n) • a=b=2 • f(n) = n • nlogba = n1 = n – siamo nel caso 2: TMERGE-SORT(n) = (n log(n)) 25 Un caso particolare • Notiamo che l'enunciato del teorema dell'esperto si semplifica un po' se f(n) è una funzione (nk), con k una qualche costante: 1. se k < logba, allora T(n) = (nlogba) 2. se k = logba, allora T(n) = (nklog(n)) 3. se k > logba, allora T(n) = (nk) – nel caso 3 la condizione aggiuntiva è automaticamente verificata – (a/bk) < 1 26 Un ulteriore risultato • Un altro teorema utile per risolvere certi tipi di ricorrenze: • Data la ricorrenza (in cui i coefficienti ai sono interi ≥ 0) (1) T (n) a T (n i) cn k i 1ih in cui poniamo a= se n m h se n m ai ∑ 1≤ i≤ h allora abbiamo che: 1. se a=1, allora T(n)=O(nk+1) 2. se a ≥ 2, allora T(n)=O(annk) • Per esempio, data la ricorrenza T(n) = T(n-1) + (n), otteniamo che T(n)=O(n2) – questa è la ricorrenza che otterremmo con una versione ricorsiva di INSERTIONSORT 27 Grafi (richiamo) • Un grafo è una coppia (V, E) in cui V è un insieme finito di nodi (detti anche vertici), e E VV è una relazione binaria su V che rappresenta gli archi del grafo – se u e v sono nodi del grafo, la coppia (u,v) è un arco, ed è rappresentata graficamente come: u v in questo caso l'arco è orientato, in quanto c'è un ordine tra i vertici, prima u, poi v – se non c'è un ordine tra i nodi (che quindi sono solo un insieme, {u,v} allora diciamo che l'arco è non orientato: u • v Un grafo è orientato se i suoi archi lo sono, non orientato altrimenti – esempio di grafo non orientato: • 1 2 3 4 Un cammino è una sequenza di nodi [v0, v1, v2, … vn] tali che tra ogni coppia di nodi della sequenza (vi, vi+1) c'è un arco – i nodi v0, … vn appartengono al cammino – la lunghezza del cammino è data da n (numero di vertici -1) • In un grafo non orientato, il cammino forma un ciclo se v0=vn, e contiene almeno 3 nodi (cioè se ha almeno 3 archi) – Un grafo che non ha cicli è aciclico • Un grafo non orientato è connesso se tra ogni coppia di vertici esiste un cammino 28 Alberi (richiamo) • Un albero è un grafo connesso, aciclico, non orientato – un albero è radicato se un nodo viene indicato come la radice radice profondità 0 nodi interni profondità 1 altezza = 3 profondità 2 profondità 3 foglie • Ogni nodo dell'albero è raggiungibile dalla radice tramite un cammino (che è unico, in quanto il grafo è aciclico) • Chiamiamo: – – – – – foglie: gli ultimi nodi dei cammini dalla radice nodi interni: tutti i nodi dei cammini tra la radice e le foglie profondità (di un nodo N): la distanza di N dalla radice altezza (dell'albero): la distanza massima tra la radice e una foglia antenato (di un nodo N): ogni nodo che precede N sul cammino dalla radice a N – padre (di un nodo N): il nodo che immediatamente precede N lungo il cammino dalla radice a N – figlio (di un nodo N): ogni nodo di cui N è padre – fratelli (di un nodo N): i nodi che hanno lo stesso padre di N • Un albero è binario se ogni nodo ha al più 2 figli 29 HEAPSORT • MERGE-SORT è efficiente dal punto di vista del tempo di esecuzione, ma non è ottimale dal punto di vista dell'uso della memoria (a meno di non usare la versione non ricorsiva): – ogni MERGE richiede di allocare 2 array, di lunghezza (n) – usa una quantità di memoria aggiuntiva rispetto all'array da ordinare che non è costante, cioè non ordina sul posto • • HEAPSORT, invece, non solo è efficiente (ordina in tempo (n log(n))), ma ordina sul posto L'idea alla base di HEAPSORT è che un array può essere visto come un albero binario: – A[1] è la radice – per ogni elemento A[i], A[2i] e A[2i+1] sono i suoi figli, e A[i/2] è il padre • Esempio: 1 2 3 4 5 6 7 8 9 10 11 12 a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 1 2 4 a8 8 a4 a1 a2 3 5 a5 a6 6 a9 a10 a11 9 10 11 a3 7 a7 a12 12 30 Gli heap (mucchi) • Uno heap binario è un albero binario quasi completo – quasi completo = tutti i livelli sono completi, tranne al più l'ultimo, che potrebbe essere completo solo fino a un certo punto da sinistra – l'albero binario che deriva dall'interpretazione di un array come albero è quasi completo • Un max-heap è uno heap tale che, per ogni nodo x dell'albero, il valore contenuto nel padre di x è ≥ del contenuto di x – usando la corrispondenza albero-heap, questo vuole dire che A[i/2]≥A[i] • Esempio: 1 2 3 4 5 6 7 8 9 10 11 12 9 8 7 5 7 4 0 4 3 6 1 2 3 7 1 2 5 4 4 8 • 9 8 5 7 4 6 3 6 1 9 10 11 7 0 2 12 Si noti che in un max-heap l'elemento massimo è nella radice – dove è il minimo? 31 Alcune operazioni sugli heap • Operazioni di base: PARENT(i) 1 return i/2 LEFT(i) 1 return 2*i RIGHT(i) 1 return 2*i + 1 • Quindi, in un max-heap abbiamo che A[PARENT(i)] ≥ A[i] – esistono anche i min-heap, per le quali A[PARENT(i)] ≤ A[i] • Per realizzare l'ordinamento usiamo i max-heap • Ogni array A che rappresenta uno heap ha 2 attributi: – A.length, che rappresenta il numero totale di elementi dell'array – A.heap-size, che rappresenta il numero di elementi dello heap • A.heap-size ≤ A.length, e solo gli elementi fino a A.heap-size hanno la proprietà dello heap – però l'array potrebbe contenere elementi dopo l'indice A.heap-size, se A.heapsize<A.length – NB: Però A.length è il numero effettivo n di elementi da ordinare, non corrisponde necessariamente a una potenza di 2 (-1) 32 Algoritmi di supporto • Un algoritmo che, dato un elemento di un array tale che i suoi figli sinistro e destro siano dei max-heap, ma in cui A[i] (la radice del sottoalbero) potrebbe essere < dei suoi figli, modifica l'array in modo che tutto l'albero di radice A[i] sia un max-heap MAX-HEAPIFY(A, i) 1 l := LEFT(i) 2 r := RIGHT(i) 3 4 5 if l A.heap-size and A[l] > A[i] max := l else max := i 6 7 if r A.heap-size and A[r] > A[max] max := r 8 if max i then 9 swap A[i] A[max] 10 MAX-HEAPIFY(A, max) • TMAX-HEAPIFY = O(h), dove h è l'altezza dell'albero, che è O(log(n)), poiché l'albero è quasi completo – quindi, TMAX-HEAPIFY = O(log(n)) 33 Osservazione • Questo si sarebbe anche potuto mostrare usando il teorema dell'esperto per la seguente ricorrenza, che rappresenta il tempo di esecuzione di MAX-HEAPIFY nel caso pessimo: T(n) = T(2n/3) + (1) – nel caso pessimo l'ultimo livello dell'albero è esattamente pieno a metà, e l'algoritmo viene applicato ricorsivamente sul sottoalbero sinistro: 20 21 22 23 23 34 Da array a heap (1) • Un algoritmo per costruire un max-heap a partire da un array – idea: costruiamo il max-heap bottom-up, dalle foglie, fino ad arrivare alla radice • osservazione fondamentale: tutti gli elementi dall'indice A.length/2 in poi sono delle foglie, quelli prima sono dei nodi interni • i sottoalberi fatti solo di foglie sono, presi singolarmente, già degli heap, in quanto sono fatti ciascuno di un unico elemento 35 Da array a heap (2) BUILD-MAX-HEAP(A) 1 A.heap-size := A.length //heap-size viene inizializzata a n 2 for i := A.length/2 downto 1 3 MAX-HEAPIFY(A, i) • • • Costo di BUILD-MAX-HEAP? – ad occhio, ogni chiamata a MAX-HEAPIFY costa O(log(n)), e vengono fatte n/2 chiamate (con n che è A.length), quindi il costo è O(n log(n)) – ma in realtà questo limite non è stretto... Osserviamo che: – l'altezza di un albero quasi completo di n nodi è log2(n) – se definiamo come “altezza di un nodo di uno heap” la lunghezza del cammino più lungo che porta ad una foglia, il costo di MAX-HEAPIFY invocato su un nodo di altezza h è O(h) – il numero massimo di nodi di altezza h di uno heap è n/2h+1 Quindi MAX-HEAPIFY viene invocato n/2h+1 volte ad ogni altezza h, quindi il costo di BUILD-MAX-HEAP è lgn n h 2 h+1 O(h) = O n 2 h h=0 h=0 lgn cioè O(n), in quanto è noto che h 1/ 2 = =2 h 2 (1 1/ 2 ) h=0 2 36 Infatti: 1 x = ( 1 x) h=0 h h 1 d x = ( 1 x) h=0 hx h 1 = 1 2 dx ( 1 x) h=0 x h hx = 2 ( 1 x) h=0 h 1/ 2 = =2 h 2 (1 1/ 2 ) h=0 2 Oppure: esercizio (per induzione) h n2 = 2 n h 2 h=1 2 n 37 HEAPSORT • Possiamo a questo punto scrivere l'algoritmo di HEAPSORT: HEAPSORT(A) 1 BUILD-MAX-HEAP(A) 2 for i := A.length downto 2 3 swap A[1] ↔ A[i] 4 A.heap-size := A.heap-size − 1 5 MAX-HEAPIFY(A,1) – idea: a ogni ciclo piazziamo l'elemento più grande (che è il primo dell'array, in quanto questo è un max-heap) in fondo alla parte di array ancora da ordinare (che è quella corrispondente allo heap) • fatto ciò, lo heap si decrementa di 1, e si ricostruisce il max-heap mettendo come radice l'ultima foglia a destra dell'ultimo livello, e invocando MAXHEAPIFY • La complessità di HEAPSORT è O(n log(n)), in quanto – BUILD-MAX-HEAP ha costo O(n) – MAX-HEAPIFY è invocato n volte, e ogni sua chiamata ha costo O(log(n)) 38 Un «corollario» dei MAX-HEAP: le code con priorità (Priority queues) • Sfruttano l’ordinamento parziale dei MAX-HEAP (resp. MIN-HEAP) per gestire efficientemente ,e dinamicamente, i massimi (minimi) di vari insiemi: da cui il nome della struttura: • Arricchiscono i MAX-HEAP con le seguenti funzioni: • Mantenendo la proprietà MAX-HEAP! • HEAP-INSERT (A, key): A := A {key} • HEAP-MAXIMUM (A) • HEAP-EXTRACT-MAX (A): • return MAXIMUM (A) and A:= A- {MAXIMUM (A)} • HEAP-INCREASE-KEY (A, i, key): aumenta il valore della chiave dell’elemento i-esimo al nuovo valore key, che deve essere > key(A[i]). 39 Implementazione delle funzioni delle code con priorità • HEAP-MAXIMUM (A) • return A[1] • HEAP-EXTRACT-MAX (A): • • • • • • if A.heap.size < 1 return error «heap-underflow» max := A[1]; A[1] := A[A.heap.size]; A.heap.size := A.heap.size -1; MAX-HEAPIFY(A, 1) return max • HEAP-INCREASE-KEY (A, i, key) • • • if key < A[i] return error «wrong key» A[i] := key: while i > 1 and A[Parent(i)] < A[i] • swap A[i] and A[Parent(i)]; • i := Parent(i) • HEAP-INSERT (A, key) • • • A.heap.size := A.heap.size +1; A[A.heap.size] := - ; HEAP-INCREASE-KEY (A, A.heap.size, key) 40 QUICKSORT • QUICKSORT è un algoritimo in stile divide-et-impera – ordina sul posto • • Nel caso pessimo (vedremo) ha complessità (n2) Però in media funziona molto bene (in media ha complessità (n log(n))) – inoltre ha ottime costanti • Idea di base del QUICKSORT: dato un sottoarray A[p..r] da ordinare: – (dividi) riorganizza A[p..r] in 2 sottoarray A[p..q-1] e A[q+1..r] tali che tutti gli elementi di A[p..q-1] siano ≤ A[q] e tutti gli elementi di A[q+1..r] siano ≥A[q] – (impera) ordina i sottoarray A[p..q-1] e A[q+1..r] riutilizzando QUICKSORT – (combina) nulla! L'array A[p..r] è già ordinato QUICKSORT(A, p, r) 1 if p < r 2 q := PARTITION(A, p, r) 3 QUICKSORT(A, p, q−1) 4 QUICKSORT(A, q+1, r) • Per ordinare un array A: QUICKSORT(A,1,A.length) 41 PARTITION • La cosa difficile di QUICKSORT è partizionare l'array in 2 parti: PARTITION(A, p, r) 1 x := A[r] 2 i := p − 1 3 for j := p to r − 1 4 if A[j] ≤ x 5 i := i + 1 6 swap A[i] ↔ A[j] 7 swap A[i+1] ↔ A[r] 8 return i + 1 – l'elemento x (cioè A[r] in questa implementazione) è il pivot – Scelta (a priori) nondeterministica • Complessità di PARTITION: (n), con n = r-p+1 i i=j p 1 5 4 5 4 5 4 5 4 r j 3 8 6 7 i j r 3 p 1 7 6 i p 1 8 3 x j p 1 r 3 7 6 8 7 i j r 6 7 8 42 q Complessità di QUICKSORT (1) • Il tempo di esecuzione di QUICKSORT dipende da come viene partizionato l'array • Se ogni volta uno dei 2 sottoarray è vuoto e l'altro contiene n-1 elementi si ha il caso pessimo – la ricorrenza in questo caso è: T(n) = T(n-1) + (n) • abbiamo visto che la soluzione di questa ricorrenza è O(n2) • si può anche dimostrare (per esempio per sostituzione) che è anche (n2) – un caso in cui si ha sempre questa situazione completamente sbilanciata è quando l'array è già ordinato 43 Complessità di QUICKSORT (2) • Nel caso ottimo, invece, i 2 array in cui il problema viene suddiviso hanno esattamente la stessa dimensione n/2 – la ricorrenza in questo caso è: T(n) = 2T(n/2) + (n) – è la stessa ricorrenza di MERGE-SORT, ed ha quindi la stessa soluzione (n log(n)) • Notiamo che se la proporzione di divisione, invece che essere n/2 ed n/2, fosse n/10 e 9n/10, comunque la complessità sarebbe (n log(n)) – solo, la costante “nascosta” dalla notazione sarebbe più grande – abbiamo già visto qualcosa di molto simile per la suddivisione n/3 e 2n/3 44 QUICKSORT nel caso medio (solo intuizione) • In media ci va un po' bene ed un po' male – bene = partizione ben bilanciata – male = partizione molto sbilanciata • Qualche semplificazione: – ci va una volta bene ed una volta male – quando va bene => ottimo • n/2 e n/2 – quando va male => pessimo • n-1 e 0 (n) n 0 n-1 (n-1)/2 - 1 (n-1)/2 45 QUICKSORT nel caso medio (solo intuizione) • Albero di ricorsione in questo caso (ogni divisione costa n): (n) n 0 n-1 (n-1)/2 - 1 (n-1)/2 – costo di una divisione “cattiva” + una divisione “buona” = (n) • è lo stesso costo di una singola divisione “buona” – dopo una coppia “divisione cattiva” – “divisione buona” il risultato è una divisione “buona” – quindi alla fine il costo di una coppia “cattiva – buona” è lo stesso di una divisione “buona”, ed il costo di una catena di tali divisioni è la stessa... – l’altezza dell’albero è 2.log(n) invece di log(n) … – ... quindi (n log(n)) • le costanti moltiplicative peggiorano un po', ma l'ordine di grandezza non cambia! 46 QUICKSORT nel caso medio (in generale .. ) • Assumendo che ogni ripartizione posizioni il pivot con uguale probabilità in ogni posizione: 1 n 1 T (n) cn T (k ) T (n 1 k ), n k 0 T (0) T (1) c • Che, con un po’ di pazienza, si può dimostrare essere (n.log(n)) 47 Limite inferiore per l'ordinamento (1) • E' stato dimostrato che l'ordinamento basato su confronto (come INSERTION-SORT, MERGE-SORT, o HEAPSORT, per esempio) deve fare (n log(n)) confronti nel caso pessimo: • Idea della dimostrazione: ogni computazione di un qualsiasi algoritmo parte da un array e produce un risultato che è una permutazione dell’array originario. • Tutte le possibili computazioni devono poter produrre tutte le possibili permutazioni: • Qualsiasi algoritmo deve avere almeno n! diverse possibili computazioni: se le rappresentiamo mediante un albero: a1, a2, … an a1 >= a2 a1 < a2 a5, a 8, … a23 a12, a21, … a6 48 Limite inferiore per l'ordinamento (2) • Lunghezza della computazione massima = altezza dell’albero. • L’altezza minima: quando l’albero è bilanciato: • (log n): ogni algoritmo è (log n!). • Qual è l’ordine di grandezza di log n! ? n n n! n(n 1)(n 2) 2 2 e quindi , n n log( n!) log 2 2 n 2 che è (n⋅log n). D’altra parte, log(n!) log n log(n 1) cosicché log(n!) n⋅log(n). • questo risultato ha come conseguenza che un algoritmo come MERGE-SORT (o HEAPSORT) è “ottimale” nel caso pessimo • tuttavia, questo non significa che l'ordinamento è un “caso chiuso”, che abbiamo trovato la soluzione ideale, o che dobbiamo sempre usare MERGE-SORT o HEAPSORT – in effetti, alla fin fine l'algoritmo di ordinamento forse più usato è QUICKSORT, che non è “ottimale” nel caso pessimo... • inoltre, possiamo in effetti fare meglio di MERGE-SORT e HEAPSORT! – però dobbiamo evitare di fare confronti 49 COUNTING-SORT (1) • Ipotesi fondamentale: i valori da ordinare non sono più grandi di una certa costante k • Idea di base: se nell'array ci sono me valori più piccoli di un certo elemento e (il cui valore è ve) nell'array ordinato l'elemento e sarà in posizione me+1 – quindi, basta contare quante "copie" dello stesso valore ve sono contenute nell'array – usiamo questa informazione per determinare, per ogni elemento e (con valore ve tale che 0 ve k), quanti elementi ci sono più piccoli di e – dobbiamo anche tenere conto del fatto che nell'array ci possono essere elementi ripetuti • es. 2, 7, 2, 5, 1, 1, 9 • pseudocodice – parametri: A è l'array di input (disordinato), B conterrà gli elementi ordinati (cioè è l'output), e k è il massimo tra i valori di A • A e B devono essere della stessa lunghezza n 50 COUNTING-SORT (2) COUNTING-SORT (A, B, k) 1 for i := 0 to k 2 C[i] := 0 3 for j := 1 to A.length 4 C[A[j]] := C[A[j]] + 1 5 //C[i] ora contiene il numero di elementi uguali a i 6 for i := 1 to k 7 C[i] := C[i] + C[i - 1] 8 //C[i] ora contiene il numero di elementi ≤ i 9 for j := A.length downto 1 10 B[C[A[j]]] := A[j] 11 C[A[j]] := C[A[j]] - 1 Se A = 2,5,3,0,2,3,0,3 – A.length = 8 – B deve avere lunghezza 8 • Se eseguiamo COUNTING-SORT(A, B, 5) – prima di eseguire la linea 5 (cioè alla fine del loop 3-4) C = 2,0,2,3,0,1 – prima di eseguire la linea 8 C = 2,2,4,7,7,8 – dopo le prime 3 iterazioni del ciclo 9-11 abbiamo 1. B = _,_,_,_,_,_,3,_ , C = 2,2,4,6,7,8 2. B = _,0,_,_,_,_,3,_ , C = 1,2,4,6,7,8 3. B = _,0,_,_,_,3,3,_ , C = 1,2,4,5,7,8 – alla fine dell'algoritmo B = 0,0,2,2,3,3,3,5 , C = 0,2,2,4,7,7 • 51 COUNTING-SORT (3) • La complessità di COUNTING-SORT è data dai 4 cicli for: – il ciclo for delle linee 1-2 ha complessità (k) – il ciclo for delle linee 3-4 ha complessità (n) – il ciclo for delle linee 6-7 ha complessità (k) – il ciclo for delle linee 9-11 ha complessità (n) • La complessità globale è (n + k) • Se k è O(n), allora il tempo di esecuzione è O(n) – lineare! • COUNTING-SORT è "più veloce" (cioè ha complessità inferiore) di MERGE-SORT e HEAPSORT (se k è O(n)) perché fa delle assunzioni sulla distribuzione dei valori da ordinare (assume che siano tutti ≤ k) – sfrutta l'assunzione: è veloce se k è O(n), altrimenti ha complessità maggiore (anche di molto) di MERGE-SORT e HEAPSORT 52 Strutture Dati 53 Scopo delle strutture dati • Le strutture dati sono “aggeggi” usati per contenere oggetti – rappresentano collezioni di oggetti – spesso (ma non sempre) gli oggetti di in una struttura dati hanno una chiave, che serve per indicizzare l'oggetto, e dei dati satelliti associati (che sono i dati di interesse che porta con sé l'oggetto) • per esempio, si fa ricerca sulla chiave per accedere ai dati satelliti, che possono essere qualunque • Ci sono 2 tipi di operazioni sulle strutture dati: – operazioni che modificano la collezione – operazioni che interrogano la collezione 54 • Alcune operazioni tipiche sulle strutture dati: – SEARCH(S, k) • restituisce l'oggetto (o, meglio il suo riferimento) x nella collezione S con chiave k, NIL se nessun oggetto nella collezione ha chiave k • è un'operazione di interrogazione – INSERT(S, x) • inserisce l'oggetto x nella collezione S • è un'operazione che modifica la collezione – DELETE(S, x) • cancella l'oggetto x dalla collezione S (op. di modifica) – MINIMUM(S) • restituisce l'oggetto nella collezione con la chiave più piccola (op. di interrogazione) – MAXIMUM(S) • restituisce l'oggetto nella collezione con la chiave più grande (op. di interrogazione) – SUCCESSOR(S, x) • restituisce l'oggetto che segue x nella collezione, secondo una qualche relazione di ordinamento (op. di interrogazione) • per esempio, potrebbe essere l'elemento con la prossima chiave più grande, se c'è un ordinamento sulle chiavi – potrebbe essere qualcosa d'altro (la sua definizione dipende dalla specifica struttura dati) – PREDECESSOR(S,x) • restituisce l'oggetto che precede x nella collezione, secondo una qualche relazione di ordinamento (op. di interrogazione) 55 Pile (Stack) • • • • Cominciamo con un esempio semplicissimo di struttura dati: la pila Ad un livello astratto, una pila è una collezione di oggetti sulla quale possiamo fare le seguenti operazioni: – controllare se è vuota – inserire un elemento nella collezione (PUSH) – cancellare un elemento dalla collezione (POP) • l'operazione di POP restituisce l'elemento cancellato Una pila è gestita con una politica LIFO (Last In First Out) – l'elemento che viene cancellato (pop) è quello che è stato inserito per ultimo (cioè quello che è nella pila da meno tempo) • cioè, se viene fatta una PUSH di un oggetto e su una pila S, seguita immediatamente da una POP su S, l'elemento restituite dalla POP è lo stesso e di cui era stata fatta la PUSH Se la pila può contenere al massimo n elementi, possiamo implementarla come un array di lunghezza n – per tenere traccia dell'indice dell'elemento che è stato inserito per ultimo viene introdotto un attributo, chiamato top • cioè, se una pila S è implementata mediante un array, S.top è l'indice dell'ultimo elemento inserito – se S.top = t, allora S[1], S[2], ... S[t] contengono tutti gli elementi, e S[1] è stato inserito prima di S[2], che è stato inserito prima di S[3], ecc. • se S.top = 0, la pila è vuota, e nessun elemento può essere cancellato • se S.top = S.length = n, la pila è piena, e nessun elemento vi può essere aggiunto 56 pseudocodice per le operazioni sulle pile • Se una pila è implementata tramite un array, lo pesudocodice per le operazioni su di essa è il seguente: STACK-EMPTY(S) 1 if S.top = 0 2 return TRUE 3 else return FALSE PUSH(S, x) 1 if S.top = S.length 2 error “overflow” 3 else S.top := S.top + 1 4 S[S.top] := x POP(S) 1 if STACK-EMPTY(S) 2 error “underflow” 3 else S.top := S.top - 1 4 return S[S.top + 1] • Tutte le operazioni vengono eseguite in tempo T(n) = O(1) – poiché la pila è limitata, non servono cicli, quindi la complessità è costante • Si noti la separazione tra interfaccia astratta e implementazione concreta: • Esercizio: implementare la medesima interfaccia per una pila realizzata mediante lista a puntatori. 57 Code (queue) (1) • Le code sono simili alle pile, salvo che una coda è gestita con una politica FIFO (First In First Out) • A livello astratto, una coda è una collezione di oggetti sulla quale si possono fare le seguenti operazioni: – (controllare se è vuota) – inserire un elemento nella collezione (ENQUEUE) – cancellare un elemento dalla collezione (DEQUEUE) • si noti che l'operazione di DEQUEUE restituisce l'elemento cancellato • Una coda è gestita con una politica FIFO – l'elemento che viene cancellato è quello che era stato inserito per primo (cioè quello che è rimasto nella coda per più tempo) 58 Code (queue) (2) • Se una coda può contenere al più n elementi, allora, come per le pile, possiamo implementarla tramite un array di lunghezza n – ora però dobbiamo tenere traccia di 2 indici: • l'indice del prossimo elemento da eliminare (quello che è nella coda da più tempo), • l'indice della cella nell'array in cui sarà memoerizzato il prossimo elemento inserito nella coda – utilizziamo 2 attributi, head e tail • se Q è una coda implementata mediante un array, Q.head è l'indice dell'elemento da più tempo nell'array • Q.tail è l'indice in cui il prossimo elemento inserito dovrà essere memorizzato – cioè, Q.tail-1 è l'indice dell'ultimo elemento inserito 59 Operazioni sulle code • Prima di introdurre lo pseudocodice di ENQUEUE e DEQUEUE, analizziamo come funziona una coda implementata come array – gli elementi di una coda Q hanno indici Q.head, Q.head+1, ... Q.tail-1 – se Q.tail = Q.length e un nuovo elemento è inserito, il prossimo valore di tail sarà 1 • la coda funziona in modo “circolare” • per esempio, se la coda ha lunghezza 10, Q.tail = 10 e noi inseriamo un nuovo elemento, dopo l'accodamento abbiamo che Q.tail = 1 – se Q.head = Q.tail la coda è vuota – se Q.head = Q.tail+1 la coda è piena • se la coda non è piena, c'è sempre almento una cella libera tra Q.tail e Q.head • quindi, se dobbiamo implementare mediante un array una coda Q che contiene al massimo n elementi, l'array deve avere n+1 celle 60 Pseudocodice per le operazioni sulle code • (che non controlla se la coda è piena/vuota) tempo di esecuzione: ENQUEUE(Q, x) T(n) = O(1) 1 Q[Q.tail] := x 2 if Q.tail = Q.length 3 Q.tail := 1 4 else Q.tail := Q.tail + 1 tempo di esecuzione: DEQUEUE(Q) T(n) = O(1) 1 x := Q[Q.head] 2 if Q.head = Q.length 3 Q.head := 1 4 else Q.head := Q.head + 1 5 return x … e i controlli di coda piena/vuota? (in realtà c’è un po’ di ridondanza …) 61 Liste (doppiamente) concatenate • Una lista concatenata è una struttura dati in cui gli elementi sono sistemati in un ordine lineare, in modo simile ad un array – l'ordine è dato non dagli indici degli elementi, ma da una “catena” di puntatori • Una lista doppiamente concatenata è fatta di oggetti con 3 attributi: – key, che rappresenta il contenuto dell'oggetto – next, che è il puntatore all'oggetto seguente • cioè il successore dell'oggetto nell'ordinamento lineare – prev, che è il puntatore all'oggetto precedente • cioè il predecessore • Se x è un oggetto nella lista, se x.next = NIL, x non ha successore – cioè è l'ultimo elemento della lista • Se x.prev = NIL, x non ha predecessore – cioè è il primo elemento della lista, la testa (head) • ogni lista L ha un attributo L.head, che è il puntatore al primo elemento della lista (ed eventualmente un L.tail) • Esempio di lista doppiamente concatenata prev head[L] 9 key 16 next 4 1 62 • Altri tipi di liste: – singolarmente concatenate • gli elementi non hanno il puntatore prev – ordinate • l'ordinamento degli elementi nella lista è quello delle chiavi • il primo elemento ha la chiave minima, l'ultimo la massima – non ordinate – circolari • il puntatore prev di head punta alla coda (tail), e il puntatore next della coda punta alla testa 63 Operazioni su una lista doppiamente concatenata (1) • Ricerca – input: la lista L in cui cercare e la chiave k desiderata – output: il puntatore ad un elemento che ha k come chiave, NIL se la chiave non è nella lista LIST-SEARCH(L, k) 1 x := L.head 2 while x ≠ NIL and x.key ≠ k 3 x := x.next 4 return x • Nel caso pessimo (quando la chiave non è nella lista) T(n) = (n) • Anche in questo caso è buona norma separare l’interfaccia della struttura dati dalla sua implementazione (un lista sequenziale può essere concatenata mediante puntatori ma anche realizzata mediante un array) 64 Operazioni su una lista doppiamente concatenata (2) • Inserimento (in testa) – input: la lista L, e l'oggetto x da aggiungere, inizializzato con la chiave desiderata – output: inserisce x all'inizio della lista L • (anche se un elemento con la stessa chiave esiste già nella lista) LIST-INSERT(L, x) 1 x.next := L.head 2 if L.head ≠ NIL 3 L.head.prev := x 4 L.head := x 5 x.prev := NIL • T(n) = O(1) • Occhio alla differenza tra oggetto e chiave! 65 Operazioni (3) • Cancellazione – input: la lista L, e l'oggetto x da cancellare • si noti che non si passa come argomento la chiave da cancellare, ma tutto l'oggetto – output: cancella x dalla lista LIST-DELETE(L, x) 1 if x.prev ≠ NIL 2 x.prev.next := x.next 3 else L.head := x.next 4 if x.next ≠ NIL 5 x.next.prev := x.prev //differenze tra C e Java? • T(n) = O(1) 66 Operazioni (4) NB • se cancelliamo tramite la chiave, e non direttamente tramite l'oggetto, allora la complessità diventa O(n), perchè dobbiamo prima cercare l'elemento – dobbiamo cioè prima chiamare LISTSEARCH, che ha tempo di esecuzione T(n) = O(n) – Domanda_1: se L è singolarmente concatenata T(n) di LIST-DELETE(L, x)è O(?) – Domanda_2: se voglio ordinare una lista concatenata? 67 Dizionari e indirizzamento diretto • Dizionario: insieme dinamico che supporta solo le operazioni di INSERT, DELETE, SEARCH Agli oggetti di un dizionario si accede tramite le loro chiavi • • Se la cardinalità m dell'insieme delle possibili chiavi U (m=|U|)è ragionevolmente piccola, la maniera più semplice di realizzare un dizionario è tramite un array di m elementi – con questo si ha l'indirizzamento diretto – in questo caso l'array si dice tabella a indirizzamento diretto • Ogni elemento T[k] dell'array contiene il riferimento all'oggetto di chiave k, se un tale oggetto è stato inserito in tabella, NIL altrimenti 0 NIL U 18 1 K 4 2 3 1 NIL 7 ... 2 T 2 dati 3 dati 3 4 NIL 68 Operazioni su una tabella a indirizzamento diretto DIRECT-ADDRESS-SEARCH(T, k) 1 return T[k] DIRECT-ADDRESS-INSERT(T, x) 1 T[x.key] := x DIRECT-ADDRESS-DELETE(T, x) 1 T[x.key] := NIL • Hanno tutte T(n)=O(1) • Però, se il numero effettivamente memorizzato di chiavi è molto più piccolo del numero di chiavi possibili, c'è un sacco di spreco di spazio... è un po’ come il counting sort. 69 Tabelle hash • Una tabella hash usa una memoria proporzionale al numero di chiavi effettivamente memorizzate nel dizionario – indipendentemente dalla cardinalità dell'insieme U di chiavi • Idea fondamentale: un oggetto di chiave k è memorizzato in tabella in una cella di indice h(k), con h una funzione hash – se m è la dimensione della tabella, h è una funzione h: U → {0..m-1} – la tabella T ha m celle, T[0], T[1], ... , T [m-1] – h(k) è il valore hash della chiave k • Problema: ho |U| possibili chiavi ed una funzione che le deve mappare su un numero m (< |U|, ma tipicamente << |U|) di slot della tabella – necessariamente avrò delle chiavi diverse (tante!) k1, k2 tali che h(k1)=h(k2) – in questo caso ho delle collisioni • Ci sono diverse tecniche per risolvere le collisioni • Una tipica è quella del concatenamento (chaining) 70 Risoluzione di collisioni tramite concatenamento (1) • Idea della tecnica del concatenamento: gli oggetti che vengono mappati sullo stresso slot vengono messi in una lista concatenata U 0 NIL 1 K k2 T k2 k3 2 NIL k3 3 k5 k5 4 NIL 5 NIL 71 U 0 NIL T 1 K k2 k2 k3 2 NIL k3 3 k5 k5 4 NIL 5 NIL • Operazioni sulle tabelle in questo caso: CHAINED-HASH-INSERT(T, x) inserisci x in testa alla lista T[h(x.key)] CHAINED-HASH-SEARCH(T, k) cerca un elemento con chiave k nella lista T[h(k)] CHAINED-HASH-DELETE(T, x) cancella x dalla lista T[h(x.key)] • INSERT si fa in tempo O(1) (assumendo l'elemento da inserire non sia già in tabella) • SEARCH si fa in tempo proporzionale alla lunghezza di T[h(k)] • DELETE si fa in tempo O(1) se la lista è doppiamente concatenata – in input c'è l'oggetto da eliminare, non solo la chiave – se singolarmente concatenata, proporzionale alla lunghezza di T[h(x.key)] 72 Analisi della complessità delle operazioni (1) • Nel caso pessimo, in cui tutti gli n elementi memorizzati finiscono nello stesso slot la complessità è quella di una ricerca in una lista di n elementi, cioè O(n) • In media, però, le cose non vanno così male... • Siano: – m la dimensione della tabella (il numero di slot disponibili) – il fattore di carico, = n/m • siccome 0 ≤ n ≤ |U| avremo 0 ≤ ≤ |U|/m • Ipotesi dell'hashing uniforme semplice: ogni chiave ha la stessa probabilità 1/m di finire in una qualsiasi delle m celle di T, indipendentemente dalle chiavi precedentemente inserite 73 Analisi della complessità delle operazioni (2) • Sotto questa ipotesi, la lunghezza media di una lista T[j] è m E [ n j ]= 1 n n = =α ∑ i m i= 1 m quindi il tempo medio per cercare una chiave k non presente nella lista è (1+) – 1 è il tempo per calcolare h(k), che si suppone sia costante • (1+) è anche il tempo medio per cercare una chiave k che sia presente nella lista – la dimostrazione però richiede qualche calcolo in più... • In pratica: – se n = O(m), allora = n/m = O(m)/m = O(1) – quindi in media ci mettiamo un tempo costante • Quindi la complessità temporale è O(1) (in media) per tutte le operazioni (INSERT, SEARCH, DELETE) 74 Funzioni hash (1) • Come scelgo una buona funzione hash h? • In teoria, ne dovrei prendere una che soddisfa l'ipotesi di hashing uniforme semplice – per fare ciò, però, dovrei sapere quale è la distribuzione di probabilità delle chiavi che devo inserire • se le chiavi sono tutte “vicine”, la funzione hash dovrebbe essere tale da riuscire a separarle • se invece so che le chiavi sono distribuite in modo uniforme in [0..K-1] mi basta prendere h(k) = (k/K)m – tipicamente si usano delle euristiche basate sul dominio delle chiavi • Attenzione: tipica assunzione delle funzioni hash: la chiave k è un intero non-negativo (cioè è in ℕ) – facile convertire una qualunque informazione trattata da un calcolatore in un intero non-negativo, basta per esempio interpretare come tale la sequenza di bit corrispondente – Quanto risulterà uniforme? 75 Funzioni hash (2) • Come scelgo una buona funzione hash h? • Metodo della divisione: h(k) = k mod m – facile da realizzare e veloce (una sola operazione) – evitare certi valori di m: • potenze di 2 (m non deve essere della forma 2p) – se no k mod m sono solo i p bit meno significativi di k – meglio rendere h(k) dipendente da tutti i bit di k – spesso si prende per m un numero primo non troppo vicino ad una potenza esatta di 2 • per esempio m=701, che ci darebbe, se n=2000, in media 3 elementi per lista concatenata 76 Metodo della moltiplicazione (1) • Moltiplichiamo k per una costante A reale tale che 0 < A < 1, quindi prendiamo la parte frazionaria di kA; il risultato lo moltiplichiamo per m, e ne prendiamo la parte intera • Cioè: h(k) = m(kA mod 1) – in cui x mod 1 = x – x è la parte frazionaria di x • In questo caso il valore di m non è critico, funziona bene con qualunque valore di A – spesso come m si prende una potenza di 2 (cioè m=2p), che rende semplice fare i conti con un calcolatore – in questo caso, è utile prendere come A un valore che sia della forma s/2w, con w dimensione della parola di memoria del calcolatore (con 0<s<2w) • se k sta in una sola parola (k<2w), ks=kA2w è un numero di 2w bit della forma r12w+r0, ed i suoi w bit meno significativi (cioè r0) costituiscono kA mod 1 • il valore di hash cercato (con m=2p) è costituito dai p bit più significativi di r0 77 Metodo della moltiplicazione (2) • Un valore di A proposto (da Knuth) che funziona bene è A ( 5 1) / 2 – è l'inverso della sezione aurea – se si vuole applicare il calcolo precedente, occorre prendere come A la frazione della forma s/2w più vicina all'inverso della sezione aurea • dipende dalla lunghezza della parola w 78 Indirizzamento aperto • Un altro modo di evitare collisioni è tramite la tecnica dell'indirizzamento aperto • In questo caso la tabella contiene tutte le chiavi, senza memoria aggiuntiva – quindi il fattore di carico non potrà mai essere più di 1 • L'idea è quella di calcolare l'indice dello slot in cui va memorizzato l'oggetto; se lo slot è già occupato, si cerca nella tabella uno slot libero – la ricerca dello slot libero però non viene fatta in ordine 0,1,2,...,m-1; la sequenza di ricerca (detta sequenza di ispezione) è un valore calcolato dalla funzione hash • dipende anche dalla chiave da inserire • la sequenza deve essere esaustiva, deve coprire tutte le celle • La funzione hash ora diventa: h : U {0,1,..., m-1} {0,1,..., m-1} – la sequenza di ispezione h(k, 0), h(k, 1),..., h(k, m1) deve essere una permutazione di 0, ... ,m-1 79 Operazioni in caso di indirizzamento aperto (1) • Inserimento di un oggetto: HASH-INSERT(T, k) 1 i := 0 2 repeat 3 j := h(k, i) 4 if T[j] = NIL 5 T[j] := k 6 return j 7 else i := i + 1 8 until i = m 9 error “hash table overflow” 80 Operazioni in caso di indirizzamento aperto (2) Ricerca: HASH-SEARCH(T, k) 1 i := 0 2 repeat 3 j := h(k, i) 4 if T[j] = k 5 return j 6 else i := i + 1 7 until T[j] = NIL or i = m 8 return NIL • La cancellazione è più complicata, in quanto non possiamo limitarci a mettere lo slot desiderato a NIL, altrimenti non riusciremmo più a trovare le chiavi inserite dopo quella cancellata – una soluzione è quella di mettere nello slot, invece che NIL, un valore convenzionale come DELETED • però così le complessità non dipendono più (solo) dal fattore di carico 81 Analisi di complessità (1) • Il tempo impiegato per trovare lo slot desiderato (quello che contiene la chiave desiderata, oppure quello libero in cui inserire l'oggetto) dipende (anche) dalla sequenza di ispezione restituita dalla funzione h – quindi dipende da come è implementata h • Per semplificare un po' il calcolo facciamo un'ipotesi sulla distribuzione di probabilità con la quale vengono estratte non solo le chiavi, ma anche le sequenze di ispezione • Ipotesi di hashing uniforme: ognuna delle m! permutazioni di 0, ... ,m-1 è ugualmente probabile che venga selezionata come sequenza di ispezione – è l'estensione dell'hashing uniforme semplice visto prima al caso in cui l'immagine sia non più solo lo slot in cui inserire l'elemento, ma l'intera sequenza di ispezione 82 Analisi di complessità (2) • L'analisi viene fatta in funzione del fattore di carico = n/m – siccome abbiamo al massimo un oggetto per slot della tabella, n≤m, e 0 ≤ ≤ 1 • Sotto l'ipotesi di hashing uniforme valgono i seguenti risultati (i calcoli sono sul libro): – il numero medio di ispezioni necessarie per effettuare l'inserimento di un nuovo oggetto nella tabella è m se = 1 (se la tabella è piena), e non più di 1/(1-) se <1 (se la tabella cioè ha ancora spazio disponibile) – il numero medio di ispezioni necessarie per trovare un elemento presente in tabella è (m+1)/2 se =1, e non più di 1/ log(1/(1-)) se <1 83 Tecniche di ispezione (1) • In pratica, costruire funzioni hash che soddisfino l'ipotesi di hashing uniforme è molto difficile • Si accettano quindi delle approssimazioni che, nella pratica, si rivelano soddisfacenti • Tre tecniche: – ispezione lineare – ispezione quadratica – doppio hashing • nessuna di queste tecniche produce le m! permutazioni che sarebbero necessarie per soddisfare l'ipotesi di hashing uniforme • tuttavia, nella pratica si rivelano “buone a sufficienza” • Tutte e 3 le tecniche fanno uso di una (o più) funzione hash ausiliaria (ordinaria) h': U {0,1,..., m-1} 84 Tecniche di ispezione (2) • Ispezione lineare: h(k,i) = (h'(k)+i) mod m – in questo caso l'ispezione inizia dalla cella h'(k), e prosegue in h'(k)+1, h'(k)+2, ... fino a che non si arriva a m-1, quindi si ricomincia da 0 fino a esplorare tutti gli slot di T – genera solo m sequenze di ispezione distinte • la prima cella ispezionata identifica la sequenza di ispezione – soffre del fenomeno dell'addensamento (clustering) primario • lunghe sequenze di celle occupate consecutive, che aumentano il tempo medio di ricerca 85 Ispezione quadratica • Nel caso dell'ispezione quadratica: h(k,i) = (h'(k)+c1i+c2i2) mod m – c1 e c2 sono costanti ausiliarie (con c2 ≠ 0) – c1 e c2 non possono essere qualsiasi, ma devono essere scelte in modo che la sequenza percorra tutta la tabella – ancora una volta, la posizione di ispezione iniziale determina tutta la sequenza, quindi vengono prodotte m sequenze di ispezione distinte – soffre del fenomeno dell'addensamento secondario: chiavi con la stessa posizione iniziale danno luogo alla stessa sequenza di ispezione 86 Doppio hashing • Doppio hashing: h(k,i) = (h1(k)+i h2(k)) mod m – h1 e h2 sono funzioni hash ausiliarie – perché la sequenza prodotta sia una permutazione di 0, ... ,m-1 h2(k) deve essere primo rispetto a m (non deve avere divisori comuni tranne l'1) • posso ottenere questo prendendo come m una potenza di 2, e facendo in modo che h2 produca sempre un valore dispari • oppure prendendo come m un numero primo, e costruendo h2 in modo che restituisca sempre un valore < m • esempio: h1(k) = k mod m h2(k) = 1 + (k mod m') – con m' < m (per esempio m' = m-1) – numero di sequenze generate ora è (m2) in quanto ogni coppia (h1(k), h2(k)) produce una sequenza di ispezione distinta 87 Alberi binari • Un albero binario è fatto di 3 elementi: un nodo radice; un albero binario che è il sottoalbero sinistro, ed un albero binario che è il sottoalbero destro – è una definizione ricorsiva – un sottoalbero può essere vuoto (NIL) • Ad ogni nodo dell'albero associamo un oggetto con una chiave • Esempio di albero binario radice sottoalbero sinistro 3 2 4 7 1 5 6 sottoalbero destro 88 Rappresentazione di alberi binari (1) • Tipicamente si rappresentano alberi binari mediante strutture dati concatenate – abbiamo però anche visto la rappresentazione mediante array • Ogni nodo dell'albero è rappresentato da un oggetto che ha i seguenti attributi – key, la chiave del nodo (che per noi ne rappresenta il contenuto) • tipicamente ci sono anche i dati satelliti – p, che è il (puntatore al) nodo padre – left, che è il (puntatore al) sottoalbero sinistro • left è la radice del sottoalbero sinistro – right che è il (puntatore al) sottoalbero destro • è la radice del sottoalbero destro • ogni albero T ha un attributo, T.root, che è il puntatore alla radice dell'albero 89 Rappresentazione di alberi binari (2) • Si noti che: – se il sottoalbero sinistro (destro) di un nodo x è vuoto, allora x.left = NIL (x.right = NIL) – x.p = NIL se e solo se x è la radice (cioè x = T.root) • Per esempio: T.root 3 2 4 7 1 5 6 90 Alberi binari di ricerca (Binary Search Trees-1) • Un albero binario di ricerca (Binary Search Tree, BST) è un albero binario che soddisfa la seguente proprietà: – per tutti i nodi x del BST, se l è un nodo nel sottoalbero sinistro, allora l.key x.key; se r è un nodo del sottoalbero destro, allora x.key r.key • tutti i nodi l del sottoalbero sinistro di un nodo x sono tali che, per tutti i nodi r nel sottoalbero destro di x vale l.key r.key • esempio 5 3 2 7 5 8 91 Alberi binari di ricerca (Binary Search Trees-2) • Una tipica operazione che viene fatta su un albero è di attraversarlo (walk through) • Lo scopo dell'attraversamento di un albero è di produrre (le chiavi associate a) gli elementi dell'albero • Ci sono diversi modi di attraversare un albero; un modo è l'attraversamento simmetrico (inorder tree walk) – prima si visita il sottoalbero sinistro e si restituiscono i suoi nodi – quindi si restituisce la radice – quindi si visita il sottoalbero destro e si restituiscono i suoi nodi • Si noti che: – come spesso accade con gli algoritmi sugli alberi (che è una struttura dati inerentemente ricorsiva), l'attraversamento simmetrico è un algoritmo ricorsivo – con l'attraversamento simmetrico gli elementi di un albero sono restituiti ordinati • per esempio, l'attraversamento simmetrico sull'albero precedente produce le chiavi seguenti: 2, 3, 5, 5, 7, 8 92 Algoritmi di attraversamento (1) • Nel dettaglio: INORDER-TREE-WALK(x) 1 if x ≠ NIL 2 INORDER-TREE-WALK(x.left) 3 print x.key 4 INORDER-TREE-WALK(x.right) • Se T è un BST, INORDER-TREEWALK(T.root) stampa tutti gli elementi di T in ordine crescente • Se n è il numero di nodi nel (sotto)albero, il tempo di esecuzione per INORDER-TREEWALK è (n) – se l'albero è vuoto, è eseguito in tempo costante c – se l'albero ha 2 sottoalberi di dimensioni k e n-k-1, T(n) è dato dalla ricorrenza T(n) = T(k) + T(n-k-1) + d, che ha soluzione (c+d)n+c • lo si può vedere sostituendo la soluzione nell'equazione 93 Algoritmi di attraversamento (2) • Altre possibili strategie di attraversamento: anticipato (preorder tree walk), e posticipato (postorder tree walk) – in preorder, la radice è restituita prima dei sottoalberi – in postorder, la radice è restituita dopo dei sottoalberi • Esercizi: • scrivere lo pseudocodice per PREORDERTREE-WALK e POSTORDER-TREE-WALK • scrivere lo pseudocodice per Breadthfirst-TREE-WALK (il cui risultato per l’albero precedente deve essere: 5, 3, 7, 2, 5, 8) 94 Operazioni sui BST (1) • Sfruttiamo la proprietà di essere un BST per realizzare la ricerca: – confronta la chiave della radice con quella cercata – se sono uguali, l'elemento è quello cercato – se la chiave della radice è più grande, cerca nel sottoalbero sinistro – se la chiave della radice è più grande, cerca nel sottoalbero destro TREE-SEARCH(x, k) 1 if x = NIL or k = x.key 2 return x 3 if k < x.key 4 return TREE-SEARCH(x.left, k) 5 else return TREE-SEARCH(x.right, k) • Il tempo di esecuzione è O(h), con h l'altezza dell'albero 95 Operazioni sui BST (2) • L'elemento minimo (risp. massimo) in un BST è quello che è più a sinistra (risp. destra) • Sfruttiamo questa proprietà per definire il seguente algoritmo, che semplicemente “scende” nell'albero – MINIMUM scende a sinistra, mentre MAXIMUM scende a destra – gli algoritmi restituiscono l'oggetto nell'albero con la chiave minima, non la chiave stessa • Entrambi gli algoritmi hanno tempo di esecuzione che è O(h), con h l'altezza dell'albero TREE-MINIMUM(x) 1 while x.left ≠ NIL 2 x := x.left 3 return x TREE-MAXIMUM(x) 1 while x.right ≠ NIL 2 x := x.right 3 return x 96 Operazioni sui BST (3) • Il successore (risp. predecessore) di un oggetto x in un BST è l'elemento y del BST tale che y.key è la più piccola (risp. più grande) tra le chiavi che sono più grandi (risp. piccole) di x.key – di fatto, se il sottoalbero destro di un oggetto x dell'albero non è vuoto, il successore di x è l'elemento più piccolo (cioè il minimo) del sottoalbero destro di x – invece, se il sottoalbero destro di x è vuoto, il successore di x è il primo elemento y che si incontra risalendo nell'albero da x tale che x è nel sottoalbero sinistro di y TREE-SUCCESSOR(x) 1 if x.right ≠ NIL 2 return TREE-MINIMUM(x.right) 3 y := x.p 4 while y ≠ NIL and x = y.right 5 x := y 6 y := y.p 7 return y • Il successore del massimo è NIL • Il tempo di esecuzione per TREE-SUCCESSOR è O(h) • Esercizio: scrivere l'algoritmo TREE-PREDECESSOR e darne la complessità 97 Inserimento (1) • Idea di base per l'inserimento: scendere nell'albero fino a che non si raggiunge il posto in cui il nuovo elemento deve essere inserito, ed aggiungere questo come foglia • Supponiamo, per esempio, di volere inserire un nodo con chiave 7 nell'albero seguente: 5 3 1 8 4 9 98 Inserimento (2) • eseguiamo i seguenti passi: – confrontiamo 5 con 7 e decidiamo che il nuovo elemento deve essere aggiunto al sottoalbero destro di 5 – confrontiamo 8 con 7 e decidiamo che 7 deve essere aggiunto al sottoalbero sinistro di 8 – notiamo che il sottoalbero sinistro di 8 è vuoto, e aggiungiamo 7 come sottoalbero sinistro di 8 • quindi, otteniamo il nuovo albero: 5 3 1 8 4 7 9 99 Insert: pseudocodice TREE-INSERT(T, z) 1 y := NIL 2 x := T.root 3 while x ≠ NIL 4 y := x 5 if z.key < x.key 6 x := x.left 7 else x := x.right 8 z.p := y 9 if y = NIL 10 T.root := z //l'albero T era vuoto 11 elsif z.key < y.key 12 y.left := z 13 else y.right := z • Si noti che inseriamo un oggetto, z, che assumiamo sia stato inizializzato con la chiave desiderata • Il tempo di esecuzione di TREE-INSERT è O(h) – infatti, scendiamo nell'albero nel ciclo while (che al massimo richiede tante ripetizioni quanta è l'altezza dell'albero), e il resto (linee 8-13) si fa in tempo costante 100 Cancellazione (1) • Quando cancelliamo un oggetto z da un albero, abbiamo 3 possibili casi (a seconda che z sia una foglia o un nodo interno): – il nodo z da cancellare non ha sottoalberi – il nodo z da cancellare ha 1 sottoalbero – il nodo z da cancellare ha 2 sottoalberi • Il caso 1 è quello più facile, basta mettere a NIL il puntatore del padre di z che puntava a z: 5 3 1 5 8 4 3 9 8 1 9 Nel caso 2,dobbiamo spostare l'intero sottoalbero di z su di un livello: 5 3 1 5 7 4 3 9 8 1 9 4 8 10 10 101 Cancellazione(2) • Nel caso 3 dobbiamo trovare il successore del nodo da cancellare z, copiare la chiave del successore in z, quindi cancellare il successore – cancellare il successore potrebbe richiedere di spostare un (il) sottoalbero del successore un livello su – si noti che in questo caso l'oggetto originario z non è cancellato, ma il suo attributo key viene modificato (l'oggetto effettivamente cancellato è quello con il successore di z) 5 3 1 7 4 6 12 8 14 9 5 3 1 8 4 6 12 9 14 102 Delete: pseudocodice (1) TREE-DELETE(T, z) 1 if z.left = NIL or z.right = NIL 2 y := z 3 else y := TREE-SUCCESSOR(z) 4 if y.left ≠ NIL //y non è il successore di z 5 x := y.left 6 else x := y.right 7 if x ≠ NIL 8 x.p := y.p 9 if y.p = NIL 10 T.root := x 11 elsif y = y.p.left 12 y.p.left := x 13 else y.p.right := x 14 if y ≠ z 15 z.key := y.key 16 return y 103 Delete: pseudocodice (2) • In TREE-DELETE, y è il nodo effettivamente da cancellare • Se z ha non più di un sottoalbero, allora il nodo y da cancellare è z stesso; altrimenti (se z ha entrambi i sottoalberi) è il suo successore (linee 1-3) – Si noti che y non può avere più di un sottoalbero • nelle linee 4-6, ad x viene assegnata la radice del sottoalbero di y se y ne ha uno, NIL se y non ha sottoalberi • le linee 7-13 sostituiscono y con il suo sottoalbero (che ha x come radice) • nelle linee 14-15, se z ha 2 sottoalberi (che corrisponde caso in cui il nodo y da cancellare è il successore di z, non z stesso), la chiave di z è sostituita con quella del suo successore y 104 Analisi di complessità (1) • Il tempo di esecuzione per TREE-DELETE è O(h) – TREE-SUCCESSOR è O(h), il resto è fatto in tempo costante • Tutte le operazioni sui BST (SEARCH, MINIMUM, MAXIMUM, SUCCESSOR, PREDECESSOR, INSERT, DELETE) hanno tempo di esecuzione che è O(h) – cioè, alla peggio richiedono di scendere nell'albero • Quindi... quanto vale l'altezza di un BST (rispetto al numero dei suoi nodi)? 105 Analisi di complessità (2) • Per un albero completo, h = (log(n)) – un albero è completo se e solo se, per ogni nodo x, o x ha 2 figli, o x è una foglia, e tutte le foglie hanno la stessa profondità 5 3 2 7 5 7 8 • Nel caso pessimo, però, che si ha se tutti i nodi sono “in linea”, abbiamo h = (n) 2 4 5 9 106 Analisi di complessità (3) • Tuttavia, un BST non deve per forza essere completo per avere altezza h tale che h = (log(n)) – abbiamo per esempio visto che questa proprietà vale anche per alberi quasi completi • Abbiamo che h = (log(n)) anche per un albero bilanciato – informalmente, diciamo che un albero è bilanciato se e solo se non ci sono 2 foglie nell'albero tali che una è “molto più lontana” dalla radice dell'altra (se si trovano a profondità molto diverse) • ci potrebbero essere diverse nozioni di "molto più lontano" • una possibile definizione di albero bilanciato (Adelson-Velskii e Landis) è la seguente: un albero è bilanciato se e solo se, per ogni nodo x dell'albero, le altezze dei 2 sottoalberi di x differiscono al massimo di 1 • per esempio 5 2 1 9 4 107 • Ma anche 108 Analisi di complessità (4) • Ci sono diverse tecniche per mantenere un albero bilanciato: – alberi rosso-neri (red-black) – alberi AVL – B- trees – etc. • Inoltre, si può dimostrare che l'altezza attesa di un albero è O(log(n)) se le chiavi sono inserite in modo casuale 109 Alberi rosso-neri (red-black) (1) • Gli alberi rosso-neri (RB) sono BST “abbastanza” bilanciati, tali che l'altezza dell'albero h è O(log(n)) – ed è possibile realizzare tutte le operazioni più importanti in tempo O(log(n)) • Negli alberi RB non si ha mai che un ramo dell'albero sia lungo più del doppio di un altro ramo – è una nozione di “bilanciamento” diversa da quella degli alberi AVL, ma dà comunque h = O(log(n)) • Idea alla base degli alberi RB: – ogni nodo ha un colore, che può essere solo rosso o nero – i colori sono distribuiti nell'albero in modo da garantire che nessun ramo dell'albero sia 2 volte più lungo di un altro 110 Alberi rosso-neri (red-black) (2) • Ogni nodo di un albero RB ha 5 attributi: key, left, right, p, e color – convenzione: le foglie sono i nodi NIL, tutti i nodi non NIL (che hanno quindi una chiave associata) sono nodi interni • Un BST è un albero RB se soddisfa le seguenti 5 proprietà: 1. 2. 3. 4. 5. ogni nodo è o rosso o nero la radice è nera le foglie (NIL) sono tutte nere. i figli di un nodo rosso sono entrambi neri per ogni nodo x tutti i cammini da x alle foglie sue discendenti contengono lo stesso numero bh(x) di nodi neri bh(x) è la altezza nera (black height) del nodo x Il nodo x non è contato in bh(x) anche se nero. 111 Esempi di alberi RB 26 17 41 14 21 10 7 16 12 19 15 30 23 47 28 20 38 35 39 3 • Per comodità, per rappresentare tutte le foglie NIL, si usa un unico nodo sentinella T.nil – è un nodo particolare accessibile come attributo dell'albero T – tutti i riferimenti a NIL (compreso il padre della radice) sono sostituiti con riferimenti a T.nil 26 17 41 14 21 10 7 16 12 15 19 30 23 47 28 20 38 35 39 3 T.nil 112 Proprietà degli alberi RB • Un albero rosso-nero con n nodi interni (n nodi con chiavi, per la convenzione usata) ha altezza h ≤ 2 log2(n+1) – si dimostra che il numero di nodi interni di un (sotto)albero con radice x è ≥ 2bh(x)-1 – per la proprietà 4, almeno metà dei nodi dalla radice x (esclusa) ad una foglia sono neri, quindi bh(x) ≥ h/2, e n ≥ 2h/2-1, da cui discende che h ≤ 2 log2(n+1) 113 Proprietà degli alberi RB • Come conseguenza di questa proprietà, SEARCH, MINIMUM, MAXIMUM, SUCCESSOR e PREDECESSOR richiedono tempo O(log(n)) se applicate ad un albero RB con n nodi – queste operazioni non modificano l'albero, che viene semplicemente trattato come un BST – il loro pseudocodice è come quello visto in precedenza • INSERT e DELETE si possono anch'esse fare con complessità O(log(n)), ma devono essere modificate rispetto a quelle viste prima – devono essere tali da mantenere le 5 proprietà degli alberi RB • Il meccanismo fondamentale per realizzare INSERT e DELETE è quello delle rotazioni 114 Rotazioni • Le rotazioni possono essere verso sinistra (LEFTROTATE) o verso destra (RIGHT-ROTATE) LEFT-ROTATE(T,x) x y RIGHT-ROTATE(T,y) LEFT-ROTATE(T,x) 1 y := x.right 2 x.right := y.left 3 4 5 6 7 8 9 10 11 12 y x //il sottoalbero sinistro di y //diventa quello destro di x if y.left ≠ T.nil y.left.p := x y.p := x.p //attacca il padre di x a y if x.p = T.nil T.root := y elsif x = x.p.left x.p.left := y else x.p.right := y y.left := x //mette x a sinistra di y x.p := y • Una rotazione su un BST mantiene la proprietà di essere BST • Esercizio per casa: scrivere lo pseudocodice per RIGHT-ROTATE 115 RB-INSERT • L'inserimento è fatto in modo analogo a quello dei BST, ma alla fine occorre ristabilire le proprietà dagli alberi RB se queste sono state violate – per ristabilire le proprietà si usa un algoritmo RB-INSERTFIXUP (che vedremo dopo) RB-INSERT(T, z) 1 y := T.nil 2 x := T.root 3 while x ≠ T.nil 4 y := x 5 if z.key < x.key 6 x := x.left 7 else x := x.right 8 z.p := y 9 if y = T.nil 10 T.root := z //l'albero T e' vuoto 11 elsif z.key < y.key 12 y.left := z 13 else y.right := z 14 z.left := T.nil 15 z.right := T.nil 16 z.color := RED 17 RB-INSERT-FIXUP(T, z) • Uguale a TREE-INSERT, salvo che per l'uso di T.nil al posto di NIL e l'aggiunta delle righe 14-17 116 RB-INSERT-FIXUP RB-INSERT-FIXUP(T, z) 1 if z = T.root 2 T.root.color = BLACK 3 else x := z.p // x e' il padre di z 4 if x.color = RED 5 if x = x.p.left // se x e' figlio sin. 6 y := x.p.right // y e' lo zio di z 7 if y.color = RED 8 x.color := BLACK // Caso 1 9 y.color := BLACK // Caso 1 10 x.p.color := RED // Caso 1 11 RB-INSERT-FIXUP(T,x.p) // Caso 1 12 else if z = x.right 13 z := x // Caso 2 14 LEFT-ROTATE(T, z) // Caso 2 15 x := z.p // Caso 2 16 x.color := BLACK // Caso 3 17 x.p.color := RED // Caso 3 18 RIGHT-ROTATE(T, x.p) // Caso 3 19 else (come 6-18, scambiando “right”↔“left”) • RB-INSERT-FIXUP è invocato sempre su un nodo z tale che z.color = RED – per questo motivo, se la condizione alla linea 4 è non verificata (cioè se il padre di z è di colore nero) non ci sono ulteriori modifiche da fare 117 Funzionamento di RB-INSERT-FIXUP • Caso 1: y rosso – quindi x.p, che è anche y.p, non può essere rosso o l'albero originario avrebbe violato la proprietà 4 x.p x.p 7 x 5 x 5 y 9 7 y 9 z 3 z 3 x.p x 3 z x.p 7 9 5 y x 3 7 9 y z 5 – quindi ripeto la procedura su x.p, in quanto il padre di x.p potrebbe essere di colore rosso, nel qual caso la proprietà 4 degli alberi RB non sarebbe (ancora) verificata 118 Funzionamento di RB-INSERT-FIXUP (2) • Caso 2: y nero e z figlio destro di x x.p x 3 x' = z 9 y z 5 d 7 x.p 7 z' = x 3 9 y 5 d – a questo punto ci siamo messi nel caso 3 • Caso 3: y nero e z figlio sinistro di x x.p x 5 9 y z 3 x.p 7 d 7 x 5 y 9 z 3 5 d 3 7 9 d – a questo punto l'albero è a posto, non ci sono più modifiche da fare • Ogni volta che RB-INSERT-FIXUP viene invocato esso può o terminare (casi 2 e 3), o venire applicato ricorsivamente risalendo 2 livelli nell'albero (caso 1) – quindi può essere invocato al massimo O(h) volte, cioè O(log(n)) – si noti che una catena di invocazioni di RB-INSERTFIXUP esegue al massimo 2 rotazioni (l'ultima chiamata) 119 RB-DELETE RB-DELETE(T, z) 1 if z.left = T.nil or z.right = T.nil 2 y := z 3 else y := TREE-SUCCESSOR(z) 4 if y.left ≠ T.nil 5 x := y.left 6 else x := y.right 7 x.p := y.p 8 if y.p = T.nil 9 T.root := x 10 elsif y = y.p.left 11 y.p.left := x 12 else y.p.right := x 13 if y ≠ z 14 z.key := y.key 15 if y.color = BLACK 16 RB-DELETE-FIXUP(T,x) 17 return y • • Uguale a TREE-DELETE, salvo che per l'uso di T.nil al posto di NIL (che permette l'eliminazione dell'if alla linea 7), e l'aggiunta delle righe 15-16 Se viene cancellato un nodo rosso (cioè se y.color = RED) non c'è bisogno di modificare i colori dei nodi – per come è fatto RB-DELETE, viene cancellato un nodo (y) che ha al massimo un figlio – x – diverso da T.nil, e se y.color = RED il nodo x che prende il posto di y è per forza nero 120 RB-DELETE-FIXUP RB-DELETE-FIXUP(T, x) 1 if x.color = RED or x.p = T.nil 2 x.color := BLACK // Caso 0 3 elsif x = x.p.left // x e' figlio sinistro 4 w := x.p.right // w e' fratello di x 5 if w.color = RED 6 w.color := BLACK // Caso 1 7 x.p.color := RED // Caso 1 8 LEFT-ROTATE(T,x.p) // Caso 1 9 w := x.p.right // Caso 1 10 if w.left.color = BLACK and w.right.color = BLACK 11 w.color := RED // Caso 2 12 RB-DELETE-FIXUP(T,x.p) // Caso 2 13 else if w.right.color = BLACK 14 w.left.color := BLACK // Caso 3 15 w.color := RED // Caso 3 16 RIGHT-ROTATE(T,w) // Caso 3 17 w := x.p.right // Caso 3 18 w.color := x.p.color // Caso 4 19 x.p.color := BLACK // Caso 4 20 w.right.color := BLACK // Caso 4 21 LEFT-ROTATE(T,x.p) // Caso 4 19 else (come 4-21, scambiando “right”↔“left”) • Idea: il nodo x passato come argomento si porta dietro un “nero in più”, che, per fare quadrare i conti, può essere eliminato solo a certe condizioni 121 Funzionamento di RB-DELETE-FIXUP • x è ha preso il posto del nodo eliminato • Caso 0: x è un nodo rosso, oppure è la radice; x 5 • x 5 x 5 x 5 Caso 1: x è un nodo nero, il suo fratello destro w è rosso, e di conseguenza il padre x.p è nero 3 3 x 1 7 w 5 x 1 9 d 7 w 3 9 5 7 d 5 w x 1 9 d – diventa o il caso 2, o il caso 3, o il caso 4 • Caso 2: x è nero, suo fratello destro w è nero con figli entrambi neri 3 x 3 7 w 1 5 9 d 7 w 1 5 9 d – se arriviamo al caso 2 dal caso 1, allora x.p è rosso, e quando RBDELETE-FIXUP viene invocato su di esso termina subito (arriva subito al caso 0) 122 Funzionamento di RB-DELETE-FIXUP (2) • Caso 3: x è nero, suo fratello destro w è nero con figlio sinistro rosso e figlio destro nero 3 x 1 3 7 w 5 3 x 1 9 7 w d 5 x 1 9 5 w 7 d d 9 – diventa il caso 4 • x Caso 4: x è nero, suo fratello destro w è nero con figlio destro rosso 3 3 7 7 w 1 5 • x 9 d 7 w 1 5 3 9 d 1 9 5 d Ogni volta che RB-DELETE-FIXUP viene invocato esso può o terminare (casi 0, 1, 3 e 4), o venire applicato ricorsivamente risalendo un livello nell'albero (caso 2 non proveniente da 1) – quindi può essere invocato al massimo O(h) volte, cioè O(log(n)) – si noti che una catena di invocazioni di RB-DELETE-FIXUP esegue al massimo 3 rotazioni (se il caso 1 diventa 3 e poi 4) 123 Richiamo sui grafi • Un grafo è una coppia G = (V, E), in cui: – V è un insieme di nodi (detti anche vertici) – E è un insieme di archi (detti anche lati, o edges) • Un arco è una connessione tra 2 vertici – 2 vertici connessi da un arco sono detti adiacenti – se un arco e connette 2 vertici u e v, può essere rappresentato dalla coppia (u, v) di vertici che connette • quindi, E V2 • |V| è il numero di vertici nel grafo, mentre |E| è il numero di archi – 0 |E| |V|2 • Ci sono 2 tipi di grafi: orientati e non orientati – in un grafo non orientato, un arco (u, v) è lo stesso di (v, u) (non c'è nozione di direzione da un nodo all'altro) – In un grafo orientato(u, v) "va dal" nodo u al nodo v, ed è diverso da (v, u) • Esempio di grafo non orientato a d V = {a, b, c, d, e} E = {(b, a) (a, c) (b, c) (d, c) (e, d) (b, e)} c e b – L'ordine dei vertici negli archi è irrilevante • Esempio di grafo orientato: a d c b e V = {a, b, c, d, e} E = {(a, b) (a, d) (d, a) (b, e) (c, e) (e, d), (e, e)} 124 Rappresentazione di grafi in memoria (1) • Come possiamo rappresentare un grafo in memoria? • 2 tecniche principali: – liste di adiacenza – matrice di adiacenza • Grafo orientato a d c e b a b b e c e d a e e a V = {a, b, c, d, e} E = {(a, b) (a, d) (d, a) (b, e) (c, e) (e, d), (e, e)} a b c d e d Liste ad. d b c d e [ ] 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 1 0 1 1 0 1 Matrice ad. 125 Rappresentazione di grafi in memoria (2) a d c b e a b c d e b e e a e a V = {a, b, c, d, e} E = {(a, b) (a, d) (d, a) (b, e) (c, e) (e, d), (e, e)} a b c d e d Liste ad. d b c d e [ ] 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 1 0 1 1 0 1 Matrice ad. • Nel caso di liste di adiacenza abbiamo un array di liste – c'è una lista per ogni nodo del grafo – per ogni vertice v, la lista corrispondente contiene i vertici adiacenti a v • In una matrice di adiacenza M, l'elemento mij è 1 se c'è un arco dal nodo i al nodo j, 0 altrimenti • In entrambi i casi, dato un nodo u in un grafo G, l'attributo u.Adj rappresenta l'insieme di vertici adiacenti a u 126 Rappresentazione di grafi (3) • Quanto è grande una rappresentazione con liste di adiacenza? – il numero totale di elementi nelle liste è |E| – il numero di elementi nell'array è |V| – la complessità spaziale è (|V| + |E|) • Quanto è grande la matrice di adiacenza? – la dimensione della matrice è |V|2, quindi la sua complessità è (|V|2) • Le liste di adiacenza sono in generale migliori quando |E| (|V|2), cioè quando il grafo è sparso (quando il numero di nodi connessi “non è tanto grande”) – si ricordi che |E| |V|2, cioè |E| = O(|V|2) • Se il grafo è completo (o quasi), tanto vale usare una matrice di adiacenza – un grafo orientato è completo se, per ogni coppia di nodi u e v, sia l'arco (u, v) che l'arco (v, u) sono in E 127 Rappresentazione di grafi (4) • Quale è la complessità temporale per determinare se un arco (u, v) appartiene al grafo: – quando il grafo è rappresentato mediante liste di adiacenza? – quando il grafo è rappresentato con una matrice di adiacenza? • Quale è la complessità temporale per determinare il numero di archi che escono da un nodo u del grafo – quando il grafo è rappresentato mediante liste di adiacenza? – quando il grafo è rappresentato con una matrice di adiacenza? 128 Rappresentazione di grafi (5) • Possiamo rappresentare un arco (u, v) di un grafo non orientato come 2 archi orientati, uno che va da u a v, ed uno che va da v ad u • Per esempio: a d c e a b c b c a e c b a d d c e e b d b a a b V = {a, b, c, d, e} E = {(b, a) (a, c) (b, c) (d, c) (e, d) (b, e)}c d e b c d e [ ] 0 1 1 0 0 1 0 1 0 1 1 1 0 1 0 0 0 1 0 1 0 1 0 1 0 – si noti che in questo caso la matrice di adiacenza è simmetrica, quindi tutta l'informazione che serve per descrivere il grafo si trova sopra la diagonale principale 129 Visita in ampiezza (Breadth-First Search) (1) • Problema: – input: un grafo G, e un nodo s (la sorgente) di G – output: visitare tutti i nodi di G che sono raggiungibili da s (un nodo u è raggiungibile da s se c'è un cammino nel grafo che va da s a u) • Algoritmo: Breadth-First Search (BFS) • Idea dell'algoritmo: prima visitiamo tutti i nodi che sono a distanza 1 da s (cioè che hanno un cammino di lunghezza 1 da s), quindi visitiamo quelli a distanza 2, quindi quelli a distanza 3, e così via • Quando visitiamo un nodo u, teniamo traccia della sua distanza da s in un attributo u.dist 130 Visita in ampiezza (Breadth-First Search) (2) • Inoltre, mentre visitiamo i nodi, li coloriamo (cioè li marchiamo per tenere traccia della progressione dell'algoritmo) – un nodo è bianco se deve essere ancora visitato – un nodo è grigio se lo abbiamo già visitato, ma dobbiamo ancora completare la visita dei nodi ad esso adiacenti – un nodo è nero dopo che abbiamo visitato tutti i suoi nodi adiacenti • L'algortitmo in breve: – all'inizio tutti i nodi sono bianchi, tranne s (la sorgente), che è grigio – manteniamo i nodi di cui dobbiamo ancora visitare i nodi adiacenti in una coda (che è gestita con politica FIFO!) • all'inizio la coda contiene solo s – a ogni iterazione del ciclo, eliminiamo dalla coda un elemento u, e ne visitiamo i nodi adiacenti che sono ancora bianchi (cioè che devono essere ancora visitati) • Si noti che, se u.dist è la distanza del nodo u da s, la distanza dei nodi bianchi adiacenti ad u è u.dist+1 (a meno che non mettiamo dei pesi agli archi …) 131 BFS: pseudocodice BFS(G, s) 1 for each u ∊ G.V – {s} 2 u.color := WHITE 3 u.dist := ∞ 4 s.color := GRAY 5 s.dist := 0 6 Q := ∅ 7 ENQUEUE(Q, s) 8 while Q ≠ ∅ 9 u := DEQUEUE(Q) 10 for each v ∊ u.Adj 11 if v.color = WHITE 12 v.color := GRAY 13 v.dist := u.dist +1 14 ENQUEUE(Q, v) 15 u.color := BLACK • • • Le linee 1-7 sono la fase di inizializzazione dell'algoritmo (che ha complessità O(|V|)) Le linee 8-15 sono quelle che effettivamente visitano i nodi; ogni nodo nel grafo G è accodato (e tolto dalla coda) al massimo una volta, quindi, nel ciclo for della linea 10, ogni lato è visitato al massimo una volta; quindi, la complessità del ciclo while è O(|E|) La complessità totale di BFS è O(|V| + |E|) 132 Ricerca in profondità (Depth-First Search) (1) • BFS si basa sull'idea di visitare i nodi con una politica FIFO (che è realizzata mediante una coda) • Come alternativa possiamo usare una politica LIFO • Se usiamo una politica LIFO, otteniamo un algoritmo di visita in profondità (depth-first search, DFS) – l'idea in questo caso è che, ogni volta che “mettiamo un nodo in cima allo stack”, immediatamente cominciamo a visitare i nodi a lui adiacenti • cioè continuiamo con la visita dei nodi che sono adiacenti a quello che da meno tempo è nello stack • in BFS non è così: visitiamo i nodi che sono adiacenti a quello che da più tempo è nella coda – non è sufficiente, per ottenere un algoritmo di DFS, cambiare ENQUEUE con PUSH, e DEQUEUE con POP nell'algoritmo di BFS... • non appena visitiamo un nodo, dobbiamo ripetere DFS su di esso... • DFS è implementato in modo naturale in modo ricorsivo 133 Ricerca in profondità (Depth-First Search) (2) • In realtà, l'algoritmo DFS risolve un problema leggermente diverso da BFS • Problema risolto dall'algoritmo DFS – input: un grafo G – output: visitare tutti i nodi di G • nell'algoritmo BFS visitiamo solo i nodi che sono raggiungibili dalla sorgente s! • DFS è spesso usato come parte (cioè come sottoalgoritmo) di un algoritmo più complesso (da cui il problema leggermente diverso da quello risolto da BFS) – è spesso usato come “passo preparatorio” prima di lanciare l'algoritmo “principale” 134 DFS: considerazioni • Come in BFS, in DFS coloriamo i nodi di bianco, grigio e nero (con lo stesso significato che in BFS) • Come detto in precedenza, questa volta usiamo una politica di visita LIFO, quindi usiamo un meccanismo analogo a quello dello stack – in questo caso, il meccanismo a stack viene dal fatto che l'algoritmo è ricorsivo • invece di fare push e pop di vertici su uno stack, facciamo push e pop di chiamate ricorsive dell'algoritmo sullo stack delle chiamate (vedere corsi base informatica): – push = invochiamo l'algoritmo ricorsivamente – pop = la chiamata ricorsiva termina • L'algoritmo DFS tiene traccia di “quando” i nodi sono messi sullo stack ed anche di “quando” sono tolti da esso – c'è una variabile (globale), time, che è messa a 0 nella fase di inizializzazione dell'algoritmo, e che è incrementata di 1 sia appena dopo un nodo è messo sullo stack che appena prima di togliere un nodo dallo stack – usiamo la variabile time per tenere traccia di 2 altri valori: • il “tempo” di quando inizia la “scoperta” (discovery) di un nodo, ed il “tempo” di quando la scoperta termina • l'inizio della scoperta di un nodo u è memorizzata nell'attributo u.d, mentre la sua fine nell'attributo u.f 135 DFS: pseudocodice DFS(G) 1 for each u ∊ G.V 2 u.color := WHITE 3 time := 0 4 for each u ∊ G.V 5 if u.color = WHITE 6 DFS-VISIT(u) • in cui l'algoritmo DFS-VISIT è il seguente: DFS-VISIT(u) 1 u.color := GRAY 2 time := time + 1 3 u.d := time 4 for each v ∊ u.Adj 5 if v.color = WHITE 6 DFS-VISIT(v) 7 u.color := BLACK 8 u.f := time := time + 1 • • • Le linee 1-3 di DFS inizializzano i nodi colorandoli tutti di bianco, e mette il tempo a 0 – tempo di esecuzione (|V|) L'algoritmo DFS-VISIT è ripetuto (linee 4-6 di DFS) fino a che non ci sono più nodi da visitare – come in BFS, ogni nodo è “messo sullo stack” (che in questo caso corrisponde ad invocare DFS-VISIT sul nodo) solo una volta – quindi, ogni lato è visitato esattamente una volta durante l'esecuzione del ciclo for delle linee 4-6 di DFS, quindi 136 queste prendono tempo (|E|) In tutto, la complessità di DFS è (|V| + |E|) Ordinamento Topologico (1) • Supponiamo di avere un grafo orientato aciclico (directed acyclic graph, DAG) che rappresenta le precedenze tra eventi • Come questo, per esempio mutande calze orologio pantaloni scarpe camicia cintura cravatta giacca 137 Ordinamento Topologico (2) • O, se preferite, come questo (che rappresenta un Network Part Program di un Flexible Manufacturing System) begin O 2 1 4 3 10 9 8 6 5 7 end 138 Ordinamento topologico (3) • Un ordinamento topologico di un DAG è un ordinamento lineare dei nodi del grafo tale che, se nel DAG c'è un arco (u, v), allora il nodo u precede v nell'ordinamento • Per esempio, un ordinamento topologico del primo DAG della slide precedente potrebbe dare il seguente ordinamento calze mutande pantaloni scarpe orologio camicia cintura cravatta giacca – si noti che questo non è l'unico possibile ordinamento ammissibile... • Di fatto, un ordinamento topologico restituisce un ordinamento che rispetta le precedenze tra eventi – per esempio, nel caso del network part program, un ordinamento topologico restituisce una sequenza di operazioni che è compatibile con le precedenze tra di esse • cioè tale che, quando eseguiamo Oi, tutte le operazioni preparatorie necessarie sono state completate 139 Ordinamento topologico (4) • Il problema dell'ordinamento topologico di un DAG è il seguente: – input: un DAG G – output: una lista che è un ordinamento topologico di G • si ricordi che una lista è un ordinamento lineare, in cui l'ordine è dato da come gli oggetti nella lista sono connessi tra loro • Idea per l'algoritmo: – visitiamo il DAG con un algoritmo DFS – quando coloriamo un nodo u di G di nero (cioè ogni volta che finiamo di visitare un nodo di G), inseriamo u in testa alla lista – dopo che abbiamo visitato tutti i nodi di G, la lista che abbiamo costruito è un ordinamento topologico di G, e lo restituiamo 140 Ordinamento topologico: pseudocodice TOPOLOGICAL-SORT(G) 1 L := ∅ 2 for each u ∊ G.V 3 u.color := WHITE 4 for each u ∊ G.V 5 if u.color = WHITE 6 TOPSORT-VISIT(L, u) 7 return L • in cui TOPSORT-VISIT è: TOPSORT-VISIT(L, u) 1 u.color := GRAY 2 for each v ∊ u.Adj 3 if v.color = WHITE 4 TOPSORT-VISIT(L, v) 5 crea l'elemento di lista x 6 x.key := u 7 LIST-INSERT(L, x) 8 u.color := BLACK • Il tempo di esecuzione di TOPSORT è lo stesso di DFS, cioè (|V| + |E|) – le linee 5-7 di TOPSORT-VISIT impiegano tempo (1) (come le linee 2-3 e 7-8 di DFS-VISIT), ed il resto dell'algoritmo è come DFS • tranne la gestione della variabile time, che possiamo evitare 141 Argomenti Avanzati 142 Programmazione dinamica (1) (cenni) • Come la tecnica divide-et-impera si basa sull'idea di scomporre il problema in sottoproblemi, risolvere quelli, e ricombinarli – si applica però quando i problemi non sono indipendenti, cioè condividono dei sottoproblemi – quando si risolve un sottoproblema comune, si mette la soluzione in una tabella, per riutilizzarla in seguito • il termine “programmazione” qui non si riferisce alla codifica in linguaggi di programmazione, ma al fatto che è una tecnica tabulare • Programmazione dinamica spesso usata per problemi di ottimizzazione – la “soluzione” è un ottimo del sottoproblema • un problema potrebbe avere più soluzioni ottime 143 Programmazione dinamica (2) • Tipici passi nello sviluppo di un algoritmo di programmazione dinamica: – caratterizzare la struttura delle soluzioni ottimali – definire ricorsivamente il valore di una soluzione ottimale del problema – calcolare una soluzione ottimale in modo bottom-up • dai sottoproblemi più semplici a quelli più difficili, fino al problema originario – costruire una soluzione ottimale del problema richiesto 144 Problema: taglio delle aste (1) • Il prezzo di un'asta di acciaio dipende dalla sua lunghezza • problema: date delle aste di lunghezza n che posso tagliare in pezzi più corti, devo trovare il modo ottimale di tagliare le aste per massimizzare il ricavo che posso derivare dalla vendita delle aste – il ricavo massimo lo potrei avere anche non tagliando l'asta, e vendendola intera • Esempio di tabella dei prezzi: lunghezza i 1 2 3 4 5 6 7 8 9 10 prezzo pi 1 5 8 9 10 17 17 20 24 30 – per esempio, un'asta di lunghezza 4 può essere tagliata in tutti i modi seguenti (tra parentesi il prezzo): [4](9), [1+ 3](9), [2+2](10), [3+1](9), [1+1+2](7), [1+2+1](7), [2+1+1](7), [1+1+1+1](4) • il taglio ottimale in questo caso è unico, ed è [2,2] 145 Problema: taglio delle aste (2) • Data un'asta di lunghezza n, ci sono 2n-1 modi di tagliarla (secondo coordinate intere) – ho n-1 punti di taglio, se indico con una sequenza di n-1 0 e 1 la decisione di tagliare o no ai vari punti di taglio (per esempio, per un'asta lunghezza 4, la decisione di non tagliare è data da 000; la decisione di tagliare solo a metà è data da 010, ecc.), ogni sequenza corrisponde ad un numero binario, e con n-1 cifre binarie posso rappresentare fino a 2n-1 valori • Chiamiamo rn il ricavo massimo ottenibile dal taglio di un'asta di lunghezza n – per esempio, dati i prezzi della tabella di cui sopra abbiamo r4 = 10, mentre r10 = 30 (derivante da nessun taglio) 146 Sottostruttura ottima (1) • Per un qualunque n, la forma di rn è del tipo ri+rn-i – a meno che l'ottimo preveda di non tagliare l'asta; in questo caso abbiamo rn = pn, il prezzo dell'asta intera – in altre parole, rn = max(pn, r1+rn-1, r2+rn-2, ..., rn-1+r1) • Quindi, l'ottimo è dato dalla somma dei ricavi ottimi derivanti dalle 2 semiaste ottenute tagliando l'asta in 2 – l'ottimo incorpora cioè i 2 ottimi delle soluzioni dei 2 sottoproblemi – notiamo che per forza è così: se non fosse vero che ri ed rn-i sono gli ottimi dei rispettivi sottoproblemi, allora, sostituendo per esempio ad ri una soluzione ottima del taglio di un'asta di lunghezza i otterremmo un ricavo totale > rn, che non potrebbe essere più ottimo 147 Sottostruttura ottima (2) • Quando la soluzione di un problema incorpora le soluzioni ottime dei suoi sottoproblemi, che si possono risolvere indipendentemente, diciamo che il problema ha una sottostruttura ottima • Riformulando l'espressione dell'ottimo rn, inoltre, possiamo fare dipendere rn dall'ottimo di un solo sottoproblema: – rn = prezzo del primo pezzo tagliato + taglio ottimo della restante asta, cioè rn = pi + rn-i • ciò vale anche nel caso particolare in cui l'asta non va tagliata; in questo caso è rn = pn + r0, con r0 = 0 • Quindi, rn = max1≤i≤n(pi+rn-i) 148 Algoritmo ricorsivo (1) • Applicando l'espressione ricorsiva appena vista della soluzione del problema del taglio delle aste otteniamo il seguente algoritmo CUT-ROD(p,n) 1 if n = 0 2 return 0 3 q := -∞ 4 for i := 1 to n 5 q = max(q, p[i]+CUT-ROD(p,n-i)) 6 return q n 1 T j • Tempo di esecuzione: T(n) = 1 + – cioè T(n) = 2n j=0 • lo si può vedere sostituendo la soluzione nella ricorrenza 149 Algoritmo ricorsivo (2) • Tempo di esecuzione: 2n • Il tempo di esecuzione è così alto perché gli stessi problemi vengono risolti più e più volte: 4 3 1 2 1 1 0 0 0 2 1 0 0 0 0 150 Algoritmo di programmazione dinamica (1) • Usando un po' di memoria extra, si riesce a migliorare di molto il tempo di esecuzione – addirittura diventa polinomiale – trade-off spazio-temporale: aumento la complessità spaziale, riducendo quella temporale • Idea: memorizzo il risultato dei sottoproblemi già calcolati, e quando li reincontro, invece di ricalcolarli, mi limito ad andare a prendere il risultato dalla tabella – risolvo ogni problema distinto una volta sola – il costo diventa polinomiale se il numero di problemi distinti da risolvere è polinomiale, e la risoluzione dei singoli problemi richiede tempo polinomiale 151 Algoritmo di programmazione dinamica (2) • 2 tecniche per implementare la programmazione dinamica: un metodo topdown, ed uno bottom-up • Nel metodo top-down, comincio a risolvere il problema di dimensione n, e ricorsivamente vado a risolvere i sottoproblemi via via più piccoli; aumento però l'insieme dei parametri passati con una tabella nella quale memorizzo i risultati già calcolati – prima di lanciare la ricorsione sul problema più piccolo, controllo nella tabella se non ho già calcolato la soluzione – questa tecnica va sotto il nome di memo-ization • Nel metodo bottom-up, parto dai problemi più piccoli, e li risolvo andando in ordine crescente di dimensione; quando arrivo a risolvere un problema di dimensione i, ho già risolto tutti i problemi di dimensioni < i 152 Versione memo-ized di CUT-ROD MEMOIZED-CUT-ROD(p, n) 1 crea un nuovo array r[0..n] 2 for i := 0 to n 3 r[i] := -∞ 4 return MEMOIZED-CUT-ROD-AUX(p,n,r) MEMOIZED-CUT-ROD-AUX(p,n,r) 1 if r[n] ≥ 0 2 return r[n] 3 if n = 0 4 q := 0 5 else q := -∞ 6 for i := 1 to n 7 q = max(q, p[i]+MEMOIZED-CUT-ROD-AUX(p,n-i,r)) 8 r[n] = q 9 return q 153 Versione bottom-up di CUT-ROD BOTTOM-UP-CUT-ROD(p, n) 1 crea un nuovo array r[0..n] 2 r[0] := 0 3 for j := 1 to n 4 q := -∞ 5 for i := 1 to j 6 q := max(q,p[i]+r[j-i]) 7 r[j] := q 8 return r[n] • BOTTOM-UP-CUT-ROD è facile vedere che ha complessità T(n) = Θ(n2) per i 2 cicli annidati • Anche MEMOIZED-CUT-ROD ha complessità T(n) = Θ(n2), in quanto ogni sottoproblema è risolto da MEMOIZED-CUT-ROD una volta sola, ed il ciclo 6-7 fa n iterazioni per risolvere un problema di dimensione n – quindi si fanno in tutto n + (n-1) + (n-2) + ... + 1 iterazioni 154 Complessità di CUT-ROD con prog. din. (1) • Gli algoritmi CUT-ROD visti fino ad ora restituiscono il massimo ricavo, ma non il modo in cui l'asta va tagliata • Modifichiamo BOTTOM-UP-CUT-ROD per tenere traccia non solo del massimo, ma del modo di effettuare il taglio EXTENDED-BOTTOM-UP-CUT-ROD(p, n) 1 crea 2 nuovi array r[0..n] e s[0..n] 2 r[0] := 0 3 for j := 1 to n 4 q := -∞ 5 for i := 1 to j 6 if q < p[i]+r[j-i] 7 q = p[i]+r[j-i] 8 s[j] = i 9 r[j] := q 10 return (r,s) – s[j] mi dice quale è la lunghezza del primo pezzo nel taglio ottimale di un'asta di lunghezza j 155 Complessità di CUT-ROD con prog. din. (2) Esempio di risultato di un'esecuzione: i 0 1 2 3 4 5 6 7 8 9 10 pi 0 1 5 8 9 10 17 17 20 24 30 r[i] 0 1 5 8 10 13 17 18 22 25 30 s[i] 0 1 2 3 2 2 6 1 2 3 10 Per stampare il taglio che mi dà il ricavo massimo uso il seguente algoritmo PRINT-CUT-ROD-SOLUTION(p, n) 1 (r,s) := EXTENDED-BOTTOM-UP-CUT-ROD(p,n) 2 while n > 0 3 print s[n] 4 n := n - s[n] 156 Algoritmi golosi (1) • Per quanto con la programmazione dinamica un sottoproblema non venga risolto più di una volta, comunque occorre analizzare diverse soluzioni per decidere quale è l'ottimo • A volte però non serve provare tutte le soluzioni: è dimostrabile che una sola può essere quella ottima • Questo è esattamente quel che succede negli algoritmi “golosi” (greedy) 157 Algoritmi golosi (2) • Il problema della scelta delle attività: – n attività a1,a2,..,an usano la stessa risorsa • es: lezioni da tenere in una stessa aula – Ogni attività ai ha un tempo di inizio si ed un tempo di fine fi con si < fi – ai occupa la risorsa nell’intervallo temporale semiaperto [si, fi) – ai ed aj sono compatibili se [si, fi) e [sj, fj) sono disgiunti – voglio scegliere il massimo numero di attività compatibili • supponiamo che le attività a1,a2,..,an siano ordinate per tempo di fine non decrescente f1 ≤ f2 ≤ ... ≤ fn altrimenti le ordiniamo in tempo O(n log n) • Esempio i 1 2 3 4 5 6 7 8 9 10 11 si 1 3 0 5 3 5 6 8 8 2 12 fi 4 5 6 7 8 9 10 11 12 13 14 – insieme di attività compatibili: {a3, a9, a11} – massimo numero di attività compatibili: 4 • un esempio: {a2, a4, a9, a11} 158 Soluzione del problema (1) • Possiamo risolvere il problema con un algoritmo di programmazione dinamica • Definiamo Sij come l'insieme delle attività che iniziano dopo la fine di ai e terminano prima dell'inizio di aj – quindi che sono compatibili con ai (e quelle che non terminano dopo ai) e con aj (e quelle che iniziano non prima di aj) – Sij = {at ∈ S : fi ≤ st < ft ≤ sj } • Chiamiamo Aij un insieme massimo di attività compatibili in Sij – supponiamo che ak sia una attività di Aij, allora Aij è della forma: Aij = Aik {ak} Akj • è fatto dell'ottimo del sottoproblema Sik più l'ottimo del sottoproblema Akj – se così non fosse, allora potrei trovare un insieme di attività più grande di Aik (resp. Akj), in Sik, il che vorrebbe dire che Aij non è un ottimo di Sij (assurdo) • questa è dunque una sottostruttura ottima 159 Soluzione del problema (2) • Memorizziamo in una tabella c la dimensione dell'ottimo del problema Aij, cioè c[i, j] = |Aij| • Allora abbiamo che c[i, j] = c[i, k] + c[k, j] + 1 • Se non sappiamo che la soluzione ottima include l'attività ak, dobbiamo provare tutte le attività in Sij, cioè c[i, j] = 0 se Sij = = maxak Sij {c[i, k] + c[k, j] + 1} se Sij ≠ – esercizio per casa: scrivere gli algoritmi di programmazione dinamica con memoization e con tecnica bottom-up 160 Algoritmo goloso • E' inutile però provarle tutte per risolvere il problema Sij, è sufficiente prendere l'attività a1 che finisce per prima in Sij, e risolvere il problema Skj, con k prima attività in Sij che inizia dopo la fine di a1 – se chiamiamo Sk l'insieme di tutte le attività che iniziano dopo la fine di ak, cioè Sk = {at ∈ S : fk ≤ st}, dopo che abbiamo preso a1, ci rimane da risolvere il solo problema S1 • Abbiamo il seguente risultato: dato un sottoproblema Sk, se am è l'attività che finisce per prima in Sk, am è inclusa in qualche sottoinsieme massimo di attività mutuamente compatibili di Sk – supponiamo che Ak sia un sottoinsieme massimo di Sk, e chiamiamo aj l'attività che finisce per prima in Ak; allora o aj = am, oppure fm ≤ fj, e se sostituisco aj con am in Ak ho ancora un sottoinsieme massimo A'k di Sk • Quindi, per risolvere il problema di ottimizzazione mi basta ogni volta scegliere l'attività che finisce prima, quindi ripetere l'operazione sulle operazioni che iniziano dopo quella scelta 161 Pseudocodice (1) • Versione ricorsiva – s e f sono array con, rispettivamente, i tempi di inizio e di fine delle attività – k è l'indice del sottoproblema Sk da risolvere (cioè l'indice dell'ultima attività scelta – n è la dimensione (numero di attività) del problema originario RECURSIVE-ACTIVITY-SELECTOR(s,f,k,n) 1 m := k + 1 2 while m ≤ n and s[m] < f[k] 3 m := m + 1 4 if m ≤ n 5 return {am} ∪ RECURSIVE-ACTIVITY-SELECTOR(s,f,m,n) 6 else return ∅ 162 Pseudocodice (2) • Versione iterativa: GREEDY-ACTIVITY-SELECTOR(s,f) 1 n := s.length 2 A := {a1} 3 k := 1 4 for m := 2 to n 5 if s[m] ≥ f[k] 6 A := A ∪ {am} 7 k := m 8 return A – entrambe hanno complessità Θ(n), in quanto considerano ogni attività una volta sola 163 Alcune domande (e risposte) finali (1) (con uno sguardo all’indietro) • Esiste una sorta di “classe universale di complessità” – cioè esiste una qualche funzione di complessità T(n) tale che tutti i problemi risolvibili impiegano al più T(n)? • Il nondeterminismo può cambiare la complessità di soluzione dei problemi? – in primis, come si definisce la complessità di un modello nondeterministico? 164 Alcune domande (e risposte) finali (2) • Cominciamo con alcune definizioni/richiami, per fissare le idee • Data una funzione T(n), indichiamo con DTIME(T) l'insieme dei problemi tali che esiste un algoritmo che li risolve in tempo T(n) • Più precisamente: – problema = riconoscimento di un linguaggio • per semplicità, consideriamo i linguaggi ricorsivi – algoritmo = macchina di Turing – (Ma quando T è un polinomio …) • Riformulando: DTIME(T) (risp. DSPACE(T)) è la classe (l'insieme) dei linguaggi (ricorsivi) riconoscibili in tempo (risp. spazio) T mediante macchine di Turing deterministiche a k nastri di memoria 165 Alcune domande (e risposte) finali (3) • Un primo risultato: data una funzione totale e computabile T(n), esiste un linguaggio ricorsivo che non è in DTIME(T) – c'è quindi una gerarchia di linguaggi (problemi) definita sulla base della complessità temporale deterministica • una cosa analoga vale per DSPACE, e per le computazioni nondeterministiche (NTIME ed NSPACE) • Lo schema di dimostrazione ricalca quello dell’indecidibilità del problema della terminazione del calcolo • a proposito... 166 Computazioni nondeterministiche (richiami) • Data una macchina di Turing nondeterministica M, definiamo la sua complessità temporale TM(x) per riconoscere la stringa x come la lunghezza della computazione più breve tra tutte quelle che accettano x – TM(n) poi è (nel caso pessimo) il massimo tra tutti i TM(x) con |x| = n • Quindi NTIME(T) è la classe (l'insieme) dei linguaggi (ricorsivi) riconoscibili in tempo T mediante macchine di Turing nondeterministiche a k nastri di memoria • Tantissimi problemi si risolvono in modo molto naturale mediante meccanismi nondeterministici (per esempio, trovare un cammino in un grafo che tocca tutti i nodi)... • ... però i meccanismi di computazione reali sono deterministici • se riuscissimo a trovare una maniera “poco onerosa” per passare da una formulazione nondeterminisitca ad una deterministica, tantissimi problemi interessanti potrebbero essere risolti (in pratica) in modo (teoricamente) efficiente – però spesso abbiamo notato una “esplosione” nel passaggio da un meccanismo ND ad uno D (quando i 2 meccanismi sono equipotenti, come è peraltro il caso delle MT) • per esempio, esplosione del numero degli stati nel passare da NDFSA a DFSA 167 Relazione tra DTIME e NTIME (1) • Sarebbe utile poter determinare, date certe interessanti famiglie di funzioni di complessità, se la classe dei problemi risolvibili non cambia nel passare da computazioni deterministiche a quelle nondeterministiche – in altri termini, se DTIME(ℱ) = NTIME(ℱ) per certe famiglie ℱ={Ti}, di funzioni – Ad esempio …. • Una fondamentale classe di problemi: P = ∪i≥1 DTIME(ni) – convenzionalmente, questi sono considerati i problemi “trattabili” • Similmente: NP = ∪i≥1 NTIME(ni) • Altre classi interessanti di problemi: PSPACE, NPSPACE, EXPTIME, NEXPTIME, EXPSPACE, NEXPSPACE 168 Relazione tra DTIME e NTIME (2) • LA domanda: P = NP? – boh... – probabilmente no, ma non si è ancora riusciti a dimostrarlo • Più in generale: LogSpace P NP PSPACE; LogSpace PSPACE Ma … • Alcuni esempi di problemi della classe NP: – Soddisfacibilità di formule di logica proposizionale (SAT): data una formula F di logica proposizionale, esiste un assegnamento dei valori alle lettere proposizionali che compaiono in F tale che F è vera? • detto in altro modo: F ammette un modello? – Circuito hamiltoniano (HC): dato un grafo G, esiste un cammino in G tale che tutti i nodi del grafo sono toccati una ed una sola volta prima di tornare al nodo di partenza? 169 Riduzione in tempo polinomiale e completezza (1) • Un linguaggio (problema) L1 è riducibile in tempo polinomiale ad un altro linguaggio L2 se e solo se esiste una MT deterministica (traduttrice) con complessità in P che per ogni x produce una stringa τ(x) tale che τ(x) L2 se e solo se x L1 • Se ℒ è una classe di linguaggi, diciamo che un linguaggio L (che non è detto che debba essere in ℒ) è ℒ-difficile rispetto alle riduzioni in tempo polinomiale se e solo se, per ogni L' ℒ, L' è riducibile in tempo polinomiale a L – cioè se risolvere L (determinare se una stringa x appartiene ad L o no) è almeno tanto difficile quanto risolvere un qualunque linguaggio in ℒ • Un linguaggio L è ℒ-completo se è ℒ-difficile ed è in ℒ • se si trovasse un problema NP-completo che è risolvibile in tempo polinomiale, allora avremmo P = NP • dualmente, se si trovasse un problema NPcompleto che non è risolvibile in tempo polinomiale, allora avremmo P NP 170 Riduzione in tempo polinomiale e completezza (2) • SAT è NP-difficile – quindi è NP-completo – si mostra codificando le computazioni di una generica MT nondeterministica M (con complessità polinomiale) in SAT, in modo che M accetti una stringa x se e solo se una opportuna formula s è soddisfacibile • HC è anch'esso NP-difficile (e NP-completo) – NP-completezza di HC si mostra riducendo SAT a HC • tantissimi altri problemi sono NP-completi... • Oggigiorno però … • … NP-completezza non è più sinonimo di “intrattabilità pratica” • Il giochino di ridurre un problema a SAT (magari “finitizzandolo”) è molto di moda … • Si apre una nuova frontiera pratico teorica • …. 171 SAT è NP-difficile • Come ridurre un generico problema NP a SAT in tempo polinomiale (deterministicamente) • L’idea base tutto sommato non è particoramente complicata (come tutte le grandi idee …): • Fornire una computazione deterministica che, per ogni MT non deterministica M con complessità polinomiale p e per ogni stringa x sull’alfabeto di M, tale che |x| = n, produca come uscita una formula proposizionale W, in tempo polinomiale p’(n), tale che W sia soddisfacibile se e solo se x L(M). 172 • Se la MT ha complessità p(n), la formula complessiva proposizionale ha dimensione p2(n) e la traduzione da MT a formule proposizionali avviene in tempo pure polinomiale. • Il nocciolo della dimostrazione deriva dall’osservazione che, se x L(M), allora esiste una sequenza di mosse, la cui lunghezza non eccede p(n), che conduce ad uno stato di accettazione. Quindi, al più p(n) + 1 celle di memoria possono essere utilizzate da M durante una simile computazione. Una volta compreso che una configurazione di una MT ha uno spazio limitato, la si può descrivere mediante un’opportuna formula proposizionale. • Ad esempio, una variabile logica Qt,k può stabilire se, all’istante t, lo stato di M sia qk. Un’altra variabile, Ct,i,k, può stabilire se, all’istante t, la cella i-esima contenga il simbolo ak e così via. Inoltre, opportuni connettivi logici possono imporre che, all’istante t = 0, la formula descriva una configurazione iniziale, che la configurazione all’istante t + 1 derivi dalla configurazione all’istante t e che, all’istante t = p(n), M si trovi in uno stato di accettazione. 173 Traduzione in SAT di una generica MT ND a complessità P(n) • M a nastro singolo, con nastro lmitato a sinistra senza perdita di generalità. Perché ? • La formula W viene costruita come congiunzione ( logico) di diverse sottoformule, o clausole, secondo le seguenti regole. Sia t la variabile tempo, con 0 t p(n), e sia i la posizione di una cella di memoria, 0 i p(n). 174 1. Descrizione della configurazione di M. Si definiscono i seguenti insiemi di variabili logiche. {Qt,k| 0 t p(n), 0 k |Q| – 1} Qt,k risulterà vera se e solo se M si troverà nello stato qk all’istante t. {Ht,i| 0 t p(n), 0 i p(n)} Ht,i risulterà vera se e solo se la testina M si troverà nella posizione i all’istante t. {Ct,i,h| 0 t p(n), 0 i p(n), 0 h |A| – 1, dove A è l’alfabeto completo di M} Ct,i,h risulterà vera se e solo se, all’istante t, la cella i-esima conterrà il simbolo ah. Abbiamo in questo modo definito un numero g (n ) (p(n ) 1) | Q | (p(n ) 1) 2 (p(n ) 1) 2 | A | di variabili logiche, dove g è una funzione (p2). In breve, diremo che si sono definite (p2(n)) variabili. Chiaramente, in ogni istante, M deve trovarsi esattamente in uno stato, la sua testina deve essere esattamente sopra una cella e ciascuna cella deve contenere esattamente un simbolo. In questo modo si ottiene un primo gruppo di clausole (clausole di configurazione, CC) che le variabili logiche devono soddisfare per descrivere le configurazioni di M. 175 Clausole di configurazione (CC) (1): Qt ,k 0t p ( n ) 0 k |Q|1 {M deve trovarsi in almeno uno stato alla volta} 0t p ( n ) 0 k1 k 2 |Q|1 Q t , k1 Qt ,k2 {Ad ogni istante M non può trovarsi in due stati diversi} H t ,i 0t p ( n ) 0i p ( n ) H 0t p ( n ) 0i , j p ( n ) i j t ,i H t , j {La testina di M si trova esattamente su di una cella alla volta} 176 Clausole di configurazione (CC) (2): Ct ,i ,h 0 t p ( n ) 0 h | A|1 0i p ( n ) C 0 t p ( n ) 0i p ( n ) 0 h , k | A|1 h k t ,i , h Ct ,i ,k {In ogni istante, ciascuna cella contiene esattamente un simbolo} Riassumendo, CC contiene il seguente numero di clausole: (p(n) + 1) |Q| + (p(n) + 1) |Q| (|Q| 1) + + (p(n) + 1)2 + (p(n) + 1)2 p(n) + + (p(n) + 1)2 |A| + (p(n) + 1)2 |A| (|A| 1) dove ciascuna clausola è l’“or” logico di un numero limitato a priori di letterali, e ciascun letterale è o una variabile logica o la sua negazione. Quindi, CC contiene (p3(n)) clausole e (p3(n)) letterali (si ricordi che |Q| e |A| sono costanti). 177 Descrizione della configurazione iniziale. All’istante t = 0, M deve trovarsi in q0, la sua testina deve trovarsi nella posizione 0 e x = ak0ak1...ak(n – 1) deve essere immagazzinata nelle prime n celle del nastro. Tutte le restanti celle devono risultare vuote. Ciò produce un secondo gruppo di clausole (Clausole della configurazione iniziale, IC), qui elencate. Q0,0 H 0,0 C0,0,k0 C0,n1,kn1 n i p ( n ) C0 ,i , 0 (si suppone che il blank sia a0) IC ha (p(n)) letterali. 178 Descrizione della configurazione di accettazione. Si adotti la convenzione per la quale, se M si ferma all’istante tH < p(n), allora mantiene la configurazione finale per tutti gli istanti t, tH t p(n). Questa convenzione provoca conseguenze sulle transizioni della macchina che verranno descritte fra poco, ma consente di stabilire che, all’istante t = p(n), M deve trovarsi in uno stato di accettazione. Ciò produce un’altra clausola (Clausola di accettazione, AC) : Q p ( n ),i1 Q p ( n ),iS dove {qi1,...,qis} = F AC ha (1) letterali. 179 Descrizione della relazione di transizione.(1) La funzione di transizione sia del tipo δ(qk,sh) = {qk,sh,N}, con la convenzione che, ogni volta che la δ originale di M risulta indefinita per qualche qk e per qualche sh, si pone δ(qk,sh) = {qk,sh,S}. Indichiamo con m la cardinalità di {qk,sh,N}. Per ogni t e per ogni i, se M si trova nello stato qk, se la sua testina è nella posizione i e se sta leggendo sh, allora, all’istante t + 1, M si trova, in modo mutuamente esclusivo, in una configurazione in cui lo stato è qk’, la posizione i memorizza sh’ e la sua testina si trova nella posizione i, oppure nella i + 1, oppure nella i – 1, in dipendenza da N, se e solo se la terna qk,sh,N appartiene a δ(qk, sh). Q 0t p ( n ) 0i p ( n ) 0 k |Q|1 0 h | A|1 t ,k H t ,i Ct ,i ,h Qt 1,k ' H t 1,i ' Ct 1,i ,h ' Qt 1,k '' H t 1,i'' Ct 1,i,h'' ... Qt 1,k m H t 1,im Ct 1,i,hm qk ' , sh ' , N ' , qk '' , sh '' , N ' ' ,..., qk m , sh m , N m d (qk , sh ) 180 Trasformata in forma disgiuntiva … … pesantuccio ma utile … Q 0t p ( n ) 0i p ( n ) 0 k |Q|1 0 h| A|1 Q t ,k Q t ,k t ,k H t ,i Ct ,i ,h Qt 1,k ' Qt 1,k m H t ,i Ct ,i,h Qt 1,k ' Qt 1,k m1 H t 1,hm H t ,i Ct ,i,h Qt 1,k ' H t 1,hm1 Qt 1,k m … (vengono considerate tutte le congiunzioni formate dalle possibili disgiunzioni di Qt+1, k, Ht+1,h e Ct+1,i,h, prendendo uno e un solo elemento da ciascun termine in “or esclusivo”, e vengono combinate con l’antecedente) 181 Descrizione della relazione di transizione.(2) Se, all’istante t, la testina si trova nella posizione i, allora, all’istante t + 1, tutte le celle, esclusa la iesima, hanno lo stesso contenuto che avevano all’istante t. H 0 t p ( n ) 0 i p ( n ) 0 k |Q|1 0 h | A|1 t ,i Ct ,i ,h Ct 1,i ,h che, ancora, si può riformulare nel modo seguente: H t ,i Ct ,i ,h Ct 1,i ,h ' 182 L’insieme TC di clausole relative alla transizione è definito come la congiunzione delle formule derivate in 4.1 e 4.2. TC ha (p2(n)) letterali (si noti che il numero di ‘’ nelle clausole 4.1 è limitato a priori perché così è la cardinalità degli insiemi {qk,sh,N} e, di conseguenza, il numero totale di clausole viene moltiplicato nella trasformazione in forma normale congiuntiva per un termine che dipende – pur esponenzialmente – solo da m). Si pone, infine, W = CC IC AC TC, che significa che W è la congiunzione di tutte le clausole che appartengono agli insiemi precedenti. W ha quindi (p3(n)) letterali. 183 Osservazioni conclusive C0 ⊢ CA, dove C0 è la configurazione iniziale e CA è una configurazione di accettazione per M, se e solo se W risulta soddisfacibile. Una formula proposizionale che contiene n occorrenze di variabili logiche si può codificare come una stringa di lunghezza (n⋅log n). Quindi, per ogni x di lunghezza n, la sua traduzione τ(x) in una stringa che codifica W è certamente non più lunga di (p4(n)). Una volta compreso che la traduzione τ può essere eseguita da una MT deterministica (anche multinastro) in un tempo (|τ(x)|), la riducibilità in tempo polinomiale è completamente dimostrata. Non bisogna effettivamente conoscere una MT M che risolva il problema originale. L’esistenza di tale macchina garantisce l’esistenza di una macchina riducente in tempo polinomiale. Tuttavia, se si conosce una simile macchina M e se è noto un limite di tempo polinomiale p per la sua computazione nondeterministica, allora la dimostrazione consente effettivamente di costruire la macchina che esegue la riduzione dei problemi. Corollario Il problema di stabilire la soddisfacibilità delle formule proposizionali in forma normale congiuntiva è NP 184 completo. Il problema del cammino Hamiltoniano (HC) è NP-difficile Ridurremo SAT a HC (non viceversa!): SAT ha fatto da “apripista” verso la NP-completezza: la sua natura riflette al meglio la generalità del concetto. Però vale anche il viceversa. Ne è quindi una naturale conseguenza il fatto che esso possa essere ridotto in modo abbastanza “naturale” a moltissimi altri problemi implicandone la NPdifficoltà (e completezza se …) Tutto ciò sottolinea la “natura forte” dell’NPcompletezza che in un certo senso ripropone all’interno della categoria dei problemi decidibili, l’approccio che ha portato alla semidecidibilità, ossia: “non so fare di meglio che enumerare le possibili soluzioni del problema (finite, infinite, esponenziali) nella dimensione del problema, … e verificare se effettivamente sono tali.” Tornando a SAT HC sfrutteremo il corollario precedente, ossia partiremo da una versione di SAT in forma normale: Per ogni formula proposizionale W in forma normale congiuntiva, costruiremo un grafo G che ammette un HC se e solo se W risulta soddisfacibile. 185 Intuizione ed esempio Il procedimento consiste nel costruire G come aggregazione di due classi di “pezzi”. I pezzi del primo tipo saranno associati a ciascuna variabile logica in W e, in un certo senso, ne mostreranno tutte le occorrenze. I pezzi del secondo tipo saranno associati a ciascuna clausola di W e legati ai nodi che appartengono ai pezzi del primo tipo, in modo tale che il loro attraversamento garantisca la verità della clausola associata. 186 W: A1 ( A1 A2) NB: L21 = A1 187 Più in generale: 188 • pi è il massimo numero fra il numero di occorrenze di Ai e quelle di Ai in W • Per ogni r, se Ljr è Ajr, si connetta il primo nodo Fjr,s di GAjr avente solo due archi uscenti a Ljr, e L jr a Tjr,s+1. Viceversa se Ljr è Ajr. (possibilie poiché pi è maggiore o uguale al numero di occorrenze di Ai in W.) • Se i GCj vengono ignorati, vi sono esattamente 2m HC nel grafo restante, . Ciò corrisponde al fatto che, se W è vuota ogni assegnamento di valori di verità, banalmente, la soddisfa. • Un possibile HC entrante in qualche GCj da Ljr deve lasciarlo da L. jr . Ad esempio, un cammino che entra in GCj da Lj2, visitando L j 2 , L j1 e lasciando infine GCj, renderebbe Lj1 inaccessibile. Ecc. … • Ciò non implica che un eventuale HC debba visitare tutti i nodi di ciascun GCj consecutivamente. 189 • Ogni possibile HC deve essere ottenuto da un HC di sostituendo qualche arco del tipo Fi,s, Ti,s+1 con un cammino che passa attraverso qualche GCj, se HC contiene l’arco IAi, Tj0. Viceversa, se HC contiene l’arco IAi, Fi0 , esso deve essere ottenuto da un HC di , sostituendo qualche arco del tipo Ti,s, Fi,s+1 con un cammino che passa attraverso qualche GCj. • Ergo, si può entrare in ogni GCj attraverso qualche Ljr mediante un HC, solo da qualche Tjr,s se IAjr, Fjr,0 è in HC e solo da qualche Fjr,s se IAjr, Tjr,0 è in HC: entrare in GCj da qualche Fjr,s significa soddisfarlo supponendo Ajr = T. … 190 Riassumendo • I modelli • Il calcolo • L’analisi e la sintesi di algoritmi • Inventati da capire e analizzare • Inventati da “catalogare” • Da “inventare” … o soltanto da • Applicare/adattare 191 Riassumendo: a voi la parola (grazie per la pazienza) 192