Lezione 11 Complessità Analisi • • • • • • 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 1 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 2 Pseudocodice • 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 “4 ” indica un commento • l’assegnamento si indica con il simbolo ‘←’ come in i←3 • il test di egualianza si indica con il simbolo ‘=‘ Convenzioni sullo pseudocodice • 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 – Nota: in C++ avremmo invece usato la convenzione A.length • nelle procedure o funzioni i parametri sono passati per valore (per copia) – Nota: non verranno mai passati oggetti per alias o indirizzo 3 Esempio INSERTION-SORT(A) 1 for j ← 2 to lenght[A] 2 do key← ←A[j] 3 4 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 4 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) 5 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 6 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 key← ←A[j] 3 4 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) 7 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 8 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 9 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) 10 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) 11 Notazione Asintotica • La notazione asintotica è un modo per indicare certi insiemi di funzioni caratterizzati da specifici comportamenti all’infinito • Questi insiemi sono indicati come ΘOΩoω • 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 12 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 13 Notazione O(g(n)) • Graficamente c g(n) f(n) n0 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 14 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) } 15 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 limn→∞ 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 limn→∞ f(n)/g(n)=∞ 16 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 17 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) 18 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) 19 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 dimensioni 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. 20 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 Crescita delle funzioni 21 Crescita delle funzioni 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 22 Andamento dei tempi di calcolo N log N 10 10^2 10^3 10^6 10^12 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 is tantaneo is tantaneo is tantaneo s ec ondi s ettim ane log N N log N N^ 2 is tantaneo is tantaneo is tantaneo is tantaneo is tantaneo is tantaneo is tantaneo is tantaneo s ec ondi is tantaneo s ec ondi s ettim ane is tantaneo m es i m ai 2^ N s ec ondi m ai m ai - Tempo impiegato da un calcolatore capace di 10^6 operazioni al secondo 23 ADT elementari Vettori e Liste 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 24 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; } 25 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 26 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 essere collegato con il primo: in questo caso avremo 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 27 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; }; 28 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 29 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 30 Rappresentazione grafica della cancellazione t t Inserimento in testa 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 31 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 32 Ricerca List-Search(L,k) 1 x← ←head[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 33 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 x← ←next[nil[L]] 2 while x ≠ nil[L] e key[x] ≠ k 3 do x ← next[x] 4 return x Rappresentazione Grafica nil[L] nil[L] 9 25 16 4 1 9 16 4 1 inserzione nil[L] 9 16 4 cancellazione 34 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 35 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 36 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 9 13 / prev key next 37