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
i3
• 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
keyA[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
keyA[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
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
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 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
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
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 /
Scarica

Lezione7