3. STRUTTURE DATI Ai due livelli di analisi e programmazione corrispondono anche due modalità di rappresentare i dati del problema. Durante l'analisi, il problema viene affrontato e risolto mediante un algoritmo che prescinde dalla natura dell'elaboratore e che agisce sui dati del problema. Tali dati dovranno essere rappresentati in una struttura, detta struttura astratta perché indipendente dall'elaboratore, che possa essere trattata dall'algoritmo nel modo più efficiente possibile. Le strutture astratte sono rappresentazioni dei dati di un problema che rispecchiano le proprietà dei dati e le relazioni usate nella stesura dell'algoritmo risolutivo. In generale si parla di tipo di dati riferendosi all'insieme costituito da una struttura 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, rappresentabili 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. strutture informative strutture interne elementari bit strutture astratte composte liste lineari sequenziali concatenate vettore catena matrice anello stringa plesso byte parola pila alberi coda grafi doppia coda indirizzo puntatore record non lineari 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 verranno 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 compresi 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'organizzazione 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 memorizzazione. 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 talvolta 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 numero intero d di byte, V[j] è memorizzato a partire dal byte di indirizzo B + jd . 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 inserimento 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 nella posizione j-esima, 0 ≤ j ≤ m , è necessario spostare a destra di una posizione tutti gli elementi che occupano le posizioni dalla j-esima alla (m-1)-esima. Tali spostamenti 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 posizione 1, ... , nella posizione m) a cui corrispondono rispettivamente m, m-1, ... , 0 spostamenti, in media si ha un costo di 1 m 1 m(m + 1) m spostamenti. i= = m + 1 i =0 m +1 2 2 La cancellazione di un elemento, se non si vuol lasciare vuota la posizione (j) dell'elemento 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 n × m è un insieme di n × m elementi (dello stesso tipo) che vengono individuati tramite due indici i e j, con 0 ≤ i ≤ n − 1 e 0 ≤ j ≤ m − 1 . Chiameremo riga i-esima della matrice M, l'insieme ordinato {M [i, k ] 0 ≤ k ≤ m − 1} e, analo- gamente, colonna j-esima l'insieme ordinato {M [k , j ] 0 ≤ k ≤ n − 1}. Una matrice può essere trasformata in un vettore tramite un processo di linearizzazione che può avvenire per righe (memorizzando la prima riga, poi la seconda, e così via) oppure per colonne. Con la linearizzazione per righe, l'elemento M [i, j ] verrà ad occupare la posi- zione im + j del vettore, mentre con la linearizzazione per colonne occuperà la posizione jn + i . 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 (infatti si parla di contiguità logica degli elementi): tali strutture si dimostrano particolarmente 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 campo 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. Supponiamo 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 memoria 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 ciascun carattere. K(j) K(j+1) H Per cancellare l'elemento K(j) è invece sufficiente modificare il puntatore di K(j-1) facendolo puntare all'elemento K(j+1). K(j-1) K(j) K(j+1) In questo modo la posizione K(j) rimane inalterata ma non è più accessibile dalla catena. In seguito a operazioni di cancellazione l'occupazione dinamica della catena diventa estremamente inefficiente perché le posizioni di memoria contenenti dati cancellati dalla catena rimangono inutilizzate. Per ottenere un comportamento più efficiente è necessario utilizzare tecniche di garbage-collection. In ogni caso il costo delle operazioni di inserimento e di cancellazione è costante, cioè non dipende dal numero di elementi presenti nella catena. Valutando il costo delle operazioni di inserimento, cancellazione, ricerca per posizione e ricerca per contenuto nelle catene e nei vettori, 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 preventiva ricerca (sequenziale) della posizione in cui inserire o dell'elemento da cancellare. Un’altra struttura concatenata è l’anello, ovvero una catena in cui il puntatore dell’ultimo 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. 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 lunghezza 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 insiemi. Come strutture astratte elementari si considerano i numeri, i caratteri e le stringhe. La più semplice struttura astratta composta è la lista lineare: essa è un insieme ordinato di n oggetti ( x1 , , x n ) , 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 cancellazione, 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 lunghezza 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 dell'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 elemento x al top della pila S ( S ⇐ x ) e di estrazione (pop) dell'elemento x che si trova al top della pila S ( S x ). 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 permettere 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 creazione, è necessario stabilire una dimensione massima N per la pila: se poniamo, ad esempio, nella nostra implementazione N=1000, avremo un vettore S di 1000 elementi. 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 posizione 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 vettore vuoto). Mentre il primo costituisce un vero e proprio errore, rimediabile solo con una nuova inizializzazione del vettore, il secondo può costituire una comune condizione di arresto per algoritmi in cui si richiede di esaminare tutti i dati. Le due operazioni possono così essere schematizzate, facendo l'ipotesi che il valore top=-1 indichi la pila vuota: S⇐x top = top + 1; if (top >= N) overflow; else S[top]=x; S x if (top == -1) underflow; else { x=S[top]; top=top-1; } Questa implementazione di una pila è certamente la più semplice e risulta anche molto 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 n ≤ N 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 esempio, 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 effettuare le operazioni di inserimento e di estrazione con costo costante. Questa implementazione non ha solo la proprietà di eseguire in tempo costante, ovvero indipendente 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'implementazione 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 possono 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 ( Q ⇐ x ) e di estrazione (dequeue o get) dell'elemento x in testa alla coda Q ( Q x ). 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 dimensione 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 utilizzare 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 effettuato il successivo inserimento. Inizialmente verrà posto F=R=0, per indicare che la coda è vuota. Ogni volta che viene 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 inserimenti 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 elementi, 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 coda è 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 sacrifica una posizione del vettore, ovvero in un vettore di lunghezza N si potranno memorizzare 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'incremento risulterà F ≠ R . Le due operazioni di inserimento e di estrazione di un elemento x in una coda Q possono così essere schematizzate: Q⇐x if (R==N-1) A=0; else A=R+1; if (A==F) overflow; else { Q[R]=x; R=A; } Q x 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 informatica • per descrivere proprietà dinamiche degli algoritmi • come struttura dati. Mentre le liste lineari rappresentano strutture unidimensionali e quindi dati tra cui esiste una relazione di dipendenza 1:1, gli alberi sono strutture bidimensionali e consentono 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 deriva la maggior parte della terminologia legata agli alberi. Un altro esempio è rappresentato 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 decrescente 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 connessi da un arco. Si definisce lunghezza del cammino il numero di archi che lo compongono. La proprietà che caratterizza un albero è la seguente: 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 cammino, 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 sugli 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 chiamato 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 ciascun 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 computer. 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 figlio 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. a Ci sono molti modi per rappresentare in un c b computer questo concetto astratto: ad esempio e d si può usare una rappresentazione sottoforma di lista di tre elementi (radice, sottoalbero si- f 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 manipolano 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 a sottoalbero destro. Puntatori nulli corrispondono a nodi esterni. c b Questo tipo di rappresentazione interna è particolarmente indicata quando si devo- d e no realizzare operazioni sull’albero che coinvolgono nodi in direzione “top- f 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 riferisce 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. Normalmente i nodi di un albero m-ario sono rappresentati da strutture con m puntatori. Definizione – Un albero ordinato è un nodo (la radice) connesso ad una sequenza ordinata 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 memorizzare 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 biunivoca 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 nodo il primo figlio come figlio sinistro ed il suo primo fratello a destra come figlio destro. 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 possiamo 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 rappresentazione tramite liste di figli e liste di fratelli, e nella parte bassa il corrispondente albero binario con la sua rappresentazione: si può osservare che le due rappresentazioni 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 associargli 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 r = 2n . D'altra parte, in ogni nodo, sia interno che esterno eccetto la radice, entra un ramo, perciò vale anche r = n + s − 1 . Dalle due uguaglianze si ricava s = n + 1 . 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 indichiamo con I n la lunghezza del cammino interno e con E n la lunghezza del cammino esterno. Teorema - E n = I n + 2n . Dimostrazione - La dimostrazione avviene per induzione su n. Se n = 0 , I 0 = E 0 = 0 . 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 livello k dell'albero con n nodi: avremo che I n +1 = I n + k perché viene aggiunto un cammino interno di lunghezza k, mentre E n +1 = E n − k + 2(k + 1) = E n + k + 2 perché viene tolto un cammino esterno di lunghezza k ma ne vengono aggiunti due di lunghezza k+1. Perciò, sottraendo membro a membro queste due ultime uguaglianze, si ottiene: E n +1 − I n +1 = E n − I n + 2 = 2n + 2 = 2(n + 1) . Teorema – Sia h l’altezza di un albero binario con n nodi interni: log 2 (n + 1) ≤ h ≤ n Dimostrazione – L’altezza dell’albero è massima quando l’albero degenera in una lista, 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 2 i nodi, si ha 2 h −1 < n + 1 ≤ 2 h , e dunque, dalla seconda disuguaglianza, passando ai logaritmi, si ha la tesi. Teorema – Si ha n log 2 n n(n − 1) < In ≤ 4 2 Dimostrazione – La lunghezza del cammino interno è massima ancora una volta nel caso dell’albero degenere: in questo caso la lunghezza del cammino interno è 0 + 1 + 2 + ... + n − 1 = n(n − 1) . La lunghezza del cammino interno minima si ha inve2 ce in corrispondenza dell’albero di altezza minima; tale albero, per il teorema precedente, ha gli n+1 nodi esterni ad altezza maggiore o uguale a log 2 (n + 1) − 1 = log 2 (n + 1) ≥ log 2 n , perciò, E n ≥ (n + 1) log 2 n . D’altra parte I n = E n − 2n ≥ (n + 1) log 2 n − 2n = = (n + 1) log 2 n − n log 2 4 > n log 2 n − n log 2 4 = n log 2 n 4 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 attraversamento 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: attraversamento 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 ricorsive: 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 realizzazione delle tre procedure varia solo nell’ordine in cui vengono invocate le due procedure 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 processa l’oggetto così ottenuto finché la pila non è vuota. Se l’oggetto estratto è un nodo, 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 attraversamento 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 nella 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, nella 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 sequenza 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 attraversare 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 modo: CONTENUTO DELLA CODA tree(E) node(E), tree(D), tree(H), node(D), tree(B), node(H), tree(F), node(B), tree(A), tree(C), node(F), tree(G), tree(D), tree(H) node(D), tree(B), node(H), tree(F), node(B), tree(A), tree(C), node(F), tree(G), node(A), OUTPUT tree(H) E tree(B) node(H), tree(F) node(B), tree(A), tree(C), node(F), tree(G), node(A), node(C) tree(F) D tree(A), tree(C) tree(C) node(F), tree(G) tree(G) node(A) node(C) H B F node(A), node(C), node(G) node(C), node(G) node(G) A C G ENUMERAZIONE DEGLI ALBERI BINARI Indichiamo con bn il numero di alberi binari distinti contenenti n nodi. Naturalmente sarà b0 = 1 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à n − 1 nodi, se il sottoalbero sinistro contiene un nodo quello destro conterrà n − 2 nodi, e, in generale, se il sottoalbero sinistro contiene k nodi, il destro ne conterrà n − k − 1 . Perciò il numero di alberi con n nodi è dato da tutte le combinazioni possibili dei due sottoalberi e dunque vale la relazione: bn = b0 bn −1 + b1bn − 2 + b2 bn −3 + (*) + bn −1b0 Consideriamo la funzione B(z ) generatrice della successione {bn }n≥0 : B( z ) = b0 + b1 z + b2 z 2 + = k ≥0 bk z k ; elevando la serie al quadrato, moltiplicando per z e sfruttando la relazione (*), si ottiene una equazione di secondo grado in B(z): zB 2 ( z ) = b0 b0 z + (b0 b1 + b1b0 ) z 2 + (b0 b2 + b1b1 + b2 b0 ) z 3 + = b1 z + b2 z + b3 z 2 3 = = B( z ) − 1 zB ( z ) − B( z ) + 1 = 0 2 e, risolvendo rispetto a B(z): B( z ) = 1 ± 1 − 4z 2z La condizione iniziale B(0) = b0 = 1 indica che la soluzione corretta è quella corrispondente al segno negativo, e dunque: B( z ) = ( ) 1 1 − 1 − 4z = 2z = 1 1 2 1− (− 1)k 2 2k z k = k 2z k ≥0 = 1 2z 1 k ≥1 2 k (− 1)k +1 2 2 k z k sostituendo n=k-1 = B( z ) = 1 2z Perciò bn = 1 n +1 n≥ 0 1 2 2 n +1 (− 1)n 2 2 n+ 2 z n+1 = 1 n≥ 0 2 n +1 (− 1)n 2 2 n+1 z n (− 1)n 2 2n+1 . D'altra parte ⋅ ( 12 − 1) ⋅ ( 12 − 2) ⋅ = n +1 (n + 1)! 1 = = 1 2 2 1 2 ⋅ ( 12 − n) = ⋅ (− 12 ) ⋅ (− 32 ) ⋅ ⋅ (− 2 n2−1 ) = (n + 1)! (−1) n 1 ⋅ 3 ⋅ 5 ⋅ ⋅ (2n − 1) = 2 n +1 (n + 1)! = (−1) n 1 ⋅ 2 ⋅ 3 ⋅ 4 ⋅ 5 ⋅ ⋅ (2n − 1) ⋅ 2n = 2 n +1 (n + 1)!(2 ⋅ 4 ⋅ 6 ⋅ ⋅ 2n) = 2n (−1) n (2n)! (−1) n = n +1 n 2 n +1 2 (n + 1)!2 n! 2 (n + 1) n Quindi bn = 1 2n n +1 n Poiché esiste una corrispondenza biunivoca tra alberi ordinati e alberi binari con sottoalbero destro della radice vuoto, ignorando la radice abbiamo una corrispondenza biunivoca tra gli alberi ordinati con n nodi e gli alberi binari con n − 1 nodi: perciò il numero di alberi ordinati con n nodi è bn −1 .