Lezione 7 I Tipi di Dato Astratto (Abstract Data Type) Sommario • Cosa sono le Strutture Dati Astratte? – Le strutture dati – Le operazioni • Come scegliere fra varie implementazioni? – Analisi degli algoritmi • Le strutture dati elementari – vettori – liste Cosa sono gli ADT • Cosa è un tipo di dato • Cosa è un tipo di dato astratto • Quali sono le operazioni definibili Quale è la questione? • Come organizzare (strutturare) i dati perché sia possibile elaborarli agevolmente tramite algoritmi? • Importanza: – in alcune applicazioni la scelta della struttura dati è l’unica scelta importante – data una struttura dati l’implementazione di un algoritmo può risultare più efficiente – guadagno di tempo o di spazio Tipo di dato • Definizione: Un tipo di dato è definito da un insieme di valori e da una collezione di operazioni su questi valori • Es: un tipo di dato è il tipo intero in cui l’insieme di valori è costituito dai numeri naturali e le operazioni dalla somma, sottrazione, moltiplicazione, divisione, etc. Verso l’astrazione • Preoccupazione principale nello scrivere un programma: – applicazione alla più ampia varietà possibile di situazioni – riutilizzo del programma – astrazione dalle implementazioni per poter lavorare a livelli di complessità maggiore L’astrazione • Si può lavorare a diversi livelli di astrazione: – bit: entità di informazione binaria (astrae dal supporto fisico (tecnologia elettronica) con cui è rappresentato) – modello di calcolatore (astrae dalla rappresentazione dell’informazione) – linguaggi di programmazione (si astrae dal linguaggio macchina e quindi dal modello di calcolatore) – algoritmi (si astrae dai linguaggi di programmazione) – ADT (si astrae dalle implementazioni algoritmiche) Utilità delle astrazioni • Lavorare a livelli alti di astrazione permette di lavorare in modo semplice su problemi complessi • si possono analizzare gli algoritmi indipendentemente dai linguaggi con i quali sono poi implementati • si possono realizzare programmi complessi tramite le strutture dati astratte indipendentemente dalla loro implementazione algoritmica Tipo di dato astratto • Definizione: Un ADT (Abstract Data Type) è un tipo di dato accessibile solo attraverso una interfaccia • Si chiama programma client il programma che usa ADT • si chiama implementazione il programma che specifica il tipo di dato (cioè i valori e le operazioni) Esempio • Un ADT che rappresenti un punto bidimensionale mette a disposizione delle operazioni come ad es. l’assegnazione, il confronto, la somma • questo viene fatto senza rivelare i dettagli implementativi interni: l’interfaccia maschera l’implementazione • è possibile rappresentare un punto mediante due coordinate cartesiane x,y oppure mediante coordinate polari r, • si vuole poter cambiare la rappresentazione interna senza che il programma client debba essere modificato Proprietà degli ADT • Gli ADT di interesse descrivono insiemi o collezioni di elementi (che a loro volta possono essere ADT) • Queste collezioni possono essere dinamiche, ovvero il numero di elementi può variare: si possono aggiungere o togliere elementi dalla collezione • Gli elementi hanno generalmente una struttura costituita da una chiave e (eventualmente) da altri dati satellite • La chiave ha in genere valori in un insieme totalmente ordinato (per cui vale la proprietà di tricomia cioè per ogni coppia di elementi a,b nell’insieme deve valere esattamente una delle seguenti relazioni: a=b, a<b, a>b) Operazioni per un ADT • • • • • • • • inserimento di un nuovo elemento cancellazione di uno specifico elemento ricerca di un elemento avente una chiave specificata minimo e massimo ovvero restituzione dell’elemento con chiave più piccola o più grande successore e predecessore ovvero restituzione dell’elemento con la minore chiave maggiore di una data chiave (o la maggiore chiave minore) selezione del k-esimo elemento più piccolo ordinamento ovvero attraversamento della collezione in ordine di chiave unione di due collezioni Quali ADT vedremo? • Vettori, Liste, Alberi, Grafi • Pile, Code e Code con priorità • Tabelle di simboli e alberi di ricerca • Ci interesseremo particolarmente delle operazioni di ordinamento e di ricerca ADT di Prima Categoria • Per una maggiore flessibilità è necessario garantire di poter utilizzare istanze degli ADT come parametri in ingresso o in uscita a funzioni, o averne istanze multiple (ad esempio un vettore di istanze) • Definizione: Un tipo di dato di prima categoria è un tipo di dato del quale possono esistere istanze multiple e che possiamo assegnare a variabili che sono dichiarate in modo specifico per memorizzare queste istanze E le implementazioni? • La differenza fra due implementazioni algoritmiche delle operazioni che permettono l’uso delle interfacce sta nell’efficienza • per poter caratterizzare l’efficienza si ricorre all’analisi degli algoritmi • l’analisi permette di stabilire quale algoritmo sia migliore in funzione delle caratteristiche dei dati su cui lavoriamo – es. l’algoritmo migliore che implementa l’operazione di ordinamento per collezioni di dati quasi ordinate è diverso da quello migliore per collezioni di dati ordinati casualmente Analisi • L’oggetto del discorso – Algoritmi e pseudocodice • • • • Cosa significa analizzare un algoritmo Modello di calcolo Analisi del caso peggiore e del caso medio Ordini di grandezza – La notazione asintotica • La velocità di crescita delle funzioni Algoritmi • Le operazioni su un ADT vengono implementate tramite algoritmi • durante l’analisi degli algoritmi conviene astrarsi dallo specifico linguaggio di programmazione • per fare questo si usa un linguaggio detto pseudocodice • nello pseudocodice si impiegano metodi espressivi più chiari e concisi che nei linguaggi di programmazione reali • nello pseudocodice si possono usare frasi in linguaggio naturale per sintetizzare procedure complesse ma non ambigue Convenzioni sullo pseudocodice • Adotteremo le stesse convenzioni utilizzate nel libro “Introduzione agli algoritmi” di T.H.Cormen, C.E.Leiserson, R.L.Rivest Jackson Libri,1999 • Le indentazioni indicano la struttura dei blocchi • i costrutti iterativi while,repeat e for e quelli condizionali if, then, else hanno la stessa interpretazione dei linguaggi Pascal o C • il simbolo “” indica un commento Convenzioni sullo pseudocodice • l’assegnamento si indica con il simbolo ‘’ come in i3 • si indica l’accesso all’elemento di posizione i-esima di un array A tramite la notazione A[i] • si accede agli attributi o campi di un oggetto usando il nome del campo seguito dal nome dell’oggetto fra parentesi quadre come in length[A] per denotare la lunghezza del vettore A • nelle procedure o funzioni i parametri sono passati per valore (per copia) Esempio INSERTION-SORT(A) 1 for j 2 to lenght[A] 2 do keyA[j] 3 si inserisce A[j] nella sequenza ordinata A[1..j-1] 4 i j-1 5 while i>0 e A[i]>key 6 do A[i+1] A[i] 7 i i-1 8 A[i+1] key Spiegazione intuitiva • Supponiamo di avere i primi x elementi del vettore già ordinati • consideriamo l’elemento di posizione x+1 e chiamiamolo key • l’idea è di scorrere gli elementi già ordinati e più grandi di key e di trovare la posizione giusta di key • mentre si scorrono gli elementi si scambia di posizione l’elemento che stiamo confrontando con key • appena si trova un elemento più piccolo di key ci si ferma Cosa significa analizzare un algoritmo • Analizzare un algoritmo significa determinare le risorse richieste per il completamento con successo dell’algoritmo stesso • le risorse di interesse possono essere quelle di memoria, di tempo, numero di porte di comunicazione, numero di porte logiche • noi saremo interessati principalmente alla risorsa di tempo computazionale Modello di calcolo • Per poter indicare il tempo di calcolo è necessario specificare un modello (ancorché astratto) di calcolo • Noi faremo riferimento ad un modello di calcolo costituito da un mono processore con accesso casuale della memoria (Random Access Machine RAM) • in questo modello ogni istruzione è eseguita in successione (ovvero senza concorrenza) • ogni istruzione viene eseguita in tempo costante anche se in generale diverso da istruzione a istruzione Dimensione dell’input • Per poter comparare l’efficienza di due algoritmi in modo generale si definisce una nozione di dimensione dell’input e si compara il tempo di calcolo dei due algoritmi in relazione ad esso • per un algoritmo di ordinamento è ragionevole aspettarsi che al crescere del numero di dati da ordinare cresca il tempo necessario per completare l’algoritmo • in questo caso la dimensione dell’input coincide con la numerosità dei dati in ingresso Dimensione dell’input • Nota: non sempre la dimensione dell’input coincide con il numero di elementi in ingresso • un algoritmo di moltiplicazione fra due numeri naturali ha come dimensione il numero di bit necessari per rappresentare la codifica binaria dei numeri • Nota: non sempre la dimensione dell’input è rappresentabile con una sola quantità • un algoritmo che opera su grafi ha come dimensione il numero di nodi e di archi del grafo Analisi del tempo computazionale • Lo scopo dell’analisi del tempo computazionale è di dare una descrizione sintetica del tempo di calcolo dell’algoritmo al variare della dimensione dell’ingresso • inizieremo con un calcolo esatto del tempo • successivamente utilizzeremo un formalismo più sintetico e compatto che fa uso degli ordini di grandezza Esempio Sia n length[A] N° n n-1 n-1 n-1 j=2..n tj j=2..n (tj-1) j=2..n (tj-1) n-1 Costo c1 c2 0 c4 c5 c6 c7 c8 INSERTION-SORT(A) 1 for j 2 to lenght[A] 2 do keyA[j] 3 si inserisce A[j] ... 4 i j-1 5 while i>0 e A[i]>key 6 do A[i+1] A[i] 7 i i-1 8 A[i+1] key Dove tj è il numero di volte che l’istruzione while è eseguita per un dato valore di j Il tempo complessivo è dato da: T(n)=c1.n + c2.(n-1)+c4.(n-1)+c5.(j=2..n tj)+c6.(j=2..n (tj-1)) +c7.(j=2..n (tj-1))+c8.(n-1) Caso migliore/peggiore • Anche a parità di numerosità dei dati in ingresso il tempo di esecuzione può dipendere da qualche caratteristica complessiva sui dati, ad esempio da come sono ordinati inizialmente • si distinguono pertanto i casi migliore e peggiore a seconda che i dati abbiano (a parità di numerosità) le caratteristiche che rendono minimo o massimo il tempo di calcolo del dato algoritmo • nell’esempio dell’insertion sort – il caso migliore è che i dati siano già ordinati – il caso peggiore è che siano ordinati in senso inverso Analisi del caso migliore • Per ogni j=2,3,…,n in 5) si ha che A[i]<key quando i ha il suo valore iniziale di j-1 • quindi vale tj=1 per ogni j=2,3,…,n • il tempo di esecuzione diviene quindi: T(n)=c1.n+c2(n-1)+c4.(n-1)+c5.(n-1)+c8.(n-1) ovvero T(n)=(c1+c2+c4+c5+c8).n -(c2+c4+c5+c8) ovvero T(n)=a.n+b • diciamo che T(n) è una funzione lineare di n Analisi del caso peggiore • Se l’array è ordinato in ordine decrescente allora si deve confrontare l’elemento key=A[j] con tutti gli elementi precedenti A[j-1], A[j-2],…,A[1] • in questo caso si ha che tj=j per j=2,3,4,…,n • si ha che: j=2..n j = n(n+1)/2 -1 j=2..n (j-1) = n(n-1)/2 • il tempo di esecuzione diviene quindi: T(n)=c1.n+c2(n-1)+c4.(n-1) +c5.(n(n+1)/2 -1) +c6.(n(n-1)/2 ) +c7.(n(n1)/2 )+c8.(n-1) T(n)=(c5/2+c6/2+c7/2).n2+(c1+c2+c4+c5/2-c672-c7/2+c8).n(c2+c4+c5+c8) T(n)=a.n2+b.n+c • diciamo che T(n) è una funzione quadratica di n Analisi del caso medio • Se si assume che tutte le sequenze di una data numerosità siano equiprobabili allora mediamente per ogni elemento key=A[j] vi saranno metà elementi nei restanti A[1,..,j-1] che sono più piccoli e metà che sono più grandi • di conseguenza in media tj=j/2 per j=2,3,4,…,n • si computa T(n) come nel caso peggiore • il tempo di calcolo risulta di nuovo quadratico in n Quale caso analizzare? • Come è accaduto anche nel caso appena visto, spesso il caso medio è dello stesso ordine di grandezza del caso peggiore • inoltre la conoscenza delle prestazioni nel caso peggiore fornisce una limitazione superiore al tempo di calcolo, cioè siamo sicuri che mai per alcuna configurazione dell’ingresso l’algoritmo impiegherà più tempo • infine per alcune operazioni il caso peggiore si verifica abbastanza frequentemente (ad esempio il caso di ricerca con insuccesso) • pertanto si analizzerà spesso solo il caso peggiore Ordine di grandezza • Per facilitare l’analisi abbiamo fatto alcune astrazioni • si sono utilizzate delle costanti ci per rappresentare i costi ignoti delle istruzioni • si è osservato che questi costi forniscono più dettagli del necessario, infatti abbiamo ricavato che il tempo di calcolo è nel caso peggiore T(n)=a.n2+b.n+c ignorando così anche i costi astratti ci • si può fare una ulteriore astrazione considerando solo l’ordine di grandezza del tempo di esecuzione perché per input di grandi dimensioni è solo il termine principale che conta e dire che T(n)=(n) Un algoritmo è tecnologia • Si consideri il seguente caso: – si abbia un personal computer capace di eseguire 106 operazioni al secondo ed un supercomputer 100 volte più veloce – si abbia un codice di insertion sort che una volta ottimizzato sia in grado di ordinare un vettore di n numeri con 2n2 operazioni – si abbia un altro algoritmo (mergesort) in grado di fare la stessa cosa con 50 n log n operazioni – si esegua l’insertion sort su un milione di numeri sul supercomputer e il mergsort sul personal computer • il risultato è che il supercomputer impiega 2(106)2/108= 5.56 ore • mentre il personal computer impiega 50 106 log 106 /106= 16.67 minuti Efficienza asintotica • L’ordine di grandezza del tempo di esecuzione di un algoritmo caratterizza in modo sintetico l’efficienza di un algoritmo e consente di confrontare fra loro algoritmi diversi per la soluzione del medesimo problema • quando si considerano input sufficientemente grandi si sta studiando l’efficienza asintotica dell’algoritmo • ciò che interessa è la crescita del tempo di esecuzione al tendere all’infinito della dimensione dell’input • in genere un algoritmo asintoticamente migliore di un altro lo è in tutti i casi (a parte input molto piccoli) Notazione Asintotica • La notazione asintotica è un modo per indicare certi insiemi di funzioni caratterizzati da specifici comportamenti all’infinito • Questi insiemi sono indicati come Oo • quando una funzione f(n) appartiene ad uno di questi insiemi lo si indica equivalentemente come – f(n) (n2) – f(n) = (n2) • la seconda notazione è inusuale ma vedremo che ha dei vantaggi di uso Notazione (g(n)) • Con la notazione (g(n)) si indica l’insieme di funzioni f(n) che soddisfano la seguente condizione (g(n))={f(n): c1, c2, n0 tali che n n0 0 c1 g(n) f(n) c2 g(n) } • ovvero f(n) appartiene a (g(n)) se esistono due costanti c1, c2 tali che essa possa essere schiacciata fra c1 g(n) e c2 g(n) per n sufficientemente grandi Notazione (g(n)) • Graficamente c2 g(n) f(n) c1 g(n) n0 Notazione O(g(n)) • Con la notazione O(g(n)) si indica l’insieme di funzioni f(n) che soddisfano la seguente condizione O(g(n))={f(n): c, n0 tali che n n0 0 f(n) c g(n) } • ovvero f(n) appartiene a O(g(n)) se esiste una costante c tali che essa possa essere maggiorata da c g(n) per n sufficientemente grandi Notazione O(g(n)) • Graficamente n0 c g(n) f(n) Notazione (g(n)) • Con la notazione (g(n)) si indica l’insieme di funzioni f(n) che soddisfano la seguente condizione (g(n))={f(n): c, n0 tali che n n0 0 c g(n) f(n) } • ovvero f(n) appartiene a (g(n)) se esiste una costante c tali che essa sia sempre maggiore di c.g(n) per n sufficientemente grandi Notazione (g(n)) • Graficamente f(n) c g(n) n0 Notazione o(g(n)) • • • • Il limite asintotico superiore può essere stretto o no 2 n2 = O(n2) è stretto 2 n = O(n2) non è stretto con la notazione o(g(n)) si indica un limite superiore non stretto • formalmente, con la notazione o(g(n)) si indica l’insieme di funzioni f(n) che soddisfano la seguente condizione o(g(n))={f(n): c>0 n0 tali che n n0 0 f(n) c g(n) } Notazione o(g(n)) • La definizione di o() differisce da quella di O() per il fatto che la maggiorazione i o() vale per qualsiasi costante positiva mentre in O() vale per una qualche costante • L’idea intuitiva è che la f(n) diventa trascurabile rispetto alla g(n) all’infinito ovvero limx f(n)/g(n)=0 Notazione (g(n)) • Analogamente nel caso di limite inferiore non stretto si definisce che con la notazione (g(n)) si indica l’insieme di funzioni f(n) che soddisfano la seguente condizione (g(n))={f(n): c>0 n0 tali che n n0 0 c g(n) f(n)} • Qui l’idea intuitiva è che sia la g(n) a diventare trascurabile rispetto alla f(n) all’infinito ovvero limx f(n)/g(n)= Tralasciare i termini di ordine più basso • Giustifichiamo perché è possibile tralasciare i termini di ordine più basso, ovvero perché possiamo scrivere 1/2 n2 - 3 n= (n2) • dalla definizione di (g(n)) si ha che si devono trovare delle costanti c1, c2 tali che 1/2 n2 - 3 n possa essere schiacciata fra c1 n2 e c2 n2 per n sufficientemente grandi, ovvero per n>n0 c1 n2 1/2 n2 - 3 n c2 n2 c1 n2 1/2 n2 - 3 n è vera per n 7 e per c1 1/14 1/2 n2 - 3 n c2 n2 è vera per n 1 e per c2 1/2 • quindi per n0=7 c1 = 1/14 e c2 = 1/2 si è soddisfatta la tesi (altri valori sono possibili ma basta trovarne alcuni) Tralasciare i termini di ordine più basso • Intuitivamente si possono tralasciare i termini di ordine più basso perché una qualsiasi frazione del termine più alto prima o poi sarà più grande di questi • quindi assegnando a c1 un valore più piccolo del coefficiente del termine più grande e a c2 un valore più grande dello stesso consente di soddisfare le disegualianze della definizione di (g(n)) • il coefficiente del termine più grande può poi essere ignorato perché cambia solo i valori delle costanti Nota • In sintesi si può sempre scrivere che a n2 + b n + c = (n2) • ovvero j=o..d ajnj= (nd) • inoltre dato che una costante è un polinomio di grado 0 si scrive: c = (n0) = (1) Uso della notazione asintotica • Dato che il caso migliore costituisce un limite inferiore al tempo di calcolo, si usa la notazione (g(n)) per descrivere il comportamento del caso migliore • analogamente dato che il caso peggiore costituisce un limite superiore al tempo di calcolo, si usa la notazione O(g(n)) per descrivere il comportamento del caso peggiore • Per l’algoritmo di insertion sort abbiamo trovato che nel caso migliore si ha T(n)= (n) e nel caso peggiore T(n)=O(n2) La notazione asintotica nelle equazioni • Seguendo la notazione n = O(n) possiamo pensare di scrivere anche espressioni del tipo • 2n2+3n+1= 2n2+O(n) • il significato di questa notazione è che con O(n) vogliamo indicare una anonima funzione che non ci interessa specificare (ci basta che sia limitata superiormente da n) • nel nostro caso questa funzione è proprio 3n+1 che è O(n) • tramite l’uso della notazione asintotica possiamo eliminare da una equazione dettagli inessenziali La notazione asintotica nelle equazioni • La notazione asintotica può anche apparire a sinistra di una equazione come in • 2n2+O(n)= O(n2) • il significato è che indipendentemente da come viene scelta la funzione anonima a sinistra è sempre possibile trovare una funzione anonima a destra che soddisfa l’equazione per ogni n • in questo modo possiamo scrivere: • 2n2+3n+1= 2n2+O(n)=O(n2) Le funzioni di interesse • O(1) il tempo costante è caratteristico di istruzioni che sono eseguite una o al più poche volte. • O(log n) il tempo logaritmico è caratteristico di programmi che risolvono un problema di grosse dimensioni riducendone la dimensione di un fattore costante e risolvendo i singoli problemi più piccoli. quando il tempo di esecuzione è logaritmico il programma rallenta solo leggermente al crescere di n: se n raddoppia log n cresce di un fattore costante piccolo. Le funzioni di interesse • O(n) il tempo lineare è caratteristico di programmi che eseguono poche operazioni su ogni elemento dell’input. Se la dimensione dell’ingresso raddoppia, raddoppia anche il tempo di esecuzione. • O(n log n) il tempo n log n è caratteristico di programmi che risolvono un problema di grosse riducendoli in problemi più piccoli, risolvendo i singoli problemi più piccoli e ricombinando i risultati per ottenere la soluzione generale. Se n raddoppia n log n diventa poco più del doppio. Le funzioni di interesse • O(n2) il tempo quadratico è caratteristico di programmi che elaborano l’input a coppie. Algoritmi con tempo quadratico si usano per risolvere problemi abbastanza piccoli. Se n raddoppia n2 quadruplica. • O(2n) il tempo esponenziale è caratteristico di programmi che elaborano l’input considerando tutte le possibili permutazioni. Rappresentano spesso la soluzione naturale più diretta e facile di un problema. Algoritmi con tempo esponenziale raramente sono applicabili a problemi pratici. Se l’input raddoppia il tempo di esecuzione viene elevato al quadrato La conversione dei secondi • Secondi 102 104 105 106 107 108 109 1010 1011 1.7 minuti 2.8 ore 1.1 giorni 1.6 settimane 3.8 mesi 3.1 anni 3.1 decenni 3.1 secoli mai Andamento dei tempi di calcolo N 10 10^2 10^3 10^6 10^12 log N 3 7 10 17 32 N log N 30 7 10^2 10 ^4 2 10^7 3 10^13 N^2 10^2 10^4 10^6 10^12 10^24 2^N 10^3 10^30 10^300 - Andamento dei tempi di calcolo N 10 10^2 10^3 10^6 10^12 N log N N log N N^2 istantaneo istantaneo istantaneo istantaneo istantaneo istantaneo istantaneo istantaneo istantaneo istantaneo istantaneo secondi secondi istantaneo secondi settimane settimane istantaneo mesi mai 2^N secondi mai mai - Tempo impiegato da un calcolatore capace di 10^6 operazioni al secondo Strutture dati elementari • Le strutture dati vettore e lista sono fra le strutture dati più usate e semplici • il loro scopo è quello di permettere l’accesso ai membri di una collezione generalmente omogenea di dati • per alcuni linguaggi di programmazione sono addirittura primitive del linguaggio (vettori in C/C++ e liste in LISP) • Sebbene sia possibile realizzare l’una tramite l’altra, i costi associati alle operazioni di inserzione e cancellazione variano notevolmente nelle diverse implementazioni Vettori • Un vettore è una struttura dati che permette l’inserimento di dati e l’accesso a questi tramite un indice intero • generalmente la memorizzazione avviene in aree contigue di memoria • nella maggior parte degli elaboratori vi è una corrispondenza diretta con la memoria centrale (questo implica alta efficienza) Esempio di programma che usa vettori Crivello di Eratostene static const int N = 1000; int main(){ int i, a[N]; //inizializzazione a 1 del vettore for (i = 2; i < N; i++) a[i] = 1; for (i = 2; i < N; i++) if (a[i]) //se numero primo elimina tutti multipli for (int j = i; j*i < N; j++) a[i*j] = 0; //stampa for (i = 2; i < N; i++) if (a[i]) cout << " " << i; cout << endl; } Crivello di Eratostene • Intuitivamente: – si prende un vettore di N elementi a 1 – si parte dal secondo elemento e si cancellano (mettono a 0) tutti gli elementi di posizione multipla di 2 – si considera l’elemento successivo che non sia stato cancellato – questo elemento non è divisibile per alcun numero precedente (altrimenti sarebbe stato messo a 0) e deve pertanto essere primo – si cancellano pertanto tutti i suoi multipli Liste • Una lista concatenata è un insieme di oggetti, dove ogni oggetto è inserito in un nodo che contiene anche un link (un riferimento) ad un (altro) nodo • si usa quando è necessario scandire un insieme di oggetti in modo sequenziale • è vantaggiosa quando sono previste frequenti operazioni di cancellazione o inserzioni • lo svantaggio sta nel fatto che si può accedere ad un elemento di posizione i solo dopo aver acceduto a tutti gli i-1 elementi precedenti Liste • Di norma si pensa ad una lista come ad una struttura che implementa una disposizione sequenziale di oggetti • in linea di principio tuttavia l’ultimo nodo potrebbe linkare il primo ed avremo così una lista circolare Liste • Una lista può essere: – concatenata semplice: un solo link – concatenata doppia (bidirezionale): due link • le liste bidirezionali hanno un link al nodo che le precede nella sequenza ed uno al nodo che le segue • con le liste concatenate semplici non è possibile risalire al nodo precedente ma si deve nuovamente scorrere tutta la sequenza • le liste concatenate doppie tuttavia occupano più spazio in memoria Convenzioni • In una lista si ha sempre un nodo detto testa ed un modo convenzionale per indicare la fine della lista • La testa di una lista semplice non ha predecessori • I tre modi convenzionali di trattare il link del nodo dell’ultimo elemento sono: – link nullo – link a nodo fittizio o sentinella – link al primo nodo (lista circolare) Implementazione C++ • La struttura di un nodo di una lista si implementa in C++ attraverso l’uso dei puntatori struct Node { int key Node * next; }; struct Node { int key Node * next; Node * prec; }; Esempio di lista (Problema di Giuseppe Flavio) struct node{ int item; node* next; node(int x, node* t){ item = x; next = t; } }; typedef node * link; int main(int argc, char * argv[]){ int i, N = atoi(argv[1]), M = atoi(argv[2]); link t = new node(1, 0); t->next = t; link x = t; for (i = 2; i <= N; i++) //creazione della lista x = (x->next = new node(i, t)); while (x != x->next){ //eliminazione for (i = 1; i < M; i++) x = x->next; //spostamento x->next = x->next->next; } cout << x->item << endl;//stampa l’ultimo elemento } Spiegazione intuitiva • Si parte da una lista circolare di N elementi • Si elimina l’elemento di posizione M dopo la testa • ci si muove a partire dall’elemento successivo di M posizioni e si elimina il nodo corrispondente • Vogliamo trovare l’ultimo nodo che rimane Operazioni definite sulla lista • Per una lista si possono definire le operazioni di: – inserimento – cancellazione – ricerca • di seguito se ne danno le implementazioni in pseudocodice per una lista bidirezionale Rappresentazione grafica della inserzione t x t x x Rappresentazione grafica della cancellazione t t Inserimento List-Insert(L,x) 1 next[x]head[L] 2 if head[L] NIL 3 then prev[head[L]]x 4 head[L]x 5 prev[x]NIL Cancellazione List-Delete(L,x) 1 if prev[x] NIL 2 then next[prev[x]]next[x] 3 else head[L]next[x] 4 if next[x] NIL 5 then prev[next[x]]prev[x] Nota: Memory leakage • Quando si cancella un nodo si deve porre attenzione alla sua effettiva deallocazione dallo heap • nel caso in cui si elimini un nodo solamente rendendolo inaccessibile non si libera effettivamente la memoria • se vi sono molte eliminazioni si può rischiare di esaurire la memoria disponibile Ricerca List-Search(L,k) 1 xhead[L] 2 while x NIL e key[x] k 3 do x next[x] 4 return x La sentinella • Si può semplificare la gestione delle varie operazioni se si eliminano i casi limite relativi alla testa e alla coda • per fare questo si utilizza un elemento di appoggio detto NIL[L] che sostituisca tutti i riferimenti a NIL • tale elemento non ha informazioni significative nel campo key ed ha inizialmente i link next e prev che puntano a se stesso Implementazioni con sentinella List-Delete(L,x) 1 next[prev[x]]next[x] 2 prev[next[x]]prev[x] List-Insert(L,x) 1 next[x]next[nil[L]] 2 prev[next[nil[l]]]x 3 next[nil[L]]x 4 prev[x]nil[L] List-Search(L,k) 1 xnext[nil[L]] 2 while x nil[L] e key[x] k 3 do x next[x] 4 return x Rappresentazione Grafica 9 16 4 1 9 16 4 nil[L] 25 1 nil[L] inserzione 9 16 4 nil[L] cancellazione Implementazione di lista con più vettori • Si può rappresentare un insieme dei oggetti che abbiano gli stessi campi con un vettore per ogni campo • per realizzare una lista concatenata si possono pertanto utilizzare tre vettori: due per i link e uno per la chiave • un link adesso è solo l’indice della posizione del nodo puntato nell’insieme di vettori • per indicare un link nullo di solito si usa un intero come 0 o -1 che sicuramente non rappresenti un indice valido del vettore Esempio head 7 1 2 3 4 5 6 7 next 3 / 2 5 key 4 1 16 9 prev 5 2 7 / 8 Nota • L’uso nello pseudocodice della notazione next[x] prev[x] e key[x] corrisponde proprio alla notazione utilizzata nella maggior parte dei linguaggi di programmazione per indicare l’implementazione vista Implementazione lista con singolo vettore • La memoria di un calcolatore può essere vista come un unico grande array. • Un oggetto è generlamente memorizzato in un insieme contiguo di celle di memoria, ovvero i diversi campi dell’oggetto si trovano a diversi scostamenti dall’inizio dell’oggetto stesso • si può sfruttare questo meccanismo per implementare liste in ambienti che non supportano i puntatori: – il primo elemento contiene la key – il secondo elemento l’indice del next – il terzo elemento l’indice del prev Esempio 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 19 4 7 13 1 / 4 16 4 19 prev key next 9 13 /