22
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

3. STRUTTURE DATI - itistulliobuzzi.it · specchiano le proprietà dei dati e le relazioni usate nella stesura ... e così anche le strutture astratte dei dati dovranno essere trasformate

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 .