Alberi binari Introduzione U na delle strutture fondamentali di tutta la programmazione è l'albero. Esiste un particolare tipo di albero, detto binario, che per le sue particolari proprietà si presta molto bene ad alcuni tipi di operazioni, quali l'analisi di espressioni matematiche o la ricerca dicotomica. Particolari tipi di alberi binari sono utilizzati come nuovi tipi di strutture dati, particolarmente ottimizzati per operazioni di ricerca o ordinamento: è il caso degli heap, degli alberi di ricerca e così via. In questo fascicolo analizzeremo tutti gli algoritmi basilari per la gestione di un albero binario, partendo dalle definizioni e ragionando su ogni algoritmo. Il linguaggio scelto per la stesura dei programmi è il C++, anche se in realtà saranno utilizzati concetti del tutto compatibili con il C. E' da sottolineare, tuttavia, che il codice è compilabile in ANSI C++, per la compilazione in C saranno necessarie alcune modifiche. Quindi quando vedete scritto [C/C++] non significa che il codice è compilabile in C, ma solo che la sintassi utilizzata non prevede elementi proprietari del C++, quali classi, template, reference e così via. Naturalmente questo fascicolo è dedicato esclusivamente alla parte relativa all'albero binario come struttura dati, quindi tutti i discorsi relativi alla sintassi del linguaggio adottato sono dati per scontati. In particolare, è bene avere buona padronanza delle strutture, dei puntatori e delle funzioni ricorsive, anche se quest'ultimo punto è presente una piccola digressione. Non c'è altro da dire: buona lettura e buona programmazione. Andrea Asta 1 1. Teoria degli alberi binari Definizioni S i definisce albero binario un insieme, eventualmente vuoto, di nodi connessi da archi detti rami. Un particolare nodo è detto radice e ogni nodo è tale da essere collegato a due sottoinsiemi distinti e disgiunti, anch'essi alberi binari (sottoalbero sinistro e destro). A B Figura 1: Esempio di albero binario C D E F G I H L M N Nell'albero mostrato in Figura 1, la radice è il nodo A, da cui si diramano altri due sottoalberi binari. Terminologia U n nodo si dice foglia se non ha figli. I nodi che non sono foglie sono detti nodi interni. Nell'esempio precedente, sono foglie i nodi F, I, M e N. Dati due nodi i e j, si definisce cammino o percorso da i a j la sequenza di rami da percorrere per passare da i a j. In un albero, esiste sempre un cammino che collega una arbitraria coppia di nodi. Si definisce profondità o livello di un nodo i, la sua distanza dalla radice, ossia il numero di archi da percorrere (ovviamente, nel caso migliore) per raggiungere i partendo dalla radice. Per convenzione la radice ha profondità 0. Si definisce altezza o profondità di un albero binario l'altezza massima delle sue foglie. Nell'esempio precedente, l'albero ha altezza 5. 2 E' possibile stabilire dei legami di parentela tra i nodi, del tutto simili a quelli della vita reale: la radice è il padre o genitore dei due nodi a lui connessi, che quindi saranno suoi figli. I figli di uno stesso genitore sono fratelli. Allo stesso modo si possono stabilire i legami di parentela nonno, zio ecc… Ogni nodo di un albero ha un solo genitore e, nel caso degli alberi binari, al massimo due figli. Un albero binario si dice pieno se sono contemporaneamente queste condizioni: 1. Tutte le foglie hanno lo stesso livello 2. Tutti i nodi interni hanno esattamente 2 figli soddisfatte Un albero binario pieno, formato da n nodi, ha profondità uguale a L = ⎣log 2 n⎦ notare che con le parentesi quadrate inferiormente si intende l'approssimazione per difetto (le parentesi quadrate superiormente intendono invece l'approssimazione per eccesso). Un albero binario pieno, di altezza h, ha un numero di nodi pari esattamente a n = 2 h +1 − 1 Un albero binario è bilanciato quando tutte le foglie hanno lo stesso livello, con un margine di errore di 1. Quindi, se l'altezza dell'albero è h, tutte le foglie dovranno avere livello h o al più h-1. Un albero binario bilanciato in cui tutte le foglie hanno livello h si dice perfettamente bilanciato. L'attraversamento di un albero consiste nell'elencarne tutti i nodi una e una sola volta. Attraversamenti P er gli alberi binari sono definiti tre metodi di attraversamento, che illustreremo uno ad uno: 1. Ordine anticipato (preorder) 2. Ordine posticipato (postorder) 3. Ordine simmetrico (inorder) Ordine anticipato Se l'albero binario non è vuoto: 1. Visito la radice 2. Attraverso il sottoalbero sinistro in ordine anticipato 3. Attraverso il sottoalbero destro in ordine anticipato Ordine posticipato Se l'albero binario non è vuoto: 1. Attraverso il sottoalbero sinistro in ordine posticipato 3 2. Attraverso il sottoalbero destro in ordine posticipato 3. Visito la radice Ordine simmetrico 1. Attraverso il sottoalbero sinistro in ordine simmetrico 2. Visito la radice 3. Attraverso il sottoalbero destro in ordine posticipato Va notato che le tre visite differiscono solo nel fatto che viene alterato l'ordine di visita dei sottoalberi e della radice. Riferendosi all'albero di Figura 1, ecco come risultano le visite. ANTICIPATA: ABDFCEGIHLMN POSTICIPATA: FDBIGMNLHECA SIMMETRICA: DFBACIGEMLNH Alberi binari di ricerca U n albero binario di ricerca (ABR, oppure BST dall'inglese Binary Search Tree) è un albero binario in cui tutti gli elementi del sottoalbero sinistro sono minori della radice, tutti gli elementi del sottoalbero destro sono maggiori della radice e con l'ulteriore vincolo per cui anche i due sottoalberi sono alberi binari di ricerca a loro volta. Non sono ammessi elementi duplicati. 10 5 Figura 2: Esempio di albero binario di ricerca 11 8 6 15 9 7 20 18 In modo intuitivo si può capire come una struttura dati del genere sia particolarmente indicata per inserimenti ordinati e per la ricerca di tipo dicotomico (che viene anche detta binaria). Una particolare proprietà dei BST è che la visita simmetrica fornisce gli elementi esattamente in ordine crescente. SIMMETRICA: 5 6 7 8 9 10 11 15 18 20 4 2. Implementazione di un albero binario Struttura di un albero binario L a prima cosa che dobbiamo affrontare è la scrittura del codice che ci permetterà di definire ogni nodo di un albero binario. E' chiaro che ogni struttura avrà almeno un campo di tipo informativo, più due puntatori ad altri nodi, che sono il figlio sinistro ed il figlio destro. Come convenzione stabiliamo che, se un nodo non ha uno o alcuno dei figli, i puntatori avranno come valore NULL. [C/C++] Struttura per i nodi struct nodo { // Campi informativi int dato; // Puntatori ai prossimi nodi nodo* sin; nodo* des; }; typedef nodo* albin; L'istruzione typedef ci permetterà di trattare il puntatore ad un nodo come un nuovo tipo di dato, evitando confusione quando dobbiamo passare alle funzioni parametri di questo tipo. Funzioni di gestione basilare L e prime funzioni che ci accingiamo a creare sono decisamente banali e spesso possono anche essere omesse: si tratta delle funzioni che restituiscono il figlio sinistro e destro di un nodo, il campo informativo, oppure un albero vuoto. L'utilizzo di queste funzioni faciliterà, in futuro, l'eventuale revisione e riadattamento del codice, anche nel prospetto di una conversione ad un altro linguaggio di programmazione. Saranno definite quindi le seguenti funzioni: bool test_vuoto(albin root); albin f_sinistro(albin); albin f_destro(albin); int dato(albin); albin albero_vuoto(); La versione basilare che presentiamo è compilabile sia in linguaggio C sia in linguaggio C++. [C/C++] Funzioni di gestione di base bool test_vuoto(albin root) { // Verifica se root è un albero vuoto return root == NULL; } 5 albin f_sinistro(albin root) { // Restituisce il figlio sinistro di root if (!test_vuoto(root)) return root->sin; } albin f_destro(albin root) { // Restituisce il figlio destro di root if (!test_vuoto(root)) return root->des; } int dato(albin root) { // Restituisce il campo informativo di root if (!test_vuoto(root)) return root->dato; } albin albero_vuoto() { // Restituisce un albero vuoto return NULL; } Utilizzando il C++ è possibile migliorare leggermente le funzioni restituendo il valore di f_sinistro() e f_destro() come reference: in questo modo saranno possibili assegnazioni del tipo f_sinistro(mioalbero) = new nodo; Del resto, restando nell'ambito del C, è possibile scrivere due semplici funzioni imposta_sinistro() e imposta_destro() che svolgono lo stesso compito. [C++] Modifica dei parametri dell'albero albin& f_sinistro(albin root) { // Restituisce il figlio sinistro di root if (!test_vuoto(root)) return root->sin; } albin& f_destro(albin root) { // Restituisce il figlio destro di root if (!test_vuoto(root)) return root->des; } int& dato(albin root) { // Restituisce il campo informativo di root if (!test_vuoto(root)) return root->dato; } 6 Ecco ora il codice in C. [C/C++] Modifica dei parametri dell'albero bool imposta_sinistro(albin root, albin what) { if (!test_vuoto(root)) { root->sin = what; return true; } return false; } bool imposta_destro(albin root, albin what) { if (!test_vuoto(root)) { root->des = what; return true; } return false; } void imposta_radice(albin* root, albin what) { *root = what; } bool imposta_dato(albin root, int x) { if (!test_vuoto(root)) { root->dato = x; } } Le funzioni di inserimento nel figlio sinistro e destro restituiscono true se l'inserimento è stato possibile, false altrimenti. D'ora in avanti faremo riferimento alle funzioni C per compatibilità maggiore, chi utilizza il C++ sappia che può risparmiare queste tre funzioni semplicemente modificando le tre precedenti come mostrato sopra. 7 3. Teoria della ricorsione Perché la ricorsione C ome avrete notato, le definizioni di albero binario e albero binario di ricerca si prestano bene ad essere implementate con una procedura ricorsiva. In realtà vedremo che quasi tutti gli algoritmi relativi agli alberi binari si prestano bene ad essere implementati ricorsivamente. Ecco qualche esempio. Conteggio del numero di foglie Se l'attuale nodo è una foglia, restituisci 1, altrimenti restituisci la somma del numero di foglie del sottoalbero sinistro e del sottoalbero destro. Calcolo della profondità La profondità di un albero è uguale a 1 sommato alla profondità massima tra il sottoalbero sinistro e quello destro. Vedremo che, per risolvere un qualsiasi problema relativo agli alberi, sarà quasi più importante pensare all'algoritmo e alla definizione in modo ricorsivo, piuttosto che scrivere il codice. Tuttavia la ricorsione non è una tecnica del tutto immediata e, se non si presta sufficiente attenzione al codice che si scrive, è facile cadere in cicli infiniti, ossia in ricorsioni che non terminano mai. Un esempio pratico V a innanzitutto chiarito che tutti gli algoritmi iterativi possono essere trasformati nella controparte ricorsiva e viceversa. Tuttavia è bene valutare un problema di efficienza: la ricorsione, da una parte rende il codice più compatto e facile da comprendere, dall'altra allunga il tempo di esecuzione, in quanto ogni chiamata a funzione richiede l'utilizzo della memoria per salvare il punto di ritorno e richiede la ricostruzione di tutte le variabili locali. Un esempio classico di ricorsione è dato dal calcolo del fattoriale di un numero. Dato un numero naturale n n∈ N si definisce fattoriale si n e si indica con n! il prodotto di tutti i numeri naturali e positivi minori o uguali a n. n n!= n(n − 1)(n − 2)... ⋅ 3 ⋅ 2 ⋅ 1 = ∏ k k =1 Data questa definizione, è facile scrivere un programma iterativo per il calcolo del fattoriale: lo stesso simbolo di produttoria sottintende l'utilizzo di un semplice ciclo per la codifica. Un'ultima nota sulla definizione è che si assume 0! = 1 8 La motivazione va cercata nel calcolo combinatorio, ma non è questo il nostro interesse, quindi possiamo prendere l'identità mostrata come un assioma. [C/C++] Fattoriale iterativo long fattoriale_iterativo (short int n) { long f = 1; for (short int i = 1; i < n; i++) f *= i; return f; } Il programma calcola il fattoriale anche nel caso di n = 0, visto che la variabile f è inizializzata a 1 e l'esecuzione non entrerà mai nel ciclo con n = 0. La funzione viene eseguita anche se è passato un parametro errato, come un numero negativo. In questo caso, però, il valore restituito sarà comunque 1. Per ovviare il problema è possibile definire meglio il parametro di ingresso, ad esempio di tipo unsigned short int, oppure inserire un ulteriore controllo all'interno della funzione per la gestione degli errori. Ad esempio, è chiaro che il fattoriale non restituirà mai un valore uguale a 0, quindi si può utilizzare questo valore di ritorno come sentinella di errore. [C/C++] Fattoriale iterativo con error checking long fattoriale_iterativo (short int n) { if (n < 0) return 0; long f = 1; for (short int i = 1; i < n; i++) f *= i; return f; } Abbiamo visto come la versione iterativa del fattoriale sia abbastanza semplice da implementare. Tuttavia, è anche possibile definire un algoritmo ricorsivo per il calcolo del fattoriale. Basta infatti capire che il fattoriale di un numero n è interpretabile anche come n!= n ⋅ (n − 1)! ossia il fattoriale di n è uguale al prodotto di n per il fattoriale di n-1. Del resto è facile capirne la dimostrazione. Il fattoriale di 6, ad esempio, si trova prendendo il fattoriale di 5 ( 5 ⋅ 4 ⋅ 3 ⋅ 2 ⋅ 1 ) e moltiplicandogli appunto 6. Con una definizione del genere è facile scrivere la procedura ricorsivo corrispondente. [C/C++] Fattoriale ricorsivo long fattoriale_ricorsivo (unsigned short int n) { 9 if (n == 0) return 1; return n * fattoriale_ricorsivo (n – 1); } Se la funzione è richiamata ad esempio con n = 5, avremo fattoriale_ricorsivo (5) 5 * fattoriale_ricorsivo (4); 4 * fattoriale_ricorsivo (3); 3 * fattoriale_ricorsivo (2); 2 * fattoriale_ricorsivo (1); 1 * fattoriale_ricorsivo (0); 1 1 * 1 = 1 2 * 1 = 2 3 * 2 = 6 4 * 6 = 24 5 * 24 = 120 120 Come si è visto nell'esempio, la ricorsione termina in modo corretto e il risultato ottenuto è quello previsto. Tuttavia, per una funzione del genere, l'utilizzo della ricorsione pare decisamente inutile e, anzi, decisamente dannoso per le prestazioni. Osserviamo infatti che: 1. La funzione ha una variabile globale di tipo long (generalmente 4 byte), che viene ricreata ad ogni chiamata ricorsiva 2. La funzione è chiamata un numero di volte pari a n più la chiamata di partenza (n+1 volte totali). Ogni volta il sistema dovrà memorizzare il punto di ritorno, andando così ad utilizzare lo stack. 3. La chiarezza dell'algoritmo non è tanto maggiore rispetto alla versione iterativa E' chiaro che, quando si verifica una situazione del genere, è bene restare nel campo dell'iterazione. Tecniche di ricorsione Abbiamo visto un esempio di procedura ricorsiva, dimostrando "brutalmente" il suo funzionamento. Adesso, tuttavia, è bene riorganizzare il tutto e descrivere i punti fondamentali della ricorsione, da seguire sempre quando si scrive una procedura ricorsiva: 1. La procedura deve risolvere manualmente almeno un caso base. 2. La procedura deve essere chiamata ricorsivamente con parametri che convergono sempre di più verso il caso base. In generale, la procedura deve essere sempre richiamata con parametri sempre più piccoli. 3. Deve essere possibile la convergenza insiemistica. Se si verificano queste tre condizioni, siamo sicuri che la nostra procedura ricorsiva terminerà. 10 Nel caso del fattoriale, il caso base era 0! e la procedura era richiamata sempre con parametri più vicini al caso base, in particolare sempre più piccoli. Il terzo punto merita un esempio apposito. Supponiamo di avere una funzione f(x) definita come segue: ⎧2 + f ( x / 2) f ( x) = ⎨ x ∈ N − {0} ⎩ f (1) = 1 La funzione gestisce un caso base e la ricorsione è chiamata con parametri sempre più convergenti verso il caso base. Tuttavia, se x è dispari, la divisione per due non porterà mai al caso base 1. Infatti, dividendo un numero dispari per 2 avremo sempre un numero dispari. Il più piccolo numero dispari naturale è 1, che, se diviso ancora per 2, porta al risultato 0. Se la funzione è richiamata con parametro 0, allora si entra in un loop infinito. I problemi di questa funzione sono quindi che il caso base gestito non è l'unico possibile e che gli insiemi non convergono. Come verificato, quindi, una procedura che non soddisfi esattamente tutti e tre i punti sopra elencati, di sicuro non terminerà. E' bene prestare attenzione particolare alla fase di progetto di un algoritmo ricorsivo, analizzando tutti i possibili casi di ingresso. 11 4. Algoritmi di base Stampa di un albero binario L a visita di un albero binario è l'algoritmo più semplice da implementare, si tratta infatti di applicare le definizioni ricorsive prima fornite alla lettera e di trascriverle in linguaggio di programmazione. Per prima cosa ci soffermeremo su un algoritmo di base, che si occupa di stampare a video tutti i nodi di un albero, utilizzando uno dei tre metodi di attraversamento. void stampa_preorder(albin); void stampa_inorder(albin); void stampa_postorder(albin); Per la stampa utilizzeremo la funzione printf(), ma è chiaro che in C++ è sufficiente utilizzare l'oggetto di output a schermo, cout. [C/C++] Stampa di un albero binario void stampa_preorder(albin root) { // Stampa in preorder if (!test_vuoto(root)) { printf ("%i ",dato(root)); stampa_preorder(f_sinistro(root)); stampa_preorder(f_destro(root)); } } void stampa_inorder(albin root) { // Stampa in inorder if (!test_vuoto(root)) { stampa_inorder(f_sinistro(root)); printf ("%i ",dato(root)); stampa_inorder(f_destro(root)); } } void stampa_postorder(albin root) { // Stampa in postorder if (!test_vuoto(root)) { stampa_postorder(f_sinistro(root)); stampa_postorder(f_destro(root)); printf ("%i ",dato(root)); } } Come si nota, per ottenere le tre diverse visite, è sufficiente scambiare l'ordine delle tre istruzioni visita-attraversa-attraversa. 12 Visita generica di un albero binario che vogliamo ottenere adesso è visitare l'albero binario, Quello quindi come abbiamo fatto nel paragrafo precedente, ma non per stampare semplicemente i campi informativi. Vogliamo adesso che anche l'azione da eseguire sul nodo visitato possa essere specificata come parametro della funzione. Si tratta soltanto di passare un puntatore a funzione che definisca cosa fare con il dato del nodo. Possiamo per prima cosa definire un puntatore generico ad una funzione, come segue: typedef int (*funz)(int); Abbiamo definito il tipo di puntatore con il nome funz. La funzione accetterà un parametro intero e restituirà un intero. Ecco adesso come possono essere trasformate le visite utilizzando i puntatori a funzione. [C/C++] Visita generica di un albero binario void visita_preorder(albin root, funz f) { // Visita in preorder if (!test_vuoto(root)) { imposta_dato(root,f(dato(root))); visita_preorder(f_sinistro(root),f); visita_preorder(f_destro(root),f); } } void visita_inorder(albin root, funz f) { // Visita in inorder if (!test_vuoto(root)) { visita_inorder(f_sinistro(root),f); imposta_dato(root,f(dato(root))); visita_inorder(f_destro(root),f); } } void visita_postorder(albin root, funz f) { // Visita in postorder if (!test_vuoto(root)) { visita_postorder(f_sinistro(root),f); visita_postorder(f_destro(root),f); imposta_dato(root,f(dato(root))); } } Il codice risultante è decisamente meno comprensibile, ma sicuramente è adattabile a situazioni differenti. Con il puntatore a funzione, possiamo infatti fare qualsiasi cosa, dal cambiare il valore del nodo, a scriverlo su file e così via. Da notare che il risultato di 13 f(x) viene salvato come nuovo valore del nodo, quindi la funzione dovrà provvedere a restituire un valore sensato al termine della propria esecuzione. Supponiamo di voler decrementare di una unità tutti i nodi di un albero: sarà sufficiente utilizzare un qualsiasi attraversamento, passando come puntatore una funzione che, ricevuto un intero x, lo restituisce decrementato. Molti altri algoritmi potrebbero essere riadattati utilizzando questo puntatore a funzione, ma visto che i puntatori a funzione non sono un concetto noto a tutti continueremo a presentare tutti gli algoritmi in maniera classica, senza ricorrere alle versioni con puntatore a funzione. Creazione dell'albero binario E sistono diversi modi per creare un albero binario: il primo è quello di creare un albero binario di ricerca, in cui gli inserimenti sono semplici e immediati da fare, l'altra prevede invece la lettura di tutti i nodi dall'utente, supponendo di avere un carattere particolare per indicare un nodo vuoto. Partiamo con la creazione del BST. Per creare un BST sarà sufficiente scorrere l'albero, procedendo a destra o a sinistra a seconda che l'elemento da inserire sia maggiore o minore di quello attualmente in esame. Il codice dell'inserimento è il seguente. [C/C++] Inserimento in un BST albin creaBST (albin root, int x) { // Crea l'albero binario di ricerca if (test_vuoto(root)) { // Creo il nodo root = new nodo; imposta_dato(root,x); imposta_sinistro(root,albero_vuoto()); imposta_destro(root,albero_vuoto()); } else if (x < dato(root)) // Inserisco a sinistra imposta_sinistro (root,creaBST(f_sinistro(root),x)); else if (x > root->dato) // Inserisco a destra imposta_destro(root,creaBST(f_destro(root),x)); else // Errore, elemento duplicato return NULL; // Restituisco il nodo return root; } La funzione riceve in ingresso la radice del BST e un numero da inserirvi, quindi provvede al giusto posizionamento. 14 A questo punto, per creare un BST sarà sufficiente leggere i valori da inserire e utilizzare questa funzione per inserirli in un albero inizialmente vuoto. E' chiaro che la forma del BST sarà alterata dall'ordine in cui sono inseriti i numeri. L'unica cosa di cui siamo sicuri è che, leggendo il BST in ordine simmetrico, avremo sempre i dati ordinati in modo crescente. [C/C++] Programma di prova per creazione di BST #include <iostream> #include <cstdlib> #ifndef EXIT_SUCCESS #define EXIT_SUCCESS 0 #endif #ifndef EXIT_FAILURE #define EXIT_FAILURE 1 #endif struct nodo { // Campi informativi int dato; // Puntatori ai prossimi nodi nodo* sin; nodo* des; }; // Definizione del tipo puntatore typedef nodo* albin; // Definizione di puntatore a funzione che riceve un parametro intero typedef int (*funz)(int); // Funzioni di base bool test_vuoto(albin); albin f_sinistro(albin); albin f_destro(albin); int dato(albin); albin albero_vuoto(); // Modifica dei figli sinistro e destro bool imposta_sinistro(albin,albin); bool imposta_destro(albin,albin); void imposta_radice(albin*,albin); // Imposta il dato bool imposta_dato(albin,int); // Attraversanenti void stampa_preorder(albin); void stampa_inorder(albin); void stampa_postorder(albin); // Creazione dell'albero albin creaBST(albin,int); int main() { // Creo l'albero vuoto albin roo; 15 imposta_radice (&root, albero_vuoto()); // Dato da leggere int n; // Leggo il BST do { cout << "Valore da inserire (0 per terminare): "; scanf("%i",&n); if (n != 0) imposta_radice (&root, creaBST(root,n)); } while (n != 0); // Stampa anticipata printf ("Preorder: "); stampa_preorder(root); printf ("\r\n"); // Stampa simmetrica printf ("Inorder: "); stampa_inorder(root); printf ("\r\n"); // Stampa posticipata printf ("Postoridine: "); stampa_postorder(root); printf ("\r\n"); // Fine del programma system ("pause"); return EXIT_SUCCESS; } Il metodo utilizzato per uscire dal ciclo è un valore sentinella, ma sarebbe stato possibile utilizzare anche un altro metodo: chiedere il numero di nodi da inserire, oppure chiedere se si desidera inserire un nuovo nodo alla fine di ogni iterazione. Questo metodo di lettura è comodo e veloce, però non permette di "disegnare" l'albero come uno vorrebbe, stabilendo per ogni nodo i propri figli. A questo punto sorge la necessità di leggere l'albero nel secondo modo presentato. Pensando all'algoritmo in modo ricorsivo sarà tutto più facile 1. Leggo una stringa a. Se la stringa è un "." allora inserisco nel nodo corrente NULL b. Altrimenti inserisco l'informazione nel nodo corrente e analizzo il sottoalbero sinistro e destro. A questo punto la funzione non dovrebbe risultare troppo complessa da scrivere. E' chiaro che questo tipo di algoritmo simula una lettura in preorder. Del resto, come abbiamo già visto, per realizzare altri tipi di lettura, non sarà necessario altro che l'inversione dell'ordine delle istruzioni. 16 [C/C++] Creazione di un albero binario albin creaAB (albin root) { // Crea l'albero leggendo i dati in ordine anticipato // (. = NULL) char str[11]; scanf("%s",str); if (str[0] == '.') { imposta_radice (&root, albero_vuoto()); } else { int num = atoi(str); imposta_radice (&root, new nodo); imposta_dato (root,num); imposta_sinistro(root,creaAB(f_sinistro(root))); imposta_destro(root,creaAB(f_destro(root))); } return root; } Notare che questa funzione non necessita del supporto di alcun ciclo: per la corretta lettura dell'albero, saranno sufficienti le istruzioni. // Creo l'albero vuoto albin root; imposta_radice (&root, albero_vuoto()); // Lettura albero binario imposta_radice (&root, creaAB(root)); Questo metodo richiede più tempo e attenzione, visto che per ogni foglia è necessario inserire due volte il carattere '.', scelto come sentinella, ma almeno permette un controllo totale sul design dell'albero. Se, nel complesso, inseriamo la sequenza 5 4 3 . . 5 . . 6 . 9 7 . . 2 . . avremo l'equivalente dell'albero 5 Figura 3: Albero binario generabile dalla stringa 5 4 3 . . 5 . . 6 . 9 7 . . 2 4 3 . . 6 5 9 7 2 17 Calcolo dei parametri dell'albero A bbiamo visto come creare e attraversare un albero, adesso vedremo come calcolarne i parametri, quali altezza, numero di nodi, numero di foglie ecc. Il primo problema che ci poniamo è calcolare il numero totale dei nodi presenti nell'albero: ragionando ricorsivamente, possiamo dire che il numero di nodi è uguale a 1 (radice) sommato al numero di nodi del sottoalbero sinistro e a quello del sottoalbero destro, a patto ovviamente che la radice non sia un insieme vuoto. [C/C++] Conteggio dei nodi int contaNodi (albin root) { // Conta i nodi if (!test_vuoto(root)) return 1 + contaNodi(f_sinistro(root)) + contaNodi(f_destro(root)); return 0; } Il secondo problema è quello di contare le foglie, ossia i nodi che non hanno figli: se il nodo in esame è una foglia, restituiamo 1, altrimenti sommiamo il numero di foglie del sottoalbero sinistro e quelle del sottoalbero destro. [C/C++] Conteggio delle foglie int contaFoglie (albin root) { // Conta le foglie if (test_vuoto(root)) { // Albero vuoto return 0; } if (test_vuoto(f_sinistro(root)) && test_vuoto(f_destro(root))) // Il nodo attuale è una foglia return 1; return contaFoglie(f_sinistro(root)) + contaFoglie(f_destro(root)); } E' chiaro che, se volessimo calcolare il numero di nodi interni, avremo almeno due strade: 1. Fare la differenza tra il numero totale di nodi e il numero di foglie 2. Calcolare il numero di nodi interni utilizzando lo stesso metodo utilizzato per le foglie ma restituendo 1 non quando viene trovata una foglia, ma quando non viene trovata. Il prossimo problema che vogliamo risolvere è quello di calcolare la profondità di un albero binario: come già detto, il processo ricorsivo è abbastanza intuitivo. Se l'albero non è vuoto, l'altezza è data dal 18 massimo livello dei due sottoalberi incrementato di 1. Se l'albero è vuoto, l'altezza è -1. Se la radice è una foglia, l'altezza è 0. [C/C++] Calcolo della profondità int altezza (albin root) { // Altezza dell'albero (RADICE => 0) if (test_vuoto(root)) return -1; int ls = altezza(f_sinistro(root)); int ld = altezza(f_destro(root)); return ls > ld ? 1 + ls : 1 + ld; } Stampe dei livelli A bbiamo visto come stampare gli alberi utilizzando uno qualsiasi dei metodi di attraversamento. Adesso ci poniamo un obiettivo leggermente superiore: per ogni nodo, oltre al campo informativo, vogliamo stampare anche il livello. Il metodo più semplice è modificare leggermente le funzioni di stampa aggiungendo una variabile static che viene modificata in modo da contenere sempre il livello esatto. Se non si vuole usare una variabile static, sarà sufficiente aggiungere alla funzione un nuovo parametro, di tipo intero, passato sempre per indirizzo. E' chiaro che, utilizzando il valore del livello, possiamo realizzare anche l'effetto di "stampa indentata" dell'albero. L'esempio più semplice è quello di stampa anticipata. [C/C++] Stampa anticipata con livelli void stampa_liv_preorder (albin root) { static int l = 0; if (!test_vuoto(root)) { for (int i = 0; i < l; i++) printf(" "); printf ("[L%i] %i\r\n",l,dato(root)); l++; stampa_liv_preorder(f_sinistro(root)); stampa_liv_preorder(f_destro(root)); l--; } } 19