Sei sulla pagina 1di 19

Alberi binari

Introduzione

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. Teoria degli alberi binari


Definizioni

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

E
F

G
I

H
L

Nell'albero mostrato in Figura 1, la radice il nodo A, da cui si


diramano altri due sottoalberi binari.

Terminologia

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

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

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

15
9

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

2. Implementazione di un albero binario


Struttura di un albero binario

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

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.

3. Teoria della ricorsione


Perch la ricorsione

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

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

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

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

Figura 3: Albero binario generabile dalla stringa

5 4 3 . . 5 . . 6 . 9 7 . . 2

4
3

. .

6
5

9
7

17

Calcolo dei parametri dell'albero

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

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