Upload
phungdan
View
213
Download
0
Embed Size (px)
Citation preview
����������������� ���
�
3. STRUTTURE DATI
Ai due livelli di analisi e programmazione corrispondono anche due modalità di rap-
presentare i dati del problema.
Durante l'analisi, il problema viene affrontato e risolto mediante un algoritmo che pre-
scinde dalla natura dell'elaboratore e che agisce sui dati del problema. Tali dati do-
vranno essere rappresentati in una struttura, detta struttura astratta perché indipen-
dente dall'elaboratore, che possa essere trattata dall'algoritmo nel modo più efficiente
possibile. Le strutture astratte sono rappresentazioni dei dati di un problema che ri-
specchiano le proprietà dei dati e le relazioni usate nella stesura dell'algoritmo risolu-
tivo. In generale si parla di tipo di dati riferendosi all'insieme costituito da una struttu-
ra e dalle operazioni definite su di essa.
Durante la fase di programmazione, l'algoritmo viene tradotto, tramite un opportuno
linguaggio di programmazione, in una forma eseguibile dalla macchina, e così anche
le strutture astratte dei dati dovranno essere trasformate in strutture interne, rappre-
sentabili nella memoria della macchina utilizzata.
Per ogni tipo di dati astratto è utile vedere quale struttura interna si presta meglio alla
sua rappresentazione. Difficilmente la trasposizione di una struttura astratta in una
struttura interna permette di eseguire tutte le operazioni in modo efficiente, perciò si
tratta di scegliere, di volta in volta, quella che globalmente presenta il comportamento
migliore oppure quella che risulta migliore per le operazioni più frequenti.
bit
byte
parola
indirizzo
puntatore
elementari
vettore
matrice
stringa
record
sequenziali
catena
anello
plesso
concatenate
composte
strutture interne
pila
coda
doppia coda
liste lineari
alberi
grafi
non lineari
strutture astratte
strutture informative
����������������� ���
��
Se chiamiamo elementi di una struttura informativa le parti indivisibili di cui essa è
costituita, si possono distinguere strutture elementari e strutture composte, cioè quelle
i cui elementi sono a loro volta composti da strutture più semplici. Nel seguito ver-
ranno descritte le strutture interne più comuni e poi alcune strutture astratte assieme a
loro possibili rappresentazioni tramite strutture interne.
STRUTTURE INTERNE
Strutture elementari
Le strutture interne elementari rispecchiano la struttura fisica dell'elaboratore. Perciò,
a livello più basso avremo i bit che rappresentano le cifre binarie. Sequenze ordinate
di 8 bit costituiscono un byte: a ciascun byte è associato un indirizzo che ne individua
la posizione all'interno della memoria. Due byte consecutivi costituiscono una parola,
mentre 4 byte consecutivi costituiscono la parola doppia1.
L'indirizzo ha come supporto la parola o la parola doppia e assume tutti i valori com-
presi tra zero e l'indirizzo più grande disponibile in memoria. Strettamente legato a
questo tipo è il tipo puntatore che può essere sia un indirizzo (assoluto o relativo) che
un simbolo in corrispondenza biunivoca con un indirizzo.
A partire da queste strutture elementari si costruiscono tutte le strutture interne che
possono essere sequenziali o concatenate.
Strutture sequenziali
Le strutture sequenziali sono le più semplici e rispecchiano fedelmente l'organizza-
zione della memoria dell'elaboratore in quanto sono costituite da elementi fisicamente
adiacenti. Chiameremo lunghezza di una struttura sequenziale il numero di elementi di
cui essa è composta e occupazione il numero di byte necessari alla sua memorizzazio-
ne.
La struttura sequenziale più semplice è il vettore. Esso è definito da:
1. un indirizzo di base B della prima posizione a partire dalla quale è memorizzato;
2. il numero massimo n di elementi;
3. il tipo dei suoi elementi.
1 Questo è sicuramente vero nelle macchine di qualche anno fa (Intel 386) mentre nelle più recenti tal-volta si denotano con parola e parola doppia dei multipli maggiori di byte.
����������������� ���
��
Il j-esimo elemento di un vettore V, indicato con V[j] per j = 0, ..., n-1, può essere
raggiunto per accesso diretto nel seguente modo: se ciascun elemento occupa un nu-
mero intero d di byte, V[j] è memorizzato a partire dal byte di indirizzo jdB + .
Il vettore è una struttura interna molto rigida perché richiede che la sua lunghezza,
cioè il massimo numero di elementi, sia fissata a priori. Inoltre le operazioni di inse-
rimento e cancellazione di un elemento richiedono un costo (inteso come numero di
spostamenti) proporzionale alla sua occupazione. Se V ha lunghezza n e contiene m<n
dati già memorizzati (nelle posizioni di indice 0, 1,...,m-1), per inserire un dato w nel-
la posizione j-esima, mj ≤≤0 , è necessario spostare a destra di una posizione tutti
gli elementi che occupano le posizioni dalla j-esima alla (m-1)-esima. Tali spostamen-
ti devono essere effettuati da destra verso sinistra per evitare sovrapposizioni, con un
ciclo di questo tipo:
for (int i = m-1; i >= j; i--) V[i+1]=V[i]; V[j]=w; m=m+1;
Poiché è possibile effettuare m+1 diversi inserimenti (nella posizione 0, nella posi-
zione 1, ... , nella posizione m) a cui corrispondono rispettivamente m, m-1, ... , 0 spo-
stamenti, in media si ha un costo di 22
)1(
1
1
1
1
0
mmm
mi
m
m
i
=++
=+ �
=
spostamenti.
La cancellazione di un elemento, se non si vuol lasciare vuota la posizione (j) dell'e-
lemento cancellato, richiede lo stesso numero di spostamenti, che però avvengono
questa volta da sinistra verso destra:
for (int i=j; i<=m-2; i++) V[i]=V[i+1]; m=m-1;
Anche la matrice è una struttura sequenziale e può essere facilmente ricondotta ad un
vettore. Una matrice mn × è un insieme di mn × elementi (dello stesso tipo) che ven-
gono individuati tramite due indici i e j, con 10 −≤≤ ni e 10 −≤≤ mj . Chiamere-
mo riga i-esima della matrice M, l'insieme ordinato { }10],[ −≤≤ mkkiM e, analo-
gamente, colonna j-esima l'insieme ordinato { }10],[ −≤≤ nkjkM . Una matrice può
essere trasformata in un vettore tramite un processo di linearizzazione che può avveni-
re per righe (memorizzando la prima riga, poi la seconda, e così via) oppure per co-
lonne. Con la linearizzazione per righe, l'elemento ],[ jiM verrà ad occupare la posi-
����������������� ���
��
zione jim + del vettore, mentre con la linearizzazione per colonne occuperà la posi-
zione ijn + .
Un'altra struttura sequenziale è la stringa: in generale i suoi elementi occupano uno o
due byte2 e rappresentano caratteri, ad eccezione del primo elemento che contiene un
numero intero n detto lunghezza della stringa e che indica il numero di caratteri di cui
è composta la stringa. Tale primo elemento in genere ha la stessa occupazione del
singolo carattere e quindi in totale una stringa occupa n+1 byte (ad esempio con una
codifica ASCII) o 2(n+1) byte (ad esempio con una codifica Unicode).
Il record è una struttura sequenziale costituita da elementi di vario tipo detti campi.
La sua occupazione è data dalla somma delle occupazioni dei suoi campi.
Strutture concatenate
Nelle strutture concatenate la sequenzialità degli elementi non è fisica ma logica (in-
fatti si parla di contiguità logica degli elementi): tali strutture si dimostrano partico-
larmente efficienti riguardo alle operazioni di inserimento e cancellazione.
La struttura concatenata più semplice è la catena: essa è costituita da
1. un puntatore testa della catena;
2. una successione di elementi di tipo record contenenti almeno due campi: un cam-
po chiave e un campo puntatore all'elemento successivo della catena.
Il puntatore testa consente di accedere al primo elemento della catena, da cui tramite il
suo campo puntatore si può accedere al secondo, e così via ai successivi. Il puntatore
dell'ultimo elemento sarà un puntatore vuoto che normalmente si indica con ∅ . La
ricerca di un elemento in una catena può essere solo sequenziale. Vediamo invece
come si effettuano le operazioni di inserimento e cancellazione di un elemento. Sup-
poniamo di voler inserire un nuovo elemento H nella catena K tra gli elementi K(j) e
K(j+1): per fare questa operazione è necessario allocare una nuova posizione di me-
moria per l'elemento H, modificare il puntatore di K(j) facendolo puntare ad H e far
puntare il puntatore di H a K(j+1).
2 La codifica dei caratteri con il codice ASCII utilizza un byte per carattere ma non viene usata da tutti i linguaggi: in Java, ad esempio, viene utilizzata la codifica Unicode che necessita di due byte per cia-scun carattere.
����������������� ���
��
Per cancellare l'elemento K(j) è invece sufficiente modificare il puntatore di K(j-1) fa-
cendolo puntare all'elemento K(j+1).
In questo modo la posizione K(j) rimane inalterata ma non è più accessibile dalla ca-
tena. In seguito a operazioni di cancellazione l'occupazione dinamica della catena di-
venta estremamente inefficiente perché le posizioni di memoria contenenti dati can-
cellati dalla catena rimangono inutilizzate. Per ottenere un comportamento più effi-
ciente è necessario utilizzare tecniche di garbage-collection. In ogni caso il costo del-
le operazioni di inserimento e di cancellazione è costante, cioè non dipende dal nume-
ro di elementi presenti nella catena. Valutando il costo delle operazioni di inserimen-
to, cancellazione, ricerca per posizione e ricerca per contenuto nelle catene e nei vet-
tori, si hanno i seguenti valori:
vettore catena
Inserimento O(m) O(1)
Cancellazione O(m) O(1)
Ricerca per posizione O(1) O(m)
Ricerca per contenuto O(m) O(m)
dove m è il numero di elementi effettivamente presenti nella struttura. Bisogna però
osservare che nelle catene sia l'inserimento che la cancellazione richiedono una pre-
ventiva ricerca (sequenziale) della posizione in cui inserire o dell'elemento da cancel-
lare.
Un’altra struttura concatenata è l’anello, ovvero una catena in cui il puntatore dell’ul-
timo elemento invece di essere nullo è posto ad indicare il primo elemento. Questo
rende la struttura circolare e consente di proseguire a scorrere la struttura dal punto in
cui ci si trova senza dover ripartire tutte le volte dall'inizio.
K(j) K(j+1)
H
K(j-1) K(j) K(j+1)
����������������� ���
��
La catena doppia o bidirezionale presenta invece due puntatori per ciascun elemento:
uno all'elemento successivo ed uno al precedente, ed è dotata di due puntatori esterni,
uno alla testa ed uno alla coda: in questo modo può essere percorsa nei due sensi.
Una struttura concatenata più complessa è il plesso: ogni suo elemento è un record la
cui struttura può variare da elemento ad elemento. Ciascun record del plesso deve
contenere vari tipi di informazione per gestire tutta la struttura:
1. il formato (informazioni sui campi del record quali il loro numero, la loro lun-
ghezza e dunque anche la lunghezza del formato);
2. i dati veri e propri;
3. i puntatori agli altri elementi del plesso.
Ovviamente è necessario un puntatore esterno al primo elemento del plesso.
STRUTTURE ASTRATTE Spesso l'insieme dei dati di un problema può essere considerato come un insieme or-
dinato di oggetti, perciò vengono studiate strutture rivolte alla rappresentazione di in-
siemi. Come strutture astratte elementari si considerano i numeri, i caratteri e le strin-
ghe. La più semplice struttura astratta composta è la lista lineare: essa è un insieme
ordinato di n oggetti ),,( 1 nxx � , dove n è detto lunghezza della lista. A seconda che
il numero dei suoi oggetti sia costante o meno nel tempo, viene detta a lunghezza fissa
o variabile. Le operazioni eseguibili su una lista sono di due tipi: a carattere locale, se
agiscono su un solo elemento della lista (come, ad esempio, l'inserimento, la cancella-
zione, la ricerca di un elemento, la modifica o l'accesso ad un elemento) o a carattere
globale se coinvolgono l'intera lista (come, ad esempio, l'unione di due o più liste, la
separazione di una lista in sottoliste, la ricerca di tutti gli elementi di un certo tipo,
l'ordinamento secondo un criterio diverso da quello iniziale, ...). Una lista lineare può
essere memorizzata semplicemente sia in un vettore sia in una catena; se è a lunghez-
za variabile, la sua memorizzazione in un vettore richiede che sia possibile prevedere
una lunghezza massima con cui inizializzare il vettore stesso. La scelta dell'una o del-
l'altra struttura interna dipenderà dal tipo di operazioni che si prevede di effettuare
maggiormente sui dati. Liste lineari con particolari vincoli di accesso prendono nomi
specifici.
PILA Una pila (stack) è una lista lineare a lunghezza variabile in cui inserimenti ed estra-
zioni vengono effettuate ad un solo estremo, detto testa (top) della pila. Questa strut-
����������������� ���
��
tura realizza il principio last-in-first-out (LIFO) poiché l'ultimo elemento ad essere
stato inserito è anche il primo ad essere estratto, esattamente come avviene in una pila
di piatti (da questa analogia deriva appunto il suo nome). Come struttura dati astratta,
sulla pila sono definite le due operazioni fondamentali di inserimento (push) di un e-
lemento x al top della pila S ( xS ⇐ ) e di estrazione (pop) dell'elemento x che si trova
al top della pila S ( xS � ). Le pile sono usate in moltissime applicazioni, ad esempio
dai browser per memorizzare l’elenco dei siti visitati in modo da consentire il ritorno
indietro, negli editor di testo per memorizzare le operazioni eseguite in modo da per-
mettere di eseguire l’operazione di “undo”, ecc.
MEMORIZZAZIONE DELLE PILE A questo punto si pone il problema di decidere quale struttura interna far corrisponde-
re alla descrizione della struttura astratta. Vediamo, per esempio, l'implementazione di
una pila tramite un vettore. Si pongono immediatamente due problemi:
1. poiché la dimensione del vettore deve essere stabilita al momento della sua crea-
zione, è necessario stabilire una dimensione massima N per la pila: se poniamo, ad
esempio, nella nostra implementazione N=1000, avremo un vettore S di 1000 e-
lementi.
2. Dobbiamo decidere come individuare l'elemento top: se inseriamo gli elementi
della pila nel vettore da sinistra verso destra, l'elemento top sarà posto nella posi-
zione non vuota più a destra. Perciò utilizzeremo una variabile intera top che
contiene l'indice di tale posizione.
S 0 1 2 ... top N-1
Le due operazioni di inserimento ed estrazione di un elemento x in una pila S devono
tener conto dei casi di overflow (che si verifica quando si vogliono inserire più di N
elementi nel vettore) e di underflow (quando si vuole estrarre un elemento da un vet-
tore vuoto). Mentre il primo costituisce un vero e proprio errore, rimediabile solo con
una nuova inizializzazione del vettore, il secondo può costituire una comune condi-
zione di arresto per algoritmi in cui si richiede di esaminare tutti i dati. Le due opera-
zioni possono così essere schematizzate, facendo l'ipotesi che il valore top=-1 indichi
la pila vuota:
����������������� ���
��
xS ⇐ top = top + 1; if (top >= N) overflow; else S[top]=x;
xS � if (top == -1) underflow; else
{ x=S[top]; top=top-1; }
Questa implementazione di una pila è certamente la più semplice e risulta anche mol-
to efficiente: tutte le operazioni vengono eseguite in tempo costante. L'occupazione di
spazio è invece proporzionale ad N, dove N è la dimensione del vettore determinata
quando la pila viene creata. Questo implica che lo spazio occupato è indipendente dal
numero effettivo Nn ≤ di elementi presenti nella pila. Ma l'aspetto sicuramente più
negativo è legato alla necessità di dover prefissare il numero massimo di elementi che
è possibile inserire nella pila: se questo valore è stabilito troppo grande si ha un inutile
spreco di memoria, ma d'altra parte, se è fissato troppo piccolo, si viene a generare un
errore non appena si tenta di inserire l'N+1-esimo elemento nella pila. Perciò questa
implementazione, grazie alla sua semplicità ed efficienza, viene utilizzata nei casi in
cui è possibile avere una buona stima sul numero di elementi che verranno inseriti
nella pila. Altrimenti è preferibile sfruttare implementazioni alternative, come ad e-
sempio, quella basata su strutture concatenate. Utilizzando una catena semplice si farà
coincidere il primo elemento della catena con la testa della pila in modo da poter ef-
fettuare le operazioni di inserimento e di estrazione con costo costante. Questa im-
plementazione non ha solo la proprietà di eseguire in tempo costante, ovvero indipen-
dente dal numero n di elementi presenti nella pila, le operazioni di push e di pop ma
garantisce anche un’occupazione di spazio proporzionale ad n. Inoltre, rispetto all'im-
plementazione basata sui vettori, questa ha l'importante vantaggio di non richiedere
una limitazione sul numero di elementi inseribili nella pila.
CODA Una coda è una lista lineare a lunghezza variabile in cui l'inserimento viene effettuato
ad un estremo (fondo o rear) e l'estrazione all'altro estremo (testa o front). La coda è
una struttura che realizza il principio first-in-first-out (FIFO) perché gli oggetti posso-
no essere estratti solo nell'ordine in cui sono stati inseriti, così come avviene in una
coda di persone davanti ad uno sportello. Come tipo di dato astratto, sulla coda opera-
����������������� ���
��
no le due funzioni fondamentali di inserimento (enqueue o put) di un elemento x in
fondo alla coda Q ( xQ ⇐ ) e di estrazione (dequeue o get) dell'elemento x in testa
alla coda Q ( xQ � ).
MEMORIZZAZIONE DELLE CODE Come abbiamo già visto per le pile, anche una coda può essere facilmente implemen-
tata tramite un vettore. Nuovamente si pone la necessità di stabilire a priori la dimen-
sione del vettore utilizzato: stabiliamo di usare un vettore Q di dimensione N=1000,
in cui gli elementi vengono inseriti nello stesso ordine in cui compaiono, e di utilizza-
re due interi F ed R per indicare le posizioni del front e rear della coda. Per essere più
precisi:
• F è l'indice della posizione di Q contenente la testa della coda, a meno che la coda
non sia vuota (nel qual caso F=R);
• R è l'indice della prima posizione libera del vettore, quella in cui deve essere effet-
tuato il successivo inserimento.
Inizialmente verrà posto F=R=0, per indicare che la coda è vuota. Ogni volta che vie-
ne inserito un nuovo elemento, dovrà essere incrementato R per indicare la successiva
posizione libera; ogni volta che viene estratto un elemento, dovrà essere incrementato
F per indicare il successivo elemento testa della coda.
Q 0 1 F R N-1
Questo meccanismo però presenta ancora un problema: infatti in seguito ad inseri-
menti e cancellazioni la parte occupata dagli elementi della coda, tende a spostarsi
verso il fondo del vettore, e, quando R=N-1, non è più possibile inserire nuovi ele-
menti, anche se in seguito ad estrazioni dalla coda, la parte iniziale del vettore può
presentare molte posizioni libere. Per poter sempre sfruttare tutte le N posizioni del
vettore, conviene perciò considerare il vettore stesso come una struttura circolare, in
cui il primo elemento segue logicamente l'ultimo:
Q 0 1 R F N-1
Questa modifica ha ancora un piccolo problema: abbiamo già detto che quando la co-
da è vuota si ha F=R; consideriamo adesso il caso in cui si inseriscano N elementi
nella coda senza estrarne nessuno: alla fine avremo anche in questo caso F=R. Quindi
non si distingue il caso di coda vuota da quello di coda che occupa tutto il vettore. Ci
����������������� ���
��
sono molti modi per risolvere questo problema. La soluzione che presentiamo sacrifi-
ca una posizione del vettore, ovvero in un vettore di lunghezza N si potranno memo-
rizzare al più N-1 elementi di una coda, e richiede di incrementare subito R quando si
vuol fare un inserimento: in questo modo, se dopo l'incremento di R risulta F=R allora
vuol dire che la coda contiene già N-1 elementi e quindi si segnala la condizione di
overflow. Se invece era F=R prima dell'incremento, cioè il vettore è vuoto, dopo l'in-
cremento risulterà RF ≠ .
Le due operazioni di inserimento e di estrazione di un elemento x in una coda Q pos-
sono così essere schematizzate:
xQ ⇐
if (R==N-1) A=0; else A=R+1;
if (A==F) overflow; else
{ Q[R]=x; R=A; }
xQ �
if (F==R) underflow; else
{ X=Q[F];
if (F==N-1) F=0; else F=F+1;
}
Anche in questo caso, l'implementazione tramite liste concatenate risolve il problema
di dover stabilire a priori il numero massimo di elementi inseribili nella coda. Faremo
coincidere il front della coda (da cui avvengono le estrazioni) con la testa della lista,
mentre il rear della coda (in cui avvengono gli inserimenti) con la coda della lista. È
utile mantenere un riferimento sia alla testa che alla coda della lista con due puntatori
head e tail. Una coda può essere memorizzata anche in un anello con un puntatore
all'ultimo elemento in modo che in un solo passo si può accedere alla testa.
ALBERI Gli alberi sono una astrazione matematica che gioca un ruolo fondamentale sia nella
progettazione che nell’analisi degli algoritmi. Infatti gli alberi vengono usati in infor-
matica
• per descrivere proprietà dinamiche degli algoritmi
• come struttura dati.
����������������� ���
��
Mentre le liste lineari rappresentano strutture unidimensionali e quindi dati tra cui esi-
ste una relazione di dipendenza 1:1, gli alberi sono strutture bidimensionali e consen-
tono di rappresentare relazioni 1:n. Il concetto di albero è usato anche in applicazioni
di uso quotidiano come ad esempio l’albero genealogico, e proprio da questo uso de-
riva la maggior parte della terminologia legata agli alberi. Un altro esempio è rappre-
sentato dall’organizzazione dei file nei sistemi operativi: i file sono organizzati in
directory annidate che sono presentate all’utente sotto forma di albero.
Esistono molti tipi di alberi e, come tipo di dati astratto, possiamo citare, in ordine de-
crescente di generalità:
• alberi o alberi liberi
• alberi con radice
• alberi ordinati
• alberi m-ari e alberi binari
TERMINOLOGIA
Un albero è un insieme non vuoto di vertici e archi che soddisfano certe proprietà. Un
vertice o nodo è un oggetto a cui può essere associato un nome e che può contenere
informazioni. Un arco è una connessione tra due vertici.
Un cammino in un albero è una lista di vertici nella quale vertici adiacenti sono con-
nessi da un arco. Si definisce lunghezza del cammino il numero di archi che lo com-
pongono.
La proprietà che caratterizza un albero è la se-
guente: per ogni coppia di nodi dell’albero esiste
uno ed un solo cammino che li unisce.
Se alcune coppie di nodi possono essere unite da
più cammini o non sono unite da nessun cammi-
no, la struttura non è un albero ma un grafo. Un
insieme disgiunto di alberi viene detto foresta.
Un albero con radice è un albero in cui un nodo viene designato come radice
dell’albero: esso rappresenta una relazione tale che
1. esiste un elemento che non dipende da nessun altro;
2. ogni altro elemento dipende da uno ed un solo elemento.
����������������� ���
��
In informatica, normalmente vengono detti alberi gli alberi con radice e alberi liberi
quelli più generali. Benché la definizione di albero non implichi alcuna direzione su-
gli archi, negli alberi con radice si pensano gli archi diretti in modo da allontanarsi da
essa. La radice viene poi rappresentata in alto e si dice che un nodo y è al di sotto di
un nodo x se x si trova sull’unico cammino che da y porta alla radice.
In un albero con radice ogni nodo è radice di un sottoalbero costituito dal nodo stesso
e dai nodi sotto di esso.
Ciascun nodo, eccetto la radice, ha esattamente un nodo al di sopra che viene chiama-
to padre, mentre i nodi al di sotto sono detti figli. I nodi senza figli sono detti foglie o
nodi terminali. Talvolta i nodi terminali sono caratterizzati in modo diverso da quelli
non terminali: in tali situazioni i nodi terminali vengono detti esterni mentre i non
terminali sono detti interni.
In certe applicazioni può essere significativo l’ordine con cui compaiono i figli di cia-
scun nodo: chiameremo perciò albero ordinato un albero con radice in cui l’ordine
dei figli di ciascun nodo è specificato. Quando si disegna un albero, implicitamente si
dà un ordine ai nodi. Lo stesso avviene quando si rappresenta un albero in un compu-
ter. Si può osservare che, in generale, diversi alberi ordinati possono corrispondere ad
uno stesso albero con radice e che diversi alberi con radice possono corrispondere ad
uno stesso albero libero. Nella figura seguente sono riportati i 14 alberi ordinati con 5
nodi. I 9 rettangoli grigi racchiudono gli alberi ordinati che corrispondono allo stesso
albero con radice, e i 3 riquadri con i margini scuri contengono gli alberi ordinati che
corrispondono allo stesso albero libero.
Se ciascun nodo ha uno specifico numero m di figli in uno specifico ordine, si parla di
albero m-ario. In questo caso, spesso, si aggiungono speciali nodi esterni fittizi senza
figli a cui possono fare riferimento quei nodi che non hanno il previsto numero m di
figli. Il più semplice tipo di albero m-ario è l’albero binario. Un albero binario è un
����������������� ���
��
albero ordinato costituito da due tipi di nodi: nodi esterni, che non hanno figli, e nodi
interni con esattamente due figli, ai quali, essendo ordinati, si fa riferimento come fi-
glio sinistro e figlio destro (ciascuno dei quali può essere un nodo esterno). Una foglia
in un albero m-ario, è un nodo interno i cui figli sono tutti esterni.
DEFINIZIONI FORMALI E MEMORIZZAZIONE DEGLI ALBERI
Definizione – Un albero binario è un nodo esterno oppure un nodo interno connesso
ad una coppia di alberi binari, che sono detti sottoalbero sinistro e sottoalbero destro
del nodo.
Ci sono molti modi per rappresentare in un
computer questo concetto astratto: ad esempio
si può usare una rappresentazione sottoforma
di lista di tre elementi (radice, sottoalbero si-
nistro, sottoalbero destro).
Con questa rappresentazione, l’albero binario
in figura, viene rappresentato dalla lista
(a,(b,null,null),(c,(d,null,null),(e,null,(f,null,null))))
Ma la rappresentazione concreta più spesso utilizzata in programmi che usano e ma-
nipolano alberi binari è una struttura concatenata che ha due puntatori per ogni nodo
interno: un puntatore sinistro che punta alla radice del sottoalbero sinistro ed un un
puntatore destro che punta alla radice del
sottoalbero destro. Puntatori nulli corri-
spondono a nodi esterni.
Questo tipo di rappresentazione interna è
particolarmente indicata quando si devo-
no realizzare operazioni sull’albero che
coinvolgono nodi in direzione “top-
down”, a partire dalla radice. Algoritmi
che invece richiedono operazioni che agiscono sui nodi in direzione “bottom-up” sono
più efficienti se si considera una struttura che ha tre puntatori, uno dei quali si riferi-
sce al nodo padre.
Definizione – Un albero m-ario è un nodo esterno oppure un nodo interno connesso
ad una sequenza ordinata di m alberi m-ari.
a
c
e d
f
b
a
b c
d e
f
����������������� ���
��
Normalmente i nodi di un albero m-ario sono rappresentati da strutture con m punta-
tori.
Definizione – Un albero ordinato è un nodo (la radice) connesso ad una sequenza or-
dinata di alberi disgiunti. Tale sequenza è detta foresta.
Dato che ogni nodo di un albero ordinato può avere un numero qualsiasi di figli e
quindi di puntatori ai nodi figli, è naturale usare una lista concatenata per memorizza-
re i figli di ciascun nodo: in particolare, ogni nodo avrà due puntatori, uno alla lista
dei suoi figli ed uno alla lista dei fratelli.
Questa rappresentazione mostra la seguente proprietà: esiste una corrispondenza biu-
nivoca tra alberi binari con n-1 nodi e alberi ordinati con n nodi.
Infatti, dato un albero ordinato, si costruisce un albero binario associando ad ogni no-
do il primo figlio come figlio sinistro ed il suo primo fratello a destra come figlio de-
stro. Ovviamente la radice dell’albero binario non avrà figlio destro dal momento che
la radice dell’albero ordinato non ha fratelli: ma se tolgo la radice dell’albero binario,
quello che ottengo è un albero binario con n-1 nodi. Da questa corrispondenza pos-
siamo anche concludere che il numero di alberi ordinati con n nodi è pari al numero di
alberi binari con n-1 nodi.
����������������� ���
��
Nella figura precedente è riportato, nella parte alta, un albero ordinato con la sua rap-
presentazione tramite liste di figli e liste di fratelli, e nella parte bassa il corrisponden-
te albero binario con la sua rappresentazione: si può osservare che le due rappresenta-
zioni sono identiche.
Definizione – Un albero con radice è un nodo (la radice) connesso ad un multinsieme
di alberi con radice.
Per rappresentare graficamente o internamente un albero con radice è necessario asso-
ciargli uno degli alberi ordinati che gli corrispondono.
Definizione – Un grafo è un insieme di nodi ed un insieme di archi che connettono
coppie distinte di nodi. Un grafo è connesso se esiste un cammino semplice (cioè in
cui nessun nodo appare due volte) che connette ogni coppia di nodi. Un cammino in
cui il nodo iniziale e finale coincidono viene detto ciclo.
Ogni albero è un grafo, ma il viceversa ovviamente non è vero. Un grafo con n nodi è
un albero se soddisfa una delle seguenti quattro condizioni:
• ha n-1 archi e non ha cicli;
• ha n-1 archi ed è connesso;
• per ogni coppia di nodi esiste esattamente un cammino che li connette;
• è connesso ma non rimane tale se si rimuove un arco.
Ciascuna di queste condizioni è necessaria e sufficiente per dimostrare le altre tre,
perciò ciascuna di esse può essere utilizzata come definizione di albero libero.
PROPRIETA’ MATEMATICHE DEGLI ALBERI BINARI
Ci soffermiamo in particolare sugli alberi binari perché saranno i più usati in seguito.
Teorema - Un albero binario con n nodi interni ha n+1 nodi esterni.
Dimostrazione - Indichiamo con r il numero di rami dell'albero e con s il numero di
nodi esterni: poiché da ciascun nodo interno escono due rami, risulta nr 2= . D'altra
parte, in ogni nodo, sia interno che esterno eccetto la radice, entra un ramo, perciò va-
le anche 1−+= snr . Dalle due uguaglianze si ricava 1+= ns . �
Il livello di un nodo è definito ricorsivamente come:
1. il livello della radice è zero;
2. il livello di ogni nodo è il livello del padre più uno.
L'altezza di un albero è invece definita come il massimo livello dei suoi nodi.
La lunghezza di un cammino è il numero di archi di cui è composto il cammino.
����������������� ���
��
La lunghezza del cammino interno di un albero binario è la somma delle lunghezze
dei cammini che collegano la radice a tutti i nodi interni, ovvero la somma dei livelli
di tutti i nodi interni dell’albero. In modo analogo, la lunghezza del cammino esterno
di un albero binario è la somma delle lunghezze dei cammini che collegano la radice a
tutti i nodi esterni, ovvero la somma dei livelli di tutti i nodi esterni dell’albero. Un
modo semplice per calcolare la lunghezza del cammino interno (esterno) in un albero
è sommare, per ogni livello k, il prodotto di k per il numero dei nodi al livello k.
Consideriamo un albero binario con n nodi interni (e dunque n+1 nodi esterni), ed in-
dichiamo con nI la lunghezza del cammino interno e con nE la lunghezza del cammi-
no esterno.
Teorema - nIE nn 2+= .
Dimostrazione - La dimostrazione avviene per induzione su n. Se 0=n , 000 == EI .
Se supponiamo vera la relazione per un albero con n nodi, costruiamo un albero con
n+1 nodi aggiungendo un nodo interno al posto di un nodo esterno che si trova al li-
vello k dell'albero con n nodi: avremo che kII nn +=+1 perché viene aggiunto un
cammino interno di lunghezza k, mentre 2)1(21 ++=++−=+ kEkkEE nnn perché
viene tolto un cammino esterno di lunghezza k ma ne vengono aggiunti due di lun-
ghezza k+1. Perciò, sottraendo membro a membro queste due ultime uguaglianze, si
ottiene: )1(222211 +=+=+−=− ++ nnIEIE nnnn .
Teorema – Sia h l’altezza di un albero binario con n nodi interni:
� � nhn ≤≤+ )1(log2
Dimostrazione – L’altezza dell’albero è massima quando l’albero degenera in una li-
sta, ovvero ogni nodo interno ha almeno un sottoalbero vuoto: in questo caso l’altezza
dell’albero è n. L’altezza dell’albero è invece minima quando gli n+1 nodi esterni si
trovano al più sui due livelli h-1 ed h dell’albero. Poiché al livello i-esimo ci sono al
massimo i2 nodi, si ha hh n 212 1 ≤+<− , e dunque, dalla seconda disuguaglianza,
passando ai logaritmi, si ha la tesi.
Teorema – Si ha 2
)1(
4log2
−≤<��
��
nnI
nn n
Dimostrazione – La lunghezza del cammino interno è massima ancora una volta nel
caso dell’albero degenere: in questo caso la lunghezza del cammino interno è
����������������� ���
��
2
)1(1...210
−=−++++ nnn . La lunghezza del cammino interno minima si ha inve-
ce in corrispondenza dell’albero di altezza minima; tale albero, per il teorema prece-
dente, ha gli n+1 nodi esterni ad altezza maggiore o uguale a
� � � � � �nnn 222 log)1(log1)1(log ≥+=−+ , perciò, � �nnEn 2log)1( +≥ . D’altra parte
� �
� �4
log4loglog4loglog)1(
2log)1(2
22222
2
nnnnnnnn
nnnnEI nn
=−>−+=
=−+≥−=
ATTRAVERSAMENTO DEGLI ALBERI
La memorizzazione e le operazioni eseguibili sugli alberi richiedono spesso un esame
degli stessi. Chiameremo visita di un nodo l'accesso al valore contenuto nel nodo e at-
traversamento di un albero, la visita sistematica di tutti i nodi dell'albero una ed una
sola volta. Per gli alberi ordinati esistono due criteri di attraversamento: attraversa-
mento anticipato che consiste nel visitare la radice e poi nell’attraversare in ordine
anticipato tutti i suoi sottoalberi (da sinistra a destra), e l'attraversamento posticipato
in cui si attraversano in ordine posticipato tutti i sottoalberi (da sinistra a destra) prima
di visitare la radice. Per gli alberi binari esiste anche l'attraversamento simmetrico che
consiste nell'attraversare in ordine simmetrico il sottoalbero sinistro, poi di visitare la
radice e quindi di attraversare in ordine simmetrico il sottoalbero destro.
Ad esempio, dato il seguente albero binario,
le tre modalità di attraversamento danno luogo alla visita dei nodi nel seguente ordine:
ANTICIPATO: E D B A C H F G
SIMMETRICO: A B C D E F G H
POSTICIPATO: A C B D G F H E
����������������� ���
��
Queste operazioni possono essere semplicemente realizzate tramite procedure ricorsi-
ve: supponiamo di avere una funzione visita(nodo) che effettua la visita di un nodo,
la funzione attraversa(radice) che effettua l’attraversamento dell’albero di cui
viene specificata la radice, e le funzioni left(nodo)e right(nodo) che restituiscono
la radice dei sottoalberi sinistro e destro di un nodo. Per un albero binario, la realiz-
zazione delle tre procedure varia solo nell’ordine in cui vengono invocate le due pro-
cedure visita e attraversa: infatti se R denota la radice dell’albero:
Attraversamento anticipato
attraversa(R) = visita(R),
attraversa(left(R)),
attraversa(right(R));
Attraversamento posticipato
attraversa(R) = attraversa(left(R)),
attraversa(right(R)),
visita(R);
Attraversamento simmetrico
attraversa(R) = attraversa(left(R)),
visita(R),
attraversa(right(R));
L’implementazione non ricorsiva delle tre procedure di attraversamento deve fare uso
esplicito di una pila. Consideriamo, per semplicità, una pila astratta che può contenere
sia nodi (nella forma node(R) dove R indica il nodo) sia alberi (nella forma tree(R)
dove R indica la radice dell’albero). La pila viene inizializzata con l’albero che deve
essere attraversato. Si inizia quindi un ciclo nel quale si fa un pop dalla pila e si pro-
cessa l’oggetto così ottenuto finché la pila non è vuota. Se l’oggetto estratto è un no-
do, questo viene visitato. Se invece l’oggetto estratto è un albero, allora devono essere
fatti una serie di inserimenti (push) nella pila il cui ordine dipende dal tipo di attraver-
samento che si desidera implementare:
attraversamento anticipato: push il sottoalbero destro,
push il sottoalbero sinistro,
push la radice;
attraversamento posticipato: push la radice,
����������������� ���
��
push il sottoalbero destro,
push il sottoalbero sinistro;
attraversamento simmetrico: push il sottoalbero destro,
push la radice,
push il sottoalbero sinistro;
Ad esempio, la procedura per l’attraversamento simmetrico diventa:
attraversa (R) = push(tree(R));
while (not empty stack)
{ pop(X)
if (X==node(Y)) visita(Y);
if (X==tree(Y))
{ push(tree(right(Y));
push(node(Y));
push(tree(left(Y));
}
}
Consideriamo il solito albero binario e vediamo come varia il contenuto della pila nel-
la procedura di attraversamento anticipato:
CONTENUTO DELLA PILA OUTPUT tree(E) tree(H), tree(D), node(E) tree(H), tree(D) E tree(H), tree(B), node(D) tree(H), tree(B) D tree(H), tree(C), tree(A), node(B) tree(H), tree(C), tree(A) B tree(H), tree(C), node(A) tree(H), tree(C) A tree(H), node(C) tree(H) C tree(F), node(H) tree(F) H tree(G), node(F) tree(G) F
����������������� ���
��
node(G) G
Un’ulteriore strategia di attraversamento di un albero ordinato è quella per livelli, nel-
la quale i nodi vengono visitati livello per livello partendo dalla radice e, all’interno di
ciascun livello, da sinistra a destra.
Ad esempio, l’attraversamento per livelli del solito albero binario dà la seguente se-
quenza di visite:
E D H B F A C G
La procedura di attraversamento per livelli non è ricorsiva e può essere implementata
usando una coda: anche in questo caso la coda viene inizializzata con l’albero da at-
traversare e, ogni volta che si estrae un oggetto di tipo albero, si inserisce nella coda la
sua radice e poi la sequenza ordinata dei suoi sottoalberi. Ad esempio, per un albero
binario la procedura di attraversamento per livelli può essere così schematizzata:
attraversa (R) = put(tree(R));
while (not empty queue)
{ get(X)
if (X==node(Y)) visita(Y);
if (X==tree(Y))
{ put(node(Y));
put(tree(left(Y));
put(tree(right(Y));
}
}
Per l’albero dell’esempio precedente il contenuto della coda varierà nel seguente mo-
do:
CONTENUTO DELLA CODA OUTPUT tree(E) node(E), tree(D), tree(H) tree(D), tree(H) E tree(H), node(D), tree(B) node(D), tree(B), node(H), tree(F) tree(B), node(H), tree(F) D node(H), tree(F), node(B), tree(A), tree(C) tree(F), node(B), tree(A), tree(C) H node(B), tree(A), tree(C), node(F), tree(G) tree(A), tree(C), node(F), tree(G) B tree(C), node(F), tree(G), node(A) node(F), tree(G), node(A), node(C) tree(G), node(A), node(C) F
����������������� ���
��
node(A), node(C), node(G) node(C), node(G) A node(G) C G
ENUMERAZIONE DEGLI ALBERI BINARI
Indichiamo con nb il numero di alberi binari distinti contenenti n nodi. Naturalmente
sarà 10 =b perché n=0 corrisponde all'albero vuoto. Un albero con n nodi è composto
dalla radice e da due sottoalberi che possono essere così organizzati: se il sottoalbero
sinistro è vuoto, quello destro conterrà 1−n nodi, se il sottoalbero sinistro contiene
un nodo quello destro conterrà 2−n nodi, e, in generale, se il sottoalbero sinistro
contiene k nodi, il destro ne conterrà 1−− kn . Perciò il numero di alberi con n nodi è
dato da tutte le combinazioni possibili dei due sottoalberi e dunque vale la relazione:
(*) 01322110 bbbbbbbbb nnnnn −−−− ++++= �
Consideriamo la funzione )(zB generatrice della successione { } 0≥nnb :
�≥
=+++=0
2210)(
k
kk zbzbzbbzB � ;
elevando la serie al quadrato, moltiplicando per z e sfruttando la relazione (*), si ot-
tiene una equazione di secondo grado in B(z):
01)()(
1)(
)()()(
2
33
221
3021120
2011000
2
=+−
−=++=
=++++++=
zBzzB
zBzbzbzb
zbbbbbbzbbbbzbbzzB
�
�
e, risolvendo rispetto a B(z):
z
zzB
2
411)(
−±=
La condizione iniziale 1)0( 0 == bB indica che la soluzione corretta è quella corri-
spondente al segno negativo, e dunque:
( )
( )
( ) =−���
���
=
=���
���
−��
�
���
−=
=−−=
�
�
≥
+
≥
1
2121
0
221
212
1
2112
1
4112
1)(
k
kkk
k
kkk
zkz
zkz
zz
zB
sostituendo n=k-1
����������������� ���
��
( ) ( )��≥
+
≥
++ −���
���
+=−��
�
���
+=
0
1221
0
12221
211
2112
1)(
n
nnn
n
nnn zn
znz
zB
Perciò ( ) 1221
211
+−���
���
+= nn
n nb .
D'altra parte
( )
( )
���
���
+−=
+−=
=⋅⋅⋅⋅+
⋅−⋅⋅⋅⋅⋅⋅−=
=+
−⋅⋅⋅⋅−=
=+
−⋅⋅−⋅−⋅=
=+
−⋅⋅−⋅−⋅=��
�
���
+
++
+
+
−
n
n
nnn
n
nn
nn
n
n
n
n
n
n
n
n
nn
n
n
n
n
n
n
2
)1(2
)1(
!2)!1(2
)!2()1(
)2642()!1(2
21254321)1(
)!1(2
12531)1(
)!1(
)()()(
)!1(
)()2()1(
1
121
1
1
212
23
21
21
21
21
21
21
21
�
�
�
�
�
Quindi ���
���
+=
n
n
nbn
2
1
1
Poiché esiste una corrispondenza biunivoca tra alberi ordinati e alberi binari con sot-
toalbero destro della radice vuoto, ignorando la radice abbiamo una corrispondenza
biunivoca tra gli alberi ordinati con n nodi e gli alberi binari con 1−n nodi: perciò il
numero di alberi ordinati con n nodi è 1−nb .