Tipi astratti di dato e strutture di dati dinamiche: note introduttive

Università degli Studi di Palermo
Facoltà di Ingegneria
Tipi astratti di dato e strutture di dati
dinamiche: note introduttive
Edoardo Ardizzone & Riccardo Rizzo
Appunti per il corso di
Fondamenti di Informatica
A.A. 2004 - 2005
Corso di Laurea in Ingegneria Informatica
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
2
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
Tipi di dato
Un tipo astratto di dato (o semplicemente tipo astratto) è un oggetto matematico costituito da
tre componenti: un insieme di valori, detto dominio del tipo; un insieme di operazioni primitive, che
si applicano a valori del dominio o che hanno come risultato valori del dominio; un insieme di
costanti, che denotano valori significativi del dominio.
Per esempio, il tipo astratto boolean potrebbe essere così definito:
- dominio: insieme di valori { true, false }
- operazioni: and, not
- costanti: true, false
Le costanti denotano, rispettivamente, i due valori del dominio. In questo esempio ne sono
state definite tante quanti sono i valori del dominio. In altri casi non è così. Nella seguente
definizione di un tipo di dato per i numeri naturali:
- dominio: quello dei numeri naturali
- operazioni: +, *, =, <, >
- costanti: 0,1
il dominio è infinito e sono definite solo due costanti significative.
Si dovrebbe notare come la definizione o specifica di un tipo astratto sia indipendente dalla
rappresentazione che dello stesso tipo astratto può essere fornita da un linguaggio di
programmazione. Il termine tipo concreto è invece normalmente adoperato per riferirsi alla
definizione e all’uso di un tipo di dato in un linguaggio di programmazione. Per esempio, in Java il
tipo boolean è un tipo concreto, al pari dei tipi char o float: essi risultano completamente
caratterizzati non solo per le loro proprietà astratte (dominio, operazioni, costanti), ma anche per
quanto riguarda i vincoli che il linguaggio impone per il loro uso.
L’affermazione precedente può ritenersi valida non soltanto per tutti i tipi di dato primitivi di
Java, ma anche per i tipi implementati tramite classi, come per esempio String e array1, che
possono pertanto essere considerati tipi concreti: anche per essi il linguaggio specifica infatti come
le variabili del tipo debbano essere dichiarate, i vincoli sul tipo e sui valori degli eventuali indici, i
metodi per accedere alle singole componenti, e così via.
Più in generale, quando si vuole utilizzare un tipo astratto in un programma, occorre fornirne
una rappresentazione in termini dei costrutti e dei tipi concreti presenti nel linguaggio utilizzato,
ovvero di altri tipi astratti. Una rappresentazione di un tipo astratto deve fornire le regole per
rappresentare il dominio, le operazioni e le costanti presenti nella specifica del tipo stesso2.
In ambito informatico è abbastanza comune l’uso del termine struttura di dati al posto del
termine tipo di dato. Si parla in questo caso di strutture astratte e strutture concrete di dati. Alcuni
autori preferiscono invece riservare il termine struttura ai tipi il cui dominio è composito, costituito
cioè da elementi decomponibili in valori più elementari, e utilizzare il termine tipo per i tipi il cui
dominio è elementare, costituito cioè da elementi atomici. Utilizzando questa terminologia, il tipo
int di Java è un tipo (concreto) di dato, mentre il tipo array è una struttura (concreta) di dati.
Come ulteriore esempio, si consideri la seguente specifica del tipo astratto insieme, cioè il
tipo di dato che consente di rappresentare collezioni di elementi di un altro tipo, per esempio
collezioni di numeri interi compresi nell’intervallo [0, 100]:
1
Come è noto, in altri linguaggi l’array è un tipo primitivo.
Sia per la specifica che per la rappresentazione dei tipi astratti si è scelto di usare, in questa sede, una descrizione in
linguaggio naturale.
2
3
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
-
-
Il dominio è costituito da tutti gli insiemi di numeri interi di valore compreso in [0, 100]. Per
esempio, {1, 4, 99} e {15, 89, 5, 3, 1} sono elementi del dominio, mentre {-1, 4} non lo è.
Le operazioni sono:
o test_ insieme_vuoto: dato un insieme, verifica se esso contiene o no elementi, fornendo
come risultato il valore booleano appropriato;
o inserisce_elemento: dato un insieme e un numero intero, restituisce come risultato
l’insieme contenente i valori inizialmente presenti in esso più il numero intero;
o cancella_elemento: dato un insieme e un numero intero, restituisce come risultato
l’insieme contenente i valori inizialmente contenuti in esso meno il numero intero;
o test_appartenenza: dato un insieme e un numero intero, restituisce true se il numero
appartiene all’insieme, false altrimenti;
La costante insieme_vuoto, cioè l’insieme che non contiene elementi.
Si osservi che le operazioni specificate sono primitive in quanto a partire da esse è possibile
definire altre operazioni sul tipo astratto. L’unione di due insiemi non compare tra le operazioni
primitive, ma può essere espressa in termini di esse, per esempio nel modo descritto dal seguente
frammento di pseudo-codice:
unione( A,B ):
se test_insieme_vuoto( A ) è true
allora il risultato è B
altrimenti considera un qualunque elemento a di A:
se test_appartenenza( B, a ) è true
allora il risultato è unione( cancella_elemento ( A, a ), B )
altrimenti il risultato è inserisce_elemento( unione( cancella( A, a ), B ), a )
La rappresentazione in Java del tipo astratto insieme, come visto in altra parte del corso3, può
essere una classe avente come variabile di istanza un array di boolean, ogni elemento del quale è
true o false a seconda che l’intero corrispondente all’indice si trovi o meno nell’insieme.
Ovviamente, almeno le operazioni primitive devono essere implementate mediante metodi forniti
dalla classe4. Si lascia al lettore il compito di completare la suddetta rappresentazione Java.
Strutture dinamiche
Nella maggior parte dei linguaggi imperativi tradizionali più comuni, come il C o il Pascal, la
dimensione fisica dei dati di un programma deve essere nota prima della esecuzione, in modo che la
quantità di memoria complessiva necessaria per eseguire il programma possa essere calcolata (dal
compilatore) al tempo della compilazione, e quindi prima dell’esecuzione5. Le strutture dati sono
dette in questo caso statiche. Questo requisito risponde ad un principio generale: tentare di
massimizzare il numero di azioni svolte al tempo della compilazione, in modo da gestire più
efficientemente il tempo di esecuzione.
3
Si riveda l’esercizio 8.16 del testo.
La classe può naturalmente contenere altri metodi di utilità, per esempio il metodo toString, o di implementazione
efficiente di operazioni non primitive.
5
Fa eccezione la programmazione ricorsiva, che come è noto richiede l’allocazione e il rilascio di un record di
attivazione, al momento, rispettivamente, della chiamata e dell’uscita dal sottoprogramma ricorsivo.
4
4
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
Molte applicazioni, tuttavia, richiedono l’elaborazione di dati la cui dimensione non è nota
prima dell’esecuzione del programma e/o può variare durante essa. Per esempio, si supponga di
dover gestire un elenco di elementi. Nei casi reali, la lunghezza dell’elenco varierà, durante la sua
utilizzazione, in dipendenza dell’inserimento di elementi o della loro cancellazione. Se si volesse
rappresentare l’elenco utilizzando un array, occorrerebbe innanzitutto dimensionare quest’ultimo in
base alla massima dimensione prevista per l’elenco. Questo può portare ad uno spreco, anche
considerevole, di memoria, in caso di sovrastima della dimensione, e non esclude il pericolo di una
saturazione della struttura concreta, in caso di sottostima, con conseguente perdita di validità della
rappresentazione. Inoltre, in alcuni casi la gestione delle operazioni di inserimento e cancellazione
può risultare particolarmente inefficiente. Cancellare un elemento da un elenco implementato
mediante un array significa infatti individuare la sua posizione e poi spostare all’indietro di una
posizione tutti gli elementi che lo seguono, in modo da non lasciare “buchi” nell’array, ossia
elementi privi di informazioni significative. Tutto ciò può richiedere un tempo considerevole, in
dipendenza della dimensione dell’elenco. Una situazione analoga si verifica per l’inserimento di un
elemento nell’elenco: una volta individuata la posizione in cui l’elemento deve essere inserito,
occorrerà spostare in avanti di una posizione tutti gli elementi successivi.
Per ovviare a questi inconvenienti, alcuni linguaggi consentono la definizione e l’uso, con
modalità limitate, di strutture di dati dinamiche, per le quali cioè la dimensione non è fissata a
priori, ma può variare durante l’esecuzione. In C e in Pascal, questo è reso possibile dall’uso di
meccanismi di allocazione e rilascio della memoria che vengono attivati da specifiche funzioni di
libreria (malloc e free del C) o procedure predefinite (new e dispose del Pascal). Il supporto per
l’accesso a tali aree della memoria è fornito dal tipo puntatore.
Anche se in Java solo i dati di tipi primitivi sono allocati staticamente, mentre gli oggetti, e
quindi anche gli array, sono creati a runtime, le considerazioni precedenti rimangono in gran parte
valide. Per esempio, le dimensioni di un array, una volta fissate, non possono essere modificate.
Rimane quindi la necessità di avvalersi di strutture di dati dinamiche, nelle applicazioni nelle quali
il dimensionamento statico dei dati non sia possibile o appropriato.
Nel seguito, verranno analizzati alcuni tipi astratti di uso comune nelle applicazioni
informatiche, come liste, pile e code, e se ne studierà la rappresentazione collegata (o concatenata),
basata su strutture di dati dinamiche6.
1. Rappresentazione collegata
L’idea di base della rappresentazione collegata di una struttura astratta è quella di associare ad
ognuno degli elementi della struttura una particolare informazione, detta riferimento o
collegamento, che permetta di individuare la locazione in cui è memorizzato l’elemento successivo
della struttura. Da ora in poi verrà utilizzata una notazione grafica in cui gli elementi sono
rappresentati mediante nodi e i riferimenti mediante archi che li collegano, come mostrato in fig. 1.
Fig. 1 – Rappresentazione collegata
6
Non vengono prese in considerazione in questa sede altre forme di rappresentazione meno comuni, per esempio la
rappresentazione sequenziale o la rappresentazione collegata basata su array, per la cui illustrazione si rimanda ai testi
specializzati.
5
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
In Java, la rappresentazione collegata può essere basata sull’uso di oggetti auto-referenziali.
Una classe auto-referenziale contiene, oltre ai campi in cui è memorizzata l’informazione, una
variabile di istanza che si riferisce ad un oggetto della stessa classe, come nel seguente esempio:
class Node {
private int data;
private Node nextNode;
}
public
public
public
public
public
//riferimento al successivo
Node ( int data )
{
void setData ( int data ) {
int getData ()
{
void setNext ( Node next ) {
Node getNext ()
{
/*
/*
/*
/*
/*
corpo
corpo
corpo
corpo
corpo
del
del
del
del
del
elemento collegato
costruttore */ }
metodo */ }
metodo */ }
metodo */ }
metodo */ }
Nell’esempio si suppone che i dati da memorizzare siano numeri interi, e quindi la classe
contiene soltanto due variabili di istanza private: l’intero data e il riferimento nextNode a un
oggetto della stessa classe. nextNode è quindi un collegamento tra due oggetti dello stesso tipo.
L’allocazione dinamica della memoria in Java avviene attraverso le ordinarie fasi di
dichiarazione e creazione di un’istanza di classe, come in:
Node newNode = new Node ( 10 );
Non esiste in Java una forma di rilascio esplicito della memoria dinamica, analoga alla free o
alla dispose, data la presenza del meccanismo di garbage collection. Nel caso in cui non ci sia
memoria disponibile, viene lanciata un’eccezione OutOfMemoryError.
2. Il tipo lista7
Una lista è una sequenza8 o collezione lineare di elementi di un determinato tipo. In
particolare, una lista semplice (o lista di atomi) è costituita da valori elementari, mentre una lista
composita (o semplicemente lista) ha elementi che possono a loro volta essere liste. Spesso viene
usata per rappresentare una lista la cosiddetta notazione parentetica, come negli esempi seguenti di
liste semplici di interi: ( ) è la lista vuota; ( 27 ) è una lista formata da un singolo atomo; ( 4
27 5 78 5 8 ) è una lista formata da sei elementi, di cui il terzo e il quinto hanno lo stesso valore.
2.1 Il tipo lista semplice
Il tipo astratto lista semplice può essere così definito:
Dominio: l’insieme di tutte le possibili liste di atomi di un certo tipo, per esempio di interi;
Operazioni primitive:
o cons: effettua l’inserimento di un atomo in testa alla lista. Pertanto, se L è una lista
semplice e A è un atomo, cons(A,L) restituisce la lista semplice costituita da A seguito
da tutti gli elementi di L.
-
7
8
In alcuni testi questa struttura dati è denominata lista collegata (linked list).
Si ricorda che una sequenza è un multinsieme finito e ordinato di elementi.
6
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
-
o car: consente di ottenere il primo elemento della lista, senza alterarla. Pertanto, se L è
una lista semplice non vuota, car(L) restituisce il primo atomo di L. Se L è vuota,
car(L) non è definita.
o cdr: restituisce la lista semplice che si ottiene da un’altra lista privandola del primo
elemento. Pertanto, se L è una lista semplice non vuota, cdr(L) restituisce la lista
semplice che si ottiene da L ignorandone il primo elemento. Se L è vuota, cdr(L) non è
definita.
o null: verifica se una lista semplice è vuota. Pertanto, se L è una lista semplice, null(L)
restituisce il valore booleano true se L è vuota, false altrimenti.
Costante lista_vuota: denota la lista che non contiene alcun atomo.
Per esempio, se L = ( 4 27 5 78 5 8 ) e A = 12, l’operazione cons(A,L) restituisce ( 12
4 27 5 78 5 8 ), l’operazione cdr(L) restituisce ( 27 5 78 5 8 ), l’operazione car(L)
restituisce 4, l’operazione null(L) restituisce false.
Utilizzando la notazione grafica introdotta in precedenza, una lista semplice e le operazioni
primitive possono essere visualizzate nel modo riportato nelle figure seguenti. Si noti la presenza di
un simbolo speciale per rappresentare il riferimento null associato all’ultimo nodo, e del
riferimento al primo elemento della lista (riferimento iniziale). Se la lista è vuota, il simbolo di fine
lista compare direttamente nel riferimento iniziale.
L
3
7
15
Fig. 2 – Rappresentazione grafica della lista L = ( 3 15 7 )
Fig. 3 – Rappresentazione grafica della lista vuota
L
3
15
7
Fig. 4 – Rappresentazione grafica dell’operazione cdr(L)
6
L
3
15
7
Fig. 5 – Rappresentazione grafica dell’operazione cons(6,L)
Si può notare come l’operazione cdr consista semplicemente nell’aggiornamento del
riferimento iniziale. Anche l’operazione cons può essere effettuata semplicemente aggiornando il
7
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
riferimento iniziale, dopo aver impostato il riferimento associato al nuovo elemento ad un valore
pari al vecchio riferimento iniziale. Naturalmente, sulle liste possono essere effettuate altre
operazioni, oltre a quelle primitive. Le figure seguenti illustrano l’operazione di inserimento di un
elemento in una generica posizione, diversa dalla prima, e l’operazione di eliminazione di un
elemento diverso dal primo. Anche queste operazioni comportano semplicemente l’aggiornamento
di alcuni riferimenti.
6
L
3
7
15
Fig. 6 – Rappresentazione grafica dell’operazione di inserimento di un elemento in posizione generica
L
3
15
7
Fig. 7 – Rappresentazione grafica dell’operazione di eliminazione di un elemento in posizione generica
Dagli esempi emergono chiaramente alcuni vantaggi della rappresentazione collegata. La
dimensione della memoria occupata dalla lista è proporzionale al numero effettivo degli elementi
compresi nella struttura, e le operazioni di aggiornamento (inserimento o eliminazione di un
elemento) non comportano lo spostamento degli altri elementi della lista, risultando così molto più
efficienti delle corrispondenti operazioni effettuate su una struttura sequenziale come un array. Il
principale svantaggio consiste nel fatto che l’accesso ad un dato elemento della lista è possibile solo
attraverso la scansione di tutti gli elementi che lo precedono. In altri termini, non è possibile
accedere direttamente ad un elemento della lista, come è invece possibile per un elemento di un
array. Questo riflette il fatto che gli elementi di un array sono normalmente immagazzinati in
posizioni contigue della memoria, per cui l’indirizzo effettivo di un dato elemento può essere
calcolato immediatamente, noti la posizione in memoria del primo elemento dell’array e l’indice
dell’elemento cercato. Invece, gli elementi consecutivi di una lista, pur essendo contigui da un
punto di vista logico, non necessariamente lo sono anche fisicamente: è il riferimento associato ad
ogni nodo che fornisce il collegamento al successivo.
Nel seguito viene mostrata una possibile implementazione del tipo lista semplice9. La classe
Nodo contiene le definizioni relative agli atomi (che nell’esempio si suppongono interi, ma è
immediata l’estensione ad altri tipi predefiniti o definiti dall’utente), la classe ListaSemplice
contiene le definizioni dei metodi che implementano le operazioni primitive, e di altri metodi che
implementano operazioni non primitive, riportate a titolo di esempio, utili per la manipolazione
delle liste. Si ricorda che, benché qualunque operazione sia implementabile mediante opportuna
combinazione di operazioni primitive, spesso l’implementazione diretta risulta più efficiente. Le
classi EmptyListException e NoNodeException contengono le dichiarazioni delle eccezioni
sollevabili durante la manipolazione di una lista semplice. Infine, la classe eseguibile ListTest è la
9
Anche la classe LinkedList del package java.util di Java API permette l’implementazione e la manipolazione
delle liste.
8
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
classe di test. Si suppone infine che il package esempilista debba essere creato nella directory
corrente, pertanto i file sorgente devono essere compilati con il comando javac –d . xxx.java.
Classi Nodo e ListaSemplice
// ListaSemplice.java
// Contiene le classi Nodo e ListaSemplice.
package esempilista;
/* La classe Nodo definisce l’atomo di una lista semplice. I campi sono con accesso al
package, per consentire l’accesso diretto da parte dei metodi di ListaSemplice */
class Nodo
{
int data;
// il dato è un numero intero
Nodo nextNode;
// riferimento al nodo successivo
// costruttore: crea un nodo con un dato e un riferimento null
Nodo( int a )
{
this( a, null );
} // end costruttore Nodo
// costruttore: crea un nodo con un dato e un riferimento al successivo
Nodo( int a, Nodo n )
{
setData ( a );
setNext ( n );
} // end costruttore Nodo
// metodi get per dati e collegamento
void setData ( int a )
{
data = a;
} // end metodo setData
void setNext ( Node next )
{
nextNode = next;
} // end metodo setNext
// metodi get per dati e collegamento
int getData( )
{
return data;
} // end metodo getData
Nodo getNext( )
{
return nextNode;
} // end metodo getNext
} // end classe Nodo
/* La classe ListaSemplice definisce i metodi che implementano le operazioni primitive e
i seguenti altri metodi ritenuti utili: setName, getName, stampa, ricerca, insertAtBack,
removeAt, removeThe, insertInOrder. */
public class ListaSemplice {
9
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
private
primo;
iniziale
private Nodo
String
nome;//
//riferimento
nome assegnato
alla lista (solo per operazioni di stampa)
// costruttore: crea una lista vuota con nome di default “Lista”
public ListaSemplice( )
{
this( "Lista" );
} // end costruttore
// costruttore: crea una lista vuota con un nome
public ListaSemplice( String s )
{
primo = null;
nome = s;
} // end costruttore
// operazione cons: inserisce un atomo in testa alla lista
public void cons( int a )
{
Nodo n = new Nodo( a, primo ); // crea un nuovo nodo, ponendo a nel campo dati, e
// ne pone il collegamento uguale all’attuale primo
// elemento della lista
primo = n;
// aggiorna il riferimento iniziale
} // end metodo cons
// operazione cdr: elimina il primo elemento della lista, lancia un’eccezione se la
// lista è vuota
public void cdr( ) throws EmptyListException
{
if( isNull( )) throw new EmptyListException (nome);
else primo = primo.nextNode;
} // end metodo cdr
// operazione car: restituisce il primo elemento della lista semplice, lancia
// un’eccezione se la lista è vuota
public int car( ) throws EmptyListException
{
if( isNull( )) throw new EmptyListException (nome);
else return primo.data;
} // end metodo car
// operazione isNull: true se la lista è vuota
public boolean isNull( )
{
return primo == null;
} // end metodo isNull
// Da ora in poi operazioni non primitive
// setName: assegna un nome alla lista
public void setName( String n )
{
nome = n;
}
// getName: restituisce il nome della lista
public String getName( )
{
return nome;
}
// operazione stampa: visualizza il contenuto della lista
public void stampa( )
{
if( isNull( )) {
System.out.println( "La lista di nome " + nome + " e' vuota.");
return;
10
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
}
else {
System.out.print( "La lista di nome " + nome + " e': ( ");
Nodo current = primo;
// fino alla fine della lista, visualizza data
while ( current != null ) {
System.out.print(current.data + " ");
current = current.nextNode;
} // fine while
System.out.print( ")\n");
} // fine else
} // end metodo stampa
/* operazione ricerca un elemento nella lista: restituisce la posizione nella lista
della prima occorrenza dell’elemento (0 è la posizione del primo elemento) oppure –1, se
l’elemento non è presente nella lista semplice */
public int ricerca( int a )
{
if( isNull( )) return -1;
Nodo current = primo;
int count = 0;
while ( current != null ) {
if ( current.data == a ) return count;
current = current.nextNode;
count++;
} // fine while, elemento non trovato
return -1;
} // end metodo ricerca
// operazione insertAtBack: inserisce un elemento alla fine della lista
public void insertAtBack ( int a )
{
if ( isNull( ) ) cons( a );
else {
Nodo current = primo;
while ( current.nextNode != null ) {
current = current.nextNode;
} // fine while, fine lista
Nodo n = new Nodo( a, null ); // crea nuovo nodo
current.nextNode = n;
// aggiusta il riferimento
} // end else
} // end metodo insertAtBack
/* operazione removeAt: cancella l’elemento in posizione data, lancia un’eccezione
NoNodeException se il nodo corrispondente alla posizione non esiste, lancia un’eccezione
EmptyListException se la lista è vuota */
public void removeAt( int i ) throws NoNodeException, EmptyListException
{
if ( i < 0 ) throw new NoNodeException( nome );
if ( isNull( )) throw new EmptyListException( nome );
boolean trovato = false; // flag utilizzato durante la scansione
Nodo current = primo; // riferimento corrente
Nodo previous = primo;
// riferimento nodo precedente
int count = 0;
while ( ( current != null ) && ( !trovato ) ){
if ( i == count ) { // trovato elemento da eliminare
trovato = true;
if (current == primo ) primo = primo.nextNode; // è il primo ed è eliminato
else previous.nextNode = current.nextNode; // non è il primo ed è eliminato
}
else { // continua scansione
previous = current;
current = current.nextNode;
count++;
} // fine else
11
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
fine scansione
if}(//
!trovato
) throw new NoNodeException( nome );
} // end metodo removeAt
/* operazione removeThe: cancella la prima occorrenza dell’elemento dato, lancia
un’eccezione NoNodeException se l’elemento non esiste, lancia un’eccezione
EmptyListException se la lista è vuota */
public void removeThe( int a)
{
if ( isNull( )) throw new EmptyListException( nome );
boolean trovato = false; // flag utilizzato durante la scansione
Nodo current = primo; // riferimento corrente
Nodo previous = primo;
// riferimento nodo precedente
while ( ( current != null ) && ( !trovato ) ){
if ( current.data == a ) { // trovato elemento da eliminare
trovato = true;
if (current == primo ) primo = primo.nextNode; // è il primo ed è eliminato
else previous.nextNode = current.nextNode; // non è il primo ed è eliminato
}
else { // continua scansione
previous = current;
current = current.nextNode;
} // fine else
} // fine scansione
if ( !trovato ) throw new NoNodeException( nome );
} // end metodo removeThe
/* operazione insertInOrder: inserisce un elemento in una lista ordinata mantenendo
l’ordinamento */
public void insertInOrder ( int a )
{
if ( isNull( ) || a <= primo.data ) cons( a );
else {
boolean inserito = false; // flag utilizzato durante la scansione
Nodo current = primo; // riferimento corrente
Nodo previous = current;
// riferimento nodo precedente durante la scansione
while ( ( current != null ) && ( !inserito ) ){
if ( current.data > a ) { // trovata posizione di inserimento
inserito = true;
Nodo n = new Nodo( a, previous.nextNode ); // crea e inizializza
previous.nextNode = n; // aggiusta riferimento
}
else { // continua scansione
previous = current;
current = current.nextNode;
} // fine else
} // fine scansione (while)
if ( !inserito ) {
Nodo n = new Nodo( a, null ); // crea e inizializza nuovo nodo
previous.nextNode = n; // aggiusta riferimento
} // fine if
} // fine primo else
} // end metodo insertInOrder
nuovo nodo
} // end classe ListaSemplice
Classi EmptyListException e NoNodeException
// EmptyListException.java
// Contiene la classe EmptyListException, che personalizza la classe RunTimeException.
package esempilista;
12
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
public class EmptyListException extends RuntimeException {
// costruttore senza argomenti
public EmptyListException()
{
this( "Lista" );
// chiama l’altro costruttore, assegnando nome di default
}
// costruttore
public EmptyListException( String name )
{
super( name + " è vuota." ); // chiama il costruttore della superclasse
}
} // end classe EmptyListException
// NoNodeException.java
// Contiene la classe NoNodeException, che personalizza la classe RunTimeException.
package esempilista;
public class NoNodeException extends RuntimeException {
// construttore senza argomenti
public NoNodeException ()
{
this( "Lista" );
// chiama l’altro costruttore, assegnando nome di default
}
// contruttore
public NoNodeException ( String name )
{
super( “Nodo non esistente in “ + name );
superclasse
}
// chiama il costruttore della
} // end classe NoNodeException
Classe ListTest
// ListTest.java
/* Questa è la classe eseguibile per il test della classe ListaSemplice. */
import esempilista.ListaSemplice;
import esempilista.NoNodeException;
import esempilista.EmptyListException;
import javax.swing.*;
import java.util.*;
public class ListTest {
public static void main( String args[] )
{
ListaSemplice lista = new ListaSemplice( "Lista di prova" ); // crea la lista
String input = JOptionPane.showInputDialog( "Digita lista iniziale di interi" );
StringTokenizer tok = new StringTokenizer( input );
while ( tok.hasMoreTokens( ))
lista.insertAtBack( Integer.parseInt( tok.nextToken( ))); // riempie la lista
lista.stampa( ); // visualizza il contenuto iniziale della lista
// test isNull
System.out.println("isNull è " + lista.isNull( ));
13
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
// test operazione car
System.out.println("Il car è " + lista.car( ));
System.out.println("Dopo il car ");
lista.stampa( ); // visualizza il contenuto della lista dopo il car
// test operazione cdr
lista.cdr( );
System.out.println("Dopo il cdr ");
lista.stampa( ); // visualizza il contenuto della lista dopo il cdr
// test operazione cons
input = JOptionPane.showInputDialog( "Digita intero da inserire in cons" );
lista.cons( Integer.parseInt( input ));
System.out.println("Dopo il cons ");
lista.stampa( ); // visualizza il contenuto della lista dopo il cons
// test removeAt
input = JOptionPane.showInputDialog(
"Digita posizione elemento da rimuovere" );
try {
lista.removeAt( Integer.parseInt( input ));
}
catch( EmptyListException e ) {System.out.println(" ECCEZIONE!");}
catch( NoNodeException nn) {System.out.println(" ECCEZIONE!");}
System.out.println("Dopo il removeAt ");
lista.stampa( ); // visualizza il contenuto della lista dopo il removeAt
// test removeThe
input = JOptionPane.showInputDialog( "Digita elemento da rimuovere " );
try {
lista.removeThe( Integer.parseInt( input ));
}
catch( EmptyListException e ) {System.out.println(" ECCEZIONE!");}
catch( NoNodeException nn) {System.out.println(" ECCEZIONE!");}
System.out.println("Dopo il removeThe ");
lista.stampa( ); // visualizza il contenuto della lista dopo il removeThe
// test ricerca
input = JOptionPane.showInputDialog( "Digita elemento da cercare " );
int pos = lista.ricerca( Integer.parseInt( input ));
if ( pos < 0 ) System.out.println( "Elemento non trovato" );
else System.out.println( "L’elemento è in posizione " + pos );
System.out.println("Dopo ricerca ");
lista.stampa( ); // visualizza il contenuto della lista dopo ricerca
// test insertInOrder - ATTENZIONE: il test presuppone che la lista sia ordinata
input = JOptionPane.showInputDialog( "Digita elemento da inserire in ordine " );
lista.insertInOrder( Integer.parseInt( input ));
System.out.println("Dopo insertInOrder");
lista.stampa( ); // visualizza il contenuto della lista dopo insertInOrder
System.exit( 0 );
} // fine main
} // fine classe ListTest
2.2 Varianti della rappresentazione collegata
14
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
Esistono delle varianti della rappresentazione collegata delle liste semplici che consentono
l’implementazione più efficiente di alcune operazioni, oltre a esibire altre caratteristiche
interessanti. Per esempio, la rappresentazione circolare è una rappresentazione collegata in cui
l’ultimo elemento punta al primo nodo, invece di contenere un riferimento nullo (fig. 8).
L
3
7
15
Fig. 8 – Rappresentazione circolare della lista L = ( 3 15 7 )
Questa rappresentazione è spesso (impropriamente) denominata lista circolare. Un’altra
variante della rappresentazione collegata è la cosiddetta rappresentazione simmetrica o lista
simmetrica, in cui ogni elemento contiene, oltre al riferimento al nodo successivo, anche il
riferimento al precedente (fig. 9).
L
3
15
7
Fig. 9 – Rappresentazione simmetrica della lista L = ( 3 15 7 )
La rappresentazione simmetrica consente in modo semplice la scansione della lista sia in
avanti sia all’indietro, e pertanto facilita l’individuazione dell’elemento che precede un determinato
elemento. Risultano quindi più agevoli le operazioni di inserimento e cancellazione. Per contro, si
ha una maggiore occupazione di memoria, a parità di numero di atomi.
L’implementazione della rappresentazione circolare e della rappresentazione simmetrica può
essere ottenuta con semplici modifiche di quella della lista semplice. A titolo di esempio, si riporta
di seguito una definizione della classe Node adatta alla rappresentazione simmetrica:
class Node
private
private
private
public
public
public
public
public
public
public
{
// per la rappresentazione simmetrica
int data;
Node nextNode;
//riferimento al successivo
Node previousNode; //riferimento al precedente
Node ( int data )
{
void setData ( int data )
{
int getData ()
{
void setNext ( Node next )
{
Node getNext ()
{
void setPrevious ( Node next ) {
Node getPrevious ()
{
}
2.3 Il tipo lista composita
15
/*
/*
/*
/*
/*
/*
/*
corpo
corpo
corpo
corpo
corpo
corpo
corpo
elemento collegato
elemento collegato
del
del
del
del
del
del
del
costruttore */ }
metodo */ }
metodo */ }
metodo */ }
metodo */ }
metodo */ }
metodo */ }
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
In una lista composita, o semplicemente lista, gli elementi possono a loro volta essere liste.
Ne risulta un tipo astratto sufficientemente generale, che ben si presta alla rappresentazione di altri
tipi astratti, come gli alberi e i grafi, oltre che alla utilizzazione in molte applicazioni. Utilizzando la
notazione parentetica, l’esempio seguente mostra una lista, con atomi interi, in cui il primo e
secondo elemento sono atomi, il terzo è una lista con un solo atomo, il quarto è un atomo, il quinto
è la lista vuota, il sesto è una lista (in cui il primo e il terzo elemento sono atomi, mentre il secondo
è una lista contenente due atomi), il settimo è un atomo: L = (4 3 (2) 15 () (3 (9 7) 4) 65).
Il tipo astratto lista può essere così definito:
- Dominio: l’insieme di tutte le possibili liste i cui elementi possono essere atomi di un certo tipo,
per esempio interi, o liste;
- Operazioni primitive:
o cons: effettua l’inserimento di un elemento (un atomo o una lista) in testa alla lista.
o car: consente di prelevare il primo elemento della lista. Può essere un atomo oppure una
lista. Se la lista è vuota, l’operazione non è definita.
o cdr: restituisce la lista che si ottiene da un’altra lista privandola del primo elemento. Non
è definita se la lista iniziale è vuota.
o null: verifica se una lista è vuota. Restituisce il valore booleano true se la lista è vuota,
false altrimenti.
o test_atomo: si applica ad un elemento della lista, restituendo true se l’elemento è un
atomo, false altrimenti.
- Costante lista_vuota: denota la lista che non contiene alcun elemento.
Si può notare che l’unica operazione primitiva non presente nel caso delle liste semplici è la
test_atomo. Le altre operazioni sono generalizzazioni delle corrispondenti operazioni definite per le
liste semplici. Per esempio, se L = (5 () 8), car(L) restituisce 5, mentre cdr(L) restituisce (()
8). Si ha inoltre test_atomo(car(L)) = true. Se L = (()), si ha invece test_atomo(car(L))
= false. Se L = (), cons((), L) restituisce (()).
Nella rappresentazione collegata di una lista, come nel caso delle liste semplici, ad ogni
elemento corrisponde un nodo che contiene il riferimento al nodo successivo. Ogni nodo contiene
inoltre un valore, se il corrispondente elemento è un atomo, oppure il riferimento iniziale di una
lista, se il corrispondente elemento è una lista. La fig. 10 mostra la rappresentazione collegata della
lista L = (4 (2) 15 () (3 (9 7) 4) 65).
L
65
15
4
2
4
3
9
7
Fig. 10 – Rappresentazione collegata della lista L = (4 (2) 15 () (3 (9 7) 4) 65)
16
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
Dato che ogni nodo può contenere due riferimenti, la rappresentazione collegata di una lista
viene anche denominata lista doppia. In generale, si parlerà di lista multipla quando ogni nodo può
contenere n riferimenti, con n > 1.
Dal punto di vista implementativo, dato che ogni nodo può contenere, oltre al riferimento al
successivo elemento, un valore o un altro riferimento, nasce il problema di definire correttamente il
nodo stesso. Una possibile soluzione prevede la definizione di una classe a tre campi: uno, come al
solito, per il riferimento al successivo elemento della lista, gli altri due, che non saranno mai
utilizzati contemporaneamente, per il dato e per l’altro riferimento. Un ulteriore campo indica,
mediante un valore prefissato, quale dei due campi alternativi è significativo, cioè se nel nodo è
memorizzato un atomo oppure un puntatore ad una lista. A titolo di esempio, si riporta di seguito
una definizione della classe Node adatta alla rappresentazione della lista composita:
class Node
private
private
private
private
}
public
public
public
public
public
public
public
public
public
{
// per la lista doppia
int data;
// eventuale atomo
Node listNode;
// eventuale riferimento ad altra lista
Node nextNode;
//riferimento al successivo elemento collegato
int tipoNodo;
// per esempio, 0 per atomo, 1 per puntatore
Node ( int data, Node ln, int tipo ) {/* corpo del costruttore */ }
void setData ( int data )
{
/* corpo del metodo */ }
int getData ()
{
/* corpo del metodo */ }
void setNext ( Node next )
{
/* corpo del metodo */ }
Node getNext ()
{
/* corpo del metodo */ }
void setListNode ( Node ln )
{
/* corpo del metodo */ }
Node getListNode ()
{
/* corpo del metodo */ }
void setTipoNode ( int tipo )
{
/* corpo del metodo */ }
int getTipoNode ()
{
/* corpo del metodo */ }
A fronte della sua semplicità, questa rappresentazione comporta uno spreco di memoria, dato
che uno dei campi di ogni nodo rimane sicuramente inutilizzato. Inoltre, la realizzazione di alcune
delle operazioni primitive dovrà tener conto della duplice natura dell’informazione contenuta in un
nodo. Per esempio, l’operazione cdr richiede un argomento che potrebbe essere un atomo, oppure il
riferimento ad un’altra lista, così come l’operazione car potrebbe restituire un atomo oppure il
puntatore ad una lista. In entrambi i casi, è consigliabile utilizzare come argomento o come valore
restituito un riferimento ad una struttura della stessa forma di quella utilizzata per memorizzare gli
elementi della lista, cioè un oggetto Node. In questa ipotesi, e a titolo di esempio, si riporta di
seguito una possibile implementazione dell’operazione car.
/* operazione car: restituisce il primo elemento della lista, in forma di oggetto
Node, o lancia un’eccezione se la lista è vuota */
public Node car( ) throws EmptyListException
{
if( isNull( )) throw new EmptyListException (nome);
else return primo;
} // end metodo car
3. Il tipo pila
17
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
Una pila o stack è un tipo astratto che consente di rappresentare un multiinsieme di elementi
la cui disciplina di gestione è di tipo LIFO (Last In, First Out): l’ultimo elemento entrato è il primo
a potere uscire. In altri termini, ogni eliminazione ha per oggetto l’ultimo elemento inserito, spesso
denominato elemento affiorante. Come detto in altra parte del corso, il meccanismo di attivazione
dei metodi, durante l’esecuzione di un programma Java, si basa su una struttura dati di questo tipo.
Le pile sono usate anche dai compilatori, durante il processo di valutazione delle espressioni
aritmetiche e durante la generazione del corrispondente codice in linguaggio macchina.
Il tipo astratto pila può essere così definito:
- Dominio: l’insieme di tutte le possibili pile di elementi di un certo tipo, per esempio di interi;
- Operazioni primitive:
o push: effettua l’inserimento di un elemento nella pila.
o top: consente di ottenere l’ultimo elemento inserito nella pila, senza alterarla. Se la pila è
vuota, l’operazione non è definita.
o pop: restituisce la pila che si ottiene da un’altra pila privandola dell’ultimo elemento
inserito. Se la pila è vuota, l’operazione non è definita.
o test_pila_vuota: verifica se una pila è vuota. Restituisce il valore booleano true se la
pila è vuota, false altrimenti.
- Costante pila_vuota: denota la pila che non contiene alcun elemento.
La disciplina di gestione LIFO richiede che nella rappresentazione di una pila si debba
conservare memoria dell’ordine in cui gli elementi vengono inseriti. E’ peraltro del tutto evidente la
stretta analogia tra il tipo pila e il tipo lista, qualora per quest’ultimo siano inibiti inserimenti e
cancellazioni se non da una delle due estremità. In questo caso, infatti, la sequenza degli elementi
nella lista riflette l’ordine di inserimento degli elementi nella pila. In particolare, se l’inserimento e
la cancellazione possono avvenire solo in testa alla lista, si ha una perfetta corrispondenza tra le
operazioni primitive dei due tipi di dati:
Operazioni sulle
pile
push
pop
top
test_pila_vuota
Operazioni sulle
liste
cons
cdr
car
null
La rappresentazione Java di una pila può quindi essere mutuata da quella già vista per le liste,
avendo l’accortezza di non predisporre metodi che violino la disciplina LIFO, come removeAt o
insertAtBack.
4. Il tipo coda
Una coda o queue è un tipo astratto che consente di rappresentare un multiinsieme di elementi
la cui disciplina di gestione è di tipo FIFO (First In, First Out): il primo elemento entrato è il primo
a potere uscire. In altri termini, ogni eliminazione ha per oggetto l’elemento inserito per primo. Gli
elementi di una coda possono essere rimossi soltanto dalla sua testa e possono essere inseriti
soltanto dal fondo. Le operazioni di inserimento e rimozione vengono di solito denominate enqueue
e dequeue.
18
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
La disciplina LIFO caratterizza molte situazioni della vita quotidiana. Per esempio, allo
sportello di un ufficio il primo ad essere servito è il primo della coda. Le code hanno inoltre molte
applicazioni in informatica: per citarne solo alcune, la gestione dei processi in un sistema operativo,
lo spooling di stampa, il routing dei pacchetti di dati da un nodo di rete al successivo.
Il tipo astratto coda può essere così definito:
- Dominio: l’insieme di tutte le possibili code di elementi di un certo tipo, per esempio di interi;
- Operazioni primitive:
o in_coda: effettua l’inserimento di un elemento nella coda.
o testa: consente di ottenere l’elemento inserito per primo nella coda, senza alterarla. Se la
coda è vuota, l’operazione non è definita.
o out_coda: restituisce la coda che si ottiene da un’altra coda privandola dell’elemento
inserito per primo. Se la coda è vuota, l’operazione non è definita.
o test_coda_vuota: verifica se una coda è vuota. Restituisce il valore booleano true se la
coda è vuota, false altrimenti.
- Costante coda_vuota: denota la coda che non contiene alcun elemento.
Per migliorare l’efficienza delle operazioni di inserimento e rimozione, nella rappresentazione
collegata di una coda si fa normalmente uso di due riferimenti: primo, che punta all’elemento di
testa della coda, e ultimo, che punta all’elemento che si trova al fondo della coda. La condizione di
coda vuota sarà indicata dal valore null per entrambi i riferimenti. Si veda la fig. 11 per un
esempio.
ultimo
primo
3
15
7
Fig. 11 – Rappresentazione collegata di una coda
Come si può facilmente verificare, l’uso del riferimento ultimo permette di evitare la
scansione della coda, al momento dell’inserimento di un nuovo elemento. Assumendo che la classe
Node abbia la definizione già vista per le liste semplici, vengono di seguito riportate delle possibili
implementazioni delle più importanti operazioni primitive del tipo coda. Gli elementi di
quest’ultima sono supposti di tipo int.
// operazione in_coda: inserisce un atomo in una coda
public void in_coda( int a )
{
Node n = new Node( a, null ); // crea un nuovo nodo, ponendo a nel campo dati
if (test_coda_vuota( )) {
primo = n;
// aggiorna il riferimento alla testa della coda
ultimo = n;
// aggiorna il riferimento al fondo della coda
}
else {
ultimo.nextNode = n;
ultimo = n;
}
} // end metodo in_coda
19
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
//operazione out_coda: elimina il primo elemento della coda, lancia un’eccezione se la
coda è vuota
public void out_coda( ) throws EmptyQueueException
{
if( test_coda_vuota( )) throw new EmptyQueueException (nome);
else {
primo = primo.nextNode;
if ( primo == null ) ultimo = null;
}
} // end metodo out_coda
5. Il tipo albero
Le strutture astratte note come alberi consentono di rappresentare relazioni gerarchiche tra
oggetti. In letteratura esistono molte definizioni di albero, qui ne viene fornita una ricorsiva.
Dato un insieme prefissato E di elementi, un albero può essere vuoto (quando non contiene
alcun elemento) o non vuoto. Un albero non vuoto può consistere di un solo elemento e ∈ E, detto
nodo, oppure può consistere di un nodo e ∈ E, collegato mediante archi (o rami) orientati a un
numero finito di altri alberi. Gli esempi seguenti mettono in rilievo l’aspetto ricorsivo della
definizione.
archi
e
radice
e1
e1
e2
e3
e4
e4
e2
foglie
e5
e3
e7
e8
e6
Fig. 12 – Esempi di alberi
Alcune definizioni:
20
Il primo nodo di un albero, solitamente disegnato in alto, è la radice dell’albero.
I nodi terminali, cioè quelli da cui non esce alcun ramo, sono le foglie dell’albero.
I nodi non terminali sono detti anche nodi interni.
Se un ramo va dal nodo n1 al nodo n2, si dice che n1 è padre di n2, e che n2 è figlio di n1.
n1 si dice antenato di n2 se n1 è padre di n2 oppure se n1 è padre di un antenato di n2. n2 si
dice discendente di n1.
I nodi figli dello stesso padre vengono detti fratelli.
Un cammino da n1 a n2 è una sequenza di archi contigui che va da n1 a n2.
e9
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
-
La lunghezza di un cammino è il numero di archi che lo costituiscono (quindi è uguale al
numero di nodi meno 1).
Il livello di un nodo è la lunghezza del cammino che collega la radice al nodo stesso.
La profondità o altezza di un albero è la lunghezza del cammino più lungo che collega la
radice ad una foglia.
Un sottoalbero di un albero è un sottoinsieme dei nodi dell’albero, collegati tra loro da rami
dello stesso albero, che risulti a sua volta un albero.
Un sottoalbero SA di un albero A si dice completo se per ogni nodo n di SA, SA contiene
anche tutti i discendenti di n.
Un albero si dice bilanciato se, fissato un numero massimo k di figli per ogni nodo, e detta h
l’altezza dell’albero, ogni nodo di livello l < h – 1 ha esattamente k figli.
Un albero si dice perfettamente bilanciato se ogni nodo di livello l < h ha esattamente k figli.
In un albero binario, ogni nodo ha al più due figli.
Nell’esempio di fig. 13a, il livello del nodo f è 2, la lunghezza del cammino dal nodo b al
nodo m è 2, l’altezza dell’albero è 3, i nodi b, e, f, m costituiscono un sottoalbero completo.
L’albero binario di fig. 13b è un esempio di albero bilanciato (k = 2, h = 3), in quanto ogni nodo di
livello < 2 ha esattamente 2 figli. L’albero diviene perfettamente bilanciato con l’aggiunta dei nodi
in rosso.
a
b
c
d
f
h
e
l
i
g
m
a)
b)
Fig. 13 – Esempi di alberi
Un albero si riduce ad una lista se ogni nodo è collegato al più ad un altro albero.
Il tipo astratto albero binario può essere così definito:
- Dominio: l’insieme di tutti i possibili alberi binari contenenti valori di un certo tipo, per
esempio di interi.
- Operazioni primitive:
o radice: restituisce il valore associato alla radice dell’albero binario. Se l’albero A è
vuoto, radice(A) non è definita.
o sinistro: consente di ottenere il sottoalbero sinistro. Pertanto, se A è un albero binario
non vuoto, sinistro(A) restituisce il sottoalbero sinistro di A. Se A è vuoto,
sinistro(A) non è definita.
21
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
-
o destro: consente di ottenere il sottoalbero destro. Pertanto, se A è un albero binario non
vuoto, destro(A) restituisce il sottoalbero destro di A. Se A è vuoto, destro(A) non è
definita.
o costruisci: serve a costruire un albero binario. Pertanto, se S e D sono alberi binari e v è
un valore del tipo previsto per i nodi dell’albero, costruisci(S,v,D) restituisce
l’albero binario formato dalla radice v, dal sottoalbero sinistro S e dal sottoalbero destro
D.
o test_albero_vuoto: verifica se un albero binario è vuoto. Pertanto, se A è un albero
binario, test_albero_vuoto(A) restituisce il valore booleano true se A è vuoto, false
altrimenti.
Costante albero_vuoto: denota l’albero binario che non contiene alcun elemento.
5.1 Rappresentazione di alberi mediante strutture dati
L’evidente analogia con le liste suggerisce una rappresentazione degli alberi basata su
record e riferimenti10. Limitando dapprima l’analisi agli alberi binari, un albero binario T può
essere rappresentato nel modo seguente:
- se T è vuoto, la lista che lo rappresenta è la lista vuota;
- se T non è vuoto, la lista che lo rappresenta è formata da tre elementi: il primo è un atomo, che
rappresenta la radice, il secondo e il terzo sono due liste, che rappresentano allo stesso modo il
sottoalbero sinistro e il sottoalbero destro.
Per esempio, la rappresentazione parentetica della lista che rappresenta l’albero di fig. 14 è la
seguente: ( 8 () ( 5 ( 25 () ()) ( 16 () ()) ) ). In figura è mostrata la corrispondente
notazione grafica, in cui albero è il riferimento iniziale dell’albero.
albero
8
8
5
5
25
16
25
16
Fig. 14 – Rappresentazione di alberi mediante liste
Ciascun nodo è rappresentato da un record con tre campi, uno per l’informazione associata al
nodo stesso (nell’esempio, un numero intero), gli altri due, rispettivamente, per il riferimento al
sottoalbero sinistro e per il riferimento al sottoalbero destro.
In Java, lo scheletro di una possibile implementazione è la seguente (i membri sono con
accesso al package):
10
Esistono altre rappresentazioni degli alberi, per esempio basate su array o altre strutture statiche, particolarmente utili
quando la struttura dell’albero non è soggetta a modifiche durante l’esecuzione del programma. Per tali
rappresentazioni si rimanda alla letteratura specializzata.
22
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
class TreeNode { // implementa il nodo
TreeNode leftNode;
//riferimento al sottoalbero sinistro
int data;
TreeNode rightNode; //riferimento al sottoalbero destro
}
public TreeNode ( int nodeData )
{
data = nodeData;
leftNode=rightNode=null; // il nodo non ha figli
}
Si è supposto che i dati da memorizzare siano numeri interi; la classe contiene soltanto tre
variabili di istanza: l’intero data e i riferimenti leftNode e rightNode a oggetti della stessa
classe.
public class Tree { //implementa l’albero
private TreeNode root;
public Tree () //albero vuoto
{
root = null;
}
//operazioni primitive
}
La implementazione delle operazioni primitive è lasciata come esercizio al lettore.
5.2 La visita degli alberi
Tra le operazioni non primitive, come già visto nel caso delle liste e di altre strutture dati, di
rilievo è la ricerca di un elemento all’interno di un albero, in particolare di un albero binario. Data
la rappresentazione utilizzata, è del tutto naturale una formulazione ricorsiva dell’algoritmo di
ricerca, basata sulla considerazione che se un elemento esiste in un albero, esso si trova nella radice
o nel sottoalbero sinistro o nel sottoalbero destro. Assumendo valide le precedenti dichiarazioni, e
già implementate le operazioni primitive, si ha pertanto:
/* operazione ricerca un elemento in un albero binario: restituisce true se
l’elemento è presente, oppure false se l’elemento non è presente */
public boolean ricerca( int a )
{
if( test_albero_vuoto( )) return false;
else if (root.data == a) return true;
else return ( sinistro().ricerca( a ) || destro().ricerca( a ) );
} // end metodo ricerca
Nel caso peggiore, questo metodo analizza tutti i nodi dell’albero (la complessità è quindi
O(n), come nel caso delle liste), ovvero effettua una visita completa dell’albero. La visita di un
albero è infatti l’operazione che consente di esaminarne tutti gli elementi. Si tratta di una
operazione necessaria, oltre che per la ricerca un particolare elemento all’interno dell’albero, anche
23
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
in altre occasioni, come quando si vogliano estrarre tutte le informazioni dall’albero, per esempio
per stamparle.
Se un problema di questo tipo ha soluzioni banali per quanto riguarda le strutture lineari,
come ad esempio le liste concatenate o gli array, nel caso di strutture non lineari come gli alberi
occorre tener conto dell’ordine in cui si visitano i diversi elementi. Esistono alcuni metodi di visita
fondamentali, che possono servire anche come base per lo sviluppo di metodi più sofisticati. Si
tratta della visita in ordine, della visita in pre-ordine (sinistro e destro), della visita in post-ordine
(sinistro e destro).
11
17
5
16
25
6
13
Fig. 15 – Esempio di albero binario
Nella visita in ordine (o visita simmetrica), un elemento viene trattato solo dopo che sono stati
attraversati con la stessa modalità i nodi del suo sottoalbero sinistro, e prima che siano trattati con
la stessa modalità in nodi del suo sottoalbero destro. Con riferimento alla fig. 15, ciò significa che
l’attraversamento in ordine dell’albero produrrebbe l’analisi dei nodi nella sequenza seguente: 25 5
16 11 6 17 13.
Nella visita in pre-ordine sinistro (o visita in ordine anticipato) un nodo viene trattato nel
momento in cui è toccato, quindi vengono visitati con la stessa modalità prima il suo sottoalbero
sinistro e dopo il suo sottoalbero destro. Per l’albero di fig. 15 si ha: 11 5 25 16 17 6 13.
Nella visita in pre-ordine destro, un nodo viene trattato nel momento in cui è toccato, quindi
vengono visitati con la stessa modalità prima il suo sottoalbero destro e dopo il suo sottoalbero
sinistro. Per l’albero di fig. 15 si ha: 11 17 13 6 5 16 25.
Nella visita in post-ordine sinistro, si analizza prima, con la stessa modalità, il sottoalbero
sinistro, poi il sottoalbero destro, infine si tratta l’elemento. Per l’albero di fig. 15 si ha: 25 16 5 6
13 17 11.
Nella visita in post-ordine destro, si analizza prima, con la stessa modalità, il sottoalbero
destro, poi il sottoalbero sinistro, infine si tratta il nodo. Per l’albero di fig. 15 si ha: 6 13 17 25 16 5
11.
A titolo di esempio, e tenendo presenti le dichiarazioni precedenti, si riporta
l’implementazione ricorsiva della visita in ordine, effettuata per stampare tutti gli elementi
dell’albero.
/* operazione stampa_in_ordine: visita ricorsivamente e in ordine l’albero, stampando
il valore di ogni nodo
public void stampa_in_ordine( )
{
if( test_albero_vuoto( )) return;
sinistro().stampa_in_ordine();
System.out.print(root.data + “
destro().stampa_in_ordine();
} // end metodo srampa_in_ordine
24
“);
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
5.3 Alberi binari di ricerca
Rappresentano una importante applicazione degli alberi binari, particolarmente utile quando
si devono memorizzare grosse quantità di dati sui quali effettuare frequentemente l’operazione di
ricerca, solo raramente quella di inserimento o cancellazione di un elemento.
Un albero binario di ricerca è un albero binario nel quale, supponendo che una relazione di
ordinamento sia definita sull’insieme dei valori dei suoi elementi, vale per ogni nodo N la seguente
proprietà: tutti i nodi del sottoalbero sinistro di N hanno valore minore o uguale di quello di N, tutti
i nodi del sottoalbero destro hanno valore maggiore di quello di N.
17
21
12
19
36
20
45
41
50
Fig. 16 – Esempio di albero binario di ricerca
Per un albero con queste proprietà, la ricerca può essere effettuata utilizzando il seguente
algoritmo, simile a quello già esaminato di ricerca binaria in una sequenza, e la cui
implementazione in Java viene lasciata come esercizio al lettore:
se l’albero è vuoto, restituisci falso
altrimenti se l’elemento cercato è uguale al valore della radice, restituisci vero
altrimenti se l’elemento cercato è minore del valore della radice,
applica lo stesso algoritmo al semialbero sinistro
altrimenti applica lo stesso algoritmo al semialbero destro
Si può osservare che nel caso peggiore il numero di confronti necessario per stabilire se
l’elemento cercato si trova nell’albero è pari alla profondità dell’albero più uno. Se l’albero è
perfettamente bilanciato, la sua profondità è pari a log2 n, quindi la complessità dell’algoritmo è
O(log2 n). Altrimenti la complessità aumenta, fino a tendere a O(n) quando l’albero degenera in una
lista. Il vantaggio ottenuto nella ricerca di un elemento nell’albero ha un prezzo, quello di dover
effettuare inserimenti e cancellazioni in modo da mantenere l’albero ordinato e perfettamente
bilanciato (o almeno bilanciato).
Un algoritmo di inserimento che rispetta l’ordinamento dell’albero binario di ricerca è il
seguente:
25
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
se l’albero è vuoto, crea un nodo senza figli, di valore uguale all’elemento da inserire
altrimenti, se l’elemento da inserire è maggiore del valore della radice,
applica lo stesso algoritmo al semialbero destro
altrimenti applica lo stesso algoritmo al semialbero sinistro
In caso di cancellazione di un elemento, e supponendo che non ci siano ripetizioni nell’albero,
una volta individuato il nodo da cancellare, occorre determinare quale elemento scegliere per
rimpiazzarlo (non ha senso lasciare il nodo vuoto, a meno che non si tratti di una foglia). Un
algoritmo di cancellazione che rispetta l’ordinamento dell’albero binario di ricerca può essere
basato sulle seguenti considerazioni: se il nodo da cancellare ha solo un figlio, lo si può rimpiazzare
direttamente con il figlio; se il nodo da cancellare ha entrambi i figli, data la definizione di albero
binario di ricerca, lo si può rimpiazzare con il discendente di minimo valore del figlio destro, o con
il discendente di massimo valore del figlio sinistro. Scegliendo la prima soluzione, uno schema
dell’algoritmo può essere il seguente:
se l’albero è vuoto, termina (non ci sono nodi da cancellare)
altrimenti, se la radice è maggiore dell’elemento da cancellare,
applica lo stesso algoritmo al semialbero sinistro
altrimenti, se la radice è minore dell’elemento da cancellare,
applica lo stesso algoritmo al semialbero destro
altrimenti (il nodo è stato individuato),
se il nodo è una foglia, cancella e termina
altrimenti, se il semialbero sinistro è vuoto,
rimpiazza con semialbero destro e termina
altrimenti, se il semialbero destro è vuoto,
rimpiazza con semialbero sinistro e termina
altrimenti (il nodo ha due figli), rimpiazza con nodo
più a sinistra del sottoalbero destro e termina
Si può facilmente verificare che entrambi gli algoritmi hanno una complessità dello stesso
ordine della profondità dell’albero, quindi risultano ancora tanto più efficienti quanto più l’albero è
bilanciato. Si deve altresì osservare che entrambi gli algoritmi preservano l’ordinamento, ma non
garantiscono il bilanciamento. D’altro canto, algoritmi di inserimento e cancellazione che
mantengano non solo l’ordinamento ma anche il bilanciamento possono risultare più complessi.
Pertanto, normalmente nella pratica si procede alla costruzione dell’albero binario di ricerca in
modo che esso risulti inizialmente bilanciato (questo è sempre possibile, qualunque sia l’insieme
dei valori da memorizzare), e si effettuano eventuali inserimenti e cancellazioni senza curare il
bilanciamento. Periodicamente, quando le degradate prestazioni degli algoritmi di ricerca,
inserimento e cancellazione lo consiglino, si può procedere al bilanciamento dell’albero.
5.4 Alberi n-ari
26
Tipi astratti di dato e strutture di dati dinamiche – Edoardo Ardizzone & Riccardo Rizzo
Quando l’organizzazione logica dei dati da rappresentare riflette una struttura gerarchica non
di tipo binario, si può ricorrere ad alberi in cui ogni nodo può avere un numero qualunque di figli.
Si parla in questo caso di alberi n-ari. Se il numero di figli di ciascun nodo è limitato e non varia di
molto da nodo a nodo (per esempio, massimo 4), è immediato estendere le tecniche già viste per gli
alberi binari ad alberi ternari, quaternari, etc. Ciascun nodo sarà in questo caso rappresentato da un
record con un numero di campi sufficiente a memorizzare, oltre all’informazione associata al nodo
stesso, i riferimenti (eventualmente nulli) ai sottoalberi rappresentativi dei figli. Il numero di tali
campi dovrà essere dimensionato basandosi sul numero massimo di figli previsto.
Tale soluzione diviene inefficiente o addirittura non praticabile quando il numero dei figli di
ciascun nodo è molto variabile (per esempio da 0 a 100) o addirittura non limitato a priori. In tal
caso, si può ricorrere ad una soluzione che renda dinamicamente variabile il numero dei figli di
ciascun nodo. Un modo semplice di realizzare la struttura consiste nell’associare ad ogni nodo una
lista di figli: ogni nodo punta solo al primo figlio di tale lista (per esempio, il primo figlio di
sinistra) ed al proprio fratello di destra. La fig. 17 illustra la tecnica.
A
A
C
B
D
C
B
D
F
E
E
G
H
M
L
P
N
R
G
F
H
L
M
N
T
P
R
T
Fig. 17 – Esempio di albero non binario e sua rappresentazione mediante liste
Riferimenti bibliografici
•
•
•
27
Deitel & Deitel, Java – Tecniche avanzate di programmazione (sec. ed.), 2003, Apogeo, cap. 9.
S. Ceri, D. Mandrioli, L. Sbattella, Informatica arte e mestiere, 1999, McGraw-Hill Italia, cap.
10.
C. Batini et al, Fondamenti di programmazione dei calcolatori elettronici, 1992, F. Angeli,
capp. 2 e 3.