Fondamenti di Informatica 1 Settimana 9 Dizionario in un array ✔ Un dizionario può anche essere realizzato con un array Dizionario – ogni cella dell’array contiene un riferimento ad una coppia chiave/valore • un oggetto di tipo Pair ✔ Ci sono due strategie possibili – mantenere le chiavi ordinate nell’array – mantenere le chiavi non ordinate nell’array 2 1 Dizionario in un array ordinato Dizionario in un array non ordinato ✔ Se le n chiavi vengono conservate in ordine ✔ Se le n chiavi vengono conservate disordinate nell’array – la ricerca ha prestazioni O(log n) • si può usare la ricerca per bisezione – l’inserimento ha prestazioni O(n) • si usa l’ordinamento per inserzione in un array ordinato • con altre strategie, occorre invece ordinare l’intero array, con prestazioni, in generale, O(n log n) – la rimozione ha prestazioni O(n) • bisogna fare una ricerca, e poi spostare mediamente n/2 elementi per mantenere l’ordinamento 3 Prestazioni di un dizionario Dizionario lg n n lg n inserimento n 1 lg n rimozione n n lg n • bisogna usare la ricerca lineare – l’inserimento ha prestazioni O(1) • è sufficiente inserire il nuovo elemento nell’ultima posizione dell’array – la rimozione ha prestazioni O(n) • bisogna fare una ricerca, e poi spostare nella posizione trovata l’ultimo elemento dell’array, perché l’ordinamento non interessa 4 ✔ La scelta di una realizzazione piuttosto di un’altra dipende dall’utilizzo tipico del dizionario nell’applicazione ✔ Ad esempio 5 Marcello Dalpasso – la ricerca ha prestazioni O(n) Prestazioni di un dizionario array array non ABR ordinato ordinato bilanciato ricerca nell’array – se nel dizionario di fanno frequenti inserimenti e sporadiche ricerche e rimozioni • la scelta più opportuna è l’array non ordinato – se i tipi di operazioni hanno la stessa frequenza • la scelta più opportuna è l’albero binario di ricerca – se il dizionario viene costruito una volta per tutte, poi viene usato per fare soltanto ricerche • la scelta più opportuna è l’array ordinato, che sfrutta la memoria in modo più efficiente dell’ABR 6 1 Fondamenti di Informatica 1 Settimana 9 Chiavi numeriche Tabella ✔ Se imponiamo una restrizione al campo di ✔ Un dizionario con chiavi numeriche intere viene detto tabella o tavola (table) applicazione di un dizionario ✔ L’analogia è la seguente – un valore è una riga nella tabella – le righe sono numerate usando le chiavi – alcune righe possono essere vuote (senza valore) – supponiamo che le chiavi siano numeri interi appartenenti ad un intervallo noto a priori si può realizzare molto semplicemente un dizionario con prestazioni O(1) per tutte le operazioni ✔ Si usa un array che contiene soltanto i riferimenti ai valori, usando le chiavi come indici nell’array – le celle dell’array che hanno come indice una chiave che non appartiene al dizionario hanno il valore null 7 Tabella ✔ Definiamo il tipo di dati astratto Table con un comportamento identico al dizionario – l’unica sostanziale differenza è che le chiavi non sono riferimenti ad oggetti di tipo Comparable, ma sono numeri interi (che evidentemente sono confrontabili) public interface Table extends Container { void insert(int key, Object value); void remove(int key); Object find(int key); } 9 chiave = 0 valore0 null chiave = 2 valore2 chiave = 3 valore3 null null dato dato dato public class ArrayTable100 implements Table { private Object[] v = new Object[100]; private int count = 0; // count rende isEmpty O(1) public void makeEmpty() { count = 0; for(int i = 0; i < v.length; i++)v[i] = null; } public boolean isEmpty() { return count == 0; } private void check(int key) { if (key < 0 || key >= v.length) throw new InvalidPositionTableException(); } public void insert(int key, Object value) { check(key); if (v[key] == null) count++; v[key] = value; } public void remove(int key) { check(key); if (v[key] != null) count--; v[key] = null; } public Object find(int key) { check(key); return v[key]; } } Tabella Tabella ✔ La tabella potrebbe avere dimensione variabile, ✔ La tabella non utilizza la memoria in modo cioè utilizzare un array di dimensione crescente quando sia necessario – l’operazione di inserimento richiede, però, un tempo O(n) ogni volta che è necessario un ridimensionamento – in questo caso non si può utilizzare l’analisi ammortizzata perché non si può prevedere quali siano le posizioni richieste dall’utente – non è più vero che il ridimensionamento avviene “una volta ogni tanto”, può avvenire anche tutte le volte – le prestazioni nel caso peggiore sono quindi O(n) 11 Marcello Dalpasso 8 10 efficiente – l’occupazione di memoria richiesta per contenere n dati non dipende da n in modo lineare • come invece avviene per tutti gli altri ADT ma dipende dal contenuto informativo presente nei dati • in particolare, dal valore della chiave massima ✔ Può essere necessario un array di milioni di elementi per contenere poche decine di dati – si definisce fattore di riempimento (load factor) della tabella il numero di dati contenuti nella tabella diviso per la dimensione della tabella stessa 12 2 Fondamenti di Informatica 1 Settimana 9 Tabella ✔ La tabella è un dizionario con prestazioni ottime – tutte le operazioni sono O(1) ma con le seguenti limitazioni – le chiavi devono essere numeri interi (non negativi) • in realtà si possono usare anche chiavi negative, sottraendo ad ogni chiave il valore dell’estremo inferiore dell’intervallo di variabilità – l’intervallo di variabilità delle chiavi deve essere noto a priori • per dimensionare la tabella – se il fattore di riempimento è molto basso, si ha un grande spreco di memoria • ciò avviene se le chiavi sono molto “disperse” nel loro insieme di variabilità 13 Intervallo 14 Tabella Funzione di hash ✔ Cerchiamo di eliminare una delle limitazioni ✔ Una funzione di hash ha – come dominio l’insieme delle chiavi che identificano univocamente i dati da inserire nel dizionario – come codominio l’insieme degli indici validi per accedere ad elementi della tabella • il risultato dell’applicazione della funzione di hash ad una chiave si chiama chiave ridotta ✔ Se manteniamo la limitazione di usare numeri – l’intervallo di variabilità delle chiavi deve essere noto a priori • per dimensionare la tabella ✔ Risolviamo il problema in modo diverso – fissiamo la dimensione della tabella in modo arbitrario • in questo modo si definisce di conseguenza l’intervallo di variabilità delle chiavi utilizzabili – per usare chiavi esterne all’intervallo, si usa una funzione di trasformazione delle chiavi • funzione di hash interi come chiavi – una semplice funzione di hash è il calcolo del resto della divisione intera tra la chiave e la dimensione della tabella 15 16 Funzione di hash Funzione di hash ✔ Per come è definita, la funzione di hash è ✔ Il fatto che la funzione di hash non sia univoca generalmente non biunivoca, cioè non è invertibile genera un problema nuovo – chiavi diverse possono avere lo stesso valore per la funzione di hash ✔ Per questo si chiama funzione di hash – è una funzione che “fa confusione”, nel senso che “mescola” dati diversi… ✔ In generale, non è possibile definire una funzione di hash biunivoca, perché la dimensione del dominio è maggiore della dimensione del codominio Marcello Dalpasso 17 – inserendo un valore nella tabella, può darsi che la sua chiave ridotta sia uguale a quella di un valore già presente nella tabella e avente una diversa chiave, ma la stessa chiave ridotta – usando l’algoritmo già visto per la tabella, il nuovo valore andrebbe a sostituire il vecchio • questo non è corretto perché i due valori hanno, in realtà, chiavi diverse – questo fenomeno si chiama collisione nella tabella e si può risolvere in molti modi diversi • una tabella che usa chiavi ridotte si chiama tabella 18 hash (hash table) 3 Fondamenti di Informatica 1 Settimana 9 Risoluzione delle collisioni Risoluzione delle collisioni ✔ Quando si ha una collisione, bisognerebbe inserire ✔ Questo sistema di risoluzione delle collisioni si il nuovo valore nella stessa cella della tabella (dell’array) che già contiene un altro valore – i due valori hanno chiavi diverse, ma la stessa chiave ridotta – ciascun valore dovrebbe essere memorizzato insieme alla sua vera chiave, per poter fare le ricerche nel modo corretto ✔ Possiamo risolvere il problema usando una lista per ogni cella dell’array – l’array è un array di riferimenti a liste – ciascuna lista contiene le coppie chiave/valore che hanno la stessa chiave ridotta chiama tabella hash con bucket – un bucket è una delle liste associate ad una chiave ridotta hash = 0 bucket0 null hash = 2 bucket2 hash = 3 bucket3 null null coppia coppia coppia coppia coppia 19 20 Tabella hash con bucket Tabella hash con bucket ✔ Le prestazioni della tabella hash con bucket non ✔ Le prestazioni dipendono fortemente dalle sono più, ovviamente, O(1) per tutte le operazioni ✔ Le prestazioni dipendono fortemente dalle caratteristiche della funzione di hash – caso peggiore: la funzione di hash restituisce sempre la stessa chiave ridotta, per ogni chiave possibile – tutti i dati vengono inseriti in un’unica lista • le prestazioni della tabella hash degenerano in quelle di una lista • tutte le operazioni sono O(n) 21 caratteristiche della funzione di hash – caso migliore: la funzione di hash restituisce chiavi ridotte che si distribuiscono uniformemente nella tabella – tutte le liste hanno la stessa lunghezza media • se M è la dimensione della tabella – la lunghezza media di ciascuna lista è n/M • tutte le operazioni sono O(n/M) – per avere prestazioni O(1) occorre dimensionare la tabella in modo che M sia dello stesso ordine di 22 grandezza di n Tabella hash con bucket Tabella hash con chiave generica ✔ Riassumendo, in una tabella hash con bucket si ✔ Rimane da risolvere un solo problema – le chiavi devono essere numeri interi (non negativi) ✔ Vogliamo cercare di realizzare una tabella hash ottengono prestazioni ottimali (costanti) se – la dimensione della tabella è circa uguale al numero di dati che saranno memorizzati nella tabella • fattore di riempimento circa unitario – così si riduce al minimo anche lo spreco di memoria – la funzione di hash genera chiavi ridotte uniformemente distribuite • liste di lunghezza quasi uguale alla lunghezza media • le liste hanno quasi tutte lunghezza uno! ✔ Se le chiavi vere sono uniformemente distribuite – la funzione di hash che “calcola il resto della divisione intera” genera chiavi ridotte 23 uniformemente distribuite Marcello Dalpasso con bucket che possa gestire coppie chiave/valore in cui la chiave non è un numero intero, ma un dato generico – ad esempio, una stringa – applicazione • contenitore di oggetti di tipo PersonaFisica • chiave: codice fiscale 24 4 Fondamenti di Informatica 1 Settimana 9 Tabella hash con chiave generica Funzione di hash per chiave generica ✔ Se vogliamo usare chiavi generiche (qualsiasi tipo di oggetto, in teoria…) – è sufficiente progettare una adeguata funzione di hash • se la funzione di hash ha come dominio l’insieme delle chiavi generiche che interessano e come codominio un sottoinsieme dei numeri interi – cioè se le chiavi ridotte sono numeri interi allora la tabella hash con bucket funziona correttamente senza modifiche ✔ Come si può trasformare una stringa in un numero intero? ✔ Ricordiamo il significato della notazione posizionale nella rappresentazione di un numero intero 434 = 4·102 + 3·101 + 4·100 ✔ Ciascuna cifra rappresenta un numero che dipende – dal suo valore intrinseco – dalla sua posizione ✔ Possiamo usare la stessa convenzione per una stringa, dove ciascun carattere rappresenta un numero che dipende – dal suo valore intrinseco come carattere • nella codifica Unicode – dalla sua posizione nella stringa 25 Funzione di hash per chiave generica 26 Funzione di hash per chiave generica ✔ Possiamo usare la stessa convenzione per una 27 ✔ Come fare per oggetti generici? – la classe Object mette a disposizione il metodo hashCode che restituisce un int con buone proprietà di distribuzione uniforme – se il metodo hashCode non viene ridefinito, viene calcolata una chiave ridotta a partire dall’indirizzo dell’oggetto in memoria – l’esistenza di questo metodo rende possibile l’utilizzo di qualsiasi oggetto come chiave in una tabella hash • invocando hashCode si ottiene un valore di tipo int – calcolando il resto della divisione intera di tale valore per la dimensione della tabella, si ottiene finalmente la chiave ridotta 28 29 30 stringa, dove ciascun carattere rappresenta un numero che dipende – dal suo valore intrinseco come carattere • nella codifica Unicode – dalla sua posizione nella stringa ✔ La base del sistema sarà il numero di simboli diversi (come nella base decimale), che per la codifica Unicode è 65536 "ABC" ⇒ 'A'·655362 + 'B'·655361 + 'C'·655360 ✔ In Java è molto semplice, perché un char è anche un numero intero... Tabella hash per chiave generica public interface HashTable extends Container { void insert(Object key, Object value); void remove(Object key); Object find(Object key); } Marcello Dalpasso 5 Fondamenti di Informatica 1 Settimana 9 Attraversamento di un albero ✔ Abbiamo visto un metodo statico per attraversare un albero, ad esempio in ordine anticipato Classi astratte public static void preOrderTraversal(BinaryTree t) { if (t == null) return; visit(t); preOrderTraversal(t.left); preOrderTraversal(t.right); } ✔ Con una realizzazione di questo tipo, ogni volta che abbiamo bisogno di realizzare un attraversamento occorre copiare il codice del metodo all’interno della classe 31 – alternativa: inseriamo il metodo in una classe contenitore di metodi 32 Attraversamento di un albero Attraversamento di un albero ✔ Inseriamo il metodo in una classe ✔ In questo modo, il metodo di attraversamento può essere invocato da qualsiasi classe – non c’è bisogno di copiare codice… public class TreeTraversal { public void preOrder(BinaryTree t) { if (t == null) return; visit(t); preOrder(t.left); preOrder(t.right); } public void postOrder(BinaryTree t) { ... } public void inOrder(BinaryTree t) { ... } } BinaryTree t = new BinaryTree(); ... TreeTraversal trav = new TreeTraversal(); trav.preOrder(t); ✔ Purtroppo, la compilazione della classe TreeTraversal non va a buon fine! – manca la definizione del metodo visit 33 Attraversamento di un albero Attraversamento di un albero ✔ Abbiamo detto che l’attraversamento è un azione generica di trasformazione di una struttura gerarchica (l’albero) in una sequenza – indipendentemente dall’elaborazione che viene svolta dal metodo visit su ciascuna etichetta che costituisce la sequenza ✔ Possiamo quindi definire diverse classi, usando TreeTraversal come “prototipo”, definendo in modo diverso al loro interno il metodo visit 35 Marcello Dalpasso 34 // attraversamenti che visualizzano le // etichette presenti nell’albero public class PrintTreeTraversal { public void preOrder(BinaryTree t) { if (t == null) return; visit(t); preOrder(t.left); preOrder(t.right); } public void postOrder(BinaryTree t) { ... } public void inOrder(BinaryTree t) { ... } private void visit(BinaryTree t) { System.out.println(t.label); } } 36 6 Fondamenti di Informatica 1 Settimana 9 Attraversamento di un albero Attraversamento di un albero // attraversamenti che rendono nulle le // etichette presenti nell’albero public class NullTreeTraversal { public void preOrder(BinaryTree t) { if (t == null) return; visit(t); preOrder(t.left); preOrder(t.right); } public void postOrder(BinaryTree t) { ... } public void inOrder(BinaryTree t) { ... } private void visit(BinaryTree t) { t.label = null; } } ✔ Le due classi che abbiamo appena visto differiscono soltanto per la definizione del metodo visit – c’è molto codice condiviso (codice da copiare…) ✔ In Java è possibile definire una classe che contenga soltanto il codice in comune – tale classe non è completa! • manca il metodo visit – però può essere usata come superclasse per derivare una classe completa senza copiare codice 37 Classe astratta Classe astratta public abstract class TreeTraversal { public void preOrder(BinaryTree t) { if (t == null) return; visit(t); preOrder(t.left); preOrder(t.right); } public void postOrder(BinaryTree t) { ... } public void inOrder(BinaryTree t) { ... } public abstract void visit(BinaryTree t); } public class PrintTreeTraversal extends TreeTraversal { public void visit(BinaryTree t) { System.out.println(t.label); } } ✔ Una classe astratta ha le seguenti caratteristiche – contiene la dichiarazione abstract prima di class – può contenere metodi di cui viene definita soltanto la firma • tali metodi devono essere dichiarati abstract – la classe può essere compilata – non si possono costruire esemplari della classe • ma si possono costruire esemplari di classi non astratte derivate da essa ✔ Una classe derivata da una classe astratta (se non è essa stessa astratta) ha le seguenti caratteristiche – deve contenere la definizione di tutti i metodi astratti della sua superclasse 39 Classe astratta o interfaccia? 40 Come fare senza classi astratte? ✔ La classe astratta è simile ad un’interfaccia, in quanto demanda ad un’altra classe la realizzazione concreta (di una parte) del suo comportamento ✔ Diversamente dall’interfaccia, però, la classe astratta definisce concretamente una parte del comportamento che sarà ereditato dalle classi derivate – in un’interfaccia, invece, non viene concretamente definito niente public class TreeTraversal { public void preOrder(BinaryTree t) { if (t == null) return; visit(t); preOrder(t.left); preOrder(t.right); } public void postOrder(BinaryTree t) { ... } public void inOrder(BinaryTree t) { ... } public void visit(BinaryTree t) { throw new RuntimeException(); } } ✔ Se non si vogliono usare classi astratte, si ottiene 41 Marcello Dalpasso 38 lo stesso risultato pratico definendo la classe completa con un metodo non utilizzabile 42 7 Fondamenti di Informatica 1 Settimana 9 Come fare senza classi astratte? public class TreeTraversal { ... public void visit(BinaryTree t) { throw new RuntimeException(); } } public class PrintTreeTraversal extends TreeTraversal { public void visit(BinaryTree t) { System.out.println(t.label); } } Intervallo ✔ In questo modo la classe – viene compilata correttamente – i suoi metodi non possono essere invocati, perché viene lanciata un’eccezione ✔ La classe deve quindi essere necessariamente estesa, sovrascrivendo il metodo visit 43 44 Realizzazione di una pila “sicura” ✔ Tutte le strutture dati che abbiamo definito Esercizio possono contenere oggetti di qualsiasi tipo Realizzazione di una pila “sicura” ✔ Solitamente vengono utilizzate per contenere oggetti omogenei – cioè oggetti che siano tutti dello stesso tipo ma in generale questo vincolo non c’è ✔ Anche quando vengono usate come contenitori di oggetti omogenei, è possibile introdurre oggetti disomogenei, per errore ✔ Vogliamo realizzare una struttura dati, ad esempio una pila, che possa contenere soltanto oggetti di un certo tipo 46 45 // rivediamo la definizione della pila public class ArrayStack implements Stack { private Object[] v = new Object[100]; private int vSize = 0; public boolean isEmpty() { return vSize == 0; } public void makeEmpty() { vSize = 0; } public void push(Object obj) { if (vSize == v.length) v = resize(v, 2*v.length); v[vSize++] = obj; } public Object top() { if (isEmpty()) throw new EmptyStackException(); return v[vSize - 1]; } public void pop() { if (isEmpty()) throw new EmptyStackException(); vSize--; } public Object topAndPop() { Object obj = top(); pop(); return obj; } private Object[] resize(Object[] old, int newLength) { Object[] newArray = new Object[newLength]; for (int i = 0; i < old.length; i++) newArray[i] = old[i]; return newArray; } } Marcello Dalpasso Realizzazione di una pila “sicura” ✔ Per realizzare una pila che possa contenere soltanto stringhe, ad esempio – si può riscrivere tutto il codice modificando il solo metodo push public class StringArrayStack implements Stack { ... public void push(Object obj) { if (!(obj instanceof String)) throw new InvalidTypeException(); if (vSize == v.length) v = resize(v, 2*v.length); v[vSize++] = obj; } } 47 48 8 Fondamenti di Informatica 1 Settimana 9 Realizzazione di una pila “sicura” Realizzazione di una pila “sicura” ✔ Si potrebbe pensare di evitare il controllo di tipo ✔ Un modo più “elegante” sfrutta l’ereditarietà – che, come sappiamo, è spesso utile per evitare di ricopiare codice… nel metodo modificando il tipo del parametro public class StringArrayStack implements Stack { ... // NON FUNZIONA public void push(String obj) { if (vSize == v.length) v = resize(v, 2*v.length); v[vSize++] = obj; } } ✔ Non funziona: il compilatore segnala che la classe non definisce tutti i metodi richiesti dall’interfaccia Stack – il metodo push ha una firma diversa 49 public class StringArrayStack extends ArrayStack { public void push(Object obj) { if (!(obj instanceof String)) throw new InvalidTypeException(); super.push(obj); } } ✔ Il metodo push viene sovrascritto (si noti la firma identica…), per cui negli esemplari di StringArrayStack verrà invocato il metodo qui definito e non quello di ArrayStack 50 Realizzazione di una pila “sicura” Realizzazione di una pila “sicura” ✔ Vediamo come utilizzare StringArrayStack ✔ Viene la tentazione di ridefinire il metodo Stack s = new StringArrayStack(); s.push("pippo"); String st = (String) s.topAndPop(); s.push(new Integer(2)); // lancia un’eccezione ✔ Usando StringArrayStack è possibile fare il cast esplicito dopo l’estrazione di un elemento con la certezza che andrà a buon fine ✔ Viene la tentazione di ridefinire il metodo topAndPop come segue, per non dover fare il cast { ... public String topAndPop() { ... } } 51 topAndPop come segue, per non dover fare il cast { ... public String topAndPop() { ... } } ✔ Questo di nuovo non funziona, per lo stesso motivo citato in precedenza – il compilatore segnala che la classe non definisce tutti i metodi richiesti dall’interfaccia Stack • il metodo topAndPop ha una firma diversa 52 Realizzazione di una pila “sicura” ✔ Alternativa senza usare l’ereditarietà – usiamo una pila come variabile privata di esemplare public class StringArrayStack implements Stack { private Stack s = new ArrayStack(); public boolean isEmpty() { return s.isEmpty(); } public void makeEmpty() { s.makeEmpty(); } public void push(Object obj) { if (!(obj instanceof String)) throw new InvalidTypeException(); s.push(obj); } public Object top() { return s.top(); } public void pop() { s.pop(); } public Object topAndPop() { return s.topAndPop(); } } 53 Marcello Dalpasso 54 9 Fondamenti di Informatica 1 Settimana 9 Insieme ✔ Il tipo di dati astratto “insieme” (set) è un contenitore (eventualmente vuoto) di oggetti distinti Insieme – senza alcun particolare ordinamento – senza memoria dell’ordine temporale in cui gli oggetti vengono inseriti od estratti – si comporta come un insieme matematico 56 55 Insieme Insieme ✔ Le operazioni consentite sull’insieme sono – inserimento di un oggetto ✔ Appartengono all’unione di due insiemi tutti e soli • fallisce silenziosamente se l’oggetto è già presente – verifica della presenza di un oggetto – ispezione di tutti gli oggetti ✔ Per due insiemi A e B, si definiscono le operazioni – unione, A ∪ B – intersezione, A ∩ B – sottrazione, A - B (oppure anche A \ B) gli oggetti che appartengono ad almeno uno dei due insiemi ✔ Appartengono all’intersezione di due insiemi tutti e soli gli oggetti che appartengono ad entrambi gli insiemi ✔ Appartengono all’insieme sottrazione A-B tutti e soli gli oggetti che appartengono all’insieme A e non appartengono all’insieme B 57 Insieme Insieme con array non ordinato public interface Set extends Container { void insert(Object obj); boolean isInSet(Object obj); Object[] get(); } ✔ Per realizzare un insieme ci sono diverse soluzioni – array non ordinato – array ordinato – albero binario di ricerca 59 Marcello Dalpasso 58 public class ArraySet implements Set { private Object[] v = new Object[100]; private int vSize = 0; public void makeEmpty() { vSize = 0; } public boolean isEmpty() { return vSize == 0; } public Object[] get() // O(n) { Object[] x = new Object[vSize]; System.arraycopy(v, 0, x, 0, vSize); return x; } public boolean isInSet(Object x) // O(n) { for (int i = 0; i < vSize; i++) if (v[i].equals(x)) return true; return false; } public void insert(Object x) // O(n) { if (isInSet(x)) return; if (vSize == v.length) v = resize(v, 2*vSize); v[vSize++] = obj; } } 60 10 Fondamenti di Informatica 1 Settimana 9 Operazioni su insiemi: unione public static Set union(Set s1, Set s2) { Set x = new ArraySet(); // inseriamo tutti gli elementi del // primo insieme Object[] v = s1.get(); for (int i = 0; i < v.length; i++) x.insert(v[i]); // inseriamo tutti gli elementi del // secondo insieme, sfruttando le // proprieta’ di insert v = s2.get(); for (int i = 0; i < v.length; i++) x.insert(v[i]); return x; } Operazioni su insiemi: intersezione public static Set intersection(Set s1, Set s2) { Set x = new ArraySet(); Object[] v = s1.get(); for (int i = 0; i < v.length; i++) if (s2.isInSet(v[i])) x.insert(v[i]); return x; } 61 Operazioni su insiemi: sottrazione public static Set subtract(Set s1, Set s2) { Set x = new ArraySet(); Object[] v = s1.get(); for (int i = 0; i < v.length; i++) if (!s2.isInSet(v[i])) x.insert(v[i]); return x; } Insieme con array ordinato ✔ Realizzando un insieme con un array ordinato, si pone il problema di confrontare gli oggetti tra loro – nell’insieme non c’è il vincolo che gli oggetti debbano realizzare l’interfaccia Comparable… ✔ Come si può introdurre una relazione d’ordine completo in un insieme di oggetti di tipo Object? ✔ Se isInSet (e, di conseguenza, insert) nell’insieme sono O(n), queste tre operazioni sono 62 O(n2) ✔ Ovviamente, occorre utilizzare soltanto metodi della classe Object 63 64 Insieme con array ordinato Insieme con array ordinato ✔ Ad esempio, il metodo toString, se non ✔ Un altro metodo di Object utile in questo contesto sovrascritto, genera una stringa che contiene l’indirizzo in memoria dell’oggetto – tale informazione è diversa per ogni oggetto (è una chiave) compareTo(Object x, Object y) { return x.toString().compareTo(y.toString()); } ✔ Se il metodo toString viene sovrascritto, ci possono essere oggetti diversi per i quali viene generata la stessa stringa… – conti correnti con lo stesso saldo... Marcello Dalpasso è hashCode – genera un numero intero che, per quanto possibile, è diverso per ogni oggetto • non è però garantito che lo sia... – anch’esso può essere sovrascritto, ma se viene sovrascritto dovrebbe mantenere le stesse proprietà int compareTo(Object x, Object y) { return x.hashCode() - y.hashCode(); } 65 66 11 Fondamenti di Informatica 1 Settimana 9 Insieme con array ordinato Insieme con array ordinato ✔ Una possibile scelta, per aumentare la probabilità di successo, è l’uso di entrambi i metodi int compareTo(Object x, Object y) { String sx = x.toString() + x.hashCode(); String sy = y.toString() + y.hashCode(); return sx.compareTo(sy); } ✔ Non esiste, però, la garanzia assoluta che due oggetti distinti non vengano considerati uguali – l’inserimento potrebbe fallire, cioè non inserire un oggetto perché ritenuto erroneamente il duplicato di un altro oggetto già presente 67 public class SortedArraySet implements Set { private Object[] v = new Object[100]; private int vSize = 0; public void makeEmpty() { vSize = 0; } public boolean isEmpty() { return vSize == 0; } public Object[] get() // O(n) { Object[] x = new Object[vSize]; System.arraycopy(v, 0, x, 0, vSize); return x; } private int compareTo(Object x, Object y) { ... } ... } 68 Insieme con array ordinato public class SortedArraySet implements Set { ... public boolean isInSet(Object x) // O(log n) { // si puo’ fare una ricerca binaria ... } public void insert(Object x) // O(n) { if (isInSet(x)) return; if (vSize == v.length) v = resize(v, 2*vSize); v[vSize++] = obj; // usiamo insertion sort che e’ O(n) // perche’ inseriamo in un array ordinato ... } } Intervallo 69 Operazioni su insiemi Operazioni su insiemi ✔ Gli algoritmi già visti per le operazioni sugli insiemi possono essere utilizzati senza alcuna modifica, anche per l’insieme realizzato con un array ordinato – la complessità di tutti gli algoritmi (unione, intersezione e sottrazione) rimane O(n2), perché il metodo insert è rimasto O(n) ✔ Senza avere informazioni sul funzionamento interno dell’insieme, non è possibile ottenere prestazioni migliori... 71 Marcello Dalpasso 70 ✔ Usare l’incapsulamento ad oltranza può essere sconveniente… ✔ In questo caso, sapendo che – l’array ottenuto con il metodo get è ordinato – l’inserimento nell’insieme avviene nel metodo insert usando un algoritmo di ordinamento per inserzione in un array ordinato, a partire dall’elemento di indice più elevato è possibile scrivere versioni più efficienti dei metodi già visti 72 12 Fondamenti di Informatica 1 Settimana 9 Operazioni su insiemi: unione Operazioni su insiemi: unione ✔ Per realizzare l’unione, osserviamo che il problema è molto simile alla fusione di due array ordinati – come abbiamo visto in MergeSort, questo algoritmo di fusione è O(n) ✔ L’unica differenza consiste nella contemporanea eliminazione (cioè nel non inserimento…) di eventuali oggetti duplicati – un oggetto presente in entrambi gli insiemi dovrà essere presente una sola volta nell’insieme unione 73 Operazioni su insiemi: unione public static Set union(SortedArraySet s1, SortedArraySet s2) { Set x = new SortedArraySet(); Object[] v1 = s1.get(); Object[] v2 = s2.get(); int i = 0, j = 0; while (i < v1.length && j < v2.length) if (v1[i].compareTo(v2[j]) < 0) x.insert(v1[i++]); else if (v1[i].compareTo(v2[j]) > 0) x.insert(v2[j++]); while (i < v1.length) x.insert(v1[i++]); while (j < v2.length) x.insert(v2[j++]); return x; } // prestazioni O(n log n) anziche’ quadratiche secondo l’algoritmo visto in MergeSort, gli oggetti vengono via via inseriti nell’insieme unione che si va costruendo ✔ Se gli inserimenti avvengono con oggetti in ordine crescente, l’ordinamento per inserzione in un array ordinato che viene usato dal metodo insert ha prestazioni O(1) per ogni inserimento! – il metodo insert ha quindi prestazioni O(log n) • perché invoca isInSet – se gli inserimenti non avvengono in ordine crescente, il metodo insert ha prestazioni medie di tipo O(n) 74 Operazioni su insiemi: intersezione public static Set intersection(SortedArraySet s1, SortedArraySet s2) { Set x = new SortedArraySet(); Object[] v1 = s1.get(); Object[] v2 = s2.get(); for (int i = 0, j = 0; i < v1.length; i++) { while (v1[i].compareTo(v2[j]) > 0) j++; if (v1[i].compareTo(v2[j]) == 0) x.insert(v1[i]); } return x; } // prestazioni O(n log n) anziche’ quadratiche 75 Operazioni su insiemi: sottrazione public static Set subtract(SortedArraySet s1, SortedArraySet s2) { Set x = new SortedArraySet(); Object[] v1 = s1.get(); Object[] v2 = s2.get(); for (int i = 0, j = 0; i < v1.length; i++) { while (v1[i].compareTo(v2[j]) > 0) j++; if (v1[i].compareTo(v2[j]) != 0) x.insert(v1[i]); } return x; } // prestazioni O(n log n) anziche’ quadratiche 77 Marcello Dalpasso ✔ Effettuando la fusione dei due array ordinati 76 Insieme con ABR ✔ Un insieme può essere realizzato anche usando un albero binario di ricerca come struttura di memorizzazione dei dati ✔ Gli algoritmi saranno identici a quelli visti per la realizzazione con un array ordinato – il metodo get richiede un attraversamento dell’albero in ordine simmetrico, che comunque è un’operazione O(n) come la copiatura dell’array ✔ C’è lo stesso problema visto per gli array… – se gli oggetti appartenenti all’insieme non sono Comparable, non si possono inserire nell’ABR 78 13 Fondamenti di Informatica 1 Settimana 9 Insieme con ABR Insieme con ABR ✔ Possiamo definire una classe involucro che trasforma oggetti qualsiasi in oggetti Comparable, usando il metodo compareTo visto in precedenza public class MyObject implements Comparable { Object x; public MyObject(Object o) { x = o; } public int compareTo(Object y) { String sx = x.toString() + x.hashCode(); String sy = y.toString() + y.hashCode(); return sx.compareTo(sy); } } public class BSTSet implements Set { private BinarySearchTree bst = new BST(); public void makeEmpty() { bst.makeEmpty(); } public boolean isEmpty() { return bst.isEmpty(); } public Object[] get() // O(n) { // crea un iteratore per l’ABR // ed effettua un attraversamento // in ordine simmetrico } ... } 79 Insieme con ABR 80 Operazioni su insiemi public class BSTSet implements Set { ... public boolean isInSet(Object x) // O(log n) { try { bst.find(new MyObject(x)); } catch (ItemNotFoundException e) { return false; } return true; } public void insert(Object x) // O(log n) { if (isInSet(x)) return; bst.insert(new MyObject(x)); } } ✔ Gli algoritmi già visti per le operazioni sugli insiemi possono essere utilizzati senza alcuna modifica, anche per l’insieme realizzato con un ABR – la complessità di tutti gli algoritmi (unione, intersezione e sottrazione) è O(n log n), perché il metodo insert è O(log n) ✔ Avendo informazioni sul funzionamento interno dell’insieme, è possibile ottenere prestazioni migliori? – la risposta è negativa 81 82 Esercizio Tipo di dati duplice 83 Marcello Dalpasso 84 14 Fondamenti di Informatica 1 Settimana 9 Strutture dati complesse Strutture dati complesse ✔ Abbiamo visto diversi tipi di dati astratti (ADT) – pila, coda, lista, albero binario, albero binario di ricerca, dizionario, tabella, tabella hash, insieme ✔ Una classe Java può ✔ In alcune situazioni può essere comodo avere a disposizione una struttura dati che realizzi contemporaneamente il comportamento di più tipi di dati astratti – ad esempio, una pila che metta a disposizione anche un iteratore per ispezionare tutti i suoi elementi, come una lista – estendere una sola altra classe • oppure implicitamente la classe Object – realizzare più interfacce ✔ Se una classe realizza (“implementa”) più di una interfaccia – deve definire tutti i metodi di tutte le interfacce – ha quindi un comportamento che è l’unione dei comportamenti definiti dalle diverse interfacce 85 Strutture dati complesse public class LinkedListStackList implements Stack, List { private LinkedList list = new LinkedList(); public void makeEmpty() // Container { list.makeEmpty(); } public boolean isEmpty() // Container { return list.isEmpty(); } public void push(Object o) // Stack { list.addFirst(o); } public void pop() // Stack { list.removeFirst(); } public Object top() // Stack { return list.getFirst(); } public Object topAndPop() // Stack { return list.removeFirst(); } public ListItr getIterator()// List { return list.getIterator(); } } 86 Strutture dati complesse 87 LinkedListStackList t = new LinkedListStackList(); Stack s = t; s.push("X"); s.push("Y"); s.push("Z"); List list = t; ListItr iter = list.getIterator(); while(iter.isInList()) { System.out.println(iter.retrieve()); iter.advance(); } s.makeEmpty(); iter.first(); while(iter.isInList()) { System.out.println(iter.retrieve()); iter.advance(); } Strutture dati complesse Strutture dati complesse ✔ In questo modo tutto funziona – i due assegnamenti ad interfaccia sono corretti perché la classe LinkedListStackList realizza entrambe le interfacce, quindi suoi esemplari possono essere visti come una delle due interfacce ✔ Si nota un’incongruenza nell’utilizzo della LinkedListStackList t = new LinkedListStackList(); Stack s = t; List list = t; ✔ Si nota un’incongruenza nell’utilizzo della struttura dati complessa – l’esemplare della struttura viene assegnato ad un riferimento del tipo della struttura stessa, e non ad un riferimento ad interfaccia come si fa di solito 89 Marcello Dalpasso Z Y X 88 struttura dati complessa – l’esemplare della struttura viene assegnato ad un riferimento del tipo della struttura stessa, e non ad un riferimento ad interfaccia come si fa di solito Stack t = new LinkedListStackList(); List list = (List) t; ✔ Questo funziona, ma non è molto esplicito – chi legge il codice può (legittimamente) chiedersi come sia possibile convertire un riferimento a Stack in un riferimento a List 90 15 Fondamenti di Informatica 1 Settimana 9 Strutture dati complesse Strutture dati complesse ✔ La cosa migliore sarebbe la definizione astratta del ✔ A questo punto, esiste il comportamento comportamento dato dall’unione dei due comportamenti, Stack e List complesso definito da un’interfaccia StackList t = new LinkedListStackList(); Stack s = t; List list = t; – successivamente, la classe LinkedListStackList si definisce come realizzazione di tale comportamento complesso public interface StackList extends Stack, List { } // nessun metodo nuovo ✔ L’utilizzo della struttura è quello visto in precedenza, ma ora è tutto più esplicito public class LinkedListStackList implements StackList { ... // tutto come prima } 91 92 Strutture dati complesse ✔ Riassumendo – una classe Java può • estendere una sola altra classe – oppure implicitamente la classe Object • realizzare una o più interfacce – un’interfaccia Java può • estendere una o più altre interfacce Intervallo 93 94 Albero binario in un array ✔ Abbiamo visto la realizzazione di un albero Esercizio Realizzazione di un albero in un array binario (e di un ABR) come struttura costituita di nodi concatenati ✔ Questo non è l’unico modo per realizzare l’ADT “albero binario” – un albero binario può essere realizzato usando un array ✔ Ricordiamo che un “albero binario” è un contenitore di oggetti disposti in modo gerarchico, dove ogni oggetto (tranne la radice) ha un genitore e può avere al massimo due figli 95 Marcello Dalpasso 96 16 Fondamenti di Informatica 1 Settimana 9 Albero binario in un array Albero binario in un array ✔ Per realizzare un albero in un array, dobbiamo ✔ Questa funzione di assegnamento degli indici definire un modo per assegnare ad ogni nodo dell’albero un numero intero da usare come indice nell’array – alla radice R assegniamo l’indice I(R) = 1 – se il nodo N è il figlio sinistro del nodo G • allora I(N) = 2 · I(G) – se il nodo N è il figlio destro del nodo G • allora I(N) = 2 · I(G) + 1 97 Albero binario in un array ✔ Per realizzare l’array possiamo usare un array di riferimenti ad Object che contenga le etichette dei nodi dell’albero, in corrispondenza agli indici ✔ Se un nodo non esiste, la cella corrispondente nell’array assume valore null – questa realizzazione funziona solo per un albero con etichette, con il vincolo che nessun nodo può avere etichetta null – per alberi senza etichette si usa un array di valori booleani (true = nodo presente, false = nodo assente) – per alberi con etichette eventualmente null bisogna usare due array, uno di Object e uno di boolean public interface BinaryTreeItr // non modificatore { // crea una copia dell’iteratore, copiando // anche la posizione attuale BinaryTreeItr clone(); // metodi di navigazione // porta l’iteratore nella radice, come quando // l’iteratore viene costruito; // posizione non valida se l’albero e’ vuoto void root(); // sposta l’iteratore di una posizione (può // portarlo ad una posizione non valida) void downLeft(); void downRight(); void up(); // metodi di ispezione boolean isInTree(); // e’ in posizione valida? boolean isInRoot(); // e’ nella radice? boolean hasLeft(); // ha figlio sinistro? boolean hasRight(); // ha figlio destro? // restituisce l’oggetto (il dato) che si // trova nella posizione attuale; restituisce // null se l’iteratore non si trova in una // posizione valida Object retrieve(); } Marcello Dalpasso segue l’ordine di un attraversamento per livelli, procedendo dall’alto in basso e da sinistra a destra 1 ✔ Notiamo subito che non tutti gli indici vengono usati – gli indici sono consecutivi solo se l’albero è completo 2 4 3 5 ✔ Notiamo anche che il primo indice è 1 e non 0 – partendo da 0 le formule non funzionano 7 15 98 // etichette sempre presenti e non null public class ArrayBinaryTree implements BinaryTree { private Object[] v = new Object[100]; private int count; // serve per isEmpty in O(1) public ArrayBinaryTree() { makeEmpty(); } public boolean isEmpty() { return count == 0; } public void makeEmpty() { for (int i = 1; i < v.length; i++) v[i] = null; count = 0; } // la cella 0 non viene usata public BinaryTreeItr getIterator() { return new ArrayBinaryTreeItr(); } private class ArrayBinaryTreeItr implements BinaryTreeItr { private ArrayBinaryTree tree; public ArrayBinaryTreeItr(ArrayBinaryTree t) { tree = t; } ... } } 99 100 public class ArrayBinaryTree implements BinaryTree { ... private class ArrayBinaryTreeItr implements BinaryTreeItr { private ArrayBinaryTree tree; int pos; public ArrayBinaryTreeItr(ArrayBinaryTree t) { tree = t; root(); } public BinaryTreeItr clone() { ArrayBinaryTreeItr x = new ArrayBinaryTreeItr(tree); x.pos = pos; return x; } public void root() { pos = 1; } public boolean isInRoot() { return pos == 1; } ... } } 101 102 17 Fondamenti di Informatica 1 public class ArrayBinaryTree implements BinaryTree { ... private class ArrayBinaryTreeItr implements BinaryTreeItr { private int left() { return pos * 2; } private int right() { return 1 + pos * 2; } private boolean isInTree(int p) { return p > 0 && p < tree.v.length && tree.v[p] != null; } public boolean isInTree() { return isInTree(pos); } public void downLeft() { pos = left(); } public void downRight() { pos = right(); } public void up() { pos = pos / 2; } public boolean hasLeft() { return isInTree(left()); } public boolean hasRight() { return isInTree(right()); } public Object retrieve() { return isInTree() ? null : tree.v[pos]; } } } Settimana 9 Attraversamenti ✔ Nell’attraversamento usiamo il metodo clone per creare un nuovo iteratore, in modo da poter navigare senza modificare la posizione attuale 103 public static void preOrderTraversal(BinaryTree t) { preOrderTraversal(t.getIterator()); } private static void preOrderTraversal(BinaryTreeItr it) { if (!it.isInTree()) return; visit(it.retrieve()); BinaryTreeItr itLeft = it.clone(); itLeft.downLeft(); preOrderTraversal(itLeft); BinaryTreeItr itRight = it.clone(); itRight.downRight(); preOrderTraversal(itRight); } 104 Attraversamenti ✔ In alternativa, si può navigare usando up public static void preOrderTraversal(BinaryTree t) { preOrderTraversal(t.getIterator()); } private static void preOrderTraversal(BinaryTreeItr it) { if (!it.isInTree()) return; visit(it.retrieve()); if (it.hasLeft()) { it.downLeft(); preOrderTraversal(it); it.up(); } if (it.hasRight()) { it.downRight(); preOrderTraversal(it); it.up(); } } Marcello Dalpasso 105 106 18