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