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.