Sei sulla pagina 1di 132

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
U nil k43 nil k3 nil
nil
k1 k5
nil k52 nil k2 nil
k2
k4 k3 h(k) nil
nil k1 nil

T
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 è
m 1
1 n
E[n j ]   ni   
m i 0 m
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
0 se h(k j )  h(ki )
X i, j  
1 se h ( k j )  h ( k i )
Nell’ipotesi di hash uniforme semplice
E(Xi,j ) = 1/m.
Il valore atteso del numero medio di operazioni
eseguite è
1 n n
1 n n
E[  (2   X i , j )]   (2   E[ X i, j ])
n i 1 j i 1 n i 1 j  i 1

1 n n
1 1 n
  (2   )  2   (n  i )
n i 1 j i 1m nm i 1
1 n 1 1 n(n  1)
 2 
nm t 0
t  2
nm 2
n 1  1
 2  2   (1   )
2m 2 2m
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
A  (rapporto
l’inverso del 5  1) / 2
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. w bit
k 
q =
r1 r0
h(k)
p bit
 p r0   p  kq 
h(k )  2 w   2  w mod 1
 2   2 
  m( kA mod 1) 
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:
1. Ispezione lineare
2. Ispezione quadratica
3. 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

h(k , i )  [h' (k )  i ] mod m


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
h(k , i )  [h' (k )  c1i  c2i ] mod m
2

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
j  h(k , i )  [ h' (k )  c1i  c2i ] mod m
2

sia una invariante del ciclo.


Calcoliamo i primi valori di h:
Per i = 0 j  h' ( k )
Per i = 1 j  [h' (k )  1] mod m
Per i = 2 j  [h' ( k )  1  2] mod m
Per i = 3 j  [h' ( k )  1  2  3] mod m
e in generale

Quindi  i (i  1) 
j  h' ( k )   mod m
 2 
 1 1 2
 h' (k )  i  i  mod m
 2 2 
Doppio hash
La funzione hash h(k,i) si ottiene da due funzione
hash ordinarie h1(k) ed h2(k) ponendo
h(k , i )  [h1 (k )  ih2 (k )] mod m
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
n n 1
  2

m m 1
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 è
1 1 m(m  1) m  1

m
i   
m i 1 m 2 2
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:
1 n 1 m m n 1 1 1 m
1
   
n i 0 m  i n i 0 m  i 

k  m  n 1 k
Possiamo maggiorare la sommatoria con un
integrale ottenendo
m m
1 1 1 1


k  m  n 1 k

  x
dx
mn

1
 (ln m  ln(m  n))

1 m 1 1
 ln  ln
 m  n  1
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 d f radicato f
a b
e c h = h c
c
g a e b g g b a e
f
h d d

f ordinato f
1 2 1 2
c h h c
1 2 3 1
≠ 1 2 3
a e b g 1
g b a e
1 1
d d
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
c posizionale

b d …
… a e f …
… … …

c k-ario (k = 4)

b d

a e
Alberi binari
Il modo più conveniente per descrivere gli alberi binari è
mediante la seguente.

Definizione ricorsiva di albero binario:


a) l’insieme vuoto Ø è un albero binario;
b) 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
L’albero:
T = (c, (b, (d, Ø, Ø), (a, (f, Ø, Ø), Ø)), (g, (e, Ø, Ø), Ø))

si rappresenta graficamente:
c
b g
d a e

f
Nella memoria l’albero:
T = (c, (b, (d, Ø, Ø), (a, (f, Ø, Ø), Ø)), (g, (e, Ø, Ø), Ø))
si rappresenta nel modo seguente:
nil
c

p
key b g
left right nil

d a e
nil nil nil nil nil

f
nil nil
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.
7
Ad esempio:
3 9
1 6 8

4
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 Complessità O(h)
y.left = z dove h è l’altezza
else y.right = z 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à:
1. ogni nodo è o rosso o nero;
2. la radice è nera;
3. le foglie (nil) sono tutte nere;
4. i figli di un nodo rosso sono entrambi neri;
5. 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
nil
c

b g
nil

d a e
nil nil nil nil nil

f
nil 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

?
c

b g

d a e

?
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
n  1 2 bh ( r1 )
1 2 bh ( r2 )
1
bh ( r ) 1 bh( r ) 1
 1 2 1  2 1  2 bh ( r )
1
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
Complessità (1)
x.p = y, y.left = x
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 Complessità (1)
y.p = x, x.right = y
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

z.p.p 7 z.p.p 7

z.p 5 9 y z.p 5 9 y

z 3 z 3
else if z == z.p.right // Caso 2
z = z.p
Left-Rotate(T, z)

z.p.p 7 z.p.p 7

z.p 3 9 y z.p 5 9 y

z 5 z 3
// 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.p.p 7 z.p.p 7 5

z.p 5 9 y z.p 5 9 y 3 7

z 3    z 3    9

   
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
else
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)
w = x.p.right
3 3 7
x 1 7 w x 1 7 w 3 9

  5 9   5 9 x 1 5 w 

           
// il fratello w è nero
if w.left.color == BLACK
and w.right.color == BLACK // Caso 2
w.color = RED
x = x.p
3 3
x 1 7 w 1 7 w

  5 9   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

3 3 3
x 1 7 w x 1 7 w x 1 5 w
  5 9   5 9    7
        9

 
// 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
3 3 7

x 1 7 w x 1 7 w 3 9

  5 9   5 9 1 5  

           
// quando termina il ciclo o x è la radice
// oppure x è rosso
x.color = BLACK // Caso 0

Caso 0: x rosso x 5 x 5

Caso 0: x radice x 5 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 17 41
size 12 7
14 21 30 47
7 4 5 1
10 16 19 23 28 38
4 2 2 1 1 3

7 12 15 20 35 39
2 1 1 1 1 1

3
1

?
0
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
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
Nella seconda fase le modifiche alla struttura sono
dovute alle rotazioni.
x Left-Rotate(T, x) y

 y Right-Rotate(T, y) x 
   
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
Left-Rotate(T, x) x
y = x.right y

x.right = y.left, y.left.p = x
y.p = x.p  
if x.p == T.nil
T.root = y LeftRot(T,x)
elseif x == x.p.left y
x.p.left = y
else x 
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 w = x.p // istruzioni aggiunte
while w ≠ T.nil
if y.color == BLACK w.size = w.size – 1, w = w.p
RB-Delete-Fixup(T, x)
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:
1. scelta della struttura dati di base;
2. scelta delle ulteriori informazioni da
memorizzare nella struttura;
3. verifica che esse si possano mantenere
durante le operazioni della struttura di base
senza aumentarne la complessità asintotica;
4. 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.
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)).
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
tempo O(log n)
w=z
while w ≠ T.nil
Ricalcola-Field(w), w = w.p
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 y
Left-Rotate(T, x)
x.p.left = y
 y x 
else
x.p.right = y    
x.p = y, y.left = x
w=x
while w ≠ T.nil Tempo O(log n)
Ricalcola-Field(w), w = w.p
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.
Tempo O(log n).
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]
low
30
high
[8,9] [25,30]
23 30 max
[17,19] [26,26]
20 26

[5,8] [15,23]
10 23

[0,3] [6,10] [19,20]


3 10 20

0 10 20 30
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.

1. [a,b] e [c,d] si intersecano


c d c d c d c d

a b a b a b a b

2. [a,b] è alla sinistra di [c,d] (b < c)


a b c d

3. [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).