Strutture dati avanzate Vedremo alcune strutture dati che permettono di eseguire in modo efficiente un determinato repertorio di operazioni: - B-alberi . - strutture dati per insiemi disgiunti. B-alberi I B-alberi sono alberi bilanciati adatti per memorizzare grandi masse di dati in memoria secondaria. Sono simili agli alberi rosso-neri ma sono progettati per minimizzare gli accessi alla memoria secondaria. Le operazioni sui B-alberi sono Insert, Delete e Search alle quali si aggiungono Split e Join. A differenza dei nodi degli alberi rosso-neri che hanno una sola chiave e due figli, i nodi dei B-alberi possono avere un numero di chiavi n ≥ 1 ed n+1 figli. Ecco un esempio di B-albero: T.root M D H B C F G Q T X J K L N P R S V W Y Z Il funzionamento di una memoria esterna su disco può essere schematizzato nel seguente modo: Meccanismo di spostamento della testina Testina di lettura-scrittura Disco Traccia Pagina Settore Le operazioni di lettura/scrittura hanno come unità elementare una pagina che normalmente contiene da 2 a 10 kbyte. L’accesso alle informazioni registrate su disco richiede alcune operazioni meccaniche: - spostamento della testina; - rotazione del disco. Il tempo di accesso al disco comprende il tempo per posizionare la testina di lettura scrittura e poi aspettare che la pagina passi sotto la testina. Esso è dell’ordine dei millisecondi. Tipicamente 10-20 msec. Il tempo di accesso alla memoria RAM, non richiedendo operazioni meccaniche è invece dell’ordine dei nanosecondi. Tipicamente 10-100 nsec. Spesso occorre più tempo per leggere i dati da disco in memoria e per scrivere i risultati su disco di quanto serva per la loro elaborazione. Per questo, nel valutare la complessità delle operazioni, terremo separate le due componenti: 1. tempo di lettura-scrittura su disco (che assumiamo proporzionale al numero di pagine lette o scritte). 2. tempo di CPU (tempo di calcolo in memoria RAM). L’operazione di lettura da disco è: DiskRead(x) dato il riferimento x ad un oggetto ricopia l’oggetto da disco in memoria. Assumiamo che sia possibile accedere al valore di un oggetto soltanto dopo che esso sia stato letto in memoria. L’operazione di scrittura su disco è : DiskWrite(x) che, dato il riferimento x ad un oggetto presente in memoria, copia tale oggetto su disco. Tipicamente l’elaborazione di un oggetto residente su disco segue lo schema: DiskRead(x) ..... // elaborazioni che accedono-modificano x DiskWrite(x) // non necessaria se x invariato ..... // elaborazioni che non modificano x Le operazioni di lettura-scrittura devono leggere e scrivere almeno una pagina e questo anche se l’oggetto x è molto più piccolo. Siccome, con i B-alberi, la maggior parte del tempo è utilizzata per le operazione di lettura-scrittura da disco è opportuno sfruttare al massimo ciascuna operazione. Per questa ragione si usano nodi la cui dimensione sia circa quella di una pagina. Il numero massimo di figli di un nodo è limitato dalla dimensione di una pagina. Un numero massimo di figli compreso tra 50 e 2000 è la norma. Un elevato grado di diramazione riduce di molto sia l’altezza dell’albero che il numero di letture da disco necessarie per cercare una chiave. La figura seguente mostra che un B-albero di altezza 2 con un grado di diramazione 1000 può contenere 109-1 chiavi (un miliardo) T.root 999 1000 999 999 999 1 nodo 999 chiavi 999 1000 ..... 1000 ..... 999 1000 999 1.000 nodi 999.000 chiavi 1.000.000 nodi 999.000.000 chiavi Poiché la radice può essere tenuta stabilmente in memoria, due accessi a disco sono sufficienti per cercare una qualsiasi chiave. Per semplicità di esposizione assumeremo che le informazioni associate a ciascuna chiave (o i puntatori a tali informazioni) siano memorizzate assieme alle chiavi in tutti i nodi dell’albero. Un’altra possibilità è mettere tali informazioni nelle foglie e mettere nei nodi interni soltanto chiavi e puntatori ai figli, massimizzando quindi il grado di diramazione dei nodi interni. Esempio di B-albero con grado massimo 4 e che usa la prima tecnica: T.root M* Q* T* X* D* H* B* C* E* F* J* K* L* N* O* P* R* S* W* Y* Z* Ogni nodo deve poter contenere 3 chiavi, 4 puntatori ai figli e 3 puntatori alle informazioni associate 10 campi in totale Esempio di B-albero con grado massimo 4 e che usa la seconda tecnica T.root M D H K P S W B*C*D* E*F*H* J*K* L*M* N*O*P* Q*R*S* T*W* X*Y*Z* Ogni nodo interno deve poter contenere 3 chiavi e 4 puntatori ai figli. Una foglia deve poter contenere 3 chiavi e 3 puntatori alle informazioni associate. al più 7 campi in totale Definizione di B-albero Un B-albero T è un albero di radice T.root tale che: 1. Ogni nodo x contiene i seguenti campi: a) x.n : il numero di chiavi presenti nel nodo. b) x.key1 ≤ x.key2 ≤ .... ≤ x.keyx.n : le x.n chiavi in ordine non decrescente. c) x.leaf : valore booleano che è true se il nodo x è foglia, false altrimenti. 2. se il nodo non è una foglia contiene anche d) x.c1, x.c2,...., x.cx.n+1: gli x.n+1 puntatori ai figli. 3. le chiavi x.key1, x.key2,...., x.keyx.n di un nodo interno separano le chiavi dei sottoalberi. In altre parole, se ki è una chiave qualsiasi del sottoalbero di radice x.ci allora k1 ≤ x.key1 ≤ k2 ≤ ... ≤ kx.n ≤ x.keyx.n ≤ kx.n+1 4. Le foglie sono tutte alla stessa profondità h detta altezza dell’albero. 5. Vi sono limiti inferiori e superiori al numero di chiavi in un nodo e tali limiti dipendono da una costante t detta grado minimo del B-albero. a) ogni nodo, eccetto la radice, ha almeno t -1 chiavi e, se non è una foglia, ha almeno t figli. b) se l’albero non è vuoto, la radice contiene almeno una chiave. c) ogni nodo ha al più 2t -1 chiavi e, se non è foglia, ha al più 2t figli. Ad ogni chiave sono generalmente associate delle informazioni ausiliarie. Assumeremo implicitamente che quando viene copiata una chiave vengano copiate anche tali informazioni. I B-alberi più semplici sono quelli con grado minimo t = 2. Ogni nodo interno ha 2,3 o 4 figli. In questo caso si dicono anche 2-3-4-alberi. Storicamente i 2-3-4-alberi sono predecessori sia dei B-alberi che degli alberi rosso-neri. Esercizio 20. Perché non possiamo avere grado minimo t = 1? Esercizio 21. Calcolare il numero massimo di chiavi che può contenere un B-albero in funzione del suo grado minimo t e della sua altezza h Esercizio 22. Dire quale struttura dati si ottiene se in ogni nodo nero di un albero rosso-nero conglobiamo i suoi eventuali figli rossi. Altezza di un B-albero Proprietà. Ogni B-albero non vuoto di grado minimo t con N chiavi ha altezza h N 1 log t 2 Dimostrazione. Invece della diseguaglianza h N 1 log t 2 h N 2 t 1 dimostriamo quella equivalente Sia T.root la radice ed h ≥ 0 l’altezza. Indichiamo con mi il numero di nodi a livello i. Allora m0 = 1 m1 = T.root.n + 1 ≥ 2 mi ≥ t mi -1 ≥ t i -1 m1 per i > 1 e dunque mi ≥ 2t i -1 per ogni i ≥ 1 Quindi N x x .n T .root .n i 1 h x .n x di livello i 1 i 1 2t i 1 ( t 1) h th 1 1 2( t 1) t 1 1 2( t h 1) 2t h 1 L’altezza di un B-albero è O(logt n) dello stesso ordine O(log2 n) degli alberi rosso-neri. Ma la costante nascosta nella O è inferiore di un fattore log2t che, per 50 ≤ t ≤ 2000, è compreso tra 5 e 11. Operazioni elementari Adottiamo le seguenti convenzioni per le operazioni sui B-alberi: 1. La radice del B-albero è sempre in memoria. 2. I nodi passati come parametri alle procedure sono stati preventivamente caricati in memoria. Defineremo le seguenti operazioni: - Create costruttore di un B-albero vuoto - Search che cerca una chiave nell’albero - Insert che aggiunge una chiave - Delete che toglie una chiave Ci serviranno anche alcune procedure ausiliarie: - SplitChild - InsertNonfull - AugmentChild - DeleteNonmin - DeleteMax Un B-albero vuoto si costruisce con la procedura: Create(T) x = AllocateNode() x.leaf = true x.n = 0 DiskWrite(x) T.root = x la cui complessità è O(1). Search(x, k) i = 1, j = x.n+1 // x.key1 .. i-1 < k ≤ x.keyj .. x.n while i < j // Ricerca binaria m = (i+j)/2 if k ≤ x.keym j=m else i = m+1 // x.key1.. i-1 < k ≤ x.keyi .. x.n if i ≤ x.n and k == x.keyi return (x, i) elseif x.leaf return nil else DiskRead(x.ci) return Search(x.ci , k) Il numero di DiskRead è al più uguale all’altezza h dell’albero e quindi O(logt N) Il tempo di CPU di Search è: T ( h 1) log 2 ( 2t 1) O(log t N log 2 t ) O(log 2 N ) Vediamo ora la Insert che aggiunge una chiave. Non possiamo aggiungere la chiave ad un nodo interno perché dovremmo aggiungere anche un sottoalbero. L’aggiunta di una chiave ad un B-albero può avvenire soltanto in una foglia e soltanto se la foglia non è piena, cioè non ha il numero massimo 2t-1 di chiavi. Possiamo garantirci che la foglia a cui arriveremo non sia piena se ad ogni passo nella discesa dalla radice ci assicuriamo che il figlio su cui scendiamo non sia pieno. Nel caso in cui tale figlio sia pieno chiamiamo prima una particolare funzione SplitChild che lo divide in due. La stessa cosa dobbiamo fare all’inizio se la radice è piena. Ecco come funziona Insert se la radice è piena in un B-albero di grado minimo t = 4 root P Q R S T U V root root.c1 T1 T2 T3 T4 T5 T6 T7 T8 y P Q R S T U V T1 T2 T3 T4 T5 T6 T7 T8 dopo di che richiama SplitChild Ecco come funziona SplitChild su un figlio pieno in un B-albero di grado minimo t = 4 x ... N W ... x x.ci x.ci y P Q R S T U V T1 T2 T3 T4 T5 T6 T7 T8 ... N S W ... y P Q R T1 T2 T3 T4 x.ci+1 T U V z T5 T6 T7 T8 SplitChild(x, i, y) // x non pieno con y figlio i-esimo pieno z = Allocate-Node() z.leaf = y.leaf z.n = t-1 // Sposta le ultime t -1 chiavi di y in z for j = 1 to t -1 z.keyj = y.keyj+t // Se y non è foglia sposta in z gli ultimi t puntatori di y if not y.leaf for j = 1 to t z.cj = y.cj+t y.n = t-1 // Mette la t-esima chiave di y in x for j = x.n downto i x.keyj+1 = x.keyj x.keyi = y.keyt // Aggiunge z come i+1-esimo figlio di x for j = x.n+1 downto i+1 x.cj+1 = x.cj x.ci+1 = z x.n = x.n +1 // Scrive su disco i nodi modificati DiskWrite(x), DiskWrite(y), DiskWrite(z) La procedura Insert in un B-albero è: Insert(T, k) r = T.root if r.n == 2t –1 s = AllocateNode() T.root = s s.n = 0, s.c1 = r, s.leaf = false SplitChild(s,1, s.c1) r=s InsertNonfull(r, k) InsertNonfull(x,k) // x non è pieno if x.leaf // Aggiunge k al nodo x i = x.n while i ≥ 1 and x.keyi > k x.keyi+1 = x.keyi , i = i -1 x.keyi+1 = k, x.n = x.n+1 DiskWrite(x) else // Cerca il figlio in cui inserirla i = 1, j = x.n+1 // x.key1..i-1 < k ≤ x.key j..x.n while i < j // Ricerca binaria m = (i+j)/2 if k ≤ x.keym j=m else i = m+1 DiskRead(x.ci) if x.ci .n == 2t -1 SplitChild(x, i, x.ci) if k > x.keyi i = i +1 InsertNonfull(x.ci , k) La procedura SplitChild richiede al più 3 DiskWrite e tempo di CPU O(1) (considerando t costante). Se x è radice di un sottoalbero di altezza h allora InsertNonfull(x, k) nel caso pessimo effettua una DiskRead e una SplitChild ad ogni livello per un totale di 4h accessi a disco e richiede tempo di CPU O(h). Quindi Insert richiede O(h) accessi a disco e O(h) tempo di CPU. G M P X A C D E J K N O R S T U V Y Z Insert(T,B) G M P X A B C D E J K N O R S T U V Y Z G M P X A B C D E J K N O R S T U V Y Z Insert(T,Q) G M P T X A B C D E J K N O Q R S U V Y Z G M P T X A B C D E J K N O Q R S U V Y Z Insert(T,L) P G M A B C D E J K L T X N O Q R S U V Y Z P G M A B C D E J K L T X N O Q R S U V P Insert(T,F) C G M A B D E F J K L Y Z T X N O Q R S U V Y Z Esercizio 23 Scrivere una funzione che cerca la chiave minima contenuta in un B-albero ed una funzione che data un valore c cerca la minima chiave k > c presente nel B-albero. Esercizio 24* In un B-albero con grado minimo t = 2 vengono inserite le chiavi 1,2,3,....,n nell’ordine. Valutare il numero di nodi dell’albero in funzione di n. Esercizio 25 Supponiamo che la dimensione di un nodo di un B-albero si possa scegliere arbitrariamente e che il tempo medio per leggere un nodo da disco sia a+bt dove t è il grado minimo ed a e b sono due costanti Suggerire un valore ottimo per t quando a = 30ms e b = 40s Togliere una chiave da un B-albero è un po’ più complicato che inserirla. Possiamo togliere una chiave solo da una foglia e possiamo farlo soltanto se la foglia non ha il minimo numero di chiavi t -1 (o se la foglia è anche la radice). Se dobbiamo togliere una chiave k = x.keyi da un nodo x che non è foglia scambiamo prima la chiave k con la massima chiave k' del sottoalbero di radice x.ci. k' = y.keyy.n è l’ultima chiave dell’ultima foglia y del sottoalbero di radice x.ci. Inoltre nel B-albero non vi è alcuna chiave di valore compreso tra k' e k. Scendendo dalla radice per cercare la chiave k da togliere ci dobbiamo anche assicurare che i nodi su cui ci spostiamo non abbiano mai il numero di chiavi minimo t-1. Se il nodo figlio su cui dobbiamo scendere ha solo t-1 chiavi, prima di scendere dobbiamo aumentarlo. Possiamo farlo prendendo una chiave da uno dei fratelli vicini o, se questo non è possibile, riunendolo con uno dei fratelli vicini. P C G M A B D E F J K L T X N O Q R S U V P Delete(T,F) C G M A B D E J K L Y Z T X N O Q R S U V Y Z P C G M A B D E J K L T X N O Q R S U V P Delete(T,M) C G L A B D E J K M Y Z T X N O Q R S U V Y Z P C G L A B D E J K M T X N O Q R S U V Y Z P C G L A B D E J K T X N O Q R S U V Y Z P C G L A B D E J K T X N O Q R S U V P Delete(T,G) C L A B D E G J K Y Z T X N O Q R S U V Y Z P C L A B D E G J K T X N O Q R S U V Y Z P C L A B D E J K T X N O Q R S U V Y Z P C L A B D E J K T X N O Q R S U V Y Z Delete(T,D) C L P T X A B D E J K N O Q R S U V Y Z C L P T X A B D E J K N O Q R S U V Y Z U V Y Z C L P T X A B E J K N O Q R S C L P T X A B E J K N O Q R S U V Y Z U V Y Z C L P T X A B E J K N O Q R S C L P T X A B E J K N O Q R S U V Y Z Delete(T,B) E L P T X A B C J K N O Q R S U V Y Z E L P T X A B C J K N O Q R S U V Y Z U V Y Z E L P T X A C J K N O Q R S La procedura di rimozione di una chiave da un Balbero è: Delete(T, k) if T.root.n ≠ 0 DeleteNonmin(T.root, k) if T.root.n == 0 and not T.root.leaf T.root = T.root.c1 si limita a richiamare la funzione ausiliaria DeleteNonmin sulla radice dell’albero dopo essersi assicurata che l’albero non sia vuoto. Se al ritorno la radice è vuota e non è una foglia essa viene sostituita con il figlio. La procedura DeleteNonmin usa la procedura AugmentChild per assicurarsi di scendere sempre su di un nodo che non contiene il minimo numero di chiavi. La procedura AugmentChild aumenta il numero di chiavi del figlio i-esimo prendendo una chiave da un fratello vicino. Se entrambi i fratelli vicini hanno anch’essi il minimo numero di chiavi allora riunisce il figlio i-esimo con uno dei fratelli. AugmentChild(x, i, z) // x ha almeno t chiavi // z, figlio i-esimo di x, ha solo t-1 chiavi if i > 1 // z ha un fratello alla sua sinistra y = x.ci-1, DiskRead(y) if i > 1 and y.n > t-1 // si può togliere una chiave // dal fratello sinistro x .... N R ... x.ci-1 y K L M T1 T2 T3 T4 x x.ci-1 x.ci P Q .... M R ... z T5 T6 T7 y K L T1 T2 T3 x.ci N P Q z T4 T5 T6 T7 // Sposta avanti le chiavi in z for j = z.n+1 downto 2 z.keyj = z.keyj-1 // Rotazione delle chiavi z.key1 = x.keyi-1 x.keyi-1 = y.keyy.n if not z.leaf // Sposta anche i puntatori in z for j = z.n+2 downto 2 z.cj = z.cj-1 z.c1 = y.cy.n+1 z.n = z.n+1, y.n = y.n-1 DiskWrite(x), DiskWrite(y), DiskWrite(z) else // i = 1 o il fratello sinistro y ha solo t -1 chiavi if i ≤ x.n // z ha un fratello alla sua destra w = x.ci+1, DiskRead(w) if i ≤ x.n and w.n > t -1 // si può togliere una chiave al fratello destro x x.ci z T1 x.ci+1 P Q T2 x ... L R ... T2 S T U T4 x.ci z w T5 T6 T7 ... L S ... T1 x.ci+1 P Q R T2 T3 T4 T U T5 w T6 T7 // Rotazione delle chiavi z.keyz.n+1 = x.keyi x.keyi = w.key1 // Sposta indietro le chiavi in w for j = 2 to w.n w.keyj-1 = w.keyj if not w.leaf // Sposta anche i puntatori z.cz.n+2 = w.c1 for j = 2 to w.n+1 w.cj-1 = w.cj z.n = z.n+1, w.n = w.n-1 DiskWrite(x), DiskWrite(y), DiskWrite(z) else // i = 1 oppure i = x.n+1 ma non entrambi // Se i < x.n+1 esiste w che ha solo t -1 chiavi // Se i > 1 esiste y che ha solo t -1 chiavi if i ≤ x.n // z ha il fratello destro w con t -1 chiavi // Possiamo riunire z, x.keyi e w x ... M R U... x.ci z P Q T1 T2 T3 ... M U ... x x.ci+1 S T T4 w T5 T6 x.ci z P Q R S T T1 T2 T3 T4 T5 T6 z.keyt = x.keyi // Aggiunge x.keyi a z for j = i +1 to x.n // Sposta chiavi e puntatori in x x.keyj-1 = x.keyj for j = i+2 to x.n+1 x.cj-1 = x.cj x.n = x.n-1 for j = 1 to w.n // Copia le chiavi di w in z z.keyj+t = w.keyj if not w.leaf for j = 1 to w.n+1 // Copia i puntatori di w in z z.ct+j = w.cj z.n = 2t -1 DiskWrite(x), DiskWrite(z) else // i = x.n+1 // e z ha fratello sinistro y con t-1 chiavi // Possiamo riunire y, x.keyx.n e z ...... J M x x.cx.n y K L T1 T2 T3 x x.cx.n+1 N P z T4 T5 T6 ...... J x.cx.n y K L M N P T1 T2 T3 T4 T5 T6 // Sposta x.key x.n in y y.keyt = x.keyx.n x.n = x.n-1 // Copia le chiavi di z in y for j = 1 to z.n y.keyj+t = z.keyj if not y.leaf // Copia anche i puntatori di z in y for j = 1 to z.n+1 y.ct+j = z.cj y.n = 2t -1 DiskWrite(x), DiskWrite(y) DeleteNonmin(x, k) // x ha almeno t chiavi i = 1, j = x.n+1 // x.key1..i-1 < k ≤ x.keyj..x.n m = (i+j)/2 while i < j if k ≤ x.keym j=m else i = m+1 if i ≤ x.n and k == x.keyi // Trovata k in x if x.leaf // x è una foglia, posso togliere k // spostando indietro le chiavi successive for j = i+1 to x.n x.keyj-1 = x.keyj x.n = x.n-1 DiskWrite(x) else // Trovata k in x ma x non è una foglia DiskRead(x.ci) if x.ci .n == t -1 AugmentChild(x, i, x.ci) if i == x.n+2 // riuniti gli ultimi due figli i=i-1 if x.keyi ≠ k // k spostata in x.ci DeleteNonmin(x.ci , k) else // è rimasto k = x.keyi . Sposto in x.keyi la // massima chiave del sottoalbero x.ci e poi // elimino tale chiave dal sottoalbero DeleteMax(x, i, x.ci) else // k non è in x // Può stare solo nel sottoalbero i-esimo DiskRead(x.ci) if x.ci .n == t -1 AugmentChild(x, i, x.ci) if i == x.n+2 // riuniti gli ultimi due figli i=i-1 DeleteNonmin(x.ci , k) DeleteMax(x, i, y) // y ha almeno t chiavi // Sposta in x.keyi la massima chiave // del sottoalbero di radice y if y.leaf // sposto in x.keyi l’ultima chiave di y x.keyi = y.keyy.n y.n = y.n-1 DiskWrite(x), DiskWrite(y) else // scendo sull’ultimo figlio z = y.cy.n+1 DiskRead(z) if z.n == t -1 AugmentChild(y, y.n+1, z) // y.n è aggiornato DeleteMax(x, i, y.cy.n+1) La procedura AugmentChild richiede al più 5 accessi a disco (due DiskRead e tre DiskWrite) e tempo di CPU O(1) (considerando t costante). Se y è radice di un sottoalbero di altezza h allora DeleteMax(x, i, y) nel caso pessimo effettua 5h accessi a disco e richiede tempo di CPU O(h). Se x è radice di un sottoalbero di altezza h allora DeleteNonmin(x, k) nel caso pessimo effettua 6h accessi a disco e richiede tempo di CPU O(h). Esercizio 26. Si vuole aumentare un B-albero aggiungendo ad ogni nodo x un campo h che contiene l’altezza del sottoalbero di radice x. Dire quali sono le modifiche da apportare a Insert e Delete. Assicurarsi che la complessità asintotica non aumenti. Esercizio 27. Siano dati due B-alberi T' e T'' ed una chiave k tale che T' < k < T'' . Scrivere un algoritmo che riunisce T', k e T'' in un unico B-albero T (operazione join). Se T' e T'' hanno altezze h' ed h'' l’algoritmo deve avere complessità O(|h'-h''|+1). Se h' = h'' : n' + n'' + 1 ≤ 2t -1 k n' + n'' + 1 > 2t -1 ed n',n'' ≥ t -1 k k k n' + n'' + 1 > 2t -1 ed n'' < t -1 m k m k Se h' > h'' : k k k k k m m k Esercizio 28* Siano dati un B-albero T ed una chiave k di T. Trovare un algoritmo che divide T in un Balbero T' contenente le chiavi minori di k, la chiave k e un B-albero T'' contenente le chiavi maggiori di k (operazione split). L’algoritmo deve avere complessità O(h+1) in cui h è l’altezza di T. k