Alberi Gli alberi (finiti, ipotesi d’ora in avanti sottintesa) si possono vedere come una generalizzazione delle sequenze lineari (vettori o liste) nel senso che, mentre queste in ultime ciascun elemento possiede al più un successore immediato, in un albero i successori possono esser più di uno. lista … albero Il primo elemento di questa struttura prende il nome di radice; i sui successori immediati sono altrettante radici di alberi contenuti all’interno dell’albero dato o, come si dice, sottoalberi. Definizione induttiva di albero Fissato un insieme A di valori, l’insieme Tree(A) egli alberi etichettati con valori in A è definito induttivamente: 1) ∅ ∈ Tree( A) 2) a ∈ A, k ≥ 0, t1 ,..., t k ∈ Tree( A) ⇒ (a, t1 ,..., t k ) ∈ Tree( A) Osserviamo che (a, t1 ,..., t k ) è una sequenza ordinata, in cui a è l’etichetta della radice, e t1 ,K, t k sono i sottoalberi immediati della radice; non sempre è necessario che vi sia un ordine tra i sottoalberi immediati della radice, nel qual caso nella seconda clausola della definizione si utilizza un insieme {a, t1 ,K, t k }. Esempi: albero genealogico, albero del file system, alberi sintattici, albero delle chiamate ricorsive. Gli alberi sono particolari grafi. Un grafo è una struttura G = (V, A), dove V è l’insieme dei vertici (o nodi), mentre A ⊆ V × V è una relazione binaria tra vertici: quando uAv (che abbrevia (u, v) ∈ A ) si dice che esiste un arco incidente in u e v (in tal caso u e v si dicono adiacenti); in generale gli archi (u, v) e (v, u) sono distinti, e il grafo si dice sia orientato; quando la relazione A è simmetrica, gli archi (u, v) e (v, u) vengono identificati (alternativamente si definisce A come insieme di coppie non ordinate di vertici), e il grafo non è orientato. Graficamente si rappresentano vertici ed archi come qui di seguito illustrato: u v Arco orientato da u a v u v Arco non orientato tra u e v Gli archi si possono comporre a formare un cammino, purché i vertici in cui gli archi incidono corrispondano: la relazione “esiste un cammino da u a v” (ovvero tra u e v nel caso non orientato) è la chiusura transitiva A + di A: u w x v Cammino (orientato) da u a v Quando esiste un vertice v tale che vA+ v , ossia un cammino da un vertice a se stesso, si dice che esiste un ciclo (nel caso non orientato si parla talora di circuito ovvero di cricca, intendendo con quest’ultimo termine l’insieme dei vertici in cui incidono gli archi ciclo). Definizione degli alberi come grafi: alberi liberi Un albero libero è un grafo G = (V, A) non orientato tale che: 1) G è connesso, ovvero per ogni coppia di vertici u, v ∈V con u ≠ v sia uA + v , ossia esiste un cammino tra u e v; 2) G è aciclico, ovvero in G non vi sono cicli. Comunque in un albero libero si scelga un vertice r ∈V , resta determinato un albero nel senso della definizione induttiva di albero; in effetti l’insieme V − {r} risulta allora ripartito in sottoinsiemi disgiunti di vertici, che sono altrettanti alberi; inoltre in ciascuno di essi la scelta di r induce la scelta di una radice: l’unico vertice adiacente ad r. Assumendo che V sia finito, le due nozioni di albero sono allora equivalenti. Nelle figure seguenti si illustra un albero libero con due delle possibili scelte di una radice (il vertice scelto come radice è cerchiato con un tratto spesso e l’albero disegnato in modo da evidenziare la relazione padre-figlio con segmenti dall’alto al basso): e a a c b d d e c d Albero libero b c b Albero libero con radice in c a e Albero libero con radice in b Rappresentazione degli alberi in memoria Il grado di un albero è il massimo numero di figli di uno stesso vertice. Gli alberi binari, ossia di grado 2, sono rappresentabili in memoria con una generalizzazione diretta della rappresentazione delle liste semplici; si utilizzano cioè record con tre campi anziché due, di cui uno è destinato a contenere l’informazione sull’etichetta (label), e gli altri due (left e right) sono puntatori al primo e secondo sottoalbero della struttura che ha radice nel vertice rappresentato dal record. r label left a right c r b d a c b d Rappresentazione in memoria di un albero binario con record e puntatori Anche la codifica in C++ di questa struttura dati è simile a quella delle liste: un possibilità è quella qui di seguito riportata (caso delle etichette come interi): typedef struct BTnode* BTree; struct BTnode { int label; BTree left, right; }; BTree NewNode (int lbl, BTree lft, BTree rgt) { BTree t = new BTnode; Label(t) = lbl; Left(t) = lft; Right(t) = rgt; return t; } int& Label (BTree t) { return t->label; } BTree& Left (BTree t) { return t->left; } BTree& Right (BTree t) { return t->right; } Il caso degli alberi generali, ovvero di grado superiore a 2 pone un evidente problema per la definizione dei record che rappresentano i vertici, non essendovi a priori limiti al numero dei figli di un vertice. Naturalmente è possibile prevedere dei campi vettore per i figli, allocati dinamicamente, ma questo presuppone che l’albero sia dato per così dire tutto insieme, e non, come invece normalmente accade, costruito per successivi inserimenti di nuovi vertici o di interi sottoalberi in una struttura preesistente. Per questo motivo si adotta una soluzione basata sulla rappresentazione degli alberi binari e sul fatto che un albero di qualunque grado può essere trasformato in albero binario in modo invertibile, ossia potendosi ricostruire la struttura originaria a partire da quella in forma binaria. Il metodo si basa su una diversa interpretazione dei due sottoalberi di un vertice binario: la radice del sottoalbero di sinistra rappresenta infatti il primo figlio del vertice considerato, mentre il ramo destro (ossia la sequenza dei discendenti a destra dal vertice dato) rappresenta i fratelli successivi in un dato ordinamento: a a b e c b d f g c e d f g Trasformazione di un albero di grado 3 in un albero binario La struttura del record che rappresenta un vertice è dunque la stessa che nel caso degli alberi binari, salvo che, per rendere il codice più perspicuo, si adotta una diversa scelta dei nomi dei campi: typedef struct Tnode* Tree; struct Tnode { int label; Tree child, sibling; }; L’approccio illustrato è preferibile quando ci si aspetta di esplorare gli alberi dalla radice verso le foglie, cioè verso quei vertici privi di discendenti. Gli alberi orientati in questo modo si dicono alberi sorgente. Se invece l’esplorazione procede dalle foglie (o da qualunque altro vertice interno) verso la radice, si preferisce una realizzazione in cui ciascun vertice diverso dalla radice rinvia al vertice padre: è il caso dei cosiddetti alberi pozzo. Albero sorgente Albero pozzo A differenza della relazione padre-figlio, quella inversa figlio-padre è univoca in un albero; considerando dunque l’albero come un grafo (V, A), la relazione A −1 è una funzione finita, che si può facilmente rappresentare con un vettore di record a due campi: uno per l’etichetta (label) e l’altro (parent) per l’indice del record che rappresenta il padre nel vettore (la radice, non avendo padre, conterrà un valore non significativo come – 1, oppure il proprio stesso indice). a c b d e f e g c d a f b 6 2 4 6 -1 6 4 0 1 2 3 4 5 6 g Albero rappresentato con il “vettore dei padri” Il vettore che rappresenta questa relazione è chiamato “vettore dei padri”, ed il suo uso non è limitato alla rappresentazione di un singolo albero: in effetti questa codifica viene utilizzata per rappresentare un insieme finito di alberi (foresta) che a loro volta rappresentano partizioni dell’insieme delle etichette che occorrono nei vertici in cui ciascuna parte, che nella struttura è un albero, ha per rappresentante la radice di questo albero. x b r h f a x b w f a h r -1 0 5 0 5 -1 0 w 0 1 2 3 4 5 6 Rappresentazione della partizione {{ x, b, r , f }, {h, a, w}} Visite di alberi Pressoché tutti gli algoritmi sugli alberi comprendono una parte di navigazione attraverso la struttura, che viene detta nel caso degli alberi (e dei grafi in generale) visita. Il caso più evidente è quello della ricerca di un valore dato tra le etichette di un albero. Restringendo l’attenzione agli alberi binari, possiamo affrontare il problema estendendo a questa struttura l’algoritmo di ricerca in una lista (semplice). Delle due opzioni però che abbiamo nel caso delle liste, quella iterativa non è direttamente applicabile agli alberi, non essendo chiaro come si possa recuperare l’informazione su quale sia la radice del sottoalbero destro se nella visita si sia scelto di esplorare prima il sottoalbero sinistro (e viceversa). La versione ricorsiva invece si adatta perfettamente: bool SearchList (int n, List l) { if (IsEmptyList(l)) return false; else if (n == Head(l)) return true; else return SearchList (n, Tail(l)); } diventa infatti: bool SearchBTree (int n, Btree t) { if (IsEmptyTRee(t)) return false; else if (n == Label(t)) return true; else return SearchBTree (n, Left(t)) || SearchBTree (n, Right(t)); } Questa ricerca si basa su un’induzione sull’altezza dell’albero: se l’albero è vuoto la risposta è negativa; se l’etichetta della radice è n allora la risposta è positiva; altrimenti n occorre in t se e solo se occorre nei sottoalberi sinistro oppure destro: ma essendo questi di altezza inferiore a quella di t la funzione SearchBTree applicata ad essi è definita per ipotesi induttiva. Se si tiene conto della sequenza dei vertici dell’albero nell’ordine in cui questa funzione li ispeziona, allora ci si accorge che questa corrisponde ad una visita in profondità (DFS: Depth First Search), vale a dire ad un’esplorazione dell’albero in cui si procede dalla radice percorrendo i rami sino alle foglie e ritornando indietro all’ultima diramazione non esplorata per proseguire allo stesso modo la visita dei vertici sugli altri rami. Ad esempio una DFS dell’albero nella figura qui sotto, eseguita allo stesso modo della funzione SearchBTree, 0 12 2 9 3 4 darebbe la sequenza: 0, 2, 9, 3, 12, 4. Si tratta più specificatamente di una visita in preordine, ossia che procede a visitare la radice, e quindi con lo stesso criterio il sottolabero sinistro e quindi quello destro. Se la sequenza fosse stata: sinistro, radice, destro, si sarebbe parlato di visita in ordinata; nel caso della sequenza sinistro, destro, radice la visita si dice in postordine. Quelli che seguono sono altri esempi di visite DFS: int CardBTree (Btree t) // post: ritorna la cardinalità, ossia il numero dei vertici, di t { if (IsEmptyBTree(t)) return 0; else return CardBTree (Left(t)) + CardBTree (Right(t)) + 1; } Si dice altezza di un albero non vuoto la massima distanza tra la radice e le foglie, misurata contando il numero degli archi che formano un cammino tra la radice ed una foglia, che si dice ramo. int Height (Btree t) // pre: t non è vuoto // post: ritorna l’altezza di t { if (IsEmptyBTree(Left(t)) && IsEmptyBTree(Right(t))) // t è una foglia return 0; else if (IsEmptyBTree(Right(t))) // Left(t) non è vuoto return Height (Left(t)) + 1; else if (IsEmptyBTree(Left(t))) // Right(t) non è vuoto return Height (Right(t)) + 1; else // né Left(t) né Right(t) è vuoto return Height (Left(t)) + Height (Right(t)) + 1 } Una volta compreso come costruire una DFS nel caso degli alberi binari, la sua estensione agli alberi generali non richiede modifiche sostanziali; quello che segue è un algoritmo che stampa la sequenza delle etichette di un albero (rappresentato con record e puntatori) visitato in profondità: void DFS (Tree t) { if (!IsEmptyTree (t)) { cout << Label(t); // l’iterazione che segue considera tutti // i vertici che sono figli di quello appena vistato Tree child = Child(t); while (child != NULL) { DFS (child); child = Sibling(child); } } } Una semplice induzione sull’altezza dell’albero mostra che una DFS ha complessità temporale O(n), dove la dimensione dell’ingresso è la cardinalità dell’albero. Un’alternativa alla DFS è la BFS (Breadth First Search), visita in ampiezza o per livelli. Un livello è formato da vertici equidistanti dalla radice; i livelli sono numerati in dipendenza di tale distanza: Livello 0 Livello 1 Livello 2 Un primo approccio al problema consiste nello sviluppare una funzione che visita il livello k ≥ 0 dell’albero (binario): void Level (BTree t, int k) // stampa le etichette del livello k dell’albero t { if (!IsEmpty(t)) { if (k == 0) cout << Label(t); else { Level(Left(t), k – 1); Level(Right(t), k – 1); } } } A questo punto, per ottenere una BFS sarà sufficiente iterare la funzione Level da 0 all’altezza: void BFS (BTree t) { int h = Height (t); for (int k = 0; k <= h; k++) Level(t, k); } Qual è la complessità di questa funzione? Dipende chiaramente da quella di Level, che a sua volta è una DFS dell’albero che si ottiene da t eliminando tutti i vertici che distino più di k dalla radice: il caso peggiore si ottiene quando il rapporto tra k e la cardinalità di quet’albero è prossimo ad 1, e cioè quando l’albero sia degenere: Alberi degenere sinistro e destro Se ne deduce che Level è O(k), e quindi che, sempre nel caso peggiore, la complessità di BFS n −1 risulta O(∑k =0 k ) = O(n 2 ). Ci chiediamo allora se esista un metodo per costruire una BFS in modo che risulti della stessa complessità della DFS, cioè O(n). La soluzione cercata si basa sull’uso di una coda, vale a dire di una struttura sequenziale cui si accede solamente agli estremi: con l’operazione Enqueue si aggiunge un elemento come ultimo della coda; con l’operazione Dequeue si elimina il primo elemento dalla coda e lo si restituisce. L’effetto degli accodamenti e delle eliminazioni sugli elementi di una coda è chiamato regola FIFO, First In First Out, ossia il primo entrato è il primo ad uscire, ed in generale nell’estrazione viene rispettata la sequenza cronologica degli inserimenti. Dal momento che il tipo dei valori che sono sulla coda non incide sulla natura delle operazioni che gestiscono la coda, alcuni linguaggi tra cui il C++, consentono di implementare questa ed altre simili strutture dati come classi parametriche rispetto al tipo. Il costrutto utilizzato è template <class T> class … { … }; Una classe di questo genere la si può pensare come un funtore che, dato un tipo, restituisce una classe, completa di tutti i suoi membri, quale sarebbe stata definita sostituendo nel codice che segue la dichiarazione template il tipo dato alla variabile di tipo T (per maggiori informazioni si consulti il capitolo 16 del testo di Schildt). template <class T> class Queue { public: Queue() { front = rear = NULL; } bool IsEmpty() const { return front == NULL; } void Enqueue (T t); // post: aggiunge t in fondo T Dequeue (); // pre : coda non vuota // post: elimina e ritorna il primo el. private: QElement<T> *front, *rear; }; La realizzazione di questa classe si basa sull’uso di una lista di record concatenati di cui mantiene un puntatore al primo elemento, front, ed un all’ultimo, rear. front … rear Il tipo QElement dei record che formano la lista è anch’esso realizzato con una struttura parametrica in T: template <class T> struct QElement { T elem; QElement* next; }; Le operazioni Enqueue e Dequeue sono ordinarie operazioni di inserimento davanti e al fondo di una lista semplice, in cui occorre però distinguere quando l’inserimento avvenga in una coda vuota, ovvero quando l’estrazione produca una coda vuota: in queste situazioni il comportamento di Enqueue e Dequeue deve infatti coinvolgere sia front che rear, là dove normalmente coinvolge solo uno dei due puntatori (rear nel caso di Enqueue, front in quello di Dequeue). template <class T> void Queue<T>::Enqueue (T t) { QElement<T>* newEl = new QElement<T>; newEl->elem = t; newEl->next = NULL; if (rear == NULL) { front = newEl; rear = newEl; } else { rear->next = newEl; rear = rear->next; } template <class T> T Queue<T>::Dequeue () { T t = front->elem; QElement<T>* tobedeleted = front; front = front->next; if (front == NULL) rear = NULL; delete tobedeleted; return t; } } Osserviamo che, con questa implementazione, le operazioni di accodamento ed estrazione richiedono sempre tempo costante. Tornando al problema di implementare una BFS in tempo lineare, possiamo finalmente scrivere: void BFS (BTree t) { if (!IsEmptyTree(t)) { Queue<Tree> q; // qui la classe Queue si specializza al tipo BTree q.Enqueue(t); while (!q.IsEmpty()) { t = q.Dequeue(); cout << Label(t); if (!IsEmptyBTree(Left (t))) q.Enqueue(Left (t)); if (!IsEmptyBTree(Right(t))) q.Enqueue(Right(t)); } } } L’invariante di ciclo di questo algoritmo è che la sequenza delle etichette degli elementi sulla coda costituisce sempre una sottosequenza della sequenza in uscita, ossia della visita per livelli. Quanto alla complessità, poiché ogni vertice dell’albero entra nella coda esattamente una volta, e la visita di ciascuno di essi richiede tempo O(1), essa è chiaramente O(n) nella cardinalità dell’albero, come richiesto. Si lascia come esercizio l’estensione di questo algoritmo al caso generale degli alberi di grado qualunque. Alberi binari di ricerca Tra gli impieghi più frequenti degli alberi vi è quello per la realizzazione di collezioni di elementi su cui si eseguono fondamentalmente tre operazioni: la ricerca di un elemento, l’inserimento e la cancellazione. Tali tipi di dato sono chiamati Insiemi dinamici o Dizionari. Supponiamo che i valori che etichettano i vertici degli alberi siano ordinabili in modo lineare (cosicché due elementi sono sempre confrontabili): se non esiste alcuna relazione tra l’ordine relativo delle etichette e la posizione dei vertici nell’albero, per la ricerca non vi è alternativa alla funzione SearchBTree vista sopra (costo O(n)). Supponiamo invece di ricercare valori (diciamo interi) su alberi binari che soddisfino la seguente definizione: Definizione degli alberi binari di ricerca (Binary Search Tree – BST) Un albero binario è di ricerca se, essendo etichettato con valori da un dominio totalmente ordinato, è vuoto oppure: 1) i sui sottoalberi sinistro e destro sono entrambi di ricerca, 2) l’etichetta della radice è maggiore di tutte le etichette del sottoalbero sinistro e minore di tutte quelle del sottoalbero destro. 8 3 10 1 6 4 13 È chiaro che se t è un BST ed il valore cercato è minore (maggiore) di quello in radice, la ricerca può limitarsi al sottoalbero sinistro (destro), e così via ricorsivamente. Nel caso peggiore l’algoritmo esplora il ramo più lungo ed è dunque O(h) dove h è l’altezza dell’albero: bool BST_Search (Btree t, int k) // pre: t è un BST con etichette intere // post: ritorna true se e solo se k occorre in t { if (IsEmptyBTree(t)) return false; else if (k == Label(t)) return true; else if (k < Label(t)) return BST_Search(Left(t)); else return BST_Search(Right(t)); } Naturalmente, poiché h nel caso peggiore è n – 1, dove n è la cardinalità dell’insieme, non vi è in generale nessun vantaggio ad usare un BST in luogo di una struttura lineare come un vettore ordinato, il quale consente comunque una ricerca O(log n) con l’algoritmo della ricerca dicotomica. La ragione della scelta di queste strutture dati per la rappresentazione dei dizionari risiede in due fatti distinti: 1) tutte le tre operazioni fondamentali su un BST sono O(h), come verrà mostrato di seguito; al contrario le operazioni di inserimento e cancellazione su un vettore ordinato sono Ω(n), a causa della necessità di effettuare traslazioni degli altri valori presenti sul vettore; 2) se la struttura dell’albero è sufficientemente simile a quella di un albero completo, allora h ∈ O(log n) e l’albero si dice bilanciato: sono note tecniche che, all’atto di inserire ovvero eliminare un elemento da un albero bilanciato impediscono che questo perda tale caratteristica, e lo fanno con complessità ancora O(h): il risultato è una struttura dati che consente di effettuare tutte le operazioni fondamentali in tempo O(log n). Trascurando l’aspetto del bilanciamento (per cui si rinvia il lettore interessato ad un testo di algoritmi che tratti ad esempio i cosiddetti alberi rosso-neri, oppure gli alberi AVL), di seguito si illustrano gli algoritmi di inserimento e cancellazione su un BST. BTree Insert (int k, BTree t) // pre: t è un BST // post: ritorna il BST ottenuto inserendo k in t; // se k è presente in t, ritorna t senza modifiche { if (!IsEmptyBTree(t)) return NewNode(k, NULL, NULL); else if (Label(t) == k) return t; else if (k < Label(t)) { Left(t) = Insert(k, Left (t)); return t; } else { Right(t) = Insert(k, Right(t)); return t; } } L’algoritmo è in sostanza una ricerca che, nel caso in cui il valore k non sia presente, aggiunge un nuovo nodo contenente k il quale risulterà una foglia dell’albero così modificato. Si osservi come la funzione ritorni, e quindi assegni nuovamente, tutti i puntatori dei vertici sul ramo attraversato nella ricerca: sebbene questo possa sembrare dispendioso e soprattutto inutile, non altera la complessità, che rimane O(h), ma rende semplice il codice, perché nell’aggiungere il nuovo nodo non abbiamo bisogno di sapere se l’inserimento avvenga a sinistra o a destra di un nodo preesistente, oppure se il nuovo nodo sia la radice del BST modificato (che allora conterrà solo quel vertice). L’eliminazione di un vertice contenete un determinato valore da un BST pone un problema: concentrandoci infatti sul caso in cui il vertice da eliminare occorra in radice (diversamente ci penserà la ricorsione a svolgere il lavoro) si possono presentare quattro casi qui di seguito raffigurati, a seconda della presenza o meno di sottoalberi non vuoti della radice: k k k left k right left right Il primo caso è banale poiché l’albero risultante è vuoto; il secondo ed il terzo sono semplici, essendo sufficiente ritornare il sottoalbero sinistro e destro rispettivamente; il quarto caso richiede maggiore attenzione, non essendo chiaro come si possano fondere tra loro i due alberi risultanti dall’eliminazione della radice. Tuttavia si può osservare che se il massimo del sottoalbero sinistro, oppure il minimo del destro rimpiazzasse la radice, il risultato sarebbe ancora un BST. Bisogna tenere conto del fatto che in questo processo di rimpiazzamento il vertice che diventerà la nuova radice andrà comunque eliminato dal sottoalbero in cui si trova; ma il massimo (minimo) in un BST non può avere un sottoalbero destro (sinistro), dunque la sua eliminazione ricade in uno dei primi due casi (ovvero del primo e terzo nel caso del minimo). 8 3 6 10 1 6 3 13 1 10 4 13 4 L’eliminazione di 8 dall’albero sopra a sinistra comporta la migrazione di 6 in radice, ma anche la sua eliminazione dal sottoalbero sinistro, ossia, ricorsivamente, dal sottoalbero con radice 6, che viene rimpiazzato dal suo sottoalbero sinistro, nell’esempio costituito dal solo vertice 4. L’albero che ne risulta è raffigurato sopra a destra. BTree DelMax (BTree t, BTree& m) // pre: t non è vuoto // post: ritorna l’albero che si ottiene da t rimuovendo il massimo // il cui indirizzo (puntatore) è assegnato ad m (parametro // passato per riferimento) { if (IsEmpty(Right(t)) // il massimo è in radice { m = t; return Left(t); } else // il massimo di t è il massimo di Right(t) { Right(t) = DelMin (Right(t), m); return t; } } BTree Delete (int k, Btree t) // pre: t è un BST // post: ritorna il BST ottenuto eliminando k da t; // se k non è presente in t, ritorna t senza modifiche { if (IsEmptyBTree(t)) return NULL; else if (k == Label(t)) // elimina la radice: distingue 4 casi { BTree p; if (IsEmptyBTree(Left(t)) && IsEmptyBTree(Right(t))) { delete t; return NULL; } else if (IsEmptyBTree(Right(t))) { p = Left(t); delete t; return p; } else if (IsEmptyBTree(Left(t))) { p = Right(t); delete t; return p; } else // la radice ha due sottoalb. non vuoti { Left(t) = DelMax (Left(t), p); Left (p) = Left(t); Right(p) = Right(t); delete t; return p; } } else if (k < Label (t)) { Left (t) = Delete(k, Left (t)); return t; } else { Right(t) = Delete(k, Right(t)); return t; } }