Lezione 11 Analisi - Dipartimento di Ingegneria dell`Informazione

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