Implementazione di dizionari Problema del dizionario dinamico Scegliere una struttura dati in cui memorizzare dei record con un campo key e alcuni altri campi in cui sono memorizzati i dati associati alla chiave key. Su tale struttura si devono poter eseguire in modo efficiente le operazioni: Insert che aggiunge un nuovo record Search che cerca un record di chiave key Delete che toglie un record dalla struttura
Tavole ad indirizzamento diretto Funzionano bene quando le chiavi sono degli interi positivi non troppo grandi. Ad esempio se le chiavi appartengono all’insieme U = {0,1,...,m-1} (con m non troppo grande) possiamo usare un array T[0..m-1] in cui ogni posizione (cella) T[k] corrisponde ad una chiave k. Generalmente T[k] è un puntatore al record con chiave k. Se la tavola non contiene un record con chiave k allora T[k] = nil.
La realizzazione delle operazioni in tempo costante O(1) è semplice: Search(T,k) return T[k] Insert(T,x) T[x.key] = x Delete(T,x) T[x.key] = nil
Inconvenienti Con l’indirizzamento diretto occorre riservare memoria sufficiente per tante celle quante sono le possibili chiavi. Se l’insieme U delle possibili chiavi è molto grande l’indirizzamento diretto è inutilizzabile a causa delle limitazioni di memoria. Anche quando la memoria sia sufficiente se le chiavi memorizzate nel dizionario sono soltanto una piccola frazione di U la maggior parte della memoria riservata risulta inutilizzata.
Tavole hash Una tavola hash richiede memoria proporzionale al numero massimo di chiavi presenti nel dizionario indipendentemente dalla cardinalità dell’insieme U di tutte le possibili chiavi. In una tavola hash di m celle ogni chiave k viene memorizzata nella cella h(k) usando una funzione h : U {0..m-1} detta funzione hash.
Siccome |U| > m esisteranno molte coppie di chiavi distinte k1 ≠ k2 tali che h(k1) = h(k2). Diremo in questo caso che vi è una collisione tra le due chiavi k1 e k2. Nessuna funzione hash può evitare le collisioni. Dovremo quindi accontentarci di funzioni hash che minimizzino la probabilità delle collisioni e, in ogni caso, dovremo prevedere qualche meccanismo per gestire le collisioni.
Risoluzione delle collisioni con liste Gli elementi che la funzione hash manda nella stessa cella vengono memorizzati in una lista La tavola hash è un array T[0..m-1] di m puntatori alle cime delle liste nil T k1 k3 k5 k4 k2 U k3 nil nil k3 k4 k2 nil nil k2 k5 nil k1 h(k)
La realizzazione delle operazioni è facile: Search(T,k) “cerca nella lista T[h(k)] un elemento x tale che x.key == k” return x Insert(T,x) “aggiungi x alla lista T[h(x.key)]” Delete(T,x) “togli x dalla lista T[h(x.key)]”
Search richiede tempo proporzionale alla lunghezza della lista T[h(k)] Insert si può realizzare in tempo O(1) Delete richiede una ricerca con Search dopo di che l’eliminazione dell’elemento dalla lista si può realizzare in tempo O(1)
Analisi di hash con liste Supponiamo che la tavola hash T abbia m celle e che in essa siano memorizzati n elementi. Una Search di un elemento con chiave k richiede tempo O(n) nel caso pessimo in cui tutti gli n elementi stanno nella stessa lista h(k) della chiave cercata. Valutiamo la complessità media di Search in funzione del fattore di carico α = n/m. Siccome n è compreso tra 0 e |U| 0 ≤ α ≤ |U|/m.
Supporremo che h(k) distribuisca in modo uniforme le n chiavi tra le m liste. Più precisamente assumeremo la seguente ipotesi di hash uniforme semplice. “ogni elemento in input ha la stessa probabilità di essere mandato in una qualsiasi delle m celle”
Siano n0,n1,...,nm-1 le lunghezze delle m liste. La lunghezza attesa di una lista è Proprietà: Nell’ipotesi di hash uniforme semplice la ricerca di una chiave k non presente nella tavola hash richiede tempo (1+α) in media.
Dimostrazione: La Search calcola j =h(k) (tempo (1)) e poi controlla tutti gli nj elementi della lista T[j] (tempo (nj)). Nell’ipotesi di hash uniforme semplice E[nj] = α e quindi l’algoritmo richiede tempo medio (1+α).
Proprietà: Nell’ipotesi di hash uniforme semplice la ricerca di una chiave k presente nella tavola hash richiede tempo (1+α) in media. Dimostrazione: Assumiamo che ogni chiave presente nella tavola abbia la stessa probabilità di essere la chiave cercata.
Una ricerca di un una chiave k presente nella tavola richiede il calcolo dell’indice j = h(k), il test sulle chiavi che precedono k nella lista T[j] e infine il test su k (numero operazioni = 2 + numero elementi che precedono k nella lista). Le chiavi che precedono k nella lista T[j] sono quelle che sono state inserite dopo di k.
Supponiamo che k = ki sia l’i-esima chiave inserita nella lista. Per j = i +1,...,n sia Xi,j la variabile casuale che vale 1 se kj viene inserita nella stessa lista di k e 0 altrimenti Nell’ipotesi di hash uniforme semplice E(Xi,j ) = 1/m.
Il valore atteso del numero medio di operazioni eseguite è Se n ≤ cm per qualche costante positiva c allora α = O(1) e (1+α) = (1).
Funzioni hash Che proprietà deve avere una una buona funzione hash? Essa dovrebbe soddisfare l’ipotesi di hash uniforme semplice: “Ogni chiave ha la stessa probabilità 1/m di essere mandata in una qualsiasi delle m celle, indipendentemente dalle chiavi inserite precedentemente”
Ad esempio, se le chiavi sono numeri reali x estratti casualmente e indipendentemente da una distribuzione di probabilità uniforme in 0 ≤ x < 1 allora h(x) = mx soddisfa tale condizione. Sfortunatamente l’ipotesi di hash uniforme semplice dipende dalle probabilità con cui vengono estratti gli elementi da inserire; probabilità che in generale non sono note.
Le funzioni hash che descriveremo assumono che le chiavi siano degli interi non negativi. Questo non è restrittivo in quanto ogni tipo di chiave è rappresentato nel calcolatore con una sequenza di bit e ogni sequenza di bit si può interpretare come un intero non negativo.
Metodo della divisione h(k) = k mod m Difetto: non “funziona bene” per ogni m. Ad esempio se m = 2p è una potenza di due allora k mod m sono gli ultimi p bit di k. In generale anche valori di m prossimi ad una potenza di 2 non funzionano bene. Una buona scelta per m è un numero primo non troppo vicino ad una potenza di 2. Ad esempio m = 701.
Metodo della moltiplicazione h(k) = m(kA mod 1) in cui A è una costante reale con 0 < A < 1 ed x mod 1 = x – x è la parte frazionaria. Vantaggi : la scelta di m non è critica e nella pratica funziona bene con tutti i valori di A anche se ci sono ragioni teoriche per preferire l’inverso del rapporto aureo
h(k) si calcola facilmente se si sceglie m = 2p e A = q/2w con 0 < q < 2w dove w è la lunghezza di una parola di memoria. k q = r1 w bit h(k) p bit r0
Randomizzazione di funzioni hash Nessuna funzione hash può evitare che un avversario malizioso inserisca nella tavola una sequenza di valori che vadano a finire tutti nella stessa lista. Più seriamente: per ogni funzione hash si possono trovare delle distribuzioni di probabilità degli input per le quali la funzione non ripartisce bene le chiavi tra le varie liste della tavola hash.
Possiamo usare la randomizzazione per rendere il comportamento della tavola hash indipendente dall’input. L’idea è quella di usare una funzione hash scelta casualmente in un insieme “universale” di funzioni hash. Questo approccio viene detto hash universale.
Un insieme H di funzioni hash che mandano un insieme U di chiavi nell’insieme {0,1,...,m-1} degli indici della tavola hash si dice universale se: “per ogni coppia di chiavi distinte j e k vi sono al più |H|/m funzioni hash in H tali che h(j) = h(k)” Se scegliamo casualmente la funzione hash in un insieme universale H la probabilità che due chiavi qualsiasi j e k collidano è 1/m, la stessa che si avrebbe scegliendo casualmente le due celle in cui mandare j e k.
Proprietà : Supponiamo che la funzione hash h sia scelta casualmente in un insieme universale H e venga usata per inserire n chiavi in una tavola T di m celle e sia k una chiave qualsiasi. La lunghezza attesa E[nh(k)] della lista h(k) è α = n/m se k non è presente nella tavola ed è minore di α+1 se k è presente. Quindi, indipendentemente dalla distribuzione degli input, una Search richiede tempo medio (1+α) che, se n = O(m), è (1).
Risoluzione delle collisioni con indirizzamento aperto Con la tecnica di indirizzamento aperto tutti gli elementi stanno nella tavola. La funzione hash non individua una singola cella ma un ordine in cui ispezionare tutte le celle. L’inserimento di un elemento avviene nella prima cella libera che si incontra nell’ordine di ispezione. Nella ricerca di un elemento si visitano le celle sempre nello stesso ordine.
La funzione hash è una funzione h(k,i) che al variare di i tra 0 ed m-1 fornisce, per ciascuna chiave k, una sequenza di indici h(k,0), h(k,1),..., h(k,m-1) che rappresenta l’ordine di ispezione. Siccome vogliamo poter ispezionare tutte le celle, la sequenza deve essere una permutazione dell’insieme degli indici 0,1,..., m-1 della tavola.
La realizzazione delle operazioni è: Insert(T, k) i = 0 repeat j = h(k, i) if T[ j ] == nil T[ j ] = k return j i = i +1 until i == m “Errore : tavola piena”
Search(T, k) i = 0 repeat j = h(k, i) if T[ j ] == k return j i = i +1 until i == m or T[ j ] == nil La realizzazione di Delete è più complicata Non possiamo infatti limitarci a porre nil nella cella!!! Perché ???
La Delete si limita ad assegnare alla chiave dell’elemento da togliere un particolare valore diverso da ogni possibile chiave: Delete(T, i) T[ i ] = deleted
La Search continua a funzionare invariata: Search(T, k) i = 0 repeat j = h(k, i) if T[ j ] == k return j i = i +1 until i == m or T[ j ] == nil
La Insert deve essere modificata: Insert(T, k) i = 0 repeat j = h(k, i) if T[ j ] == nil or T[ j ] == deleted T[ j ] = k return j i = i+1 until i == m “Errore: tavola piena”
Con l’indirizzamento aperto la funzione hash fornisce una sequenza di ispezione. In questo caso l’ipotesi di hash uniforme diventa: “Ogni chiave ha la stessa probabilità 1/m! di generare una qualsiasi delle m! possibili sequenze di ispezione”
Vi sono tre tecniche comunemente usate per determinare l’ordine di ispezione: Ispezione lineare Ispezione quadratica Doppio hash Nessuna delle tre genera tutte le m! sequenze di ispezione. Le prime due ne generano soltanto m e l’ultima ne genera m2.
Ispezione lineare La funzione hash h(k,i) si ottiene da una funzione hash ordinaria h'(k) ponendo L’esplorazione inizia dalla cella h(k,0) = h'(k) e continua con le celle h'(k)+1, h'(k)+2, ecc. fino ad arrivare alla cella m-1, dopo di che si continua con le celle 0,1,ecc. fino ad aver percorso circolarmente tutta la tavola.
L’ispezione lineare è facile da implementare ma soffre del problema dell’addensamento primario: “i nuovi elementi inseriti nella tavola tendono ad addensarsi attorno agli elementi già presenti” Una cella libera preceduta da t celle occupate ha probabilità (t +1)/m di venir occupata dal prossimo elemento inserito. Quindi sequenze consecutive di celle occupate tendono a diventare sempre più lunghe.
Ispezione quadratica La funzione hash h(k, i) si ottiene da una funzione hash ordinaria h'(k) ponendo dove c1 e c2 sono due costanti con c2 ≠ 0. I valori di m, c1 e c2 non possono essere qualsiasi ma debbono essere scelti opportunamente in modo che la sequenza di ispezione percorra tutta la tavola. Un modo per fare ciò è suggerito nel problema 11-3 del libro.
Osserviamo che se h'( j) = h'(k) anche le due sequenze di ispezione coincidono. Questo porta ad un fenomeno di addensamento secondario (meno grave dell’addensamento primario). L’addensamento secondario è dovuto al fatto che il valore iniziale h'(k) determina univocamente la sequenza di ispezione e pertanto abbiamo soltanto m sequenze di ispezione distinte.
Problema 11-3 del libro: Consideriamo la seguente procedura: j = h'(k) i = 0 while i < m and “T[j] non è la cella cercata” i = i+1 j = ( j+ i ) mod m Dimostrare che la sequenza delle j che viene generata è una sequenza di ispezione quadratica.
Dobbiamo dimostrare che esistono due costanti c1 e c2 con c2 ≠ 0 tali che sia una invariante del ciclo. Calcoliamo i primi valori di h: Per i = 0 Per i = 1 Per i = 2 Per i = 3
e in generale Quindi
Doppio hash La funzione hash h(k,i) si ottiene da due funzione hash ordinarie h1(k) ed h2(k) ponendo Perché la sequenza di ispezione percorra tutta la tavola il valore di h2(k) deve essere relativamente primo con m (esercizio 11.4-3 del libro). Possiamo soddisfare questa condizione in diversi modi.
Possiamo scegliere m = 2p potenza di 2 ed h2(k) = 2 h'(k) + 1 con h'(k) funzione hash qualsiasi per una tavola di dimensione m' = m/2 = 2p-1. Un altro modo è scegliere m primo e scegliere h2(k) che ritorna sempre un valore minore di m. Un esempio è: h1(k) = k mod m e h2(k) = 1 + (k mod m') dove m' è minore di m (di solito m' = m-1).
Con l’hash doppio abbiamo (m2) sequenze di ispezione distinte. Questo riduce notevolmente i fenomeni di addensamento e rende il comportamento della funzione hash molto vicino a quello ideale dell’hash uniforme.
Analisi dell’indirizzamento aperto Assumiamo l’ipotesi di hash uniforme, ossia che ogni permutazione di 0,1,..., m-1 sia ugualmente probabile come ordine di ispezione. Valutiamo la complessità media di Search in funzione del fattore di carico α = n/m. Notiamo che con l’indirizzamento aperto n ≤ m e quindi 0 ≤ α ≤ 1.
Proprietà: Assumendo l’ipotesi di hash uniforme, il numero medio di celle ispezionate nella ricerca di una chiave k non presente in una tavola hash con indirizzamento aperto è m se α = 1 e al più 1/(1-α) se α < 1. Dimostrazione: Se α = 1 non ci sono celle vuote e la ricerca termina dopo aver ispezionato tutte le m celle.
Se α < 1 la ricerca termina con la prima cella vuota incontrata durante la sequenza di ispezione. Per l’ipotesi di hash uniforme la prima cella ispezionata può essere con uguale probabilità una qualsiasi delle m celle. Siccome ci sono n celle occupate la probabilità che la prima cella ispezionata risulti occupata e che quindi si debba ispezionare anche la successiva è α = n/m.
La probabilità che si debba ispezionare una terza cella è la probabilità α = n/m che la prima cella risulti occupata moltiplicata per la probabilità (n-1)/(m-1) che anche la seconda cella risulti occupata, ossia In generale la probabilità che si debba ispezionare la i-esima cella della sequenza è
Dunque noi ispezioniamo una prima cella con probabilità 1, una seconda cella con probabilità α, una terza cella con probabilità minore di α2, una quarta con probabilità minore di α3 e così via. Il numero atteso di celle ispezionate è quindi minore di
Conseguenza : Assumendo l’ipotesi di hash uniforme, il numero medio di celle ispezionate quando inseriamo una nuova chiave in una tavola hash con indirizzamento aperto è m se = 1 e al più 1/(1-α) se α < 1.
Proprietà: Assumendo l’ipotesi di hash uniforme, il numero medio di celle ispezionate nella ricerca di una chiave k presente in una tavola hash con indirizzamento aperto è (m+1)/2 se α = 1 e al più 1/α ln [1/(1-α)] se α < 1. Dimostrazione: Se α = 1 la chiave cercata può trovarsi, con uguale probabilità, nella prima, seconda, ..., ultima cella e quindi il numero medio di celle ispezionate è
Se α < 1 la ricerca ispeziona le stesse celle visitate quando la chiave cercata è stata inserita nella tavola. Supponiamo che la chiave cercata sia stata inserita dopo altre i chiavi. Il numero medio di celle ispezionate è al più 1/(1-α) =1/(1-i/m), ossia m/(m – i). Mediando su tutte le n chiavi presenti nella tavola otteniamo:
Possiamo maggiorare la sommatoria con un integrale ottenendo
Ecco una tavola dei valori di 1/α ln [1/(1-α)] α 1/ ln [1/(1-)] 0.3 1.19 0.5 1.39 0.7 1.72 0.9 2.56 0.95 3.15 0.99 4.65
Alberi Alberi liberi : grafi non orientati connessi e senza cicli. Alberi radicati : alberi liberi in cui un vertice è stato scelto come radice. Alberi ordinati : alberi radicati con un ordine tra i figli di un nodo.
= ≠ libero radicato ordinato f c h e a d g b f c h e a d g b f c h e a 1 3 2 ordinato 1 3 2 f c h e a d g b ≠
Alberi posizionali : alberi radicati in cui ad ogni figlio di un nodo è associata una posizione. Le posizioni che non sono occupate da un nodo sono posizioni vuote (nil). Alberi k-ari : alberi posizionali in cui ogni posizione maggiore di k è vuota. Alberi binari : alberi k-ari con k = 2. Il figlio in posizione 1 si dice figlio sinistro e quello in posizione 2 si dice figlio destro. Alberi binari
posizionale c … b d … … a e f … … … c b d a e k-ario (k = 4)
Alberi binari Il modo più conveniente per descrivere gli alberi binari è mediante la seguente. Definizione ricorsiva di albero binario: l’insieme vuoto Ø è un albero binario; se Ts e Td sono alberi binari ed r è un nodo allora la terna ordinata (r, Ts ,Td ) è un albero binario.
L’albero vuoto Ø si rappresenta graficamente con quadratino nero Per rappresentare l’albero T = (r, Ts , Td) si disegna un nodo etichettato r e sotto di esso le due rappresentazioni dei sottoalberi Ts e Td , con Ts alla sinistra di Td r Ts Td
T = (c, (b, (d, Ø, Ø), (a, (f, Ø, Ø), Ø)), (g, (e, Ø, Ø), Ø)) L’albero: T = (c, (b, (d, Ø, Ø), (a, (f, Ø, Ø), Ø)), (g, (e, Ø, Ø), Ø)) si rappresenta graficamente: c b g a d f e
Nella memoria l’albero: T = (c, (b, (d, Ø, Ø), (a, (f, Ø, Ø), Ø)), (g, (e, Ø, Ø), Ø)) si rappresenta nel modo seguente: p left right c nil key g b e a f d
Alberi binari di ricerca Un albero binario di ricerca è un albero binario in cui la chiave di ogni nodo è maggiore o uguale delle chiavi dei nodi del sottoalbero sinistro e minore o uguale delle chiavi dei nodi del sottoalbero destro. Ad esempio: 7 3 9 6 1 4 8
Operazioni sugli alberi binari di ricerca Stampa della lista ordinata dei nodi: Stampa(x) if x ≠ nil Stampa(x.left) print x Stampa(x.right)
Complessità: T(0) = c T(n) = T(k)+b+T(n-k-1) Verifichiamo per sostituzione che T(n) = (c + b) n + c T(0) = c = (c + b)0 + c T(n) = T(k) + b + T(n-k-1) = = (c + b)k +c+b+(c + b)(n-k-1)+c = (c +b)n +c
Ricerca di una chiave: Search(x, k) if x == nil or k == x.key return x if k < x.key return Search(x.left, k) else return Search(x.right, k) Complessità O(h) dove h è l’altezza dell’albero.
Si può anche fare iterativa: Search(x, k) while x ≠ nil and k ≠ x.key if k < x.key x = x.left else x = x.right return x Complessità O(h) dove h è l’altezza dell’albero.
Ricerca del minimo e del massimo: Minimum(x) // x ≠ nil while x.left ≠ nil x = x.left return x Maximum(x) // x ≠ nil while x.right ≠ nil x = x.right return x Complessità O(h) dove h è l’altezza dell’albero.
Ricerca di successivo e precedente Successor(x) if x.right ≠ nil return Minimum(x.right) y = x.p while y ≠ nil and x == y.right x = y, y = y.p return y Il precedente si ottiene cambiando right in left e Minimum in Maximum. Complessità O(h) dove h è l’altezza dell’albero.
Inserzione di un nuovo elemento Insert(T, z) // z.left = z.right = nil x = T.root, y = nil // y padre di x while x ≠ nil // cerco dove mettere z y = x if z.key < y.key x = y.left else x = y.right z.p = y // metto z al posto della foglia x if y == nil T.root = z elseif z.key < y.key y.left = z else y.right = z Complessità O(h) dove h è l’altezza dell’albero.
Eliminazione di un elemento Si riporta una versione semplificata, dove si spostano chiavi tra nodi diversi. Questo potrebbe rendere inconsistenti altri puntatori, a tali nodi. A lezione, discussa una versione che non soffre di questo problema. Vedi Libro Paragrafo 12.3
Eliminazione di un elemento: Delete(T, z) // z ≠ nil if z.left == nil or z.right == nil // tolgo z y = z // che ha al più un solo figlio else // tolgo il successore di z che non ha // sottoalbero sinistro y = Successor(z), z.key = y.key // cerco l’eventuale unico figlio x di y if y.left == nil x = y.right else x = y.left
// metto x al posto di y if x ≠ nil x.p = y.p if y.p == nil T.root = x elseif y == y.p.left y.p.left = x else y.p.right = x Complessità O(h) dove h è l’altezza dell’albero.
Alberi rosso-neri Le operazioni sugli alberi binari di ricerca hanno complessità proporzionale all’altezza h dell’albero. Gli alberi rosso-neri sono alberi binari di ricerca in cui le operazioni Insert e Delete sono opportunamente modificate per garantire un’altezza dell’albero h = O(log n) Bisogna aggiunge un bit ad ogni nodo: il colore che può essere rosso o nero.
Oltre ad essere alberi binari di ricerca, gli alberi rosso-neri soddisfano le proprietà: 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 i cammini da x alle foglie sue discendenti contengono tutti lo stesso numero bh(x) di nodi neri: l’altezza nera di x; Notare che il nodo x non viene contato in bh(x) anche se è nero.
Esempio di albero rosso-nero: 26 17 41 14 21 30 47 10 16 19 23 28 38 7 12 15 20 35 39 3
c nil b g nil d nil a nil e nil f nil
E’ utile usare una sentinella al posto di nil 26 17 41 14 21 30 47 10 16 19 23 28 38 7 12 15 20 35 39 3 ?
c b g d a e f ? T.nil
Proprietà: Un albero rosso-nero con n nodi interni ha altezza h ≤ 2 log2(n+1) Dimostrazione: Osserviamo che i nodi rossi in un cammino dalla radice r alle foglie possono essere al più bh(r) e quindi h ≤ 2 bh(r). Basta quindi dimostrare che bh(r) ≤ log2(n+1) ossia che n ≥ 2bh(r) - 1
Dimostriamo n ≥ 2bh(r) – 1 per induzione sulla struttura dell’albero rosso-nero. Se T = Ø la radice r è una foglia, bh(r) = 0 e n = 0 = 2bh(r) – 1
Sia T = (r,T1,T2) e siano r1 ed r2 le radici di T1 e T2 ed n1 ed n2 il numero di nodi interni di T1 e T2. Allora: bh(r1) ≥ bh(r)-1 bh(r2) ≥ bh(r)-1 n = 1+ n1 + n2 Per ipotesi induttiva
Conseguenza: Su di un albero rosso-nero con n nodi interni le operazioni Search, Minimum, Maximum, Successor e Predecessor richiedono tutte tempo O(log n)
Anche le operazioni Insert e Delete su di un albero rosso-nero richiedono tempo O(log n) ma siccome esse modificano l’albero possono introdurre delle violazioni alle proprietà degli alberi rosso-neri ed in tal caso occorre ripristinare le proprietà. Per farlo useremo delle operazioni elementari, dette rotazioni, che preservano la proprietà di albero binario di ricerca.
x y Left-Rotate(T, x) y x Left-Rotate(T, x) y = x.right // y non deve essere la sentinella T.nil x.right = y.left, y.left.p = x // y.left può essere T.nil y.p = x.p if x.p == T.nil T.root = y elseif x == x.p.left x.p.left = y else x.p.right = y x.p = y, y.left = x Complessità (1)
x y Right-Rotate(T, y) y x Right-Rotate(T, y) x = y.left // x non deve essere la sentinella T.nil y.left = x.right, x.right.p = y // x.right può essere T.nil x.p = y.p if y.p == T.nil T.root = x elseif y == y.p.left y.p.left = x else y.p.right = x y.p = x, x.right = y Complessità (1)
Inserimento di un nuovo elemento RB-insert(T, z) // z.left = z.right = T.nil Insert(T, z) z.color = RED // z è rosso. L’unica violazione // possibile delle proprietà degli alberi // rosso-neri è che z sia radice (prop. 2) // oppure che z.p sia rosso (prop. 4) RB-Insert-Fixup(T, z)
RB-Insert-Fixup(T, z) while z.p.color == RED // violata la proprietà 4 if z.p == z.p.p.left // l’altro caso è simmetrico y = z.p.p.right if y.color == RED // Caso 1 z.p.color = y.color = BLACK z.p.p.color = RED z = z.p.p 5 9 3 7 z.p.p y z.p z 5 9 3 7 z.p.p y z.p z
else if z == z.p.right // Caso 2 Left-Rotate(T, z) 3 9 5 7 z.p.p y z.p z 5 9 3 7 z.p.p y z z.p
// z figlio sinistro // Caso 3 z.p.color = BLACK z.p.p.color = RED Right-Rotate(T, z.p.p) else // simmetrico con right e left scambiati // alla fine del ciclo l’unica proprietà violata può // essere soltanto la 2 T.root.color = BLACK // Caso 0 z 5 9 3 7 z.p.p y z.p 5 9 3 7 z.p.p y z z.p 5 9 3 7
Complessità. Ogni volta che si ripete il ciclo while il puntatore z risale di due posizioni. Quindi il ciclo può essere ripetuto al più h volte e la complessità di RB-Insert-Fixup è O(log n). Quindi RB-Insert ha complessità O(log n).
Cancellazione di un elemento Rb-Delete(T, z) // z ≠ T.nil if z.left == T.nil or z.right == T.nil y = z else y = Successor(z), z.key = y.key // elimino y che ha almeno un sottoalbero vuoto if y.left == T.nil x = y.right x = y.left // x sottoalbero di y, l’altro è sicuramente vuoto
// metto x al posto del padre y x.p = y.p if y.p == T.nil T.root = x elseif y == y.p.left y.p.left = x else y.p.right = x // Se y è rosso non ci sono violazioni if y.color == BLACK // Se y era nero l’unica violazione delle // proprietà degli alberi rosso neri è che // i cammini che passano per x contengano // un nodo nero in meno RB-Delete-Fixup(T, x)
RB-Delete-Fixup(T, x) while x ≠ T.root and x.color == BLACK if x == x.p.left // l’altro caso è simmetrico w = x.p.right if w.color == RED // Caso 1 w.color = BLACK x.p.color = RED Left-Rotate(T, x.p) x 1 7 3 w 5 9 1 7 3 w x 5 9 1 7 3 w x 5 9
// il fratello w è nero if w.left.color == BLACK and w.right.color == BLACK // Caso 2 w.color = RED x = x.p 1 7 3 w x 5 9 1 7 3 w 5 9
else if w.right.color == BLACK // Caso 3 w.left.color = BLACK w.color = RED Right-Rotate(T, w) w = x.p.right 1 7 3 w x 5 9 1 7 3 w x 5 9 1 7 w 5 9 3 x
// Caso 4 w.color = x.p.color x.p.color = w.right.color = BLACK Left-Rotate(T, x.p) x = T.root else // simmetrico con right e left scambiati 1 7 3 5 9 1 7 3 w x 5 9 1 7 3 w x 5 9
// quando termina il ciclo o x è la radice // oppure x è rosso x.color = BLACK // Caso 0 5 Caso 0: x rosso x 5 x Caso 0: x radice x 5
Complessità di RB-Delete-Fixup. Con i casi 0, 3 e 4 il ciclo while termina immediatamente e dunque essi richiedono tempo costante. Dopo aver eseguito il caso 1 viene eseguito una sola volta il caso 2 e poi uno dei casi 0, 3 o 4. Quindi anche il caso 1 richiede tempo costante. Solo il caso 2 può essere ripetuto sul padre di x. Quindi il ciclo può essere ripetuto al più h volte e la complessità è O(log n).
Come aumentare gli alberi La soluzione di alcuni problemi algoritmici richiede la progettazione di una struttura dati appropriata. Spesso una tale struttura si può ottenere aumentando strutture dati note. Supponiamo ci serva una struttura dati su cui poter eseguire, oltre alle operazioni previste per gli alberi di ricerca, anche l’operazione di statistica ordinale Select(k) che ritorna il nodo con la chiave k-esima.
Un modo per farlo è aumentare gli alberi rosso-neri aggiungendo a ciascun nodo x un ulteriore campo intero x.size in cui memorizzare il numero di nodi interni del sottoalbero di radice x.
26 20 key size 17 12 41 7 14 7 21 4 30 5 47 1 10 4 16 2 19 2 23 1 28 1 38 3 7 2 12 1 15 1 20 1 35 1 39 1 3 1 ?
x.size = 1 + x.left.size + x.right.size Osservazione. Se usiamo la sentinella T.nil e poniamo T.nil.size = 0 allora per ogni nodo interno x vale l’equazione x.size = 1 + x.left.size + x.right.size
Possiamo realizzare facilmente Select: Select(x, k) // 1 ≤ k ≤ x.size // Trova il nodo con chiave k-esima // nel sottoalbero di radice x i = 1 + x.left.size if i == k return x elseif i > k return Select(x.left, k) else return Select(x.right, k-i)
Possiamo realizzare facilmente anche l’operazione inversa Rank(x) che trova la posizione k della chiave di x nella sequenza ordinata delle chiavi dell’albero Rank(T, x) // Trova la posizione k della chiave di x i = 1 + x.left.size y = x while y.p ≠ T.nil if y == y.p.right i = i + 1 + y.p.left.size y = y.p return i
Nella seconda si ripristinano le proprietà violate. Naturalmente dobbiamo modificare RB-Insert e RB-Delete per mantenere aggiornato il campo size dei nodi RB-Insert (T, z) // z.left = z.right = T.nil Insert (T, z) z.color = RED RB-Insert-Fixup (T, z) RB-Insert ha due fasi. Nella prima si scende dalla radice fino ad una foglia che viene quindi sostituita con il nuovo nodo. Nella seconda si ripristinano le proprietà violate.
Per la prima fase basta mettere 1 nel campo size del nuovo nodo z e aumentare di 1 il campo size di tutti i nodi incontrati scendendo verso la foglia che verrà sostituita con z.
Insert(T, z) z.size = 1 // istruzione aggiunta x = T.root, y = T.nil while x ≠ T.nil x.size = x.size + 1 // istruzione aggiunta y = x if z.key < y.key x = y.left else x = y.right z.p = y if y == T.nil T.root = z elseif key[z] < key[y] x.left = z else x.right = z
x.size = 1 + x.left.size + x.right.size Nella seconda fase le modifiche alla struttura sono dovute alle rotazioni. x Left-Rotate(T, x) y y x Right-Rotate(T, y) I campi size dei nodi diversi da x e y rimangono invariati. Basta quindi ricalcolare i campi size dei due nodi x e y usando la relazione: x.size = 1 + x.left.size + x.right.size
LeftRot(T,x) x y Left-Rotate(T, x) y = x.right x.right = y.left, y.left.p = x y.p = x.p if x.p == T.nil T.root = y elseif x == x.p.left x.p.left = y else x.p.right = y x.p = y, y.left = x y.size = x.size // istruzioni aggiunte x.size = 1 + x.left.size + x.right.size
Anche la RB-Delete ha due fasi: Nella prima viene tolto un nodo y avente uno dei sottoalberi vuoto sostituendolo con la radice dell’altro sottoalbero. Per questa fase basta diminuire di 1 il campo size di tutti i nodi nel cammino dalla radice a tale nodo.
RB-Delete(T, z) // z ≠ T.nil if z.left == T.nil or z.right == T.nil y = z else y = Successor(z), z.key = y.key if y.left == T.nil x = y.right else x = y.left x.p = y.p // mette x al posto di y if y.p == T.nil T.root = x elseif y == y.p.left y.p.left = x else y.p.right = x if y.color == BLACK RB-Delete-Fixup(T, x) w = x.p // istruzioni aggiunte while w ≠ T.nil w.size = w.size – 1, w = w.p
Nella seconda fase di RB-Delete le modifiche alla struttura dell’albero sono dovute alle rotazioni e quindi è sufficiente la modifica delle rotazioni già vista per RB-Insert. Le istruzioni aggiunte (quelle in verde) non aumentano la complessità delle operazioni RB-Insert e RB-Delete.
Teorema generale dell’aumento L’aumento di una struttura dati richiede quattro passi: scelta della struttura dati di base; scelta delle ulteriori informazioni da memorizzare nella struttura; verifica che esse si possano mantenere durante le operazioni della struttura di base senza aumentarne la complessità asintotica; sviluppo delle nuove operazioni.
Per gli alberi rosso-neri c’è un teorema che ci facilita il passo 3. Teorema dell’aumento Sia x.field un nuovo campo che aumenta un albero rosso-nero T. Se il valore di x.field si può calcolare in tempo O(1) usando soltanto le altre informazioni presenti in x e quelle presenti nei figli x.left e x.right comprese x.left.field e x.right.field allora il campo x.field si può mantenere aggiornato eseguendo RB-Insert e RB-Delete senza aumentare la complessità O(log n) di tali operazioni.
x.size = 1 + x.left.size + x.right.size Osservazione Il campo x.size soddisfa tale proprietà x.size = 1 + x.left.size + x.right.size Se usiamo la sentinella T.nil e poniamo T.nil.size = 0 questa formula vale per ogni nodo interno, compresi quelli senza figli.
Dimostrazione L’idea è che una modifica di x.field implica la modifica del campo field degli antenati di x ma non degli altri nodi. RB-Insert(T, z) // z.left = z.right = T.nil Insert(T, z) z.color = RED RB-Insert-Fixup(T, z) Nella prima fase Insert il nodo z aggiunto non ha figli e quindi z.field si può calcolare direttamente in tempo costante. Basta quindi ricalcolare il campo field di z e di tutti i suoi antenati (tempo O(log n)).
tempo O(log n) Insert(T, z) x = T.root, y = T.nil while x ≠ T.nil y = x if z.key < y.key x = y.left else x = y.right z.p = y if y == T.nil T.root = z elseif z.key < y.key x.left = z else x.right = z w = z while w ≠ T.nil Ricalcola-Field(w), w = w.p tempo O(log n)
Nella seconda fase RB-Insert-Fixup l’unico caso che può essere ripetuto è il caso 1 che non richiede rotazioni. Negli altri casi vengono eseguite al più 2 rotazioni e ciascuna di esse richiede il ricalcolo del campo field dei due nodi ruotati e dei loro antenati. Tempo O(log n). Osservazione Nel caso del campo size non occorreva ricalcolarlo negli antenati ma questo non è sempre vero.
Left-Rotate(T, x) y = x.right x.right = y.left, y.left.p = x y.p = x.p if x.p == T.nil T.root = y elseif x == x.p.left x.p.left = y else x.p.right = y x.p = y, y.left = x w = x while w ≠ T.nil Ricalcola-Field(w), w = w.p Left-Rotate(T, x) x y Tempo O(log n)
Nella prima fase di RB-Delete viene sostituito un nodo y con un suo figlio x. Basta quindi ricalcolare il campo field di tutti gli antenati di x. Tempo O(log n). Nella seconda fase l’unico caso che può essere ripetuto è il caso 2 che non effettua rotazioni. Negli altri casi vengono eseguite al più 3 rotazioni e ciascuna di esse richiede il ricalcolo del campo field dei due nodi ruotati e dei loro antenati.
Alberi di intervalli Vogliamo aumentare gli alberi rosso-neri per ottenere una struttura dati che supporta operazioni su un insieme dinamico di intervalli [a,b] con a e b numeri reali. Oltre a Insert e Delete vogliamo una operazione Search(a,b) che ritorna un nodo dell’albero il cui intervallo ha intersezione non vuota con l’intervallo [a,b]. Un intervallo [a,b] si rappresenta con i due numeri reali a e b.
Dunque ogni nodo x di un albero di intervalli ha due campi x.low = a e x.high = b in cui sono memorizzati gli estremi di [a,b]. Il campo x.low è usato come chiave mentre x.high viene trattato come informazione associata. Aggiungiamo inoltre un campo x.max che mantiene il valore massimo tra gli estremi degli intervalli contenuti nel sottoalbero di radice x. Per la sentinella poniamo T.nil.max = -
[16,21] 30 [8,9] 23 [25,30] [5,8] 10 [26,26] 26 [0,3] 3 [6,10] [15,23] [17,19] 20 [19,20] low max high 30 10 20
x.max si può calcolare in tempo O(1) come massimo tra x.high, x.left.max e x.right.max x.max = MAX(x.high, x.left.max, x.right.max) Se poniamo T.nil.max = - questa vale anche quando x.left e/o x.right sono T.nil . Dunque, per il teorema dell’aumento, il campo max si può mantenere eseguendo RB-Insert e RB-Delete senza aumentare la complessità asintotica di tali operazioni.
Vediamo ora come realizzare Interval-Search(a,b) che ritorna un nodo dell’albero il cui intervallo ha intersezione non vuota con l’intervallo [a,b]. Dobbiamo però prima decidere come si controlla se due intervalli si intersecano.
Per gli intervalli vale la tricotomia. [a,b] e [c,d] si intersecano a c b d [a,b] è alla sinistra di [c,d] (b < c) a b c d [a,b] è alla destra di [c,d] (d < a) c d a b
Usando questa proprietà due intervalli [a,b] e [c,d] si intersecano se e solo se i casi 2 e 3 non sono veri Quindi il test di intersezione è semplicemente: (b ≥ c) and (d ≥ a) e si esegue in tempo costante O(1)
Interval-Search(x, a, b) // cerca un nodo nel sottoalbero di radice x // il cui intervallo interseca [a,b] if x == T.nil or “[a,b] interseca x” return x // altrimenti o [a,b] è alla sinistra di x o // [a,b] è alla destra di x if x.left.max ≥ a // se [a,b] non interseca nessun intervallo nel // sottoalbero sinistro allora certamente non // interseca nessun intervallo nel sottoalbero destro // Posso limitarmi a cercare nel sottoalbero sinistro return Interval-Search(x.left, a, b)
else // x.left.max < a // [a,b] non interseca gli intervalli nel sottoalbero // sinistro // Posso limitarmi a cercare nel sottoalbero destro return Interval-Search(x.right, a, b) Complessità limitata dall’altezza dell’albero. Quindi O(log n).