Sei sulla pagina 1di 51

PROGRAMMAZIONE 2

Appunti del Corso


(L. Lesmo - febbraio 2008)

PARTE PRIMA Introduzione alla ricorsione, ai puntatori e alle liste

INDICE Indice dei Capitoli 1.


1.1 1.1.bis 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 1.10 1.11 1.12

Programmi e ricorsione (tramite un caso di studio: il sort)


Un metodo di soluzione: il Selection Sort Il Selection Sort (continua) Un altro modo per ordinare una sequenza: merge-sort .. Implementazione di merge-sort ... Implementazione di merge-sort con una classe MioVett Le liste . Vettori e liste: alcuni vantaggi e svantaggi .. Altre operazioni sulle liste .. La classe LinkedList di Java Ancora sulla memoria . Le eccezioni in Java Input e output con files . Merge-Sort sulle liste . 3 9 13 15 19 22 26 28 31 31 35 38 42

Indice dei Richiami di Java


1: Generalit ..... 2: Vettori ..... 3: Spazio di memoria 4: Passaggio dei parametri ai metodi . 5: Metodi statici . 4 5 7 10 12

Indice delle Procedure e dei Programmi


Proc. 1 - Selection Sort . 9 Proc. 2 - Il main della classe MioProg, che usa Selection Sort .... 13 Proc. 3 - Merge di vettori . 17 Proc. 4 - Merge Sort - Versione 1 . 18 Proc. 5 - Merge Sort - Versione 2 . 19 Proc. 6 - La Classe MioVett .. 21 Proc. 7 - Un programma che usa la Classe MioVett .... 22 Proc. 8 - printList: visualizza gli elementi di una lista .... 25 Proc. 9 - I metodi insertFirst e deleteFirst ... 26 Proc. 10 - in_lista: Ce un elemento in una lista? ...... 29 Proc. 11 - modifElem: Modifica un elemento di una lista .. 29 Proc. 12 - insertAfter: Inserisci un elemento in una lista dopo un elemento specificato 30 Proc. 13 - deleteElem: Cancella un elemento da una lista .. 31 Proc. 14 - getFirstDato e printList con trattamento delle eccezioni . 37 Proc. 15 Definizione delleccezione NoSuchElementException .. 37 Proc. 16 - readListFromFile: Leggi una lista da un file .. 40 Proc. 17 - Operazioni di base sulle liste . 40 Proc. 18 - splitList: dividi una lista in due met (versione 1) .. 44 Proc. 19 - splitList: dividi una lista in due met (versione 2) .. 46 Proc. 20 - mergeList: merge di due liste ordinate . 49 Proc. 21 - sortList: ordinamento di una lista . 50

Indice delle figure


Figura 1: Classi e oggetti nellambiente Java .. 5 Figura 2: Allocazione dello spazio: lo stack e lo heap 8 Figura 3: Rappresentazione semplificata del riferimento ad un vettore 8 Figura 4: Passaggio ad un metodo di un valore intero (non ok) . 11 Figura 5: Passaggio ad un metodo di un valore intero (ok) . 11 Figura 6: Variabili di istanza della classe MioVett . 20 Figura 7: Struttura di una lista generica . 22 Figura 8: Struttura di una istanza della classe MiaLista .. . 24 Figura 9: Uso di una variabile per mantenere una posizione su una lista 24 Figura 10: Iteratori realizzati con istanze della classe MiaLista 25 Figura 11: Implementazione di una lista con le classi MiaLista e ListElem 25 Figura 12: Realizzazione di un cursore su una lista: loopElem 25 Figura 13: Avanzamento di un cursore su una lista: loopElem 26 Figura 14: Eliminazione di un elemento da una lista 27 Figura 15: Inserimento di un elemento in una lista 28 Figura 16: Realizzazione di un cursore su una lista: loopElem (ripetizione) 30 Figura 17: Allocazione di un nuovo blocco sullo stack ... 32 Figura 18: Allocazione di blocchi sullo stack in caso di chiamate ricorsive 33 Figura 19: Contenuto di un blocco sullo stack .... 34 Figura 20: Rientri da metodi in presenza di una situazione di errore ..... 35 Figura 21: Split di una lista ..... 43 Figura 22: Un altro modo per fare lo split di una lista .. ..... 45 Figura 23: Split: situazione iniziale .... ..... 45 Figura 24: Prima di un passo di split .... ..... 46 Figura 25: Dopo il passo di split .... ..... 46

1. Programmi e Ricorsione
La programmazione unattivit creativa. Non si tratta, infatti, di imparare a memoria delle formule o delle leggi, bens di inventare delle soluzioni per dei problemi. Deve essere chiaro che il punto chiave non la scrittura del programma (cosa che voi comunque dovrete fare in Java, ma avreste potuto fare con altri linguaggi, come C, Prolog, o Lisp), bens la scoperta del modo per ottenere il risultato. Supponiamo che a un impiegato il capufficio dia un pacco di 200 fogli dicendogli: Queste sono delle nuove fatture. Le vorrei in ordine alfabetico tra mezzora! Indipendentemente da qualunque calcolatore, o linguaggio di programmazione, limpiegato dovr trovare un modo (o un metodo, o un algoritmo) per fare il lavoro, e cio per effettuare un sort dei fogli. E possibile che, con un metodo efficiente, limpiegato termini il lavoro nel tempo richiesto, mentre con un metodo meno efficiente pu non rispettare i tempi. Allo stesso modo, se debbo trovare una parola in un vocabolario, non comincio dalla prima pagina e poi procedo pagina per pagina fino ad arrivare alla parola desiderata, ma applico un metodo (cio un algoritmo) che rende la ricerca pi rapida. 1. 2. Non si devono quindi confondere due aspetti della programmazione: Trovare il metodo di soluzione Tradurre tale metodo nel linguaggio di programmazione che si vuole (o si deve) usare.

1.1 Un metodo di soluzione: il Selection Sort


Limpiegato che deve ordinare i 200 fogli pu inizialmente pensare di farlo nel modo seguente: Beh, mi scorro tutta la pila di fogli, e trovo quello col nominativo che in ordine alfabetico viene prima; lo tolgo dalla pila e lo metto sul tavolo. Poi nella pila rimanente cerco quello, di nuovo, col primo nome in ordine alfabetico e lo metto sopra il primo, e cos via. Per poi la pila mi viene allincontrario e devo invertirla. Forse posso cominciare dallultimo; o magari dal primo, ma mettendo i fogli sul tavolo rovesciati. Gi, per quanto ci metto a fare sto lavoro? Il povero impiegato non sa che ha a che fare con un (per nulla banale) problema di sort. E la soluzione che ha adottato simile a quella nota col nome di Selection Sort (e che, purtroppo per lui, non per nulla efficiente). Proviamo a vedere quanto tempo gli ci vorr per finire il lavoro. Supponiamo innanzitutto che per verificare il nominativo su un foglio (e per confrontarlo con quello che attualmente il primo in ordine alfabetico tra tutti quelli gi esaminati) ci voglia un secondo. La prima volta , per trovare il primo in ordine alfabetico, dovr guardare tutti i 200 fogli (200 secondi); estratto il primo, per trovare il secondo dovr guardare solo i 199 fogli rimanenti (199 secondi); per il terzo 198 fogli (198 secondi), e cos via. Il tempo totale , ovviamente: (200 + 199 + 198 + 197 + 196 + ... + 5 + 4 +3 + 2 + 1) secondi E chiaro che, se il nostro impiegato si mette a fare la somma, esaurisce gi il suo tempo. Ma noi sappiamo che, con il seguente semplice ragionamento (che voi dovreste gi conoscere) si fa prima: 200 + 199 + 198 + 197 + 196 + ... + 102 + 101 + 1 + 2 + 3 + 4 + 5 + ... + 99 + 100 = ==== ===== ===== ===== ===== ===== ==== 201 + 201 + 201 + 201 + 201 + ... + 201 + 201 E cio la somma dei primi 200 numeri equivale a sommare 100 volte il valore 201; la formula generale : (n/2)*(n+1) (nel nostro caso n=200) Per cui, il tempo totale sara di 20100 secondi . Ahiahiahi: sarebbero 5 ore e 35 minuti.

Prima di vedere se si sarebbe potuto fare meglio, cerchiamo di capire le differenze tra lesempio dellimpiegato e un ordinamento (ad es. di un vettore) su un calcolatore. Lunica differenza rilevante che in un calcolatore non c il tavolo (quello su cui posare i fogli estratti). Ma non si pu fare finta di averlo? Beh, se i 200 fogli sono rappresentati da un vettore, il modo pi semplice per fare finta di avere un tavolo quello di simulare un posto dove mettere i fogli (valori) estratti. Questo pu essere fatto introducendo un secondo vettore (che conterr il risultato). Per, a livello di strutture dati, questo non basta. Debbo anche simulare di togliere il foglio dalla pila. E questo, lavorando su un vettore non per nulla comodo (uso non comodo per dire che facile scrivere il programma, ma che loperazione molto lenta). Infatti, per simulare di aver tolto il 50-esimo foglio devo spostare tutti gli elementi successivi del vettore (cio devo mettere vett[51] in vett[50], poi vett[52] in vett[51], e cos via fino allassegnazione di vett[200] a vett[199]). Dopodich devo aggiornare la dimensione della pila (che passa da 200 a 199 fogli). Naturalmente vett[200] esiste ancora, ma come se fosse vuoto. Nellalgoritmo effettivo, dunque, faremo una cosa un po diversa: innanzitutto, non metteremo i fogli ordinati sul tavolo, ma allinizio della pila. Inoltre, per spostare un foglio da una posizione ad un'altra (ad es. dalla posizione 70 alla posizione 25), scambieremo i fogli 70 e 25. Prima di passare alla scrittura del programma Java, credo sia opportuno fare alcuni richiami di Java.

Richiami di Java 1: Generalit


I concetti fondamentali del linguaggio Java sono stati introdotti nel corso di Programmazione I. Penso per sia bene ricordare alcune idee fondamentali, prima di scrivere il programma per il Selection Sort in Java. Contrariamente a quello che avviene in altri linguaggi di programmazione, il concetto di programma in Java secondario. Il concetto primario infatti quello di Oggetto (infatti, Object-Oriented Programming: Programmazione Orientata agli Oggetti). Voi dovete immaginare il vostro programma come situato allinterno di un enorme scatolone che contiene oggetti. Il vostro compito, per scrivere il programma, quindi quello di inserire nello scatolone un nuovo insieme di oggetti, che si comportino in modo tale da realizzare ci che voi volete faccia il programma. Nello scatolone vi sono due tipi di oggetti: Classi e Istanze. Una classe la definizione di un certo tipo di oggetti. Unutile analogia pu essere quella delle specie naturali: Gatto una classe (una specie, o tipo, o categoria), mentre i vari gatti che ci sono al mondo sono istanze della classe Gatto. Mentre evidente che le istanze hanno delle propriet caratteristiche (per un gatto il colore, let, il peso, etc.), meno ovvio che anche le classi hanno delle caratteristiche. Ad esempio, per la classe Gatto let massima per un gatto, e magari lindirizzo di un veterinario esperto in gatti. Poich esistono solo oggetti1, che sono delle cose statiche, ma noi vogliamo fare delle cose con gli oggetti, ci si deve chiedere come questo pu essere realizzato. La risposta semplice: mandando dei messaggi agli oggetti. Se, ad esempio, invio il messaggio Pappa al mio gatto (che una istanza), provoco una reazione: il mio gatto viene verso di me. Perch questo avviene? Perch il mio gatto, dentro la testa, ha un metodo che gli permette di riconoscere alcuni (semplici) messaggi vocali e di reagire opportunamente ad essi. Esattamente la stessa cosa avviene per gli oggetti di Java: un oggetto in grado di rispondere a un messaggio solo se ha al proprio interno un metodo, che gli dice come comportarsi in risposta al messaggio. Allinterno di questo metodo, loggetto in questione potr inviare messaggi ad altri metodi e operare in base alla risposta a questi messaggi, fino a completare lattivit richiesta dal messaggio iniziale (il mio gatto venuto da me). Ora per sorge un problema: come fare a mandare il messaggio alloggetto giusto? (se io grido Pappa nello scatolone, arriveranno alcune decine di gatti, non solo il mio). Ebbene, lo scatolone di oggetti organizzato in scatole pi piccole al suo interno. Una di queste scatole il file che contiene gli oggetti che compongono il mio programma. Ma anche allinterno di questa scatola vi sono tanti oggetti; come si fa a sapere qual quello giusto? semplicemente quello che ha come nome lo stesso nome della scatola (cio del file).

Come avete visto in precedenza, questo non del tutto vero! In particolare, i tipi primitivi non sono oggetti. Quelli principali sono boolean, char, int, float (oltre a questi, esistono anche byte, short, long, double). Anche void un tipo primitivo, anche se un po particolare

Il mio file MioPro Classe33 MioPro System Gatto

Lambiente Java

Il mio gatto g1 g2 Rettangolo g3 g4

Figura 1: Classi e oggetti nellambiente Java Nella figura 1, compaiono un po di classi (nel vero ambiente Java ve ne sono centinaia) e ho inserito anche 4 istanze di gatti. Ho anche evidenziato la connessione tra i gatti e la classe Gatto (in Java, ogni istanza membro di una classe). MioPro la scatola che contiene le classi che ho definito nel mio fileprogramma (ce ne sono due, e una di esse deve, appunto, chiamarsi MioPro). Per, la classe MioPro pu essere in grado di rispondere a messaggi diversi. Come faccio a richiedere proprio lesecuzione (in generale) del programma? Quando attivo la classe, Java interpreta questa attivazione come una richiesta di eseguire un metodo di nome main; sono quindi obbligato a inserire tra i vari metodi della classe MioPro il metodo main: questo il metodo che verr applicato quando viene richiesta lesecuzione del programma. Vista lorganizzazione delineata sopra, in Java, a differenza che in altri linguaggi di programmazione, la prima cosa da fare non tanto decidere quali procedure (che sarebbero i nostri metodi) servono, bens decidere quali sono gli oggetti coinvolti (innanzitutto, quali classi). Se ci riferiamo di nuovo allesempio dellimpiegato, i tipi di oggetti coinvolti sono le fatture; sar quindi necessario definire una classe Fattura. Ciascuna istanza di Fattura, dovr includere, al proprio interno un Nominativo, che specificher chi ha emesso la fattura, e che servir allimpiegato per effettuare lordinamento. Per ora, non preoccupiamoci di questo (le Classi possono essere strutturate al loro interno); poich a noi interessa lalgoritmo di ordinamento, e non i dettagli dellesempio dellimpiegato, supponiamo di dover semplicemente ordinare dei numeri interi. In questo caso, non mi serve una nuova classe, perch il tipo di dato numero intero gi predefinito. Per noi non dobbiamo lavorare su singoli numeri interi, ma su un gruppo di numeri (200, nellesempio). Ovviamente, sarebbe possibile introdurre 200 variabili diverse di tipo int, e cio num1, num2, num3, num200, ma poich le operazioni che dobbiamo fare si ripetono uguali per tutti i numeri, molto pi conveniente usare una struttura dati composta, ad esempio un vettore (potete provare, come utile esercizio, a scrivere il metodo per lordinamento di 10 numeri che utilizza il Selection Sort, usando 10 variabili intere invece di un vettore di 10 elementi; ve lo sconsiglio con 200).

Richiami di Java 2: Vettori


Devo innanzitutto premettere che io user il termine vettore in modo un po diverso da quello standard di Java. Volendo essere precisi, dovrei dire array a una dimensione, anche perch Java ha una sua classe che si chiama Vector. Poich, per, lespressione corretta un po lunga, io user vettore. Voi ricordatevi che, tutte le volte che nel testo compare la parola vettore essa va intesa, nella terminologia Java, come array a una dimensione.

Come tutte le altre cose (ma vedi la nota 1, sopra), i vettori in Java sono degli oggetti. Sono, in particolare degli oggetti compositi, formati da altri oggetti pi semplici, tutti dello stesso tipo (e cio, tutti gli elementi di un vettore sono istanze della stessa classe). Per ora, mi limiter a considerare vettori di numeri. Sebbene i vettori siano oggetti, essi sono di uso molto comune, per cui Java permette delle espressioni pi semplici di quelle standard per trattare i vettori. In particolare, come vedremo, non sempre necessario utilizzare la new per creare un nuovo vettore. Una caratteristica fondamentale dei vettori in Java (come in molti altri linguaggi) che essi hanno una dimensione fissa. Vi sono due modi per specificare la dimensione di un vettore. Il primo quello di far coincidere la dichiarazione di esistenza del vettore con la sua inizializzazione: int [] mioVettore = {1, 2, 33, 13, 10}; Le due parentesi quadre (aperta e chiusa) informano il compilatore Java che mioVettore non una variabile intera (int), ma , appunto, un vettore di variabili intere. Il contenuto della parentesi graffa specifica i valori iniziali degli elementi del vettore; poich nella parentesi ci sono 5 valori, allora il vettore sar formato da 5 elementi. Nel seguito del programma sar possibile riferirsi ai singoli elementi del vettore tramite l'espressione mioVettore [ind] Dove ind una variabile (in generale pu essere unespressione che produce un valore intero o una costante) intera che pu assumere un valore da 0 a 4: purtroppo, in Java, il primo elemento sempre individuato dallindice 0, e non 1, come sarebbe pi naturale. In questo caso, se io scrivessi: mioVettore [5] oppure mioVettore [7] oppure mioVettore [var] in cui var abbia attualmente un valore maggiore di 4, otterrei una segnalazione di errore e il programma si interromperebbe! In alcuni casi non per possibile fornire subito i valori degli elementi che compongono il vettore. Supponiamo, ad esempio, di voler costruire un nuovo vettore che abbia gli elementi in ordine inverso rispetto a mioVettore. In questo caso il risultato (supponiamo si chiami nuovoVettore) dovr essere lungo 5, ma non so quali sono i valori (ovviamente so quali sono i valori, in questo semplice esempio, ma non voglio fare linversione a mano, voglio che la faccia il programma). In un caso come questo, debbo definire un vettore nuovo di 5 elementi interi vuoti. Questo posso farlo con due istruzioni: int [] nuovoVettore; nuovoVettore = new int[5]; Si sono qui separate le due fasi: prima si dichiara lesistenza delloggetto nuovoVettore, specificandone la classe (vettore di interi); in altre parole dico che nuovoVettore una istanza della classe vettore di interi; poi, separatamente, chiedo a Java di creare tale istanza, riservando lo spazio necessario e mettendo dentro la variabile nuovoVettore un riferimento allo spazio (locazione di memoria) che stato riservato per esso. Notate che se io usassi lespressione nuovoVettore[2] prima di aver effettuato la new, avrei una segnalazione di errore, perch il vettore non esiste ancora! Ricordate comunque che le due operazioni viste sopra possono anche essere condensate in unistruzione unica, che per corrisponde alle due operazioni di base: int [] nuovoVettore = new int[5]; Vorrei infine osservare che anche lespressione nuovoVettore = new int[5]; corrisponde, dal punto di vista di Java, a due diverse operazioni: la prima quella di riservare lo spazio (trovare spazio in memoria) per il vettore (e cio per 5 interi adiacenti); questo fatto da new int[5]. La seconda lassegnazione alla variabile nuovoVettore del riferimento al vettore. In altre parole, cos come var1 = var2 + var3;

lassegnazione alla variabile (supponiamo intera, cio int) var1 del valore dellespressione var2 + var3, esattamente allo stesso modo nuovoVettore = new int[5]; lassegnazione alla variabile nuovoVettore del valore dellespressione new int[5], che , appunto, il riferimento allo spazio allocato (riservato) per il nuovo vettore.

Richiami di Java 3: Spazio di memoria


Poich sopra ho parlato di riservare lo spazio per il vettore, opportuno ricordare come viene gestita la memoria in Java. Larea di memoria disponibile ad un programma Java ripartita in due sezioni: lo stack e lo heap.2 Come dicono i nomi, lo stack (pila) strutturato, mentre lo heap (mucchio) non ha una struttura predefinita. Scopo dello stack mantenere le informazioni necessarie per lesecuzione dei singoli metodi, mentre tutti gli oggetti Java sono memorizzati nello heap. Parliamo, per ora, dello heap. 3.1 Lo heap Lo heap pu essere visto come una lunga sequenza di celle di memoria. Quando un metodo richiede la creazione di un nuovo oggetto (new), Java deve trovare dentro questa sequenza lo spazio libero necessario per loggetto. Nel caso dei vettori, lo spazio necessario deve essere costituito da una sequenza di celle adiacenti. Cio, se si vuole creare un vettore di 5 interi, Java dovr trovare una quantit di spazio libero sufficiente per memorizzare 5 interi, cio 20 bytes3. Una volta fatto ci, dovr fornire al metodo che ha richiesto la creazione qualcosa che gli permetta di accedere allo spazio riservato; ci che Java fornisce al metodo un riferimento al vettore. Il termine riferimento piuttosto astratto: questo fatto di proposito per evitare di legarsi ad una particolare implementazione. Se pu aiutare, si pu pensare che il riferimento ad un vettore sia lindirizzo della prima cella di memoria riservata per il vettore, ma Java, di per s, non dice come deve essere realizzato un riferimento, dice solo come funziona! Il riferimento viene inserito in una locazione di memoria, di cui il metodo conosce la posizione. In questo modo, quando il metodo usa unespressione come mioVettore[2], possibile determinare la posizione della cella che contiene il terzo elemento del vettore. Ma dove viene in realt messo il riferimento? Esso viene posto allinterno dello stack. Vediamo allora brevemente come viene gestito lo stack. 3.2 Lo stack Ogni volta che inizia lesecuzione di un metodo, sullo stack viene riservata una quantit di spazio (record di attivazione, o blocco) pari a quella (determinata dal compilatore) necessaria per i parametri e le variabili locali del metodo. Quando il metodo inizia lesecuzione, nota la posizione iniziale del blocco sullo stack ad esso associato (ad esempio, il blocco inizier dalla cella 124.001) e, per ogni parametro o variabile del metodo, nota la sua posizione allinterno del blocco (ad esempio, il riferimento a mioVettore si trover nella quarantesima cella del blocco). A questo punto, dovrebbe
2

C una terza sezione della memoria, che contiene i dati statici, e cio quelli che non cambiano (strutturalmente) durante lesecuzione del programma. In questarea sono contenute le definizioni delle classi e dei metodi associati. Sebbene ogni dato elementare (come int) abbia una dimensione predefinita, che diversa a seconda del tipo (ad esempio, un int occupa 4 bytes, un char 2 bytes, un intero long 8 bytes, ecc.), io nel seguito eviter di considerare questo problema, parlando, in generale di celle di memoria. Questo impreciso, ma semplifica notevolmente la presentazione. E anche da osservare che i vettori sono oggetti Java (di tipo array) che contengono altre informazioni, oltre i dati veri e propri; ad esempio, quando si usa lespressione nioVettore.length, si richiede di prelevare il valore della variabile length dallistanza mioVettore. Ci significa, che unistanza di vettore contiene anche una variabile (di istanza) in cui memorizzato il numero di elementi. 7

essere ovvio come si fa ad accedere ad un elemento del vettore. Se, ad esempio, bisogna leggere il valore di mioVettore[3], si prende la posizione del blocco sullo stack associato al metodo che si sta eseguendo (124.001), ad essa si somma la posizione del riferimento allinterno del blocco meno 1 (124.001+40-1); in questa cella (124.040) ci sar il riferimento al vettore (ad es. 302.024, se il riferimento costituito da un indirizzo); a questo punto, se ogni intero occupa una cella, e poich le celle del vettore sono adiacenti, immediato determinare la posizione della cella voluta: 302.024+3 = 302.027. Si noti che qui non necessario togliere 1, proprio perch la numerazione degli elementi del vettore parte da 0. La situazione dunque quella mostrata nella figura 2. Suggerisco di studiare bene il disegno di figura 2: questo lunico modo per comprendere bene come funzionano i riferimenti in Java. Notate che tutti i numeri che ho usato sono casuali: lo stack potrebbe iniziare da qualsiasi indirizzo di memoria, cos pure come lo heap (basta che non si sovrappongano, ovviamente). Naturalmente, sia leggere che disegnare una figura come quella sopra richiede parecchio tempo, per cui, pi frequentemente, si usano rappresentazioni come quelle in figura 3. Anchio far altrettanto, ma deve essere chiaro che solo la figura 2 chiarisce davvero come viene utilizzato lo spazio di memoria in Java. Ricordate per anche ci che stato detto nella nota 3: anche il disegno del vettore come sequenza di elementi una semplificazione, poich listanza vera e propria include anche altre informazioni (ad es. la variabile length richiede dello spazio). Anche una figura complessa come la fig.2 quindi una semplificazione della situazione effettiva. Maggiori dettagli (con esempi) sul funzionamento dello stack li trovate pi avanti (1.10) e nel capitolo 2 (sugli alberi).

lo stack 1 2 3 120.000

lo stack 124.001

124.040

302.024

lo heap

302.024

302.027

33
mioVettore

13

10

Figura 2: Allocazione dello spazio: lo stack e lo heap

mioVettore 1 2 33 13 10

Figura 3: Rappresentazione semplificata del riferimento ad un vettore

1.1.bis Un metodo di soluzione: il Selection Sort (continua)


Possiamo finalmente tornare al Selection Sort. Il metodo che realizza tale algoritmo riportato nel riquadro Proc.1. Esso costituito da un unico ciclo for, che viene ripetuto tante volte quanti sono gli elementi del vettore. In questo ciclo, ad ogni passaggio, si trova lelemento che deve essere messo in posizione i. Lidea molto semplice: se i primi i-1 elementi sono ordinati, allora in posizione i dovr andare il pi piccolo dei rimanenti (ordinamento per valori crescenti). Notate che il controllo di fine ciclo i < size, strettamente minore, non minore o uguale, proprio perch la numerazione parte da 0 e termina a size-1. Allinterno del ciclo si fanno 4 operazioni (notate lutilit di un buon allineamento delle righe del metodo!). Queste 4 operazioni hanno lo scopo di trovare lelemento pi piccolo tra quelli non ancora esaminati e di metterlo nella posizione corretta nel vettore. Per far ci, si suppone che lelemento pi piccolo sia il primo tra quelli non esaminati, si mette il suo valore nella variabile minval, e la sua posizione nella variabile minind (inizializzazione della ricerca: prime due operazioni del ciclo). Poi, si esaminano tutti quelli rimanenti (ciclo for interno: terza operazione del ciclo). Se si trova un elemento pi piccolo di quello trovato finora, lo si mette in minval, e si mette la sua posizione in minind. Alla fine del for interno, in minval avremo il valore pi piccolo e in minind la sua posizione nel vettore. Se lelemento pi piccolo non nella posizione corretta (if: quarta operazione del ciclo), si effettua uno scambio (body dellif)4.

static void SelectionSort (int [] argVett, int size) {for (int i = 0; i < size-1; i++) {int minval = argVett [i]; int minind = i; for (int j = i+1; j < size; j++) if (argVett [j] < minval) {minval = argVett[j]; minind = j;}; // Fine del for interno if (minind != i) {int temp = argVett[i]; argVett[i]=argVett[minind]; argVett[minind] = temp;} } // Fine del for esterno } // Fine del Selection Sort ! Proc.1 - Selection Sort Per quanto riguarda il for esterno, esso inizia dal primo elemento del vettore ed esamina tutti gli elementi. Al termine del loop interno avr trovato lelemento pi piccolo di tutti. Se esso gi in prima posizione, non fa nulla, altrimenti lo scambia con quello in prima posizione. A questo punto in argVett[0] c sicuramente lelemento pi piccolo, la variabile di ciclo i viene incrementata, e inizia la ricerca dellelemento pi piccolo da argVett[1] (secondo elemento del vettore) in avanti. Come si era detto, nel selection sort che vedete nel riquadro, invece di togliere il foglio, lo si scambia col primo non ancora ordinato. Cos non c bisogno di un secondo vettore (risparmio di spazio) e non c bisogno di modificare la dimensione della pila, n di spostare i fogli successivi (risparmio di tempo). Come potete osservare, lo spazio reale non del tutto analogo a quello del calcolatore (ma immaginate che limpiegato non avesse un tavolo su cui appoggiarsi e dovesse fare tutto il lavoro con i fogli sulle ginocchia). E anche il tempo reale non lo stesso di quello del calcolatore: scambiare a mano due fogli in una pila richiede magari 10 volte pi tempo che controllare un nome su un foglio,
4

In realt, il metodo di Selection sort si differenzia da quello detto Insertion sort proprio in quanto si effettua uno scambio di variabili e non uno spostamento (come invece avevamo fatto nellesempio dellimpiegato). Spero che vi possiate convincere che il risultato corretto, poich, dopo K cicli, i primi K elementi sono ordinati in ambo i casi, e questo quello che interessa. 9

mentre scambiare due elementi di un vettore pu richiedere 2 o 3 volte pi tempo che controllare il valore di un elemento. In ogni caso, il metodo realizzato rimane molto inefficiente. Prima di vedere se si pu trovare un metodo migliore, per, riprendiamo un altro argomento fondamentale in Java (cos come in ogni altro linguaggio di programmazione): il passaggio dei parametri.

Richiami di Java 4: Passaggio dei parametri ai metodi


Supponiamo di voler ordinare il vettore mioVettore, che, come abbiamo visto, memorizzato nello heap. Per far ci, visto che per ora lunico metodo che conosciamo SelectionSort, non possiamo fare altro che applicare a mioVettore tale metodo. Questo si ottiene con lespressione SelectionSort (mioVettore, 5); In questo modo, io ho mandato un messaggio (vedremo nel prossimo paragrafo chi il ricevente di questo messaggio), in cui si chiede di applicare il metodo SelectionSort, a mioVettore, informando anche il metodo che mioVettore composto da 5 elementi5. Quando SelectionSort inizia lesecuzione, per, si trova ad eseguire delle operazioni su argVett, e non su mioVettore! Per comprendere come questo pu funzionare, necessario guardare di nuovo la figura 2. Quando SelectionSort inizia lesecuzione, viene riservato dello spazio sullo stack (supponiamo sia proprio quello disegnato in grigio, che inizia dalla posizione 124.001). Chi ha inviato il messaggio, ovviamente, sa dove si trova mioVettore (e cio a partire dalla cella 302.024). Inoltre, sia chi ha inviato il messaggio, sia il metodo SelectionSort sanno anche che la posizione in cui sono memorizzate le informazioni sul primo argomento (mioVettore per il mittente, argVett per il ricevente) sono nella quarantesima posizione del blocco sullo stack: sufficiente che il mittente copi in tale cella il riferimento a mioVettore. Quindi, durante lesecuzione, SelectionSort, andr a prendere dalla cella 124.040 il riferimento a argVett, e andr quindi ad agire sulle celle di memoria in cui mioVettore stato memorizzato. In questo modo, qualunque operazione, sia per leggere che per scrivere il vettore, viene in realt effettuata su mioVettore. Di conseguenza, quando le operazioni di SelectionSort sono terminate, chi ha inviato il messaggio si trover mioVettore ordinato. E necessario per ricordarsi che quasi tutti gli elementi di Java sono oggetti, non proprio tutti! Come si visto nel 1.3, i tipi di base (interi, reali, caratteri, booleani), NON sono oggetti. Di conseguenza, per questi elementi, nonostante il modo per il passaggio dei parametri sia esattamente lo stesso, il risultato dellesecuzione di un metodo differente. Cosa intendo, quando dico che il modo di passaggio dei parametri esattamente lo stesso? Semplicemente, che ci che deve fare Java la stessa cosa in entrambi i casi: copiare un valore nella cella opportuna dello stack. La differenza sta nel fatto che nel caso degli oggetti (tra cui i vettori) ci che viene copiato il riferimento alloggetto, mentre nel caso dei tipi di base ci che viene copiato il valore stesso (non c nessun oggetto cui riferirsi). Ma, come dicevo, leffetto dellesecuzione del metodo molto differente. Supponiamo di usare un metodo per raddoppiare il valore di un intero: static void raddoppia (int mioNumero) {mioNumero = mioNumero*2}; Sebbene il metodo sia corretto, un messaggio come il seguente (che, ad esempio, compaia nel main) non ottiene il risultato voluto: int numeroInput = 147; raddoppia (numeroInput); Per comprendere perch, supponiamo che anche i numeri siano oggetti veri e propri. Allora la situazione, allatto dellesecuzione del metodo, sarebbe quella di figura 4. Non preoccupiamoci, per
5

Come sapete, la dimensione non sarebbe necessaria, perch si pu ottenere tramite la variabile length. Ovviamente, questo non cambia per nulla lalgoritmo. Inoltre, il metodo che abbiamo visto pu anche essere usato per ordinare i primi size elementi di un vettore (che magari molto pi lungo). 10

ora, di dove si trova la cella associata a numeroInput (sar anchessa sullo stack); ci che importa che stata copiata la freccia (e cio il riferimento ad un oggetto nello heap, che contiene il valore 147). E importante che facciate attenzione! Copiare la freccia, vuol dire semplicemente copiare il numero (presumibilmente un indirizzo) che contenuto nella cella da cui la freccia parte! Confrontate ancora le figure 2 e 3. La freccia si usa solo per comodit di chi guarda la figura: non ci sono frecce in Java! Ora, se io facessi in questa situazione mioNumero = mioNumero*2, tutto sarebbe OK: Java utilizza il riferimento per recuperare il valore (147) , e poi lo stesso riferimento per scrivere il valore raddoppiato; per cui, nella cella dello heap andrebbe a finire il valore 294. Quando il metodo termina, il riferimento numeroInput individuerebbe il valore 294, come desiderato. Questo proprio quello che succede in SelectionSort, in cui il primo argomento un vettore, e cio un oggetto.
STACK HEAP

mioNumero

PRIMA
numeroInput

147

STACK

HEAP

mioNumero

DOPO

numeroInput

294

Figura 4: Passaggio ad un metodo di un valore intero SE i numeri interi fossero oggetti (ma non cos!) Ma la situazione non quella di figura 4, bens quella di figura 5! Notate che il passaggio del parametro avvenuto esattamente come prima: il contenuto di numeroInput stato copiato in mioNumero. Solo che questa volta, il contenuto di tipo diverso: non un riferimento, ma un valore. Per cui, quando il metodo preleva il valore iniziale di mioNumero, lo preleva dallo stack, e quando va a modificare tale valore, lo modifica sullo stack. E chiaro, quindi, che in questo caso il valore di numeroInput rimane invariato (quando il metodo termina lesecuzione, la sua porzione di spazio sullo stack viene rilasciata, per cui anche della modifica di mioNumero non rimane pi traccia). Come si pu fare, allora? In questo caso, bisogna scrivere un metodo che non sia void. Il metodo, cio, anzich modificare esso stesso il valore, dovr restituire al mittente del messaggio il valore desiderato:
STACK HEAP

mioNumero

147

PRIMA
numeroInput 147

STACK

HEAP

mioNumero

294

DOPO

numeroInput 147

Figura 5: Passaggio effettivo ad un metodo di un valore intero. La modifica interna al metodo non ha leffetto desiderato!

11

static int raddoppia (int mioNumero) {return mioNumero*2;}; e il metodo che vuole raddoppiare il numero dovr fare: numeroInput = raddoppia (numeroInput); Sar cio compito del mittente prendere il valore restituito dal metodo e memorizzarlo nella variabile.

Richiami di Java 5: Metodi statici


Se guardate il riquadro Proc.1, vedete che il primo termine che compare static. Questa parola chiave indica che il metodo SelectionSort un metodo statico. Ci significa che il metodo associato alla classe, e non alle istanze della classe. Per capire meglio, torniamo alla figura 1. Abbiamo detto che, quando definisco un programma (ad esempio mioPro), questo consiste nella definizione di una o pi classi (che sono oggetti Java). Abbiamo anche visto che una delle classi deve avere lo stesso nome del file che contiene il programma, e che tale classe deve includere il metodo main. Infine, abbiamo ricordato che vi sono sia classi (es. Gatto) che istanze. Nellesempio dei gatti, inoltre, abbiamo supposto che il messaggio Pappa sia inviato ad una istanza della classe. Cosa succede, per se io voglio ottenere linformazione relativa al veterinario per gatti? Non debbo certo mandare il messaggio di richiesta di informazioni al mio gatto (n ad alcun gatto in particolare), ma debbo inviarlo alla classe stessa. Non solo, ma questa informazione pu essere reperibile (anche se, magari, non molto utile) anche se non esiste nel mio ambiente Java nessuna istanza di Gatto. I metodi che vengono attivati inviando un messaggio alla classe vengono appunto detti metodi statici. Il motivo per cui viene utilizzato questo termine che, di norma, le classi costituiscono la parte pi stabile di un ambiente Java: un gatto pu nascere o morire, ma il concetto di gatto rimane. Tutto ci che associato alle classi viene dunque detto statico nella terminologia Java (v. Nota 2). E perch, nel nostro caso, viene utilizzato un metodo statico, se ci che vogliamo ordinare un singolo vettore (e cio unistanza della classe dei vettori)? La risposta che, per introdurre questi primi concetti di programmazione in Java, abbiamo adottato un approccio solo in parte coerente con i principi della programmazione a oggetti! Quello che noi abbiamo fatto stato infatti di definire la classe mioPro (in realt non labbiamo ancora fatto; vedi sotto il riquadro Proc.2) e di associare ad essa la procedura di sort. Al contrario, se avessimo voluto essere pi fedeli ai principi della programmazione ad oggetti, avremmo dovuto definire, allinterno del file mioPro, una classe iniziale (con il metodo main) e poi una classe distinta MioVett. A questo punto sarebbe stato possibile: 1. 2. 3. Creare nuove istanze di MioVett (ad esempio listanza Vett33) Definire un metodo SelectionSort non statico, che fa esattamente le stesse cose che abbiamo visto, e che associato alla classe MioVett Inviare alle istanze il messaggio SelectionSort, la cui esecuzione comporta lordinamento dellistanza che riceve il messaggio.

Poich tutto ci un po pi complicato, abbiamo seguito la strada di definire una classe che include solo metodi, non istanze, e quindi lunico modo per eseguire i metodi quello di inviare dei messaggi alla classe stessa, richiedendo quindi lesecuzione di metodi, appunto, statici. Il metodo main che pu essere utilizzato per provare il metodo SelectionSort riportato nel riquadro Proc.2. Nel programma, ho introdotto due cicli for per stampare il vettore (prima nellordine originale e poi dopo lordinamento). La condizione di terminazione del ciclo stata espressa con i < mioVettore.length length, come sapete, la variabile (di istanza) degli oggetti di tipo vettore che ne contiene la lunghezza. Loperatore di confronto, come abbiamo gi visto, deve essere < e non <=, perch la numerazione degli elementi parte da 0.

12

public class MioPro {public static void main (String [] args) {System.out.println (" Ordinamento di un vettore"); int [] mioVettore = {7, 3, 5, 10, 1, 33, 4, 3, 8, 19}; /* visualizzazione del vettore iniziale */ System.out.print (" Input: "); for (int i = 0; i < mioVettore.length; i++) System.out.print (mioVettore[i] + ", "); System.out.println (""); /* esecuzione del metodo di ordinamento */ SelectionSort (mioVettore, mioVettore.length); /* visualizzazione del vettore ordinato */ System.out.print (" Risultato: "); for (int i = 0; i <mioVettore.length; i++) System.out.print (mioVettore[i] + ", "); System.out.println (""); } // Fine del metodo main static void SelectionSort (int [] argVett, int size) {. Qui va la definizione di Selection Sort del riquadro Proc.1 . } // Fine del Selection Sort ! } // fine della definizione della classe Mio Pro Proc.2 Il main della classe MioPro, che usa SelectionSort Un ultimo commento. Abbiamo detto che tutto il meccanismo di esecuzione di programmi in Java consiste nellinvio di messaggi. Quindi, anche la riga SelectionSort (mioVettore, mioVettore.length) linvio di un messaggio! Ma mandare di un messaggio implica la presenza di un ricevente. Chi il ricevente di SelectionSort (mioVettore, mioVettore.length)? Il ricevente la classe stessa (poich, come abbiamo visto, si tratta di un metodo statico) che ha inviato il messaggio. In altre parole, il ricevente coincide col mittente!

1.2 Un altro modo per ordinare una sequenza: merge-sort


Torniamo ora al modo di ordinamento. Limpiegato pu, in un modo o nellaltro, essersi reso conto che limpresa irrealizzabile, e quindi pu aver escogitato un altro metodo. Supponiamo di dividere la pila iniziale in 4 pile di 50 fogli ciascuna (diciamo che questa divisione richiede 1 minuto). Poi ordina ciascuna pila, e poi mette insieme le pile ordinate (merge). Oramai sappiamo quanto ci vuole a ordinare una pila di 50 fogli: ((50/2)*51) secondi = 1275 secondi (cio 21 minuti e 15 secondi) Che, moltiplicato per 4, fa 1 ora e 25 minuti. Se per fare il merge da 4 pile, ci mette 2 secondi a foglio, sono altri 400 secondi (6 minuti e 40 secondi). Per cui il tempo totale : 1 minuto + (suddivisione in 4 pile) 1 ora 25 minuti + (sort delle 4 pile) 6 minuti 40 secondi = (merge) ==== ==== ==== ==== ==== 1 ora 32 minuti 40 secondi (totale) Ora va un p meglio: abbiamo risparmiato pi di 4 ore! A questo punto, vorrei ribadire quanto gi detto sopra: il problema del sort un problema per nulla banale. Gli algoritmi di sort vengono studiati sostanzialmente per due motivi: 1. Sono utili dal punto di vista applicativo (capita spesso di dover ordinare degli elenchi) 2. Sono stati ben studiati, e quindi ne sono note le caratteristiche di efficienza

13

Quello che NON si richiede che uno studente inventi un buon algoritmo di sort. N voi n io sapremmo far meglio di quanto gi si sa. Ma se uno studente comprende bene la differenza, in termini di efficienza, che c tra due diversi algoritmi di sort, allora sulla buona strada per capire cos lefficienza degli algoritmi (o, come si dice, di analizzarne la complessit computazionale). Il che, come abbiamo visto nellesempio dellimpiegato, non solo una questione di diletto scientifico. Torniamo ora al metodo. A parte che la scadenza imposta dal capufficio non ancora rispettata, si deve osservare che limpiegato sta ora usando due metodi diversi. Un metodo (chiamiamolo merge-sort) per la pila completa (dividerla in pile pi piccole, ecc.) e un altro metodo (il solito selection-sort) per ordinare ciascuna delle pile piccole. Ci si pu quindi chiedere se non si pu fare tutto col merge-sort (che, come abbiamo visto, porta notevoli vantaggi di tempo). In altre parole, non si pu applicare lo stesso merge-sort anche alle pile di 50 fogli? Non si vede perch no! Se la pila iniziale fosse stata di 50 fogli, tutti i ragionamenti visti sopra si sarebbero potuti fare esattamente nello stesso modo. Per cui possiamo prendere la prima pila di 50 fogli, la dividiamo in 4 pile (due di 13 fogli e due di 12), ordiniamo le 4 sotto-sottopile e poi facciamo il merge. Per ordinare ciascuna delle sottosottopile (di 13, 13, 12, 12 fogli) facciamo lo stesso. Prendiamo la prima (di 13) e la suddividiamo in 4 (di 4, 3, 3, 3 fogli). Poi prendiamo la prima di esse (4 fogli) e facciamo quattro pile di un foglio. Ora sul tavolo (in verit un po grande) limpiegato ha pile delle seguenti dimensioni: 1 1 1 1 3 3 3 13 12 12 50 50 50 Le prime 4 pile sono gi ordinate (contengono solo un foglio) e quindi si pu fare il merge, ottenendo 4* 3 3 3 13 12 12 50 50 50 In cui lasterisco indica che la pila ordinata. Allora possiamo suddividere la prima non ordinata: 4* 1 1 1 3 3 13 12 12 50 50 50 e farne il merge: 4* 3* 3 3 13 12 12 50 50 50 cos pure come per le due successive (in due passi - suddivisione e merge - ciascuna): 4* 3* 3* 3* 13 12 12 50 50 50 e finalmente possiamo fare il merge delle prime 4 sotto-sotto pile: 13* 13 12 12 50 50 50 e cos via, fino ad avere la pila completa ordinata. Poich il merge-sort della pila originale di 200 fogli stato ottenuto tramite il merge-sort di ciascuna delle sottopile di 50, e cos via per pile sempre pi piccole, abbiamo inventato il nostro primo metodo ricorsivo. Vediamo ora se efficiente (nei calcoli sotto suppongo che unoperazione di merge da 4 pile richieda 2 secondi a foglio, mentre la suddivisione di una pila richiede 0.3 secondi a foglio). Per ordinare la pila di 200 fogli: Tempo di suddivisione (1 minuto) 4 * Tempo di ordinamento delle pile da 50 (?1) Tempo di merge (400 secondi) Per ordinare una pila di 50 fogli: Tempo di suddivisione (15 secondi) 4 * Tempo di ordinamento delle pile da 13 (?2) Tempo di merge (100 secondi) Per ordinare una pila di 13 fogli: Tempo di suddivisione (4 secondi) 4 * Tempo di ordinamento delle pile da 3 (?3)

! non si sa ancora !

(sarebbe 2 da 13 e 2 da 12, ma cambia poco)

(sarebbe 3.9 secondi) (sarebbe una da 4 e 3 da 3)

14

Tempo di merge (26 secondi) Per ordinare una pila di 3 fogli: Tempo di suddivisione (1 secondo) + 3 * Tempo di ordinamento delle pile da 1 (0 secondi) Tempo di merge (6 secondi)
(sarebbe 0.9 secondi) (sono gi ordinate)

Quindi, 7 secondi il tempo totale per una pila da 3 fogli (il valore da sostituire a ?3, sopra). Quindi, 58 secondi il tempo totale per una pila da 13 fogli (il valore da sostituire a ?2, sopra). Quindi, 347 secondi il tempo totale per una pila da 50 fogli (il valore da sostituire a ?1, sopra). Quindi, 1848 secondi il tempo totale per una pila da 200 fogli (quello che ci serviva). Poich 1848 secondi sono 30 minuti e 48 secondi, ora possiamo quasi rispettare la scadenza (avremo meno di un minuto di ritardo)! Torniamo ora al nostro algoritmo ricorsivo, che possiamo sintetizzare nel modo seguente: Per fare il merge-sort di una pila di N fogli: Suddividi la pila in 4 pile da N/4 (circa) fogli ciascuna; Fai il merge-sort di ciascuna sottopila Fai il merge delle 4 pile da N/4 fogli che sono state appena ordinate N.B. Se N vale 1, non fare nulla, perch la pila gi ordinata

Questa versione sintetica permette di evidenziare gli aspetti principali di qualunque algoritmo ricorsivo: 1 Il problema da risolvere viene suddiviso in problemi pi semplici dello stesso tipo, pi altre operazioni (nel nostro caso, ordinare un pila di N/4 fogli pi semplice che ordinare una pila di N fogli; le altre operazioni necessarie sono la suddivisione iniziale e il merge finale) Esiste un caso (o pi duno) in cui il problema cos semplice che pu essere considerato risolto (nel nostro esempio, un solo caso, e cio quello di pile di un unico foglio).

Ora che abbiamo una buona soluzione intuitiva del problema del sort, possiamo passare alla seconda fase, e cio quella della scrittura del programma. Come ho detto sopra, vi sono due aspetti da considerare: la scelta delle strutture dati e la scelta delle strategie di controllo. Si noti, comunque, che ladozione di un algoritmo ricorsivo costituisce gi, almeno in parte, una scelta sulle strategie di controllo. Prima di procedere, per, osserviamo che ho scelto di dividere N per 4, perch rendeva pi breve lesempio. Avrei, allo stesso modo, potuto decidere di dividere per 2, 3, o 10. Su un calcolatore, la scelta pi naturale quella di dividere per due (questo rende anche pi semplice il merge). Questo far nellimplementazione che stiamo per vedere.

1.3 Implementazione di merge-sort


Per quanto riguarda le strutture dati, chiaro che, dovendo trattare dei pacchi di fogli, dovremo utilizzare delle strutture dati composte; quelle a disposizione in Java sono i vettori (pi in generale le array a n dimensioni) o le liste, che per vedremo in seguito. Consideriamo dunque i vettori. Nella valutazione del lavoro da fare, ci che entra in gioco sono solo quelle che ho chiamato altre operazioni, poich la risoluzione del problema base (quello risolto, e cio ordinare una pila di un solo foglio) ha costo 0 (si veda, in particolare, la valutazione del costo che abbiamo fatto sopra per il merge-sort dei 200 fogli; notate che questo vero per il sort, ma non tutti i problemi sono cos). Dobbiamo quindi, nel nostro caso, vedere cosa succede per le due operazioni di suddivisione e di merge.

15

Per quanto riguarda la suddivisione, il problema banale. Soprattutto se si sa gi, come nel nostro caso, la lunghezza (L) del vettore (inpVett, diciamo). Basta avere due vettori che possano contenere le due met (firstHalf e secondHalf). Allora si copieranno i primi L/2 elementi in firstHalf con un semplice ciclo di firstHalf[i] = inpVett[i] e i rimanenti elementi in SecondHalf, con un ciclo del tipo secondHalf[i] = inpVett[L/2 + i] (si veda sotto la realizzazione effettiva, in cui si vede che un vettore di input di lunghezza dispari non crea difficolt). Per merge si intende unoperazione che ha come argomenti due liste ordinate e come risultato la lista ordinata che include gli elementi che compaiono negli argomenti. Es: merge ([1, 7 ,8, 32, 32, 50], [2, 3, 5, 7, 32, 40, 60, 61, 62] [1, 2, 3, 5, 7, 7, 8, 32, 32, 32, 50, 60, 61, 62] Si noti che i due argomenti devono essere gi ordinati, altrimenti non si pu parlare di merge. Inoltre, possono esservi elementi ripetuti, come si vede nellesempio (sono vettori, non insiemi). Vediamo dunque cosa si pu fare utilizzando, come strutture dati, i vettori. Presumibilmente, avremo bisogno di tre vettori, due per gli argomenti e uno per il risultato. Dopodich possiamo confrontare i primi due elementi dei vettori di input e mettere nel vettore in output il pi piccolo, avanzando sul vettore da cui abbiamo preso lelemento. Bisogna per sempre ricordarsi i casi limite. Nel merge, essi corrispondono alla situazione in cui uno dei due argomenti vuoto. Quando abbiamo esaurito ambedue i vettori, abbiamo terminato le operazioni. Proviamo ora a scrivere un programma iterativo che realizzi questo algoritmo. La procedura (Merge1) avr tre argomenti (due vettori di input e uno di output) e le lunghezze dei due vettori di input. static void merge1 (int [] vett1, int [] vett2, int [] outVett, int size1, int size2) dove size1 e size2 sono le dimensioni effettive dei due vettori di input. Ora viene il difficile. Debbo spostarmi su tre vettori, per cui verrebbe naturale pensare di utilizzare tre cicli for; purtroppo questa soluzione non praticabile, perch lincremento degli indici di ciclo dipende da cosa succede dentro il ciclo. Ad esempio, devo avanzare sul primo vettore (incrementare lindice) solo se il suo primo elemento pi piccolo del primo elemento del secondo vettore, altrimenti lindice deve rimanere invariato. Per, lindice sul vettore di output viene incrementato regolarmente. Quindi possiamo avere un unico ciclo for (sulloutput) con incrementi manuali degli indici dei due vettori di input6. int i1 = 0; int i2 = 0; [ ciclo sul vettore di output; il valore massimo dellindice pari alla dimensione finale del vettore di output, cio alla somma delle dimensioni dei due vettori di input ] for (iOut = 0; iOut < size1+size2; iOut++) {if ( i1 == size1) { il primo vettore di input terminato: metti nelloutput il primo elemento rimasto nel secondo vettore di input, e avanza sul vettore } {outVett [iOut] = vett2[i2]; i2++;} else if (i2 == size2) { analogamente se terminato il secondo vettore; si noti che non possono essere terminati tutti e due, altrimenti saremmo usciti dal ciclo } {outVett [iOut] = vett1[i1]; i1++;} else if (inplist1[i1] > inplist2[i2]) { se nessuno dei due terminato, si mette nelloutput lelemento pi piccolo } {outVett[iOut] = vett2[i2]; i2++;} else {outVett[iOut] = vett1[i1]; i1++;} } }. Il metodo completo riportato nel riquadro Proc.3:

Nelle procedure e nei programmi che compaiono nei riquadri non inserir dei commenti. Questo perch ampi commenti sono riportati nel testo. Ci non toglie che invece voi dovete inserire commenti, visto che i vostri programmi non sono accompagnati da un testo esplicativo.

16

static void merge (int[] vett1, int[] vett2, int[] outVett, int size1, int size2) {int i1 = 0; int i2 = 0; for (int iOut = 0; iOut < size1 + size2; iOut++) {if (i1 == size1) {outVett [iOut] = vett2 [i2]; i2++;} else {if (i2 == size2) {outVett [iOut] = vett1 [i1]; i1++;} else {if (vett1 [i1] > vett2 [i2]) {outVett [iOut] = vett2 [i2]; i2++;} else {outVett [iOut] = vett1 [i1]; i1++;} } // End of 'if vett1' } // End of 'if i2' } // End of 'if i1' } // End of merge ! Proc. 3 - Merge di vettori Ora possiamo pensare al metodo merge-sort. Poich dobbiamo lavorare con i vettori, avremo bisogno, concettualmente, di 2 vettori distinti: Il vettore che contiene i dati iniziali da ordinare Un vettore che contenga il risultato Inoltre, ci vorranno altri 4 vettori locali alla procedura: 2 vettori che contengono i dati suddivisi da ordinare 2 vettori che contengono i dati suddivisi dopo che sono stati ordinati Nella prima versione introdurr tutti i 6 vettori; poi, vedremo se si pu risparmiare qualcosa. Lintestazione della procedura pu essere: static void mergeSort (int [] inpVett, int [] outVett, int size) in cui size la dimensione del vettore da ordinare; ora debbo introdurre i due vettori per i dati suddivisi (non ancora ordinati) e quelli per i dati suddivisi ordinati: int [] firstHalf; int [] secondHalf; int [] sortedFirstHalf; int [] sortedSecondHalf; Per ora ho solo dichiarato lesistenza dei vettori! Non ho ancora riservato spazio per essi. Questo lo far con delle new. Ma prima debbo sapere quanto spazio riservare, e per far ci divido size per 2. int size1 = size / 2; int size2 = size - size1; Se size dispari, size1 sar troncata (se size 13, size1 sar 6). Non posso quindi usare size1 anche per la seconda met del vettore, per cui viene introdotta anche size2. Ora posso chiedere di riservare lo spazio sullo heap. firstHalf = new int [size1]; sortedFirstHalf = new int [size1]; secondHalf = new int [size2]; sortedSecondHalf = new int [size2]; Notate che non deve essere riservato spazio per inpVett e outVett! Per quello che abbiamo visto sul passaggio dei parametri, (v. p.10) le operazioni svolte su di essi sono in realt effettuate sui

17

corrispondenti vettori del metodo che ha inviato il messaggio di mergeSort. Ora il primo passo, la suddivisione del vettore. for (int i = 0; i < size1; i++) firstHalf[i] = inpVett[i]; for (int i =0; i < size2; i++) secondHalf[i] = inpVett[size1+i]; Ora dobbiamo ordinare i due mezzi vettori e poi fare il merge: mergeSort0 (firstHalf, sortedFirstHalf, size1); mergeSort0 (secondHalf, sortedSecondHalf, size2); merge (sortedFirstHalf, sortedSecondHalf, outVett, size1, size2) S, ma quando mi fermo? Ho dimenticato la condizione per la fine della ricorsione! Questa va messa allinizio della procedura: se il vettore lungo 1, allora gi ordinato. if (size == 1 ) {outVett[0] = inpVett[0];}

static void mergeSort0 (int[] inpVett, int[] outVett, int size) {if (size == 1) outVett [0] = inpVett [0]; else {int [] firstHalf; int [] secondHalf; int [] sortedFirstHalf; int [] sortedSecondHalf; int size1 = size / 2; int size2 = size size1; firstHalf = new int [size1]; sortedFirstHalf = new int [size1]; secondHalf = new int [size2]; sortedSecondHalf = new int [size2]; for (int i = 0; i < size1; i++) firstHalf[i] = inpVett[i]; for (int i = 0; i < size2; i++) secondHalf[i] = inpVett[size1 + i]; mergeSort0 (firstHalf, sortedFirstHalf, size1); mergeSort0 (secondHalf, sortedSecondHalf, size2); merge (sortedFirstHalf, sortedSecondHalf, outVett, size1, size2); } // End of 'if' } // End of mergeSort0 ! Proc. 4 - Merge Sort - Versione 1

Salta per subito allocchio un notevole spreco di spazio: per ogni livello di annidamento nelle chiamate, necessario riservare spazio, oltre che per le variabili, anche per 4 vettori. Si pu per osservare che quando si effettua il merge finale, il contenuto di inpVett ormai inutile (i dati sono gi stati spostati in firstHalf e secondHalf), cosicch si pu utilizzare come vettore di output lo stesso vettore di input (ovviamente, visto come avviene il passaggio dei parametri per i vettori, in questo modo il mittente perder la versione originale disordinata del vettore; se serve, bisogner farne una copia prima). Otteniamo cos la versione riportata nel riquadro Proc.5. Notate che in questo caso la if controlla che la lunghezza sia diversa da 0. Questo perch, nel caso in cui il vettore contenga un solo elemento non bisogna fare nulla. Infatti, a differenza che nella versione 1, non pi necessario spostare lunico elemento dal vettore di input a quello di output, visto che ora essi coincidono.

18

static void mergeSort1 (int[] inpVett, int size) {if (size != 1) {int [] firstHalf; int [] secondHalf; int size1 = size / 2; int size2 = size - size1; firstHalf = new int [size1]; secondHalf = new int [size2]; for (int i = 0; i < size1; i++) firstHalf[i] = inpVett[i]; for (int i = 0; i < size2; i++) secondHalf[i] = inpVett[size1 + i]; mergeSort1 (firstHalf, size1); mergeSort1 (secondHalf, size2); merge (firstHalf, secondHalf, inpVett, size1, size2); } // End of 'if' } // End of MergeSort1 ! Proc. 5 - Merge Sort - Versione 2

1.4 Implementazione di merge-sort con una classe MioVett


Ora che abbiamo unidea chiara dellalgoritmo di merge sort e della sua implementazione, possiamo fare un passo indietro. Ho osservato in precedenza (v. Richiami di Java 5: Metodi Statici) che lapproccio che abbiamo seguito nellimplementazione non del tutto coerente con i principi della programmazione ad oggetti. Infatti, limplementazione dovrebbe essere centrata su oggetti, anzich sui metodi. In questo paragrafo cercher di ovviare a questo problema, introducendo una nuova classe di oggetti, che chiamer MioVett, che si riferisca a dei vettori di interi e che sia in grado di rispondere a vari messaggi, tra cui uno che richiede di effettuare lordinamento. Innanzitutto, dove va definita questa classe? Se noi vogliamo che essa sia disponibile a vari metodi (e cio che anche metodi definiti per altre classi possano definire nuove istanze di MioVett, ed inviare messaggi ad esse), necessario che MioVett sia public. Poich in ciascun file pu esserci solo una classe public, dovremo avere due file: uno per il main (il nostro vecchio MioPro) e uno per la nuova classe (MioVett). A questo punto dobbiamo decidere come strutturata la classe MioVett. Poich essa , in realt, limplementazione di alcune particolari funzioni sui vettori, dovr includere dei dati di tipo vettore (supponiamo, come abbiamo fatto fino ad ora, che siano vettori di interi). Dovremo dunque includere nella classe una variabile di istanza (poich ogni istanza ha i suoi propri dati) di tipo vettore. Poich questa una variabile interna della classe (non accessibile dallesterno, se non attraverso i metodi della classe), dovr essere privata. La sua definizione (se la chiamiamo datiVett) sar quindi la seguente: private int [] datiVett; Per, come abbiamo visto, questa dichiarazione dice solo che datiVett esiste, ma non serve a riservare dello spazio. Questo particolarmente utile qui, in quanto ci che si vuole fare definire una classe che vada bene per vettori di dimensione qualsiasi. Rimane per il problema di riservare lo spazio, quando si vuole creare una nuova istanza della classe. Come sappiamo, questo si fa con una new (per tutte le classi, non solo per i vettori). Per cui, presumibilmente, dovrebbe essere possibile scrivere unespressione del tipo: MioVett varVettore = new MioVett (100); mediante la quale si specifica che viene definita una nuova istanza di MioVett (di nome varVettore) e che per essa deve essere allocato spazio per 100 elementi. Affinch tale espressione sia capita dalla classe MioVett, MioVett deve includere un metodo costruttore (cio un metodo in grado di rispondere ad una new). Tale metodo, che sar ovviamente public, non si chiamer (come potrebbe essere naturale) new, ma sar identificato dallo stesso nome della classe (e quindi dovr chiamarsi, nel nostro caso, MioVett). Tutto ci che dovr fare questo metodo, nel nostro esempio, sar di creare un nuovo

19

vettore, che verr associato alla variabile (di istanza) datiVett. La quantit di spazio da riservare, e cio la lunghezza del vettore, specificata dallargomento del metodo. Ora, a me sembra si crei qui un po di confusione tra la istanza di MioVett, che stiamo creando, e listanza di datiVett, che una variabile di istanza della classe. I due oggetti, anche se in questo esempio sembrano coincidere, sono concettualmente diversi: listanza di datiVett fa parte (anche se ne lunica parte) dellistanza di MioVett, e quindi, essendone parte non la stessa cosa. Solo per chiarire questa ambiguit, introdurr una seconda variabile di istanza (non sarebbe necessario), che un intero che contiene la lunghezza del vettore. La situazione che viene a crearsi quando si fa una new di MioVett, quindi la seguente:

Istanza di MioVett Istanza di int [] (datiVett) Istanza di int (lung)

Figura 6: Variabili di istanza della classe MioVett Per ottenere questo, le variabili di istanza saranno dichiarate con: private int [] datiVett; private int lung; E a questo punto possiamo scrivere il metodo costruttore: public MioVett (int dimens) { datiVett = new int [dimens]; lung = dimens; } E ora le due operazioni di base su MioVett; poich si tratta della realizzazione di un vettore, dovremo leggere e scrivere elementi del vettore. Naturalmente, non si potr usare la notazione varVettore [5] Poich varVettore NON un vettore (tipo int []), ma un mio vettore (tipo MioVett). E quindi, dovremo definire due metodi appositi per eseguire le due operazioni. Posso chiamarli elem e store: public int elem (int pos) {return datiVett [pos];} elem ha come argomento un indice sul vettore (pos), ed public (usabile anche da metodi esterni, ad esempio quelli che compaiono in MioProg), e int (perch restituisce il valore dellelemento). Notate che qui, datiVett [pos] si pu scrivere, perch datiVett , effettivamente, un vettore di tipo standard. public void store (int pos, int val) {datiVett [pos] = val;} Nel metodo store abbiamo due parametri: la posizione (indice sul vettore: pos) e il valore da memorizzare (val). Lunica cosa da osservare che store void, perch il suo effetto di modificare il contenuto dellistanza, senza restituire nulla. Infine, possiamo riscrivere mergeSort. Non lo faccio passo per passo: il risultato finale sta nel riquadro proc.5, insieme alla nuova versione di merge. Si noti: 1. 2. mergeSort public, mentre merge private. Questo significa che dallesterno (in particolare da MioProg) potr inviare un messaggio di auto-ordinarsi ad unistanza di MioVett, ma non potr inviarle un messaggio di effettuare un merge. Il merge stato realizzato nel modo seguente: si invia il messaggio al MioVett che dovr contenere il risultato del merge, passando, come argomenti, i due MioVett, di cui fare il merge. Parafrasando il messaggio, esso potrebbe essere interpretato come prendi come tuoi elementi il merge dei due vettori che ti passo. Secondo quanto avevamo deciso nellultima versione di mergeSort, il risultato dellordinamento va nello stesso vettore di input (si ordina il vettore stesso, non si crea un nuovo vettore ordinato). Per ottenere qui lo stesso effetto, il messaggio di merge finale va inviato allistanza che conteneva i dati iniziali da ordinare (e cio a quella che ha inizialmente ricevuto il messaggio mergeSort). In pratica, il messaggio di merge deve essere inviato dallistanza xxx alla stessa istanza xxx: il

3.

20

public class MioVett { private int [] datiVett; private int lung; public MioVett (int dimens) { datiVett = new int [dimens]; lung = dimens; } public int elem (int pos) { return datiVett [pos]; } public void store (int pos, int val) { datiVett [pos] = val; } public void mergeSort1 () {if (lung != 1) {int size1 = lung / 2; int size2 = lung - size1; MioVett firstHalf = new MioVett (size1); MioVett secondHalf = new MioVett (size2); for (int i = 0; i < size1; i++) firstHalf.store (i, datiVett [i]); for (int i = 0; i < size2; i++) secondHalf.store (i, datiVett [size1 + i]); firstHalf.mergeSort1 (); secondHalf.mergeSort1 (); this.merge1 (firstHalf, secondHalf); } // End of 'if' } // End of MergeSort1 ! private void merge1 (MioVett vett1, MioVett vett2) {int i1 = 0; int i2 = 0; for (int iOut = 0; iOut < vett1.lung + vett2.lung; iOut++) {if (i1 == vett1.lung) {datiVett [iOut] = vett2.elem (i2++);} else {if (i2 == vett2.lung) {datiVett [iOut] = vett1.elem (i1++);} else {if (vett1.elem (i1) > vett2.elem (i2)) {datiVett [iOut] = vett2.elem (i2++);} else {datiVett [iOut] = vett1.elem (i1++);} ;} // End of 'if vett1' } // End of 'if i2' } // End of 'if i1' } // End of Merge ! }

Proc. 6 La Classe MioVett

mittente e il ricevente devono coincidere. Questo si realizza usando la parola chiave this, che sta ad indicare listanza da cui il messaggio parte (il mittente, appunto): this.merge1 (firstHalf, secondHalf); in realt, in casi come questo, this si pu anche omettere, in quanto Java, se non trova il ricevente, assume sia listanza stessa, ma cos il metodo pi leggibile. Infine, possiamo riportare la nuova versione del main, che si trova in MioPro. Ho lasciato il pi possibile invariata la definizione, ma a questo punto sarebbe molto pi sensato inserire un nuovo metodo public nella classe MioVett, che si occupi della stampa di un vettore, anche in modo di evitare la ripetizione delle istruzioni per la visualizzazione del vettore originale e di quello ordinato; lascio a voi lestensione. Notate solo che ho introdotto inpVett al solo scopo di evitare di fare 12 assegnazioni separate di valori a varVettore (in questo modo ho potuto usare un ciclo).

21

public class Vettori { public static void main (String [] args) { int [] inpVett = {7, 3, 108, 5, 10, 1, 77, 33, 4, 3, 8, 19}; MioVett varVettore = new MioVett (12); for (int i = 0; i < 12; i++) varVettore.store (i, inpVett [i]); System.out.print (" Input: "); for (int i = 0; i < 12; i++) System.out.print (varVettore.elem (i) + ", "); System.out.println (""); varVettore.mergeSort1 (); System.out.print (" Risultato: "); for (int i = 0; i < 12; i++) System.out.print (varVettore.elem (i) + ", "); System.out.println (""); } // End of main! } // End of Vettori Class definition Proc. 7 Un programma che usa la Classe MioVett

1.5

Le liste

La cosa importante dellalgoritmo che abbiamo visto che il metodo indipendente dalla scelta delle particolari strutture dati: , appunto, un algoritmo, non un programma. E quindi, se vogliamo utilizzare le liste al posto dei vettori non si tratta di inventare un nuovo algoritmo, ma solo di realizzare il nostro merge-sort in modo diverso. Poich quello delle liste un argomento importante, in particolare quando verranno analizzate strutture dati pi complesse, cerchiamo di vedere le caratteristiche principali delle liste e del modo in cui operare su di esse. In Java, esiste gi una classe predefinita che permette di creare delle liste e compiere operazioni su di esse: il nome della classe LinkedList. In questo capitolo, per, il mio obiettivo quello di chiarire come le liste possono essere strutturate al loro interno, per cui ci complicheremo la vita e costruiremo una nostra classe MiaLista, con caratteristiche simili (non proprio uguali) a LinkedList e definiremo alcuni metodi per operare sulle istanze di tale classe, tra cui SortList. Vediamo innanzitutto come fatta, intuitivamente, una lista: dato1 dato2 dato3 datoN

Figura 7: Struttura di una lista generica Bisogna precisare, per, che il termine lista ambiguo; esso infatti pu essere usato sia per indicare una struttura dati astratta, composta da una sequenza ordinata di elementi omogenei, sia una particolare classe di oggetti (LinkedList di Java, o MiaLista). Alcuni testi inglesi usano i termini list e linked list per riferirsi ai due concetti. Poich le possibili traduzioni non mi soddisfano, io user invece sequenza e lista. Quindi una sequenza un elenco ordinato di elementi di qualunque tipo (addirittura, gli elementi possono essi stessi essere delle sequenze), ma tutti dello stesso tipo. Invece una lista una classe, opportunamente implementata. Sebbene lanalogia tra sequenze e liste sia evidente, non per nulla detto che io debba realizzare una sequenza con una lista. Anzi, proprio questo il problema che stiamo affrontando nel nostro esempio dellimpiegato: prima avevamo deciso di realizzare la sequenza di fogli con un vettore, ora stiamo provando a vedere cosa succede con le liste. Nella figura precedente, chiaro che gli elementi della lista sono formati da due parti distinte: valori (dato1, , datoN) e riferimenti (rappresentati dalle frecce). Di conseguenza, ragionevole

22

definire una classe di oggetti (possiamo chiamarla ListElem), costituita in modo analogo. Se supponiamo che i valori siano numeri interi, la prima variabile di istanza sar dunque di tipo int. E di che tipo sar la seconda variabile? Non esiste, in Java, la classe Riferimento. Ma daltronde, sempre, quando si definisce una variabile di tipo ClasseX, ci che viene memorizzato nella variabile (o meglio, che verr memorizzato in essa allatto di una new) un riferimento allistanza; in altre parole, se io dichiaro una variabile ClasseX, ci che faccio richiedere di riservare lo spazio per contenere un riferimento a istanze di ClasseX. Ma questo proprio quello che mi serve adesso: la seconda parte di ListElem deve essere un riferimento ad unaltra istanza di ListElem, e quindi avr: private class ListElem {public int dato; public ListElem next;} Il motivo per cui la classe ListElem private, mentre le sue variabili sono public lo vedremo tra un attimo. Ora che abbiamo definito gli elementi della lista, passiamo alla definizione della lista vera e propria. A differenza dei vettori, per, un riferimento alla lista non allintera lista, ma al suo primo elemento, in quanto tutti gli altri saranno poi raggiungibili procedendo sulla lista. Per cui MiaLista avr bisogno di ununica variabile di istanza, e cio una variabile che contenga il riferimento al primo elemento della lista: public class MiaLista { private ListElem first;} In realt, la classe MiaLista conterr al proprio interno anche la classe privata ListElem, per cui la situazione effettiva la seguente: public class MiaLista { private ListElem first; Metodi della classe MiaLista private class ListElem {public int dato; public ListElem next; Metodi della classe ListElem } } E a questo punto chiaro il motivo per cui la precedente classe ListElem privata: dallesterno, si acceder agli elementi di una lista sempre tramite MiaLista, che a sua volta metter a disposizione il primo elemento, e mai direttamente ai ListElem. Inoltre, le variabili di ListElem possono essere pubbliche, poich esse sono usabili solo allinterno della classe MiaLista (che lunica, come abbiamo detto, a vedere i ListElem), e quindi non utilizzabili direttamente da procedure esterne. Ci si potrebbe chiedere perch tutte queste complicazioni. Perch, ad esempio, non lasciamo che una lista sia semplicemente identificata da un ListElem (cio il primo elemento della lista)? Sarebbe tutto molto pi semplice, ma questo porta a delle difficolt di gestione della lista vuota (senza elementi). Infatti, se si volesse creare una lista vuota, lunico modo sarebbe: ListElem nuovaLista = null; Se si applicasse una new ListElem, infatti, si creerebbe un nuovo elemento, e questo non quello che si desidera (la lista vuota non ha elementi). Purtroppo, a un riferimento null non si possono inviare messaggi. Non posso quindi creare un metodo per la stampa della lista (es. printList) che funzioni anche per la lista vuota. Se io scrivessi: nuovaLista.printList (); otterrei infatti un messaggio di errore (NullPointerException). Questi problemi vengono risolti introducendo MiaLista. La lista vuota viene infatti creata nel modo seguente:

23

MiaLista nuovaLista = new MiaLista (); che, in presenza di un costruttore del tipo: public MiaLista () { first = null;} Produce la creazione di una nuova istanza, come si vede nella figura che segue: STACK nuovaLista HEAP istanza di MiaLista first null

Figura 8: Struttura di una istanza della classe MiaLista Dovrebbe essere evidente che in questa situazione il messaggio nuovaLista.printList (); Non provoca problemi, se il metodo printList verifica innanzitutto che first != null. In realt, ci sarebbe un altro modo per realizzare le liste, che consiste nel definire la classe ListElem come classe public (e quindi come classe autonoma allinterno di un file separato). Il vantaggio di questa seconda soluzione che sarebbe possibile in un metodo esterno (ad es. nel main) avere delle variabili di classe ListElem che identificano una posizione allinterno di una lista (queste variabili vengono di norma dette cursori o iteratori), come mostrato in fig. 9. STACK
nuovaLista mioIteratore

HEAP istanza di MiaLista first dato next istanze di ListElem

dato

next null

Figura 9: Uso di una variabile che mi permette di mantenere una posizione su un elemento interno di una lista

Questo, naturalmente, non possibile con la definizione di ListElem private, perch in tal caso, essa non visibile allesterno. In questa situazione, lunica possibilt per mantenere una posizione allinterno della lista quella mostrata in fig. 10, che ovviamente pi pesante da realizzare. Proviamo a scrivere il metodo printList, che abbastanza semplice. Non semplicissimo, per! Concettualmente, esso opera nel modo seguente: stampa il dato del primo elemento e poi procedi sullelemento successivo, e cos via, fino a che si trova il riferimento null (fine della lista). Il messaggio printList va inviato ad un oggetto di tipo MiaLista. Si noti che, definendo ListElem come private (come abbiamo fatto noi), il metodo printList deve essere interno alla classe MiaLista, e non possiamo definirlo in un metodo esterno (ad es. nel main) per i motivi detti sopra, e cio che la dicharazione ListElem loopElem produrrebbe un errore, poich la classe ListElem non visibile.

24

STACK
nuovaLista mioIteratore

HEAP istanza di MiaLista first dato next

istanze di ListElem dato next null

first istanza di MiaLista Figura 10: Se ListElem una classe privata, gli iteratori debbono essere realizzati come riferimenti a istanze di MiaLista

public void printList () {for (ListElem loopElem = first; loopElem != null; loopElem.next) System.out.print (loopElem.dato + ", "); System.out.println ("");} Proc. 8 printList: visualizza gli elementi di una lista Vediamo ora come funziona la printList. Supponiamo che la lista da visualizzare sia provaLista: STACK
loopElem

HEAP Istanza di MiaLista first 7 Istanze di ListElem 33 15 null

provaLista

Figura 11: Implementazione di una lista mediante le classi MiaLista e ListElem Linizializzazione del ciclo for produce lassegnazione alla variabile loopElem del first del ricevente, come mostrato in fig.12: STACK
loopElem

HEAP Istanza di MiaLista first 7

Istanze di ListElem 33 15 null

provaLista

Figura 12: Realizzazione di un cursore su una lista: loopElem

25

Ora, visto che loopElem non null, si effettua la stampa del valore 7, prelevando il valore del campo dato, visibile da MiaLista perch public. A questo punto possiamo avanzare sulla lista, assegnando alla variabile loopElem (lo fa il for) il valore del next dello stesso loopElem (la freccia a fianco del valore 7, nella figura); si arriva cos nella situazione seguente:

STACK
loopElem

HEAP Istanza di MiaLista first 7 Istanze di ListElem 33 15 null

provaLista

Figura 13: Avanzamento di un cursore su una lista: loopElem Quando loopElem si riferisce allelemento 15, lincremento del ciclo fa s che il riferimento null venga copiato in loopElem, cos che al passo successivo il ciclo termina. Prima di tornare al problema dellordinamento, vediamo ancora due semplici metodi, quello che inserisce un elemento allinizio di una lista, e quello che consente di cancellare il primo elemento di una lista.

public void insertFirst (int val) {ListElem newelem = new ListElem (); newelem.dato = val; newelem.next = first; first = newelem;} public void deleteFirst () {if (first != null) {first = first.next;} } Proc. 9 I metodi insertFirst e deleteFirst

1.6 Vettori e liste: alcuni vantaggi e svantaggi


Possiamo ora vedere rapidamente i vantaggi e gli svantaggi rispettivi dei vettori e delle liste. Sinteticamente si pu dire che per quanto riguarda laccesso ai dati, i vettori sono pi efficienti, mentre per alcune operazioni di modifica le liste sono migliori. Supponiamo, ad esempio di voler modificare il 50-esimo elemento di un vettore (ad esempio, se si tratta di un vettore di interi, di voler incrementare il valore). Come sappiamo, sufficiente una semplice operazione: vettore[50]++; Questa semplicit dovuta al fatto che gli elementi dei vettori sono memorizzati uno dopo laltro, per cui Java in grado, sapendo dove si trova il primo elemento del vettore e sapendo quanto spazio occupa ogni elemento, di determinare con semplici calcoli dove si trova il 50-esimo elemento. (la formula start + pos*elemSize, dove start la posizione del primo elemento, pos lindice dellelemento che interessa, e elemSize lo spazio occupato da ogni elemento, supponendo che il primo elemento abbia pos=0; nel nostro caso, pos sarebbe 49; si veda la figura 2). Se si usano le liste, invece, non possibile calcolare la posizione del 50-esimo elemento, bisogna invece seguire uno per uno gli elementi della lista e i relativi riferimenti, contando fino a che punto si arrivati. Questo tipo di modifica (cambiamento di un valore) quindi pi rapido se si usano i vettori. La stessa cosa si pu dire per qualunque operazione di accesso: prendere vett[725] quasi immediato, mentre prendere il settecentoventiseiesimo elemento della lista richiede molto pi tempo.

26

Consideriamo ora due operazioni di tipo diverso: la cancellazione di un elemento dalla sequenza e linserimento di un nuovo elemento nella sequenza. Supponiamo, ad esempio, di avere la sequenza che segue e che lelemento 137 debba essere eliminato dalla sequenza (magari si tratta di un fornitore che non opera pi, o di un cliente di una banca che ha chiuso il conto corrente). 55, 137, 0, 99, 25, , 100 Nel caso dei vettori bisogna fare due cose: specificare che la dimensione effettiva (size, o dim, o max, o qualunque altra variabile usata per questo) se prima era n, ora di n-1, e spostare tutti gli elementi che seguono quello cancellato. Esattamente il contrario si far nel caso di inserimento: se dopo lelemento 137 bisogna inserire un nuovo elemento con valore 77, si dovranno spostare tutti gli elementi, in modo che vi sia una cella libera che possa ricevere il valore 77. Pu sembrare che questi spostamenti siano banali, ma provate a farli voi su un vettore di 100 elementi con gomma e matita e vi renderete conto del problema. Nel caso delle liste, tutto pi semplice. Se vogliamo cancellare il valore 137, non dobbiamo fare altro che prendere il valore del riferimento associato ad esso (il suo next) e metterlo al posto del riferimento associato al 55 (cosicch il next di 55 diventa 0). Finito! Lunico problema potrebbe essere quello di trovare lelemento precedente della lista (il 55) in modo da poter modificare il suo riferimento. Ma per come avviene lo spostamento sulle liste, per arrivare allelemento con valore 137, siamo appena passati dal 55, e quindi sufficiente ricordarsi il riferimento allelemento precedente7.

55

137

100

null

55

137

100

null

Figura 14: Eliminazione di un elemento da una lista

La figura 14 illustra graficamente loperazione. Si noti che il riferimento associato a 137 non stato modificato! Ma tanto, da 137 non ci si passer pi nello scorrimento della lista e quindi il contenuto delle due celle (il 137 e il suo riferimento) non pi di interesse. Del tutto analogo linserimento del 77 tra il 137 e lo 0. E sufficiente: 1. 2. 3. 4. Trovare dello spazio libero nello heap per due celle contigue (il valore e il riferimento associato). Mettere il nuovo valore nella prima di queste celle (la variabile di istanza dato) Mettere il riferimento associato a 137 nel nuovo riferimento. In questo modo il nuovo elemento punta a quello che prima era successivo a 137, e cio 0. Mettere al posto del riferimento di 137, il riferimento alla nuova istanza (77). In questo modo lelemento successivo a 137 diventa il nuovo elemento inserito, e cio 77.

Anche qui: sembra complicato, ma provate a farlo a mano su un foglio con gomma e matita e vedete quanto tempo risparmiate rispetto a spostare 100 elementi.

In realt, per la macchina, le operazioni sono un p pi complicate. Infatti, il calcolatore dovrebbe scoprire che le due celle (dato e puntatore) cancellate non servono pi. In questo modo pu utilizzarle in seguito per successive operazioni di new. Purtroppo, le cose sono ancora pi difficili di quanto sembri, perch possibile che ad una cella facciano riferimento diversi riferimenti utili: cancellarne uno non vuol dire che la cella non serve pi. Esistono dei programmi piuttosto complessi, che, ogni tanto, verificano se ci sono celle a cui nessuno punta e che quindi possono essere riciclate (questi programmi si chiamano garbage collectors, letteralmente raccoglitori di immondizia, v. 1.9).

27

55

137

100

null

77 55 137 0 100 null

Figura 15: Inserimento di un elemento in una lista C infine un ultimo argomento a favore delle liste. Supponiamo di avere a disposizione 1000 celle di memoria e di dover memorizzare cinque sequenze, ma senza sapere allinizio quanto sono lunghe (magari sono lette da un file o inserite da tastiera). Nel caso dei vettori, la soluzione pi ragionevole quella di riservare 200 celle per vettore. Se la lunghezza effettiva delle cinque sequenze 150, 180, 30, 100, 60, tutto va bene. Ma se invece la lunghezza 80, 20, 210, 60, 10, allora il programma deve inviare una segnalazione di errore, perch la terza sequenza non ha spazio a sufficienza (nonostante che la quantit totale di spazio necessaria sia di 380 celle). Al contrario, nel caso delle liste, non necessario dichiarare a priori la lunghezza di ogni lista, per cui in ambo i casi le cose funzionano. Si potrebbe obiettare che, comunque, lo spazio effettivo occupato da una lista maggiore di quello occupato da un vettore (per ogni dato ci vuole anche il riferimento associato) e che quindi in molti casi i vettori hanno spazio a sufficienza, mentre le liste no. Ci significa, in pratica, che parte dello spazio occupato da oggetti interni, relativi alla struttura dati (i riferimenti) che non sono di nessun interesse per lapplicazione (e cio il programma, a cui interessa solo memorizzare i suoi dati). Ci sono due risposte a questa (ragionevole) obiezione: 1. In molti casi i dati occupano pi di una cella. Supponendo, infatti, che i dati non siano dei numeri interi, ma degli oggetti veri e propri (ad es. di un conto bancario), pu essere che ciascuno di essi occupi, ad esempio 100 celle. In questo caso, una cella (riferimento) usata per ogni 100 celle di dati utili, e quindi lo spreco di solo 1/101 (cio meno delluno per cento). Vi sono sempre, nei calcolatori, dei trade-off. Si deve cio pagare qualcosa da una parte per ottenere qualcosa da unaltra parte. Nel nostro caso si tratta di pagare in termini di occupazione complessiva di spazio, in cambio di una maggiore flessibilit (lunghezza variabile delle sequenze). Dipende ovviamente dal caso particolare se ha maggior valore lo spazio risparmiato o se ha maggiore importanza la flessibilit. Infatti, esistono e sono usati sia le liste che i vettori: in alcuni casi meglio usare i vettori, in altri le liste (se cos non fosse, non ci sarebbero due strutture dati alternative).

2.

1.7

Altre operazioni sulle liste

Ora che abbiamo visto le caratteristiche pi importanti delle liste, possiamo vedere una realizzazione in Java di alcune operazioni fondamentali: a) c) Verifica se nella lista c lelemento X. Cancella lelemento X (se c) b) Modifica il valore dellelemento X (se c) in Y. d) Inserisci lelemento Y dopo lelemento X (se c) Poich Programmazione 2 deve servire da introduzione alla ricorsione, diamo la versione ricorsiva delle procedure che realizzano le operazioni viste sopra. Prendiamo la prima operazione (ricerca). Alla domanda c lelemento X nella lista? si pu rispondere molto semplicemente: O il primo elemento della lista X, o X, se c, nella parte restante della lista. Detto in modo un poco pi preciso: lelemento X nella lista se il primo elemento X o se lelemento X nella parte restante della lista. Ho messo in corsivo la parte rilevante della definizione ri-corsiva: abbiamo, come visto sopra, definito X nella lista tramite la stessa richiesta, applicata ad un oggetto pi semplice. Questo oggetto la parte restante della lista, che pi semplice da trattare, perch pi corta. Non

28

abbiamo per ancora finito: dobbiamo specificare quando la ricerca termina. Questo facile: si finisce o quando abbiamo trovato lelemento o quando la lista finita! E in questo secondo caso, la risposta negativa. Ora, per, dobbiamo affrontare un problema che sorger ancora in seguito. Per effettuare le chiamate ricorsive, necessario operare su un iteratore, che ragionevolmente di classe ListElem. Ma il metodo inLista della classe MiaLista; si deve quindi definire un metodo interno che operi sui ListElem. Nel metodo inLista, quindi, effettueremo solo linizializzazione della ricorsione, dopo aver verificato che la lista non sia vuota. Nel metodo inListaIntern, dopo aver effettuato i 2 (!) controlli di terminazione, facciamo la chiamata ricorsiva. Si noti che, a prima vista, literatore (che si chiamava loopElem in printList) manca. Ma in realt c: il ricevente del metodo inListaIntern, che si sposta verso la fine della lista ad ogni chiamata ricorsiva!

public boolean inLista (int val) /* Nella classe MiaLista! */ {if (first == null) return false; else return first.inListaIntern (val);} private boolean inListaIntern (int val) /* Nella classe ListElem! */ {if (dato == val) {return true;} else if (next == null) {return false;} else {return next.inListaIntern (val);} } } Proc. 10 - inLista: C un elemento in una lista? Ora il cambiamento del valore, che praticamente uguale. Anche qui viene introdotto il cursore e viene restituito un valore booleano. Il valore indica il successo delloperazione: true se lelemento stato trovato e modificato, false se lelemento da modificare (identificato dal vecchio valore) non stato trovato.

public boolean modifElem (int oldVal, int newVal) {if (first == null) return false; else return first.modifElemIntern (oldVal, newVal); } private boolean modifElemIntern (int oldVal, int newVal) { if (dato == oldVal) {dato = newVal; return true; } else {if (next == null) return false; else return next.modifElemIntern (oldVal, newVal); } } Proc. 11 - modifElem: Modifica un elemento di una lista

29

Passiamo ora allinserimento di un elemento dopo un altro elemento. Qui, precElem il valore (dato) dellelemento che precede quello nuovo che deve essere inserito. Ad esempio, il risultato di fig.15 si otterrebbe inviando alla lista il messaggio insertAfter (137, 77);

public boolean insertAfter (int precElem, int newVal) {if (first == null) return false; else return first.insertAfterIntern (precElem, newVal); } private boolean insertAfterIntern (int precElem, int newVal) {if (dato == precElem) {ListElem nuovoElemento = new ListElem (); nuovoElemento.dato = newVal; nuovoElemento.next = next; next = nuovoElemento; return true; } else {if (next == null) return false; else return next.insertAfterIntern (precElem, newVal); } } Proc. 12 - insertAfter: Inserisci un elemento in una lista dopo un elemento specificato

E ora, la cancellazione di un elemento. Purtroppo, sebbene loperazione di cancellazione sia pi semplice di quella di inserimento, qui c un problema. La cancellazione deve modificare il next dellelemento precedente a quello da cancellare, non il next dellelemento stesso. E questo, per come ci stiamo spostando sulle liste, non si pu fare. Riconsideriamo, per capire perch, la precedente figura 12, che ripeto sotto come figura 16.

STACK
loopElem

HEAP Istanza di MiaLista first 7 Istanze di ListElem 33 15 null

provaLista

Figura 16: Realizzazione di un cursore su una lista: loopElem (ripetizione) Supponiamo ora, per semplicit, di dover cancellare il primo elemento della lista (7). Quello che devo fare non altro che first = first.next Ma non sul cursore, bens sulla lista originale. Se invece vogliamo cancellare lelemento 33, non possiamo spostare il cursore in avanti, perch altrimenti non avremmo pi modo di trovare il campo next associato allelemento 7 (che quello da modificare). Ricordo, infatti, che le istanze sono memorizzate nello Heap in una posizione che dipende dallo spazio libero trovato e non in sequenza

30

(come nei vettori) o vicino uno allaltro (come nelle figure). La soluzione di questo problema un po seccante, perch richiede la duplicazione di alcune istruzioni, ma non mi sembra si possa fare meglio. Innanzitutto, si fanno i controlli (first == null e first.dato == val) sulla lista ricevente e poi, solo se questi falliscono, si procede nel modo standard. Naturalmente, se first.dato uguale al valore di input, si modifica il first. A questo punto, per, il cursore indietro di un elemento nel controllo: nellesempio, il suo first 7, ma 7 gi stato controllato. Ma questo proprio quello che ci serve (mantenere il riferimento allelemento precedente). Se, infatti, lelemento da cancellare il 33, il next dellelemento 7 che va modificato. I metodi risultanti sono nel riquadro successivo. Notate che un problema analogo (con una soluzione analoga) si presenta per linserimento di un elemento prima di un elemento specificato. Lascio a voi, come esercizio, la stesura dei metodi relativi.

public boolean deleteElem (int val) {if (first == null) return false; else {if (first.dato == val) { first = first.next; return true; } else return first.deleteElemIntern (val); } } private boolean deleteElemIntern (int val) {if (next == null) return false; else { if (next.dato == val) { next = next.next; return true; } else return next.deleteElemIntern (val); } } Proc. 13 - deleteElem: Cancella un elemento da una lista

1.8

La classe LinkedList di Java

Come sappiamo, molte classi sono gi predefinite in Java. Questo ha lo scopo di mettere a disposizione del programmatore degli strumenti che gli evitino del lavoro supplementare. In effetti, tutto ci che noi abbiamo fatto fin qui sulle liste inutile, in quanto tutte le operazioni che noi abbiamo definito tramite nostri metodi sulle liste si possono effettuare usando, anzich MiaLista, la classe Java LinkedList. Ovviamente, lo scopo della definizione di una nostra classe quello di imparare a programmare, e la scrittura e lo studio dei vari metodi che abbiamo visto fin qui dovrebbe aver aiutato a chiarire le idee sulluso dei riferimenti e della ricorsione. E questo, dopotutto, lo scopo del corso di Programmazione: imparare a programmare. Visto per che la classe LinkedList si comporta in modo un po diverso dalla classe MiaLista, bene chiarire le differenze. Sebbene lorganizzazione interna delle liste sia del tutto analoga, vi una differenza molto importante. In un metodo esterno a quelli da noi definiti (per intenderci, un qualunque metodo che non faccia parte della classe MiaLista) non vi alcun modo per posizionarsi su un elemento intermedio di una lista. Tutti i messaggi che si possono inviare (inLista, insertAfter, deleteElem, ) si riferiscono alla lista come un tuttuno. Solo internamente alla classe abbiamo una posizione (identificata dal ricevente dei messaggi Intern) diversa dallinizio della lista. Lapproccio seguito nellimplementazione della classe LinkedList permette, al contrario, di utilizzare un cursore direttamente in un metodo esterno. Il termine che usa Java per indicare un cursore iterator. Il procedimento che consente ad un metodo di acquisire unistanza di iterator il seguente: LinkedList listaEsempio = new LinkedList (); ListIterator cursore = listaEsempio.listIterator ();

31

A questo punto cursore unistanza di ListIterator, e quindi un normale oggetto Java, che pu ricevere dei messaggi. Ad esempio: cursore.next (); per spostarsi in avanti sulla lista, o cursore.remove (); per cancellare lelemento attuale della lista. Ci si pu chiedere come si fa a realizzare la remove, visto il problema, che noi abbiamo affrontato nel paragrafo precedente, di modificare il puntatore dellelemento che precede quello da cancellare. La soluzione, per LinkedList, sta nella seconda differenza fondamentale rispetto alla nostra implementazione: le liste di Java hanno un doppio collegamento, e cio ciascun elemento include sia il riferimento al successivo che il riferimento al precedente (cos come si pu accedere direttamente non solo al first della lista, ma anche al last). Ma c anche una differenza sostanziale di approccio: nella nostra implementazione, noi abbiamo isolato (incapsulato) le liste in modo molto pi rigido. A rigore, questo pi vicino allidea di base della programmazione ad oggetti: limita linterfaccia verso lesterno alle operazioni utili, mantenendo allinterno della classe tutti i dettagli implementativi; tra questi, ad esempio, vi sono le modalit di uso del cursore e le primitive di spostamento sulle liste. Naturalmente, il nostro stato solo un esercizio, ed chiaro che la classe LinkedList offre al programmatore una flessibilit molto maggiore: essa cio uno strumento per un programmatore e non per lutente finale delle liste.

1.10 Ancora sulla memoria


Vorrei qui dare qualche informazione supplementare sulla gestione della memoria. In particolare, sul ruolo dello stack e sul garbage collector. Ho gi osservato, in un precedente richiamo di Java, che lo stack , a differenza dello heap, organizzato in blocchi (record di attivazione). Compito principale dello stack di consentire lesecuzione dei metodi, mettendo a disposizione spazio per le variabili locali e per gli argomenti. Lo stack (pila) appunto organizzato come una pila: subito prima che un metodo inizi lesecuzione, viene riservato per esso in cima allo stack (e quindi, come in una pila, sopra i blocchi gi esistenti) un gruppo di celle, la cui dimensione dipende dal metodo (quante variabili usa, quanti argomenti ha). Quando il metodo avr terminato lesecuzione, il suo blocco si trover in cima allo stack e verr eliminato. Quindi, quando un metodo inizia si mette un blocco in cima alla pila, quando termina si toglie un blocco dalla cima della pila. Ho usato i termini mettere e togliere tra virgolette, per ricordare che stiamo parlando di memoria, che gi l: non si pu mettere o togliere nulla, fisicamente. Quello che in realt succede che una parte della memoria centrale della macchina riservata allo stack (ad esempio, dalla cella 124.001 alla cella 280.000). Ad un certo istante, parte di queso spazio sar occupato dai blocchi dei metodi in esecuzione (ad esempio dalla cella 124.001 alla cella 176.820). In questa situazione, mettere un blocco sullo stack quando inizia lesecuzione del metodo X vuol dire semplicemente che (se le informazioni necessarie per lesecuzione di X occupano 120 celle) le celle dalla 176.821 alla 176.940 verranno riservate per le informazioni di X: a questo punto, la parte di stack occupata (lo stack) va dalla cella 124.001 alla cella 176.940, mentre la parte di memoria libera per eventuali crescite dello stack va dalla cella 176.940 alla cella 280.000 (v. fig. 17).
280.000 280.000 Il nuovo blocco del metodo X 176.940 176.820 Spazio attualmente occupato 124.001 124.001

Spazio riservato per lo stack Spazio attualmente occupato

Inizio esecuzione del metodo X


176.820

Figura 17: Allocazione di un nuovo blocco sullo stack

32

Ci si pu chiedere, a questo punto, come mai ci sono tanti blocchi sullo stack, se la macchina ha un unico processore, e quindi (per un certo programma) ci pu essere un solo metodo davvero in esecuzione. Il fatto che se un certo metodo (diciamo il main), durante la sua esecuzione, ha inviato un messaggio, questo ha prodotto lesecuzione del metodo associato a quel messaggio (chiamiamolo X). A questo punto ci sono due metodi contemporaneamente in esecuzione: il main (che in realt sospeso in attesa che termini X), e X stesso. E se X invia un messaggio che richiede lesecuzione del metodo Y, a quel punto ci saranno tre metodi in esecuzione: main, X e Y (e quindi tre blocchi sullo stack). Nel momento in cui Y termina lesecuzione, lo spazio riservato per il suo blocco viene reso nuovamente disponibile: lo stack si rimpicciolito! Notate che quando Y termina, saranno gi terminati tutti i metodi che Y pu aver chiamato. Ci vuol dire che a quel punto il suo blocco quello in cima allo stack; come si diceva, sullo stack si mette sempre in cima (sul top) e si toglie dalla cima. Uno dei motivi per cui lo stack deve essere piuttosto grande che lo stesso meccanismo si applica anche nel caso di chiamate ricorsive. Se voi osservate, ad esempio, lultimo metodo che abbiamo scritto (deleteElemIntern, nel riquadro Proc.13), vedete che esso invia, se non si ancora trovato lelemento da cancellare, un messaggio di deleteElemIntern. Questo comporta laggiunta di un blocco sullo stack. Questo blocco avr la stessa struttura del precedente, ma viene inviato ad un oggetto che si riferisce ad una lista pi corta. Se lelemento da cancellare il 100-esimo della lista, questo procedimento verr ripetuto 99 volte, e quindi in cima allo stack ci sar una sequenza di 99 blocchi, tutti relativi ad una esecuzione di deleteElemIntern (ma su una lista sempre pi corta). Prima di passare allo heap, vediamo ancora, a grandi linee, cosa c in ogni blocco sullo stack. Le informazioni necessarie per lesecuzione di un metodo sono sostanzialmente 4: 1. 2. 3. 4. Il riferimento alloggetto ricevente (per poter, ad esempio, accedere alle sue variabili di istanza) Gli argomenti del metodo (e cio i valori assunti dai parametri) Le variabili locali del metodo Un indirizzo di ritorno, e cio qualcosa che dica da che punto bisogna continuare lesecuzione del metodo chiamante dopo che lesecuzione di questo metodo terminata.

deleteElemIntern

deleteElemIntern deleteElemIntern deleteElemIntern

deleteElem main main

deleteElem main

deleteElem main

Figura 18: Allocazione di blocchi sullo stack in caso di chiamate ricorsive

Se consideriamo quindi deleteElem supponendo che esso sia stato richiamato dal main inviando un messaggio alloggetto listaEsempio, come nellesempio che segue public static void main (string [] args) { listaEsempio.deleteElem (18); listaEsempio.printList (); }

33

allora il blocco di deleteElem dovr contenere (nella figura i dati compaiono allincontrario solo perch, come al solito, ho supposto che lo stack sia caricato dal basso verso lalto):

Indirizzo dellistruzione nel main successiva a quella in cui si inviato il messaggio (printList) Questa parte vuota (non ci sono variabili locali) valore dellargomento val (18) riferimento al ricevente listaEsempio (un indirizzo nello heap) Figura 19: Contenuto di un blocco sullo stack Dovrebbe essere chiaro, a questo punto che, anche se la struttura dei blocchi di deleteElemIntern che compaiono nella fig.19 sempre la stessa, il loro contenuto cambia in relazione allinformazione 1 (ricevente), in quanto il messaggio stato inviato al next dellelemento attuale (avanzamento sulla lista). Torniamo ora allo heap. Come si detto, lo heap non ha una struttura interna: esso costituito semplicemente da un insieme di celle (una sequenza di celle, ovviamente, come in tutta la memoria), che pu essere usato per memorizzare gli oggetti. Quando viene richiesta una new, compito di Java : 1. 2. 3. Determinare quanto spazio occupa loggetto (e questo noto in base alla definizione della classe; notate che, per i vettori, questo passo 1 non pu essere effettuato fin quando non si dice a Java quanto lungo il vettore, ed in effetti, quando si fa la new bisogna dirlo). Andare a cercare nello heap delle celle libere in quantit sufficiente per memorizzare i dati della nuova istanza (le celle devono essere consecutive, cio una dopo laltra). Restituire al metodo che ha richiesto la new lindirizzo della prima di queste celle (il riferimento alla nuova istanza).

Dora in poi, il metodo che ha richiesto la new avr in una sua variabile (che si trova nello stack) il riferimento allistanza e potr lavorarci sopra come meglio gli aggrada. Ci si pu chiedere a questo punto se non esiste una operazione inversa della new, cio unoperazione che, cos come la new richiede di occupare nuove celle dello heap, richiede di lasciare libere delle celle dello heap che prima erano occupate. Questo sembra ragionevole: dopotutto, quando ho terminato la cancellazione dellelemento (ad esempio) che cosa me ne faccio del cursore sulla lista? Non mi serve pi, ed quindi solo spazio occupato inutilmente sullo heap. Ebbene, in Java, non esiste nessuna operazione di questo tipo (analoga alla delete in C++, o alla dispose in Pascal). Non c alcun modo, per il programmatore, di dichiarare che lo spazio occupato da un oggetto deve essere reso libero. Ma allora come possibile che, con tutta questa inutile spazzatura, lo heap prima o poi non si riempia? Mentre i programmi girano, anche in esecuzione un programma di Java che si occupa di garbage collection (raccolta di spazzatura, appunto), che fa le cose seguenti: 1. 2. Determina quali oggetti non sono pi in uso Fa s che lo spazio occupato da questi oggetti sia considerato spazio libero (disponibile per nuovi oggetti)

E evidente che i garbage collectors sono programmi molto complicati: come si fa a sapere se non esiste, da nessuna parte, un riferimento ad un oggetto? Come viene identificato lo spazio libero? Ciononostante, essi evitano al programmatore gran parte del lavoro (ed anche possibili errori).

34

1.10

Le eccezioni in Java

Era mia intenzione far vedere ora come possibile leggere i dati (di un vettore, di una lista, o di qualunque altro tipo) da un file. La lettura da file, per presuppone la possibilit di effettuare un controllo da programma di eventuali errori che si verifichino nel corso della lettura. Di conseguenza, prima di parlare dellInput-Output da files, necessario dire due parole sulla gestione degli errori in Java, e cio sulle eccezioni. Nel corso dellesecuzione di un metodo X possono verificarsi degli errori (ad esempio, ho cercato di prelevare lelemento vettore[15], ma vettore formato da solo 10 elementi). In questi casi, il metodo deve interrompere lesecuzione e inviare un messaggio di errore, che permetta di stabilire quale errore si verificato. A chi dovr essere inviato questo messaggio? In linea di principio, sar il metodo che ha richiesto lesecuzione di X (diciamo Y) che ricever linformazione che si verificato un errore. Ci significa che, dopo il messaggio che richiede lesecuzione di X, Y dovr inserire una istruzione che verifichi che non ci siano stati errori (presumibilmente una if). Purtroppo, in situazioni reali, lesecuzione di un metodo prodotta da una serie di richiami in cascata: il metodo W ha richiesto lesecuzione del metodo Z, che ha richiesto lesecuzione del metodo Y, che ha richiesto lesecuzione del metodo X. Di conseguenza, non solo Y, ma anche Z e W dovranno preoccuparsi di verificare eventuali situazioni di errore. In molti casi, per, i metodi intermedi (Z e Y, nellesempio) non possono fare nulla per gestire lerrore, eccetto che passare a loro volta il messaggio di errore al metodo chiamante. Y riceve la segnalazione di errore da X e la passa a Z, il quale la passa a W, che, forse, pu fare qualcosa di utile. La situazione mostrata nella figura 20, in cui le linee continue rappresentano linvio di messaggi che attivano i metodi e le linee sottili tratteggiate rappresentano il completamento del metodo con invio del messaggio di errore. Come si vede, sia il Metodo Y che il Metodo Z debbono necessariamente effettuare un controllo, per verificare la presenza di un errore. Tutto ci sarebbe inutile se fosse possibile al Metodo X inviare direttamente a W (che, abbiamo supposto, in grado di trattare lerrore) la segnalazione di errore. E proprio questo che consentito dal meccanismo delle eccezioni in Java.

Metodo W Z () If (errore) {}

Metodo Z Y () If (errore) {return }

Metodo Y X () If (errore) {return }

Metodo X

If (errore) {return }

Figura 20: Rientri da metodi in presenza di una situazione di errore E chiaro, daltronde, che il rientro dal metodo X non pu avvenire tramite una normale return. E anche evidente che il metodo X non pu essere obbligato a sapere chi tratter lerrore: lunica cosa che deve sapere il tipo di errore che stato prodotto. Ed in effetti, ci che deve fare X semplicemente di lanciare verso lalto la segnalazione di errore, mentre sar compito del metodo W catturare la segnalazione lanciata. Ed proprio questo che prevede Java: per sollevare uneccezione, X far una throw dellerrore, mentre W avr (se il programma scritto correttamente) predisposto una catch per intercettare quel tipo di errore. In realt, che scrive W dovr fare qualcosa di pi: anzich scrivere

35

semplicemente delle istruzioni, come siamo abituati a fare (e cio dire implicitamente: esegui le istruzioni che seguono), dovremo essere pi prudenti, dicendo: prova a eseguire il seguente blocco di istruzioni, e se per caso viene sollevata uneccezione, trattala nel modo opportuno. In definitiva, le operazioni richieste sono le seguenti: 1. Nel metodo W try { Z } catch (ClasseEccezione variabile) {trattamento dellerrore}; 2. Nel metodo X ClasseEccezione varErrore = new ClasseEccezione (); throw varErrore; o, pi semplicemente: throw new ClasseEccezione (); In cui ClasseEccezione pu essere tra quelle previste da Java, oppure pu essere definita dal programmatore. Esempi di classi gi definite in Java sono IOException (errore di input/output), EOFException (tentativo di lettura da un file in cui non ci sono pi record da leggere, un tipo particolare di IOException), IllegalArgumentException (argomento non consentito per un metodo), NullPointerException (tentativo di inviare un messaggio tramite un riferimento che ha valore null), ecc. Ovviamente, questo meccanismo per trattare gli errori non , di norma, obbligatorio: il programmatore pu decidere se preferisce una sequenza di return, o preferisce sfruttare il trattamento delle eccezioni. Vi sono per alcuni casi in cui il compilatore Java obbliga il programmatore a inserire la gestione delle eccezioni. Uno di questi casi proprio linput-output da file, in cui, ovviamente, il file deve esistere. Quando si dice (vedremo come nel prossimo paragrafo) che si vuole utilizzare un certo file, il compilatore Java si preoccupa di ci che pu succedere se tale file non esiste. In tal caso, infatti, il metodo Java che si occupa di aprire il file solleva uneccezione (throw) e sar compito del nostro metodo fare la catch, in modo da specificare cosa si deve fare se il file non esiste. Se tale catch manca, il compilatore segnala un errore e non si pu procedere con lesecuzione8. Un altro caso in cui potrebbe essere utile la gestione delle eccezioni un metodo che possiamo chiamare getNextDato (int val). Questo metodo dovrebbe restituire il valore dellelemento successivo a quello il cui dato val. Di conseguenza, il suo tipo dovrebbe essere int. Ma cosa succede se nessun elemento della lista ha valore val? Non possiamo, come in tutti i metodi che abbiamo visto in precedenza, restituire il valore booleano false: il metodo non pu essere, a seconda dei casi, int o boolean. Se i valori della lista fossero tutti interi positivi (e quindi -1 non pu essere il valore di un elemento), potremmo restituire -1 per indicare non trovato. Ma se anche gli interi negativi possono comparire nella lista, questa soluzione non funziona. Quello che si pu fare sollevare uneccezione quando si cerca di prelevare un valore da una lista vuota. Questo metodo riportato nel riquadro Proc.13. Nella getNextDato, compare la throw nella forma che abbiamo specificato. Da osservare solo la necessit di indicare nellintestazione del metodo (dopo la lista dei parametri), che questo un metodo che pu sollevare uneccezione (throws noSuchElementException). Per quanto riguarda il metodo chiamante (che ho chiamato mainPrendiDato), il messaggio di getNextDato stato inserito allinterno della clausola try: come abbiamo detto, ci da interpretarsi come: prova ad eseguire il body; nel caso in cui, durante lesecuzione, sia sollevata uneccezione, vedi se riesci a intercettarla tramite la catch. Lunica eccezione che questo metodo in grado di trattare noSuchElementException, e in tal caso lunica cosa da fare segnalare lerrore, tramite la visualizzazione di un messaggio. nSEE il nome di una variabile (obbligatoria), la cui classe , appunto, noSuchElementException. Ci che compare dopo la catch, quindi una vera e propria dichiarazione di una nuova variabile. Tale variabile un riferimento ad unistanza della classe noSuchElementException, ed utilizzabile allinterno del body che segue (che nel nostro caso solo una print), per ulteriori controlli e segnalazioni.

In realt, al posto della try-catch si pu mettere nellintestazione del metodo la specifica throws FileNotFoundException (si veda sotto la descrizione della getNextDato). In altre parole si dice a Java Questo metodo non tratta leccezione, ma se gli arriva la rilancia verso lalto. Ovviamente, la stessa throw va messa nel main; in tal caso, se il file non esiste lesecuzione viene interrotta. 36

public int getNextDato (int val) throws NoSuchElementException {if (first != null) return first.getNextDatoIntern (val); else throw new NoSuchElementException (); } public int getNextDatoIntern (int val) throws NoSuchElementException {if (next != null) { if (dato == val) return next.dato; else return next.getNextDatoIntern (val);} else throw new NoSuchElementException (); } public void mainPrendiDato () { . {try {listaEsempio.getNextDato (valore) ;} catch (NoSuchElementException nSEE) {System.out.println( Lelemento +valore+ non presente nella lista;}; }; .. Proc. 14 getFirstDato con trattamento delle eccezioni Poich NoSuchElementException deve avere istanze, essa, pur essendo un tipo di eccezione, in realt una classe con le caratteristiche comuni a tutte le classi Java: deve essere una classe pubblica (e quindi definita in un nuovo file con lo stesso nome), pu avere delle variabili di istanza e dei metodi associati alla classe. Alcune eccezioni sono gi definite nellambiente Java (ad es. IOException), ma questo non ovviamente il caso di NoSuchElementException, che abbiamo inventato noi, e quindi dobbiamo procedere alla sua definizione. Tale definizione mostrata nel box Proc.15. Lunica novit degna di nota la presenza, nellintestazione (prima riga) della classe, della specifica extends Exception. Questo vuol dire che NoSuchElementException una sottoclasse di Exception, e cio che qualunque istanza della classe NoSuchElementException anche unistanza della classe Exception (se Gatto una sottoclasse di Felino, tutti i gatti - istanze di Gatto sono felini istanze di Felino). Nella definizione della classe ho introdotto, a puro titolo di esempio (solo per mostrare che questa , appunto, una classe come tutte le altre) una variabile di istanza (type), un costruttore e un metodo che permette di prelevare il valore di type. Il metodo che fa la throw (se si verificato un errore), dovr creare una nuova istanza (new) e lanciare la nuova istanza (in realt il suo riferimento), sperando che qualche altro metodo possa catturarla. A questo punto, il metodo che include la catch ricever il riferimento alla nuova istanza nella variabile dichiarata per la catch e potr usare le informazioni contenute nellistanza.

public class noSuchElementException extends Exception {private int type; public noSuchElementException (int val) {type = val;} public int getType () {return type;} .. } Proc. 15 Definizione delleccezione NoSuchElementException

37

Infine, si noti solo che la throw (Proc.14) ha la forma throw new noSuchElementException (). Innanzitutto, questa una new come tutte le altre, con lunica differenza che non si usa una variabile, perch il riferimento allistanza si usa solo nella throw. Avrebbe lo stesso effetto una sequenza {noSuchElementException xErrore = new noSuchElementException (); throw xErrore;} ma ovviamente questo comporta lallocazione dello spazio per la variabile xErrore sullo stack (come tutte le altre variabili), senza nessuna utilit. Secondo, la new in questa forma ora scorretta, perch il costruttore che abbiamo definito in Proc.15 vuole un argomento intero. Dovremo quindi fare, ad esempio, new noSuchElementException (22).

1.11

Input e output con files

Se ora vogliamo fare degli esperimenti, per verificare che la classe MiaLista funzioni come previsto e che i metodi ad essa associati eseguano le operazioni volute, dobbiamo costruire delle liste che servano come test. Naturalmente, questo si pu fare mediante un metodo main che chieda uno per uno gli elementi della lista dalla tastiera e che esegua una successione (ciclo) di insertFirst (v. Proc.9). In questo modo, possiamo costruire, ad esempio, una lista di 10 elementi su cui provare a fare delle cancellazioni o degli inserimenti tramite insertAfter. Supponiamo per che vi sia stato un banale errore di battitura nel ricopiare la deleteElem, o che voi stiate facendo questo lavoro per verificare una vostra versione della insertBefore; se c un errore non rilevato dal compilatore verr sollevata uneccezione e il programma terminer. A questo punto voi potete cercare e, si spera, correggere lerrore e riprovare. Per, riprovare vuol dire inserire di nuovo, da tastiera, i 10 dati gi introdotti durante la prima prova. E se c un altro errore? Di nuovo, dopo la correzione, si dovranno re-inserire i 10 elementi della lista. Un primo modo per evitare questo inconveniente utilizzare un vettore. Come abbiamo visto, si pu inizializzare un vettore con una singola istruzione; poi, con un semplice ciclo, si pu creare la lista corrispondente: int [] inpVett = {7, 3, 55, 38, 3, 110, 25}; MiaLista inpList = new MiaLista (); for (int i = inpVett.length-1; i >= 0; i--) inpList.insertFirst (inpVett[i]); Nellesempio, il ciclo for opera partendo dallultimo elemento del vettore, in modo che alla fine la lista abbia gli elementi nello stesso ordine. Questo metodo, per, va bene per fare delle prove, ma tutte le volte che si vogliono cambiare i dati su cui effettuare la prova bisogna modificare e ricompilare il programma. Inoltre, necessario allocare dello spazio per il vettore, che in realt ha come unica funzione quella di fornire linput per i test, e non verr pi utilizzato in seguito. C unaltra alternativa, sicuramente pi generale, ed quella di scrivere i dati su cui effettuare le prove in un file separato da quello che contiene la classe. Una volta fatto ci, avremo bisogno di un meccanismo che mi permetta di collegare il metodo che deve leggere i dati al file che contiene i dati stessi. Questo meccanismo costituito dai flussi (inglese stream) che sono degli oggetti speciali messi a disposizione da Java. Vi sono vari tipi di flussi, tra cui i file, e i dati provenienti dalla tastiera. Anche una string pu essere utilizzata come flusso da cui prelevare dei dati. Nel seguito, noi utilizzeremo due tipi di flussi: i file e la tastiera. Concettualmente, un flusso non altro che una successione di caratteri, che vengono trasferiti al programma su richiesta (e cio quando il programma effettua unoperazione di input9). Mentre la tastiera un flusso speciale, i flussi di tipo file vengono gestiti tramite degli oggetti, ed quindi necessario dichiarare la loro esistenza, come per tutte le variabili dei metodi. Questo viene fatto nel modo seguente: File inputFile = new File (NomeInputFile); E fondamentale rendersi conto che ci che viene creato non il file (che deve gi esistere), ma una specie di descrittore del file (unistanza della classe File di Java). Tale istanza specifica dove si trova il file su disco, il tipo, la dimensione e altre informazioni generali. Nellesempio sopra, la variabile inputFile , come al solito, memorizzata nello stack e contiene un riferimento allistanza di File; per creare tale istanza, Java deve sapere di quale file si tratta: il nome del file (una stringa) passato come parametro di input alla new della classe File.
9

Esistono flussi di input, flussi di output e flussi di input/output. I file sono flussi di input/output. 38

La classe File mette a disposizione dei metodi per leggere dei caratteri (uno alla volta) dal flusso (a volte detto porto). Questo, per, piuttosto scomodo! Debbo poi mettere insieme i vari caratteri di una riga per formare il numero (supponiamo che nel nostro caso abbiamo messo un numero intero in ciascuna riga del file dati.txt). Per evitare questo lavoro, Java permette di leggere i dati controllando le varie separazioni (spazi bianchi, a capo, ecc.). Questo fatto tramite la classe Scanner. Le istanze di Scanner si posizionano su un file e permettono di effettuare varie operazioni di lettura tramite opportuni metodi, quali, ad esempio, readInt e readDouble. Ovviamente, anche le istanze di Scanner vanno create tramite una new; in questo caso, sar necessario passare come argomento listanza di File associata allo stream che identifica il file da cui si vuole effettuare la lettura: Scanner inputScanner = new Scanner (inputFile); e loperazione di lettura vera e propria sar: int prossimoIntero = inputScanner.nextInt (); Ovviamente, la classe Scanner avr al proprio interno una variabile che contiene la posizione sul file, cos che ogni volta che si esegue loperazione precedente si avanza leggendo il dato successivo del file. Come si diceva in precedenza, tutto ci richiede anche un trattamento delle eccezioni. In particolare, potrebbe succedere che, per qualche motivo, il file di input non esista (ed es. ho sbagliato a scrivere il nome, o il file stato cancellato). In questo caso, new File solleva uneccezione di tipo FileNotFoundException. Potrebbe anche succedere che allinterno del file si trovino dei caratteri non numerici. Questo, in particolare, si verifica alla fine della lettura del file, in cui la stringa la stringa vuota (). In questo caso, leccezione prodotta da nextInt della classe NoSuchElementException. Ma noi sappiamo ora come comportarci (v. sezione precedente): ogni volta che pu verificarsi uneccezione, le operazioni corrispondenti vengono inserite in una try {} e dopo il blocco incluso tra parentesi viene posta una catch. Questo dobbiamo farlo qui due volte (separatamente): la prima per lintero corpo del programma (che potrebbe fallire per mancanza del file: public void readListFromFile () {try {FileinputFile = new File ("dati.txt"); } catch (java.io.FileNotFoundException fileNotFound) {System.out.println (" Il file dati.txt non esiste");} } La seconda try va invece inserita nel loop: while (contin) {try { nxtVal = inputScanner.readInt (); insertFirst (nxtVal);} catch (NumberFormatException nFE) {contin = false;}; }; // fine while In questo modo, la variabile booleana contin, inizializzata a true, viene messa a false quando si rileva leccezione, e il ciclo termina. Nel riquadro Proc.16 trovate il metodo completo. Notate solo che la lista viene letta allincontrario: il numero nella prima linea del file sar lultimo elemento della lista. Per evitare ci, sufficiente definire insertLast (un metodo che inserisce un nuovo elemento in fondo, anzich allinizio) e usare questo metodo al posto di insertFirst. A questo punto, possiamo scrivere un programma che sia in grado di effettuare diverse operazioni su una lista. Il main costituito da due parti: 1. 2. Lettura dei dati dal file (con creazione della lista iniziale) e loro visualizzazione sul monitor Ciclo while in cui si chiede allutente quale operazione vuole effettuare. Il ciclo infinito, nel senso che la while continua fino a quando lutente non dice che vuole finire (operazione=9).

39

public void readListFromFile () {try { File inputFile = new File ("dati.txt"); Scanner inputScanner = new Scanner (inputFile); int nxtVal = 0; boolean contin = true; while (contin) {try {nxtVal = inputScanner.nextInt (); insertFirst (nxtVal);} catch (NoSuchElementException nFE) {contin = false;}; }; // fine while } // fine try open file catch (java.io.FileNotFoundException fileNotFound) {System.out.println (" Il file dati.txt non esiste");} } Proc. 16 - readListFromFile: Leggi una lista da un file
import java.io.*; public class Liste {public static void main (String [] args) { int op = 0; int newval; int oldval; MiaLista inpList = new MiaLista (); inpList.readListFromFile (); System.out.print (" Lista iniziale: "); inpList.printList (); while (op != 9) {System.out.println ("\n Operazioni eseguibili:\n"); System.out.println (" 1. Inserimento all'inizio\n 2. Inserimento 'dopo'"); System.out.println (" 3. Inserimento 'prima'\n 4. Cerca elemento in lista"); System.out.println (" 5. Cancellazione\n 6. Modifica\n"); System.out.println (" 9. Fine operazioni\n"); op = Console.readInt (" Quale operazione vuoi fare?\n "); switch (op) { case 1: newval = Console.readInt (" Elemento da inserire? "); inpList.insertFirst (newval); System.out.print (" Risultato: "); inpList.printList (); break; case 2: newval = Console.readInt (" Elemento da inserire? "); oldval = Console.readInt (" Dopo quale elemento? "); inpList.insertAfter (oldval, newval); System.out.print (" Risultato: "); inpList.printList (); break; case 3: newval = Console.readInt (" Elemento da inserire? "); oldval = Console.readInt (" Prima di quale elemento? "); inpList.insertBefore (oldval, newval); System.out.print (" Risultato: "); inpList.printList (); break; case 4: newval = Console.readInt (" Quale elemento devo cercare? "); if (inpList.inLista (newval)) {System.out.println (" L'elemento "+newval+" e' presente nella lista");}

40

else {System.out.println (" L'elemento "+newval+" non e' presente nella lista");} break; case 5: newval = Console.readInt (" Elemento da cancellare? "); inpList.deleteElem (newval); System.out.print (" Risultato: "); inpList.printList (); break; case 6: oldval = Console.readInt (" Elemento da modificare? "); newval = Console.readInt (" Nuovo valore? "); inpList.modifElem (oldval,newval); System.out.print (" Risultato: "); inpList.printList (); break; case 9: break; default: System.out.println("Operazione non prevista"); break; } // End of Switch! } // End of While! } // End of main! } // End of Liste Class definition

Proc.17 - Operazioni di base sulle liste Il body della while formato dalla presentazione dellinsieme di operazione possibili, seguita dalla lettura delloperazione desiderata dallutente e da unistruzione switch. Listruzione switch molto semplice dal punto di vista concettuale. Essa ha il compito di evitare la scrittura di inutili sequenze di if (condiz1) { op1} // (caso 1) else { if (condiz2) {op2} // (caso 2) else { if ( condiz3) { op3 } // (caso 3) . Listruzione sopra dice che se vera condiz1, allora bisogna eseguire op1, altrimenti, se vera condiz2, allora bisogna eseguire op2, ecc. Lo scopo della switch quello di consentire di non scrivere tutti gli allora e altrimenti. In altre parole, si dice solo: a seconda del caso in cui ti trovi, devi fare: se il caso condiz1, op1; se il caso condiz2, op2; ecc. Dal punto di vista sintattico (e cio di come va scritta) la switch fatta nel seguente modo: switch (espressione) { case val1: op11; op12; ; break; case val2: op21; op22; ; break; case valn: opn1; opn2; ; break; default: opd1; opd2; ; break; } espressione qualcosa che in Java ha un valore (ad esempio una variabile, un elemento di un vettore, un campo di un record, un metodo non void, unespressione aritmetica, ecc.). val1, val2, , valn sono i

41

possibili valori dellespressione. Quindi, quando si esegue una switch, se il valore di espressione uguale a val1, allora verranno eseguite le operazioni op11, op12, , se il valore uguale a val2, allora verranno eseguite le operazioni op21, op22, e cos via. Nel nostro caso espressione data semplicemente dalla variabile optype. Per cui, se lutente ha scritto 1, verr eseguita la parte di programma che segue lindicazione case 1 (e cio quella che si occupa delle operazioni di modifica della lista). Analogamente, negli altri casi. Tutto molto semplice. Solo quattro osservazioni sono necessarie: Le istruzioni op possono anche essere vuote. E cio possibile che, per alcuni valori di espressione, non si debba fare niente (questo il caso di 9 nellesempio). Se lespressione assume un valore non previsto, leffetto della switch quello specificato nello operazioni che seguono default. Solo i tipi di valori cosiddetti scalari sono permessi per espressione (e, ovviamente, per le corrispondenti etichette). Essi includono interi e caratteri, ma escludono numeri reali, riferimenti, stringhe. Ogni blocco di operazioni terminato da un break. Il break non obbligatorio: se esso manca, lesecuzione continua con le istruzioni del caso successivo. A questo punto, leffetto della switch nel nostro programma dovrebbe essere ovvio (ricordando che essa si trova allinterno del ciclo while): Se lutente scrive 1, 2, 3, 4, 5, o 6 viene eseguita la parte di programma associata a tale valore. Una volta fatto ci, lesecuzione della switch termina e si controlla la condizione di uscita dalla while. Poich essa falsa (il valore non 9), si ripete il ciclo, chiedendo una nuova istruzione allutente, ed eseguendo nuovamente la switch. Se lutente scrive 9, la switch non fa nulla (istruzione vuota) e termina. La while esterna verifica che la condizione di fine ciclo verificata e lesecuzione del programma completata

1.12

Merge-Sort sulle liste

Possiamo ora tornare al nostro problema originale, cio quello del Sort. Come ho gi detto pi volte, lalgoritmo non cambia. Lo riporto quindi qui come lavevamo descritto in modo informale in precedenza: Per fare il merge-sort di una pila di N fogli: Suddividi la pila in 2 pile da N/2 (circa) fogli ciascuna; Fai il merge-sort di ciascuna sottopila Fai il merge delle 2 pile da N/2 fogli che sono state appena ordinate N.B. Se N vale 1, non fare nulla, perch la pila gi ordinata Il metodo esattamente come allinizio, con lunica differenza che ho diviso in 2 pile, anzich in 4, come avevamo gi fatto per limplementazione con i vettori. Cominciamo quindi ad affrontare la prima delle due operazioni extra (suddivisione e merge), e cio la suddivisione della pila (che ora rappresentata come lista) in due sottoliste. Come input alla procedura di suddivisione avremo una lista (o meglio, come abbiamo visto, il riferimento allinizio della lista). Come risultato, dobbiamo ovviamente avere due liste (o meglio, due riferimenti alle due met). Graficamente, la situazione riportata sotto. Nella figura, inpList il riferimento alla lista in input, mentre outList1 e outList2 sono i riferimenti alle due liste di output. Quindi, una possibile intestazione del metodo la seguente: public void splitList (MiaLista outList1, MiaLista outList2); Ovviamente, inpList sar il ricevente del messaggio splitList.

42

Ora possiamo passare al body del metodo. Sappiamo che qualunque operazione su una lista impone una scansione (e cio lattraversamento) della lista. Sappiamo anche cosa vogliamo fare: mettere null nellelemento 44 e mettere il riferimento che era prima associato a 44 (quello a 29), come first del secondo argomento del metodo. Per c un problema! Come stabilisco che lelemento su cui fare queste operazioni proprio il 44? Beh, perch quello a met della lista (lasciamo perdere il problema delle liste di lunghezza dispari, che, come abbiamo visto nel caso dei vettori, facilmente risolvibile)! Gi, ma come faccio a sapere dove sta la met della lista? Nel caso dei vettori era facile, perch l sono obbligato a specificare la dimensione totale del vettore. E quindi, visto che la sequenza lunga 10, il quinto elemento (e cio 44) quello subito prima della met. Ma nelle liste non cos! E quindi ho solo due alternative: o assumo che mi venga data in input la lunghezza totale della lista, o sono obbligato a trovarmela. Lunico modo per trovarla percorrere tutta la lista contando gli elementi. Perci, in questo caso, devo prima percorrere tutta la lista per stabilirne la lunghezza (10), poi dividere per due e ricominciare dallinizio della lista per contare fino a 5 e trovare lelemento intermedio (il 44). Ci molto seccante (pensate sempre ad una lista di lunghezza 10.000)! Vedremo per fra breve che c un metodo molto pi astuto. Prima di continuare vorrei ribadire ancora che, come si vede anche dalla figura, il riferimento allinizio della lista una cosa ben diversa dalla lista in s. E questo avrebbe dovuto essere chiaro dalle operazioni di base sulle liste che abbiamo visto: Cambiare una lista non vuol dire cambiare il riferimento al suo inizio! Ad esempio, per aggiungere un elemento a met di una lista, il riferimento allinizio non modificato. Quindi, lo stesso riferimento identifica dopo loperazione una lista diversa da quella che si aveva prima delloperazione: il riferimento lo stesso, perch lindirizzo del primo elemento lo stesso, ma la lista diversa, perch rappresenta una sequenza con un elemento in pi. inpList 55 137 0 12

44

29 outList1 55 outList2 29 20 137

20

10

99

100

12

44

10 Figura 21: Split di una lista

99

100

Torniamo ora al metodo in s, supponendo, ad esempio, che la lunghezza della lista sia gi data in input. Facciamo inizialmente lipotesi che la lista in input non mi serva pi dopo lesecuzione del metodo. Se questa ipotesi non corretta, non devo solo dividere in due la lista, ma devo creare una copia della prima met e una copia della seconda met. Ci significa che, se la lista lunga 100, dovr eseguire 100 istruzioni new durante lesecuzione del metodo (e riservare dunque 200 nuove celle). Ma se lipotesi corretta, non devo copiare nulla! E sufficiente che metta nel riferimento associato a 44 il valore null e che metta nel first di outList2 il riferimento alla seconda met (cio a 29). Ma ora cos successo a inpList, e qual il valore da associare a outList1? Il first di inpList non stato modificato e quindi continua a puntare a 55. Per non si riferisce alla stessa lista di prima, ma a una lista che finisce dopo il 44: la lista stata modificata! E outList1 deve riferirsi esattamente alla stessa lista. E allora, a cosa serve avere due riferimenti uguali? Possiamo lavorare con solo 2 riferimenti, anzich 3: il primo punta, allinizio, alla lista intera e, dopo lesecuzione del metodo, alla prima met della lista suddivisa. Il secondo punter, come prima (outList2), alla seconda met della lista. Naturalmente, in questo caso serve un solo argomento (outList, che sarebbe il nostro vecchio outList2), perch laltro argomento il ricevente del messaggio (inpList). Possiamo ora provare a scrivere il metodo che esegue queste operazioni (assumendo di non avere in input la lunghezza, e quindi di doverla calcolare). public void splitList (MiaLista outList)

43

Non so ancora quali variabili locali mi serviranno. Per, certo, avr bisogno della lunghezza della lista, e di un cursore per avanzare sulla lista (potrei anche usare outList, ma non facciamo confusione). Introduciamo, per ora, inpLength e cursore. int inpLength; MiaLista cursore; Per prima cosa, troviamo la lunghezza totale della lista originale. Se lo facciamo in modo iterativo, avremo bisogno di un ciclo, per avanzare sulla lista, che incrementi inpLength ad ogni passo e che termini quando la lista finita. inpLength, allinizio, deve essere 0. Poich non sappiamo quante volte il body del ciclo verr ripetuto ( proprio quello che vogliamo trovare), non possiamo usare una for. Poich la procedura pu terminare subito (cio senza che il body sia eseguito neppure una volta, nel caso in cui la lista sia vuota, e cio non abbia nessun elemento) forse una while la scelta pi corretta. Proviamo: inpLength = 0; cursore.first = first; while (cursore != null) { inpLength++; cursore.first = cursore.first.next;} Questo un altro esempio di ciclo scomodo, e cio facile da scrivere, ma lungo da eseguire (devo percorrere comunque tutta la lista fino in fondo). Alluscita, avremo in length la lunghezza cercata. Ora possiamo dividere per due: int halfLenght = inpLength / 2; E ora, finalmente, la suddivisione vera e propria: cursore.first = first; for (int i = 1; i <=halfLength; i++) cursore.first := cursore.first.next; Il ciclo sopra per posizionarsi sullelemento a met della lista (il nostro 44). Questo lunico punto un p delicato. A cosa punta adesso cursore, al 44, o al 29 che lo segue? Proviamo a simulare lesecuzione su un caso semplice. Supponiamo che la lista iniziale sia lunga 2. Allora halfLength vale 1 e, allinizio del ciclo for, il first di cursore punta al primo dei due elementi. Poich il valore iniziale della variabile di ciclo e quello finale coincidono (ambedue valgono 1), il ciclo viene eseguito una volta. Al termine, il first di cursore si riferir al secondo elemento. Quindi, generalizzando, alla fine cursore punta al primo elemento della seconda met, e non allultimo della prima met! Ma allora il ciclo sopra sbagliato, perch adesso devo modificare i riferimenti!!! Posso facilmente assegnare al first di outList il first di cursore, cos ho trovato la seconda met della lista, ma come faccio a mettere nel puntatore associato a 44 il valore null? Dove sta il 44? Sono gi andato troppo avanti (e sulle liste, come le abbiamo viste noi, non si pu tornare indietro). Allora, continuare il ciclo solo fino a che i< halfLength la soluzione giusta: for (int i = 1; i < halfLength; i++} cursore.first = cursore.first.next; outList = cursore.first; cursore.first.next = null; Il metodo finale dunque: public void splitList (MiaLista outList) {int inpLength = 0; int halfLength; MiaLista cursore = new MiaLista (); cursore.first = first; while (cursore.first != null) { inpLength++; cursore.first = cursore.first.next;}; halfLenght = inpLength / 2; cursore.first = first; for (int i = 1; i < halfLength; i++) cursore.first = cursore.first.next; outList.first = cursore.first.next; cursore.first.next = null; } Proc. 18 - splitList: dividi una lista in due met (versione 1)

44

Come si diceva prima, c per un modo per evitare di scorrere la lista due volte (una volta e mezzo, in realt). Si deve considerare che stiamo qui lavorando sulla lista in input, e cio disordinata, per cui non ci interessa quali elementi saranno alla fine nelle due mezze liste: lordinamento si far dopo. Tutto ci che ci interessa che le due liste siano della stessa lunghezza (con una differenza al massimo di 1, se la lista iniziale ha lunghezza dispari). Allora, anzich mettere la prima met degli elementi nella prima mezza lista e la seconda met nella seconda mezza lista, possiamo scorrere la lista mettendo alternativamente prima un elemento nella prima sottolista e poi un elemento nella seconda, e poi di nuovo nella prima (come se si distribuisse un mazzo di carte a due giocatori), e cos via, ottenendo, alla fine, la situazione di fig.21. Notate che il disegno dei riferimenti un po confuso: ho voluto lasciare invariata la posizione degli elementi, per ricordarvi che la posizione delle istanze nello heap rimane la stessa. inpList

55

137

12

44

29 inpList 55 outList2 137

20

10

99

100

12

44

29

20

10

99

100

Figura 21: Un altro modo per fare lo split di una lista Per ottenere questo risultato, sufficiente avanzare sulla lista di due elementi per volta, mettendo (o lasciando) il primo elemento nella prima lista e mettendo il secondo nella seconda. Proviamo a farlo in modo ricorsivo La condizione di terminazione? Qui, per la prima volta, ne abbiamo due! Infatti, poich si avanza di due elementi per volta, bisogna fermarsi o quando non ci sono pi elementi (siamo in fondo alla lista, che era di lunghezza pari) o quando ne rimasto solo 1 (la lista era di lunghezza dispari). Le operazioni supplementari? Metti il secondo elemento nella seconda lista (e lascia il primo nella prima). Tutto si fa con due operazioni di assegnazione, pi la chiamata ricorsiva. Ma poich come questo funziona mi sembra piuttosto complicato, cerchiamo di capire cosa deve succedere. Proviamo a ragionare sullo split iniziale di una lista. Supponiamo che ci siano almeno due elementi. Il ragionamento, a livello di ricorsione il seguente: se io effettuo una chiamata ricorsiva della mia procedura con inpList che si riferisce al terzo elemento della lista, e le passo outList come argomento, allora il metodo modificher inpList in modo che essa sia formata da met degli elementi della lista che comincia dal terzo elemento e in outList restituir il riferimento ad una lista che contiene i rimanenti elementi. Questa lipotesi ricorsiva, che abbiamo gi visto in precedenza: suppongo che il metodo sia corretto (giusto) per un oggetto piu piccolo di quello iniziale (nel nostro caso una lista con due elementi di meno). Ma come devono essere sistemati i due elementi che abbiamo tolto? Possiamo vederlo nelle figure 22-24. inpList outList: * Figura 22: Split: situazione iniziale 55 137 0 12

45

Prima di effettuare la chiamata ricorsiva, dobbiamo sistemare outList. outList deve puntare al secondo elemento della lista (e quindi dobbiamo copiare in esso il riferimento associato a 55 (che contenuto in inpList.first.next; in realt, in first.next, visto che inpList il ricevente del messaggio)

inpList outList

55

137

12

Figura 23: Prima di un passo di split E ora, il riferimento del 55 deve saltare il 137, perch il 137 stato messo nella seconda sottolista. Questo lo facciamo copiando nel puntatore associato a 55 (first.next) il riferimento associato a 137 (che, come si vede dalla figura, attualmente first.next.next). inpList outList Figura 24: Dopo il passo di split Ora possiamo effettuare la chiamata ricorsiva, ma sulla lista accorciata. Quali sono i due nuovi riferimenti su cui lavorare? Il primo inpList.first.next (e cio linizio della lista ancora da suddividere) e il secondo outList.first, e cio lelemento 137, nel cui next andr messo il puntatore al secondo elemento della lista da suddividere (e cio al 12). A questo punto, il metodo fatto, nel modo che abbiamo visto in precedenza: splitList inizializza laccesso alle due liste, posizionandosi opportunamente sugli elementi, e poi usa splitListInternal, che invece avanza ricorsivamente sugli elementi (ListElem); ovviamente i first di inpList e outList rimarranno invariati (e quindi continueranno a identificare le due liste che ci interessano) dopo linizializzazione. Otteniamo cos: public void splitList (MiaLista outList) {if (first != null && first.next != null) { outList.first = first.next; first.next = first.next.next; outList.first.next = null; } private void splitListInternal (listElem inp, listElem out) {if (inp!= null && inp.next != null) { // Spostamento riferimenti (vedi testo) out.next = inp.next; inp.next = inp.next.next; out.next.next = null; // Richiamo ricorsivo splitListInternal (inp.next, out.next);} } Proc. 19 - splitList: dividi una lista in due met (versione 2)

55

137

12

// Inizializzazione

// Richiamo metodo interno splitListInternal (first.next, outList.first);}

46

Il metodo public splitList, se la lista vuota (first=null) o se contiene un solo elemento (first.next = null) non fa nulla, altrimenti sposta il primo elemento di inpList in outList ed effettua il richiamo al metodo privato, passando come parametri il primo elemento non ancora esaminato di inpList (first.next) e il primo elemento della nuova lista (outList.first). Il metodo interno effettua gli spostamenti di riferimenti descritti sopra e richiama se stesso ricorsivamente, dopo aver avanzato sugli elementi delle due liste. Ora possiano ritornare al problema del Merge. Il metodo del tutto analogo a quello che abbiamo gi visto per i vettori: se nessuna delle due liste finita, si inserisce nel risultato il pi piccolo tra i primi elementi delle due liste e si avanza sulla lista che conteneva quel primo elemento, e cos via fino alla fine delle due liste (si veda la Proc. 5). Supponiamo che alla procedura di Merge siano passati in input due riferimenti (firstList e secondList) e che il risultato sia poi in firstList. Ci sono quattro casi da considerare: 1. La seconda lista finita. In questo caso non si fa nulla, poich firstList si riferisce gi ad una parte terminale di lista ordinata (stiamo facendo un merge e quindi le due liste in input sono ordinate). Se anche la prima lista finita, non cambia nulla. firstList secondList: * 2. La prima lista finita, ma la seconda no. In questo caso, la parte finale (ordinata) sta nella seconda lista, ma visto che noi il risultato lo vogliamo nella prima, basta copiare il first di SecondList nel first di FirstList. Il fatto che SecondList continui a puntare al suo primo elemento irrilevante: a noi basta che sia a posto FirstList. firstList: * secondList: 194 228 194 228

firstList: secondList: 3. 194 228

Nessuna delle due liste finita e il primo elemento della prima lista pi piccolo del primo elemento della seconda lista. In questo caso, bisogna avanzare sulla prima lista. Questo vuol dire semplicemente che si effettuer il richiamo ricorsivo lasciando invariato il riferimento alla seconda lista e passando invece come primo argomento il riferimento al secondo elemento. In questo modo, al livello di annidamento attuale della procedura, firstList rimane invariata (infatti il primo elemento gi quello pi piccolo delle due liste). Ma al livello successivo di annidamento (dopo la chiamata ricorsiva), firstList si riferir allelemento successivo (400 nellesempio), e cio allinizio della lista di cui rimane da fare il merge (pi piccola di quella originale). firstList: secondList: 158 194 400 228 firstList firstList: secondList secondList: 158 194 400 228

47

Nella figura ho lasciato non sottolineati i valori dei due riferimenti a livello di metodo chiamante, mentre ho sottolineato i valori dei due riferimenti a livello di metodo chiamato (livello successivo di ricorsione). Naturalmente secondList rimane invariato (e cio si passer come secondo argomento della chiamata secondList stessa), mentre FirstList deve avanzare (e quindi si passer come primo argomento firstList.next). 4. Nessuna delle due liste finita e il primo elemento della prima lista pi grande del primo elemento della seconda lista. In questo caso, bisogna avanzare sulla seconda lista. Ma non basta! si deve anche spostare il primo elemento della seconda lista nella prima (che contiene il risultato del merge). Ora bisogna fare un po di attenzione, perch le prossime quattro righe di Java sono piuttosto complicate; secondo me, sono il clou di questa prima parte del corso, quindi non spaventatevi se vi sembrano difficili: lo sono! Per prima cosa, osserviamo bene la figura che segue. firstList: secondList: 217 164 350 219

firstList:

217

350

secondList:

164

219

Quello che stato fatto simile a quanto abbiamo gi visto per le operazioni elementari sulle liste: una cancellazione (abbiamo cancellato il 164 dalla seconda lista) e un inserimento (abbiamo inserito il 164 nella prima lista). Questo proprio quello che ci serve: il 164 lelemento pi piccolo, e quindi va inserito nel risultato (firstList). Questa doppia operazione possiamo farla in due modi: o si utilizzano i metodi che abbiamo scritto in precedenza (richiamando prima la insertBefore e poi la deleteElem), o si eseguono le operazioni sui rifermenti direttamente. Sebbene questa strada sia pi semplice, io seguir la seconda alternativa per il seguente motivo: effettuare una insertBefore richiede lesecuzione di una new. Ci significa che verrebbero duplicati degli elementi, e questo vorrei evitarlo. Se a livello concettuale si tratta di due operazioni (cancellazione e inserimento), a livello di implementazione si tratta di modificare tre riferimenti. Questi riferimenti sono: firstList, secondList e secondList.first.next (v. figura). Con essi, bisogna fare una specie di scambio a 3. Per capire cosa intendo, pensate allo scambio del valore di due variabili. Come sapete, questo si fa usando una variabile di supporto. Se si deve scambiare il valore di A col valore di B, si mette il valore di A in X (la variabile di supporto), poi il valore di B in A, e infine il valore di X (che ci servita per salvare il valore originale di A) in B. Questo metodo (della variabile di supporto) si deve usare anche quando le variabili coinvolte sono pi di 2. Ad esempio, se le variabili sono A, B, e C, e noi vogliamo mettere il valore di A in B, quello di B in C e quello di C in A (potete immaginare che i valori vengano fatti ruotare tra le variabili), non possiamo farlo direttamente senza una variabile di supporto: in caso contrario, qualunque assegnazione facessimo per prima (ad es. C = B) ci farebbe perdere uno dei valori che ci servono (nellesempio, quello di C). E quindi la sequenza di operazioni corretta : X = C; C = B; B = A; A = X; Potete facilmente verificare che lo stesso vale anche quando le variabili sono pi di 3. Se ora osserviamo di nuovo la figura, possiamo notare che ci troviamo proprio nello stesso caso. Solo che, al posto di A, B, e C, abbiamo tre riferimenti, e cio first, secondList.first e secondList.first.next. Ma la soluzione la stessa: chiamiamo savElem la nostra variabile di supporto e poi facciamo le 4 assegnazioni:

48

listElem savElem = secondList.first.next; secondList.first.next = first; first = secondList.first; secondList.first = savElem;

(X = C) (C = B) (B = A) (A = X)

Ora limplementazione. Il primo problema , come al solito, lavanzamento sulle liste. Per quanto riguarda linizializzazione, come abbiamo visto nella discussione precedente, necessario in alcuni casi assegnare un valore al riferimento finale della prima lista (ad esempio quando la prima lista vuota). Per far ci, non possiamo usare come primo argomento del metodo la parte di lista ancora da esaminare: se questa fosse vuota, non potremmo tornare indietro e fare lassegnamento di cui abbiamo appena parlato. Dobbiamo quindi fare in modo che il primo argomento si riferisca ad un elemento di lista che sia gi a posto. Per ottenere ci, il valore del primo argomento sar inizialmente uguale al first della prima lista, ma si dovr fare in modo che il primo elemento sia gi il pi piccolo di tutti, facendo quindi uno scambio iniziale, prima del richiamo alla solita mergeListInternal. Infine, per quanto riguarda mergeListInternal, tutto procede come descritto sopra, con lunica avvertenza che i controlli (per eventuali scambi o per la terminazione) vanno effettuati sul secondo elemento della prima lista e non sul primo, che si suppone sia gi stato messo a posto nel passo precedente (per i motivi appena detti).

public void mergeList (MiaLista secondList) {if (first == null) first = secondList.first; else if (secondList.first != null) {if (secondList.first.dato < first.dato) {listElem savElem = secondList.first.next; secondList.first.next = first; first = secondList.first; secondList.first = savElem;}; mergeListInternal (first, secondList.first);} } private void mergeListInternal (listElem firstElem, listElem secondElem) // Il dato di firstElem sicuramente pi piccolo del dato di secondElem {if (secondList != null) {if (firstElem.next == null) firstElem.next = secondElem; else { if (firstElem.next.dato > secondElem.dato) {listElem savElem = secondElem.next; secondElem.next = firstElem.next; firstElem.next = secondElem; secondElem = savElem;} mergeListInternal (firstElem.next, secondElem);} } // end of if (firstElem.next.dato > secondElem.dato) } // end of if (firstElem.next == null) } // end of if (secondElem!= null) Proc. 20 - mergeList: merge di due liste ordinate Ora abbiamo splitList e mergeList; dobbiamo solo pi scrivere la procedura di sort vera e propria. Ma questo banale: se la lista vuota o lunga 1, allora non si fa nulla ( gi ordinata; caso di terminazione), altrimenti la si divide in 2, si ordinano le due parti, e poi si fa il merge. Finalmente, dopo un cos lungo lavoro, siamo arrivati ad un programma quasi identico alla versione intuitiva del metodo che avevamo descritto allinizio!

49

public void sortList () {if (first != null && first.next != null) {MiaLista otherList = new MiaLista (); splitList (otherList); sortList (); otherList.sortList (); mergeList (otherList);} } Proc. 21 - sortList: ordinamento di una lista Nel capitolo successivo affronteremo una struttura dati un po pi complessa delle liste, e cio gli alberi. Un aspetto interessante degli alberi che, come vedremo, per effettuare delle operazioni su di essi la ricorsione quasi necessaria.

50