parte uno

annuncio pubblicitario
Orario: Martedì
Giovedì
Lab.
Martedì
Ricevimento
aula a
Gio.
10
11
14
14
13
13
16
16
16
Problemi, Algoritmi
e
Strutture Dati
Problema: Ordinamento
Input: una sequenza di n numeri <a1,a2,…,an>
Output: i numeri ricevuti in input ordinati dal
più piccolo al più grande ovvero:
<aπ(1),aπ(2),…,aπ(n)> dove aπ(i) ≤ aπ(i+1)
π è una opportuna permutazione degli indici 1,…,n
Idea per ordinare…
Ad ogni passo ho una sottosequenza ordinata in
cui inserisco un nuovo elemento dell’input:
ordinati
ordinati
elemento da inserire
Non necessariamente ordinati
Non necessariamente ordinati
Insertion Sort
Insertion-sort(A)
1. for j=2 to length(A)
2.
do key = A[j]
3.
{insert A[j] in A[1,…,j-1]}
4.
i = j-1
5.
while i>0 and A[i]>key
6.
do A[i+1] = A[i]
7.
i=i-1
8.
A[i+1] = key
Esempio: A = {5,2,4,6,1,3}
j=2
j=3
j=4
j=5
j=6
j=7
5
2
2
2
1
1
2
5
4
4
2
2
4
4
5
5
4
3
6
6
6
6
5
4
1
1
1
1
6
5
3
3
3
3
3
6
Ordinamento
n numeri
Insertion Sort
A[1,…,n] = vettore
i,j,key = variabili
Numeri
ordinati
Problema
Input
Algoritmo
Strutture
Dati
Output
Strutture dati usate da
Insertion-Sort
DATI: Insieme di numeri: S
OPERAZIONI: read(i)
size(S)
modify(i,x)
Insertion Sort
Insertion-sort(A)
1. for j=2 to size(A)
2.
do key = read(j)
3.
{insert A[j] in A[1,…,j-1]}
4.
i = j-1
5.
while i>0 and read(i)>key
6.
do modify(i+1,read(i))
7.
i=i-1
8.
modify(i+1,key)
Strutture Dati Astratte
DATI
+
OPERAZIONI
“Che cosa”
DATI
OP1
OP2
……………
OPn
Esempio di ADS
Dati = insieme S di numeri
OP1 = estrai il minimo
OP2 = estrai il massimo
OP3 = restituisci la taglia di S
OP4 = inserisci un nuovo numero in S
Insertion sort
ADS =
Insieme S di numeri
+
Read, Size, Modify
DS =
S=“A[1,…,n]” (vettore)
Read(i)=“A[i]”
Modify(i,x)=“A[i]=x”
ADS = che cosa vogliamo ?
DS = come lo implementiamo ?
Quando una struttura dati
è “buona” ?
Una DS è buona quando non usa troppe
risorse.
Risorse
Tempo
Spazio di memoria
Numero di processori
…
…
Dimensione del problema
Le risorse usate (tempo, spazio) si
misurano in funzione della dimensione
dell’istanza del problema che vogliamo
risolvere.
Esempio: se ordiniamo n numeri la taglia dell’input sarà n. se
moltiplichiamo due matrici nxn sarà n2. Etc…
Il tempo e lo spazio saranno funzioni in n (TIME(n), SPACE(n))
Analisi di Insertion Sort
Dati: A=<a1,a2,…,an>
Dimensione di A = n•c1
Read(i) impiega tempo c2
Modify(i,x) impiega tempo c3
NOTA: c1, c2, c3 sono indipendenti da n
e vengono perciò dette costanti
Insertion Sort: analisi del costo
computazionale
tj
A[j]
tj numero di elementi maggiori di A[j]
dipende dai dati in input
caso ottimo: tj = 0
caso pessimo: tj = j-1
caso medio: tj = (j-1)/2
Insertion Sort: analisi del costo
computazionale
Insertion-sort(A)
1.
for j=2 to length(A)
C1
n
2.
do key = A[j]
C2
n-1
3.
i = j-1
C3
n-1
4.
while i>0 and A[i]>key
C4
∑(tj+1)
C5
∑tj
C6
∑tj
C7
n-1
5.
6.
7.
do A[i+1] = A[i]
i=i-1
A[i+1] = key
n
j=2
n
j=2
n
j=2
T(n) = c1n + c2 (n-1) + c3 (n-1) +
c4 ∑(tj+1) + (c5+c6) ∑tj +
c7 (n-1)
caso ottimo: tj = 0
caso pessimo: tj = j-1
caso medio: tj = (j-1)/2
T(n) = an + b; lineare
T(n) = an2 + bn + c; quadratico
Esercizio!!!
Procedimento di
astrazione
Costo in
microsecondi
Costanti ci
Costanti a, b, c
an2 + bn + c
Ordini di
grandezza: quadratico
Analisi Asintotica
Analisi asintotica
Obiettivo: semplificare l’analisi del tempo di esecuzione di un
algoritmo prescindendo dai dettagli implementativi o di altro
genere. Classificare le funzioni in base al loro comportamento
asintotico.
Astrazione: come il tempo di esecuzione cresce in funzione della
taglia dell’input asintoticamente.
Asintoticamente non significa per tutti gli input. Esempio: input di
piccole dimensioni.
“O grande”
Limite superiore asintotico
f(n) = O(g(n)), se esistono due costanti c e n0, t.c.
f(n)  cg(n) per n  n0
f(n) e g(n) sono funzioni non negative
Notazione usata ad esempio nell’analisi del costo computazionale
nel caso pessimo
“W grande”
Limite inferiore asintotico
f(n) = W(g(n)) se esistono due costanti c e n0, t.c.
cg(n)  f(n) per n  n0
Usato per:
tempo di esecuzione nel caso ottimo;
limiti inferiori di complessità;
Esempio: il limite inferiore per la ricerca in array non ordinati è
W(n).
Eliminiamo termini di ordine inferiore e
costanti.
50 n log n è O(n log n)
7n - 3 è O(n)
8n2 log n + 5n2 + n è O(n2 log n)
Nota: anche se (50 n log n) è O(n5), ci
aspettiamo di avere approssimazioni migliori !!!
“”
“tight bound” = approssimazione stretta.
f(n) = (g(n)) se esistono c1, c2, e n0, t.c.
c1g(n)  f(n)  c2g(n) per n  n0
f(n) = (g(n)) se e solo se f(n) = O(g(n)) e f(n) =
(g(n))
O(f(n)) è spesso usato erroneamente al posto di
(f(n))
W
f(n) = (g(n))
c2 g(n)
f(n)
c1 g(n)
Taglia dell’input = n
n0
”o piccolo” e ” piccolo”
f(n) = o(g(n))
Analogo stretto di O grande
Per ogni c, esiste n0 , t.c. f(n)  cg(n) per n  n0
Usato per confrontare tempi di esecuzione. Se
f(n)=o(g(n)) diciamo che g(n) domina f(n).
f(n)=
(g(n)) analogo stretto di W grande.
Notazione Asintotica
Analogie con i numeri reali
f(n)
f(n)
f(n)
f(n)
= O(g(n))
= W(g(n))
= (g(n))
= o(g(n))
f(n) = (g(n))
f
f
f=
f<
g
g
g
g
f> g
Abuso di notazione: f(n) = O(g(n))
Versione corretta: f(n) appartiene a O(g(n))
Non tutte le funzioni si possono
confrontare !!!
n
n1+sen(n)
???
1+sen(n) oscilla tra 0 e 2
Esempi
M = numero grande, es: 10000000;
 = numero piccolo, es: 0,0000001;
Mn = (n)
Log(n) = o(n)
[Log(n)]M = o(n)
nM =o(2n)
2nM = o(n!)
Mn! = o(nn)
Limiti e notazione asintotica
f(n)/g(n) ---> c
allora
f(n) = (g(n))
f(n)/g(n) ---> 0
allora
f(n) = o(g(n))
f(n)/g(n) ---> ∞
allora
f(n) = (g(n))
Limiti e notazione asintotica
log[f(n)] = o(log[g(n)]) allora
f(n) = o(g(n))
log[f(n)] = (log[g(n)]) non è detto che
esempio: log[n] = (log[n2]) ma n = o(n2)
f(n) = o(g(n))
Tecniche
Algoritmiche:
Divide et Impera
Divide et Impera
Divide et impera:
Dividi: Se l’istanza del problema da risolvere è troppo
“complicata” per essere risolta direttamente, dividila in
due o più “parti”
Risolvi ricorsivamente: Usa la stessa tecnica divide et
impera per risolvere le singole parti (sottoproblemi)
Combina: Combina le soluzioni trovate per i sottoproblemi in
una soluzione per il problema originario.
MergeSort: Algoritmo
Dividi: se S contiene almeno due elementi (un solo elemento è
banalmente già ordinato), rimuovi tutti gli elementi da S e
inseriscili in due vettori, S1 e S2, ognuno dei quali contiene
circa la metà degli elementi di S. (S1 contiene i primi n/2
elementi e S2 contiene i rimanenti n/2 elementi).
Risolvi ricorsivamente: ordina gli elementi in S1 e S2 usando
MergeSort (ricorsione).
Combina: metti insieme gli elementi di S1 e S2 ottenendo un
unico vettore S ordinato (merge)
Mergesort: esempio
24
10
21
36
24
10
36
87
39
83
58
21
39
83
58
87
24
10
24
36
36
10
87
83
21
39
21
58
39
83
58
24
36
10
87
24
36
Merge Sort: Algoritmo
Merge-sort(A,p,r)
if p < r then
q p+r)/2
Merge-sort(A,p,q)
Merge-sort(A,q+1,r)
Merge(A,p,q,r)
Merge(A,p,q,r)
Rimuovi il più piccolo dei due elementi
affioranti in A[p..q] e A[q+1..r] e inseriscilo
nel vettore in costruzione. Continua fino a che i
due vettori sono svuotati. Copia il risultato
in A[p..r].
Merge
1
12
27
36
38
47
54
1
23
25
32
68
96
12 23
… e così via
Equazioni ricorsive:
un esempio semplice
1
se n = 1
T(n/2) + 1
se n > 1
T(n) =
Come si risolve ???
Equazioni ricorsive
Risultati e tempi di esecuzione di
algoritmi ricorsivi possono essere
descritti usando equazioni ricorsive
Un equazione ricorsiva esprime il valore
di f(n) come combinazione di
f(n1),...,f(nk) dove ni < n.
Esempio: Merge Sort
Metodo iteratvo
T(n) =
T(n/2)
+ 1
T(n/4)
+1
+ 1
T(n/8)+1
+1
+ 1
.....................
T(n/n)+1
.........................
+ 1
1 + 1 .........................
+ 1
k
Ci fermiamo quando
2k=n
Dobbiamo valutare k.
sappiamo che 2k = n, quindi
log2( 2k ) = log2(n), ovvero
k = log2(n)
Induzione
Dobbiamo dimostrare che una
affermazione è vera per ogni n≥0
Teorema.
Se
1. affermazione(0) è vera.
2. affermazione(n-1) vera implica affermazione(n) vera.
Allora affermazione(n) vera per ogni n ≥ 0
Dimostrazione per induzione:
esempio
n
affermazione(n):
i

i=1
= n(n+1)/2
1
affermazione(1):
i

i=1
= 1(1+1)/2 = 1
affermazione(n-1) “implica”
n-1
i = (n-1)(n)/2
i=1

OK
affermazione(n):
n
implica
i

i=1
= n(n+1)/2
Dimostrazione per induzione:
esempio
n-1
i + n = (n-1)(n)/2 + n =
i =
i=1 n(n+1)/2
i=1
n
...ma


L’uguaglianza tra questi due termini non è altro che affermazione(n-1)
e quindi la assumiamo vera per ipotesi induttiva.
Metodo di sostituzione
Primo passo:
Ci buttiamo a “indovinare” una possibile soluzione:
T(n) ≤ clog2(n)
Secondo passo:
la verifichiamo per induzione come segue:
Assumiamo che T(n’) ≤ clog2(n’) per n’ < n
e dimostriamo che T(n) ≤ clog2(n)
c è una costante (indipendente da n) che determineremo strada facendo…
T(n) = T(n/2) + 1

≤ clog2(n/2)
+ 1
= clog2(n) - clog2(2)
= clog2(n) - c + 1
se c ≥ 1 allora
≤
clog2(n)
Ipotesi induttiva !!!
+ 1
Equazioni ricorsive:
un esempio più complicato
(1)
se n = 1
2T(n/2) + (n)
se n > 1
T(n) =
Soluzione T(n) = (n log(n))
Albero di ricorsione
Cn + 2T(n/2)
C(n/2) + 2T(n/4)
C(n/4) + 2T(n/8)
……
(1)
(1)
C(n/2) + 2T(n/4)
C(n/4) + 2T(n/8)
……
……
C(n/4) + 2T(n/8)
……
…… …… ……
C(n/4) + 2T(n/8)
=cn
+
=cn
+
=cn
+
……
(1)
(1)
=cn
= n(log(n))
Il fattore log(n) deriva dal fatto che
l’albero ha un altezza log(n)
“Master Method”
T(n) = aT(n/b) + f(n)
a  1, b > 1, f(n) > 0
Poniamo x = logba
f(n) = O(nx-) con >0
allora
T(n) = (nx)
f(n) = (nx)
allora
T(n) = (nx log(n))
f(n) = W(nx+) con >0
af(n/b) ≤ cf(n) con c<1
per tutti gli n>n0
allora T(n) = (f(n))
… Merge sort T(n) = (n log(n))
Insertion sort
Merge sort
Worst case
(n2)
(n log(n))
Average case
(n2)
(n log(n))
(n)
(n log(n))
Best case
Perchè ordinare è importante
… velocizza molto la ricerca !!!
Binary-search(A,x)
i=0
j=length(A)-1
while i<j do
k=(i+j)/2
if A[k]=x then return true
if A[k]>x then j=k-1
if A[k]<x then i=k+1
if A[i]=x then return true
else return false
Analisi di Binary search
Poniamo D(t)=j-i. D(t) è l’ampiezza del vettore sul quale ancora
dobbiamo eseguire la ricerca dopo t confronti. Abbiamo
D(0) = n-1
………
………
D(t+1) = D(t)/2
Usciamo dal while quando D(t)<2
… ovvero se t ≥ log2n.
Quindi T(n) = (log2n)
Priority Queue (Code a Priorità)
Dati: un insieme di elementi, ognuno dei quali
ha una chiave (un intero per esempio).
Operazioni: inserimento, trova il massimo,
estrazione del massimo (massima chiave).
Applicazioni delle PQ:
Job scheduling
Event-driven simulations
Implementazione (facile) usando
vettori
Prima soluzione: vettore ordinato.
Ricerca massimo:
(1) operazioni
estrazione massimo:
(1) operazioni
inserimento:
(n) operazioni
Seconda soluzione vettore non ordinato.
Ricerca massimo:
(n) operazioni
estrazione massimo:
(n) operazioni
inserimento:
(1) operazioni
Si può fare meglio ???
Grafi e Alberi
G=(V,E)
{1,3,4,1} è un ciclo.
Un grafo senza cicli
è aciclico.
V={1,2,3,4,5,6,7,8}
E={(1,2),(1,3),(1,4),(3,4),(6,7),(7,8)}
1
4
6
2
7
3
5
8
Un albero è un grafo aciclico con un numero di nodi
uguale al numero di archi più uno ( |V|=|E|+1 )
1
Albero
3
4
7
5
3
8
6
2
1
4
6
2
Foresta
5
7
8
Radice
r
h(a) altezza del nodo a:
h(x)=1
h(y)=h(q)=2
h(w)=3
x
y
q
w
Foglie
r è la radice
x è il padre di y
y è un figlio di x
x e q sono avi di w
w e q sono discendenti di x
q è fratello di y
Heap
128
72
64
8
1
7
6
12
30
3
A={ 128, 64, 72, 8,
1 2 3 4
A(6) = 12
7, 12, 30, 1,
5 6 7 8
6,
9
3 }
10
Heap: definizione formale
Un Heap è un albero binario quasi completo. Quasi significa che
possono mancare alcune foglie consecutive a partire
dall’ultima foglia di destra.
Per ogni nodo i: Value(i) ≤ Value(Parent(i))
Nota 1: il massimo si trova nella radice
Nota 2: non c’è nessuna relazione tra il valore di un nodo e
quello di un suo fratello
Memorizzazione di un heap in un
vettore
128
72
64
8
1
7
6
3
12
30
Memorizzazione di un heap in un
vettore
Radice
posizione 1
Per ogni nodo in posizione i:
left-child(i)
posizione 2i
right-child(i)
posizione 2i+1
parent(i)
i/2
i
Heaps
Heap
A
i
2i 2i+1
B
4i
4i +3
8i
8i +7
parte del vettore già heapizzato
elemento da aggiungere al sotto heap (verde)
IDEA: facciamo scendere
il nodo i nell’albero fino
a trovare la sua posizione.
?
i
A
B
A
Bi
Heapify(A,i)
Heapify(A,i)
l=left(i)
r=right(i)
if l≤heap-size(A) and A[l]>A[i]
then largest=l
else largest=i
if r≤heap-size(A) and A[r]>A[largest]
then largest=r
if largesti
then Exchange(A[i],A[largest])
Heapify(A,largest)
Heapify: costo computazionale
Caso pessimo: il nodo si sposta fino ad arrivare alle foglie.
Heapify impiega tempo costante ad ogni livello per sistemare
A[i], A[left(i)] e A[right(i)].
Esegue aggiustamenti locali al massimo height(i) volte dove
height(i) = O(log(n))
Build-heap(A)
Build-heap(A)
heap-size(A)=length(A)
for i=length(A)/2 downto 1
do heapify(A,i)
Analisi “approssimativa”:
ogni chiamata a heapify costa O(log(n)).
Chiamiamo heapify O(n) volte,
quindi build-heap = O(nlog(n))
Domanda (esercizio): build-heap = (nlog(n)) ?
PQ implementate con Heap
Extract-max(A)
if heap-size(A)<1 then “error”
max=A[1]
A[1]=A[heapsize(A)]
heapsize(A)=heapsize(A)-1
Heapify(A,1)
return max
O(log(n))
PQ implementate con Heap
max = ??
max =
max =
Heapify(
)
PQ implementate con Heap
Insert(A,x)
heap-size(A)=heap-size(A)+1
i=heap-size(A)
while i>1 and A[parent(i)]<x
do A[i]=A[parent(i)]
i=parent(i)
A[i]=x
O(log(n))
Heap Sort: l’idea.
Heap
Heapify
Heap
Heapify
... avanti così...
Heap Sort
Heap-Sort(A)
build-heap(A)
for i=length(A) downto 2
do exchange(A[1],A[i])
heap-size(A)=heap-size(A)-1
heapify(A,1)
O(nlog(n))
È un metodo “in place”
Quicksort: l’idea
Dividi: Dividi il vettore in due parti non vuote.
Conquista: ordina le due parti ricorsivamente
Combina: fondi le due parti ottenendo un vettore ordinato.
A={10,5,41,3,6,9,12,26}
mergesort
A metà
A1={10,5,41,3} A2={6,9,12,26}
quicksort
Intorno a un Pivot, es 12
A1={10,5,3,6,9,12} A2={41,26}
Quicksort
Quicksort(A,p,r)
if p<r then
q=partition(A,p,r)
Quicksort(A,p,q)
Quicksort(A,q+1,r)
Nota:
Mergesort lavora dopo la ricorsione
Quicksort lavora prima della ricorsione
Partition è cruciale !!!
A(p,r)
i
Pivot
5 3 2 6 4 1 3 7
i
j
i
j
j
5 3 2 6 4 1 3 7
3 3 2 6 4 1 5 7
i
j
i
j
3 3 2 6 4 1 5 7
3 3 2 1 4 6 5 7
j
i
3 3 2 1 4 6 5 7
<5
≥5
(n)
in place
Analisi di QS nel caso ottimo
Caso ottimo: partizioni bilanciate
T(n) = 2T(n/2) + (n)
quindi: T(n) = (nlog(n))
Analisi di QS nel caso pessimo
Caso pessimo: partizioni sbilanciate
T(n) = T(n-1) + (n)
ricorsione
quindi: T(n) = (n2)
partition
Analisi di QS nel caso...
... non buono !
90%
10%
T(n) ???
Albero di ricorsione
n +
n
1/10 n
1/100 n
log10n
9/10 n
9/100 n
9/100 n
81/100 n
81/1000 n
729/1000 n
n
+
n
+
<n
log10/9n
(n log(n))
Analisi del caso medio di QS:
una intuizione.
Caso medio: a volte facciamo una buona
partition a volte no...
buona partition:
cattiva partition
Caso medio
le buone e le cattive partition si alternano...
1
1
cattiva
n-1
(n-1)/2
(n-1)/2
buona
dopo una cattiva e una buona partizione in
successione siamo più o meno nella situazione in cui
la cattiva partizione non è stata fatta !
QS: distribuzione degli input
Abbiamo assunto implicitamente che tutte le sequenze di numeri da ordinare
fossero equiprobabili.
Se ciò non fosse vero potremmo avere costi computazionali più alti.
Possiamo “rendere gli input equiprobabili” ?
come procediamo
mischiamo la sequenza
casualmente prima di ordinare
Scegliamo il pivot a caso.
QS “randomizzato”
QSR una una versione randomizzata della procedura Partition.
Randomized-partition(A,p,r)
i=random(p,r)
exchange(A[p],A[i])
return partition(A,p,r)
Un algoritmo randomizzato non ha un input pessimo, bensì
ha una sequenza di scelte pessime di pivot.
Insertion
sort
Merge
sort
Heap
sort
Quick
sort
Caso
pessimo
n2
n log(n)
n log(n)
n2
Caso
medio
n2
n log(n)
n log(n)
n log(n)
Caso
ottimo
n
n log(n)
n log(n)
n log(n)
= in place
È possibile ordinare in meno di
n log(n)
???
ovvero in o(n log(n))
Limite inferiore di complessità
Insertion-sort
Merge-sort
Heap-sort
Quick-sort
“Comparison-sort”
algoritmi basati su confronti
Questi metodi calcolano una soluzione che
dipende esclusivamentedall’esito di confronti fra numeri
TEOREMA (Lower Bound per algoritmi Comparison-sort):
Qualsiasi algoritmo “comparison-sort” deve effettuare nel
caso pessimo W(n log(n)) confronti per ordinare una sequenza
di n numeri.
lower bound per comparison sort
IDEA: con n numeri ho n! possibili ordinamenti. Possiamo
scegliere quello giusto tramite una sequenza di confronti.
≤
≤
>
>
≤
Ogni nodo rappresenta un confronto.
>
Esempio:
a1:a2
≤
a2:a3
≤
a1,a2,a3
≤
a1,a3,a2
n=3
>
a1:a3
{a1,a2,a3}
>
≤
>
a3,a1,a2
albero dei
confronti
a1:a3
a2,a1,a3
≤
a2,a3,a1
>
a2:a3
>
a3,a2,a1
Ogni nodo bianco rappresenta un confronto.
Ogni nodo rosso rappresenta una possibile soluzione.
3! = 6 = numero di foglie dell’albero dei confronti.
ogni (cammino dalla radice ad una) foglia rappresenta un
ordinamento
ci sono n! ordinamenti.
quanto deve essere alto un albero binario per avere
n! foglie ???
un albero binario alto h ha al massimo 2h foglie
dobbiamo avere 2h ≥ n!
Formula di Stirling: n! > (n/e)n e=2.17...
h ≥ log[(n/e)n] = nlog(n) - nlog(e) = W(nlog(n))
Il caso pessimo di un qualsiasi algoritmo comparison-sort
eseguito su una sequenza di n numeri è dato dall’altezza
dell’albero di decisione associato a quell’algoritmo.
MA
Un albero binario con n! foglie (ordinamenti) ha un altezza
W(nlog(n))
QUINDI
qualsiasi algoritmo comparison-sort,
nel caso pessimo, esegue W(nlog(n)) confronti.
Counting sort: come ordinare in
tempo lineare (!?!?)
Ipotesi di lavoro:
I numeri da ordinare appartengono all’intervallo [1,k]
Risultato: counting sort ha un
costo computazionale
O(n + k)
Se k=O(n) allora counting sort ha un
costo computazionale O(n) e quindi
“batte” tutti i comparison sort
Counting sort: un esempio
A = {3,6,4,1,3,4,1,4}
C’[3]=2 perché il numero 3 è
contenuto 2 volte in A
C’ = {2,0,2,3,0,1}
C’’[4]=7 perché ci sono 7 numeri
minori o uguali a 4
C’’ = {2,2,4,7,7,8}.
Algoritmi di ordinamento stabili
4
A
2
C
7
B
2
E
2
C
3
D
3
D
4
A
2
E
7
B
7
F
7
F
Algoritmi di ordinamento NON
stabili
4
A
2
E
7
B
2
C
2
C
3
D
3
D
4
A
2
E
7
B
7
F
7
F
Algoritmi di ordinamento stabili
Un algoritmo di ordinamento è stabile se:
Se A[i] = A[j] e i < j
allora A[i] compare nell’ordinamento prima di A[j]
ESERCIZIO: dimostrare che counting sort è stabile.
Counting-sort(A,B,k)
1.
for i=1 to k do C[i]=0
2.
for j=1 to length(A) do C[A[j]]=C[A[j]]+1
3.
for i=2 to k do C[i]=C[i]+C[i-1]
4.
for j=length(A) downto 1 do
5.
B[C[A[j]]]=A[j]
6.
C[A[j]]=C[A[j]]-1
1.
2.
3.
4.
costa (k)
costa (n)
costa (k)
costa (n)
Quindi
Counting sort = (n + k)
Radix sort
310
638
237
272
182
926
310
272
182
926
237
638
310
926
237
638
272
182
182
237
272
310
638
926
vettore
ordinato
Radix sort
Radix-sort(A,d)
for i=1 to d
do usa un “stable sort” per
ordinare A sulla cifra iesima
Ogni cifra è compresa tra 1 e k.
Usiamo counting sort (stabile).
Costo computazionale:
d(n+k) = (nd+dk).
Counting sort non lavora in place !
Bucket sort
Ipotesi: i numeri da ordinare sono
uniformemente distribuiti nell’intervallo
[0,1), ovvero
Ci si aspetta che nell’intervallo [x,x+)
ci siano tanti numeri quanti in [y,y+)
per qualunque x,y,
78
0
17
1
12
17
39
2
21
23
26
3
39
62
4
--
68
5
--
21
6
12
23
--
78
8
--
26
--
62
7
--
68
--
--
--
Bucket-sort(A)
n=length(A)
for i=1 to n
do inserisci A[i] nella lista B[nA[i]]
for i=0 to n-1
do ordina la lista B[i] usando insertion-sort
Concatena le liste B[0],...,B[n-1]
NOTA:
nA[i] restituisce il “bucket” dove inserire A[i]
Variabile Aleatoria Discreta: variabile che può
assumere un numero finito di valori con una
certa distribuzione di probabilità.
Esempio 1:
X = (Testa, Croce)
Pr(X=Testa) = Pr(X=Croce) = 1/2
Esempio 2:
Y = (1,2,3,4)
Pr(Y=1) = Pr(Y=2) = 1/3,
Pr(Y=3) = Pr(Y=3) = 1/6.
Media di una VAD
Probabilità
E[Y]= 1·1/3 + 2·1/3 + 3·1/6 + 4·1/6 = 13/6
Valori possibili di Y
Vogliamo calcolare il costo computazionale
medio di Bucket Sort.
Bucket Sort usa Insertion Sort sulle singole liste.
Assumendo che la lunghezza media
di ogni lista sia ni , il costo della singola
applicazione di Insertion Sort è (ni )2
(nel caso pessimo !!!)
Dobbiamo quindi valutare E[(ni )2].
NOTA: E[X2] diversa da E[X]2
ni = variabile aleatoria = numero di
elementi nel bucket i Pr(x ---> Bi) = 1/n
Distribuzione binomiale
Pr(ni = k) = ( n ) (1/n)k (1-1/n)n-k
E[ni] = n 1/n =k 1
E[ni2] = Var[ni] + E2[ni] = 2 - 1/n =(1)
E[ni2] = costo computazionale di insertion sort
Costo computazionale di bucket sort = O(n)
Selezione: esempio
12 3 8 1 7 6 100 91
Input
1 3 6 7 8 12 91 100
Input
ordinato
massimo
minimo
quarto elemento
nell’ordinamento
Selezione
Selezione: Calcolare l’iesimo elemento
nell’ordinamento.
Ordinamento (nlog(n))
minimo/massimo (n)
Selezione ????
Selezione
Input: un insieme A di n numeri distinti
e un numero i tra 1 e n
Output: l’elemento x in A maggiore di
esattamente i-1 altri numeri in A
Soluzione banale: ordino gli elementi di A e
prendo l’iesimo elemento nell’ordinamento.
(nlog(n)) (analisi del caso pessimo)
A[1,...,n]
1
L
R
q
n
Supponiamo che
A[i] ≤ A[j] per ogni 1 ≤ i ≤ q e ogni q < j ≤ n
Domanda: il k-esimo elemento nell’ordinamento
sta in L o in R ?
Risposta:
Facile. Se k ≤ q ---> L. Altrimenti R.
Selezione in tempo medio lineare
Rand-select(A,p,r,i)
if p=r then return A[p]
q=rand-partition(A,p,r)
k=q-p+1
if i≤k
then return Rand-select(A,p,q,i)
else return Rand-select(A,q+1,r,i-k)
caso pessimo (n2)
caso medio (n) (senza dimostrazione)
Selezione in tempo lineare
nel caso pessimo
IDEA: dobbiamo progettare un buon
algoritmo di partition
n
(1- )n
Nota: basta che  sia maggiore di
zero e indipendente da n !!!
Select(i)
1.
2.
3.
4.
5.
Dividi i numeri in input in gruppi da 5
elementi ciascuno.
Ordina ogni gruppo (qualsiasi metodo va bene).
Trova il mediano in ciascun gruppo.
Usa Select ricorsivamente per trovare il
mediano dei mediani. Lo chiamiamo x.
Partiziona il vettore in ingresso usando x
ottenendo due vettori A e B di lunghezza
k e n-k.
Se i≤k allora Select(i) sul vettore A
altrimenti Select(i-k) sul vettore B.
n/5
5
Mediano della terza colonna
Calcoliamo il mediano di M usando
Select ricorsivamente !!!
=M
Supponiamo di aver riordinato le colonne a
seconda del valore del loro mediano.
Minori o uguali
di
= mediano dei mediani.
Maggiori o uguali
di
più o meno 3 n/10
più o meno 3 n/10
Se partizioniamo intorno a
lasciamo almeno
(circa) 3n/10 elementi da una parte e almeno
(circa) 3n/10 elementi dall’altra !!! OK
Select: costo computazionale
(1)
T(n) =
(n) +
T(n/5) +
T(7n/10 )
T(n) ≤ k n,
k costante opportuna
Dim: Esercizio
se n < c
se n ≥ c
Costo per
ordinare le
colonne
Costo per
calcolare il
mediano dei
mediani
Costo per la
chiamata
ricorsiva di
select
Strutture Dati
Elementari:
Pile e Code
Pile (Stacks)
Dati: un insieme S di elementi.
Operazioni: PUSH, POP
PUSH: inserisce un elemento in S
POP: restituisce l’ultimo elemento inserito
e lo rimuove da S
Politica: Last-In-First-Out (LIFO)
Pila
PUSH…
Pila
POP…
Code (Queues)
Dati: un insieme S di elementi.
Operazioni: ENQUEUE, DEQUEUE
ENQUEUE : inserisce un elemento in S
DEQUEUE : restituisce l’elemento da più tempo
presente (il più vecchio) e lo
rimuove da S
Politica: First-In-First-Out (FIFO)
Coda
ENQUEUE…
Coda
DEQUEUE…
Implementazione di Pile
con Vettori
STACK-EMPTY(S)
PUSH(S,x)
top[S] = top[S] + 1
If top[S] = 0
S[top[S]] = x
then return TRUE
else return FALSE
POP(S)
if STACK-EMPTY(S)
then “error”
else top[S] = top[S] - 1
return S[top[S] + 1]
Implementazione di code
con Vettori
ENQUEUE(Q,x)
Q[tail[Q]]
if tail[Q]
then
else
= x
= length[Q]
tail[Q] = 1
tail[Q] = tail[Q] + 1
DEQUEUE(Q,x)
x = Q[head[Q]]
if head[Q] = length[Q]
then head[Q] = 1
else head[Q] = head[Q] + 1
Problemi con i vettori
Vettori
ma
Semplici,
Veloci
Bisogna specificare la lunghezza staticamente
Legge di Murphy
Se usi un vettore di lunghezza n = doppio
di ciò che ti serve, domani avrai bisogno di un
vettore lungo n+1
Esiste una struttura dati più flessibile?
Linked Lists
Dato 1
Head[L]
next
Dato 2
next
Dato 3
---
Elemento della lista:
Dato +
puntatore all’ elemento successivo nella lista
Doubly Linked Lists
Dato 1
Head[L]
Dato 2
----
prev
next
next
Elemento della lista:
Dato +
puntatore al predecessore +
puntatore al successore
Dato 3
prev
----
Ricerca e Inserimento
LIST-SEARCH(L,k)
x = head[L]
while x < > nil and key[x] < > k
do x = next[x]
return x
LIST-INSERT(L,x)
next[x] = head[L]
if head[L] < > nil
then prev[head[L]] = x
head[L] = x
prev[x] = nil
Cancellazione
LIST-DELETE(L,k)
x = LIST-SEARCH[L,k]
if prev[x] < > nil
then next[prev[x]] = next[x]
else head[L] = next[x]
if next[x] < > nil
then prev[next[x]] = prev[x]
Costi Computazionali
Inserimento
LIST-INSERT
1)
Cancellazione
LIST-DELETE
1)
Ricerca
LIST-SEARCH
n)
Sentinelle
Lista vuota usando le sentinelle
---
nil[L]
prev
next
Sentinelle
nil[L]
x
last
first
Dato 1
prev
next
Dato 2
prev
next
Dato 3
prev
next
Dato 4
prev
next
sentinella
Cancellazione usando le sentinelle
LIST-DELETE(L,k)
x = LIST-SEARCH[L,k]
if prev[x] < > nil
then next[prev[x]] = next[x]
else head[L] = next[x]
if next[x] < > nil
then prev[next[x]] = prev[x]
LIST-DELETE-SENTINEL(L,k)
x = LIST-SEARCH[L,k]
next[prev[x]] = next[x]
prev[next[x]] = prev[x]
Esercizio: usare le sentinelle per
SEARCH e INSERT
Alberi binari rappresentati
usando liste
Padre
Figlio sinistro
Filgio destro
Alberi generali rappresentati
usando liste
Padre
Primo figlio
Primo fratello
---
Lista dei fratelli
Costo computazionale delle
operazioni sulle liste
Singly linked
non ordinata
Ricerca
Inserimento
Cancellazione
Successore
Predecessore
Massimo
Singly linked
ordinata
Doubly linked
non ordinata
Doubly linked
ordinata
Tabelle Hash
Ogni elemento ha una chiave
Ki tale che 0 < Ki < n+1
1
Insieme
S
Elemento 1
Elemento 2
Vettore V
Elemento i
Elemento 12
Elemento 41
|S|
Tabella con indirizzamento diretto
Posizione nel vettore = valore della chiave
i
i
Elemento i-esimo
j
j
Elemento j-esimo
Tabella con indirizzamento diretto
Search(V,k)
return V[k]
Insert(V,x)
V[key[x]] = x
Delete(V,x)
V[key[x]] = nil
Costo
computazionale = (1)
Problemi…
Supponiamo che solo una parte S’ dello
spazio S delle chiavi sia
utilizzata/attiva.
Cosa succede quando |S’| << |S| ?
Si spreca spazio di memoria !!!
Soluzioni?
Problemi…
S
S’
= Spazio sprecato
Una soluzione …
Possiamo ridurre l’occupazione di spazio
da (|S|) a (|S’|)
Usando LINKED LISTS !!!
PROBLEMA (non finiscono mai):
Inserimento, Cancellazione e Ricerca
costano (|S’|) invece di (1).
Vero Problema: compromesso tra
TEMPO e SPAZIO
Usando Hash tables possiamo
raggiungere:
Tempo di accesso: (1)
Spazio di memoria: (|S’|)
Ma … in media e non nel caso pessimo !
IDEA …
h = funzione hash
i
i
Elemento i-esimo
h(i)
i
Elemento i-esimo
Funzione hash
h restituisce un numero intero
da 1 a M.
Usiamo una tabella con M posizioni
x viene memorizzato in posizione
h(key[x])
Proprietà per una “buona” h
 Deterministica ma deve sembrare “random” in
modo da minimizzare le collisioni.
 x e y generano una collisione se
x ≠ y e h(x) = h(y)
 h deve minimizzare il numero di collisioni
h(ki)=h(kj)
Risoluzione di collisioni con
“chaining”
ki
ki
kj
kj --
Chained-hash-insert(T,x)
Inserisci x in testa alla lista:
T[h(key[x])]
Chained-hash-search(T,k)
Ricerca l’elemento con chiave k nella lista:
T[h(k)]
Chained-hash-delete(T,x)
cancella x dalla lista:
T[h(key[x])]
Chaining: analisi
Load factor =n/m,
n = numero di elementi memorizzati in T
m = dimensione di T
Caso pessimo: tutte le n chiavi finiscono nella
stessa posizione. Ricerca = (n)
Caso medio:
Simple uniform hashing: Pr(h(k)=i) = Pr(h(k)=j)
Simple uniform hashing: un esempio
U=
1 (1/2)
2 (1/8)
3 (1/8)
4 (1/16)
5 (1/16)
6 (1/8)
1
h
m=2
2
NON UNIFORME !!!
PERCHE’ ???
in rosso è indicata la
probabilità che una certa chiave
debba essere inserita nella tabella
Simple uniform hashing: un esempio
U=
1 (1/2)
2 (1/8)
3 (1/8)
4 (1/16)
5 (1/16)
6 (1/8)
1
h
m=2
2
UNIFORME !!!
PERCHE’ ???
in rosso è indicata la
probabilità che una certa chiave
debba essere inserita nella tabella
Simple uniform hashing
Una funzione hash si dice uniforme
quando rende uniforme il riempimento
della tabella.
Non quando la distribuzione delle chiavi
è uniforme !!!
Teorema:
Ipotesi:
collisioni gestite con chaining
simple uniform hashing
caso medio
Tesi:
una ricerca ha costo
computazionale (1+)
Dimostrazione:
Caso di ricerca senza successo.
Load factor  è la lunghezza media di una catena.
In una ricerca senza successo il numero di
elementi esaminati è uguale alla lunghezza media
delle catene.
Calcolare h() costa 1.
Dimostrazione:
Caso di ricerca con successo.
Assumiamo di inserire elementi in testa alla
catena.
Simple uniform hashing
numero medio di
elementi in una catena dopo i inserimenti = i/m
l’elemento j ci si aspetta che venga inserito nella
posizione 1 +(j-1)/m all’interno di una catena.
Un elemento generico finirà in media nella posizione
data dalla formula:
1/n n (1+ (i-1)/m) = 1/n (n + [n(n+1)]/[2m] - n/m) =
= 1 + /2 - 1/(2m) =
i=1
= (1+)
Supponiamo che n=O(m). Ovvero che il numero di
elementi inseriti nella tabella sia proporzionale alla
dimensione della tabella. Abbiamo:
 = n/m = O(m)/m = O(1)
In questo caso la ricerca impiega tempo costante !!!
Cosa succede se gli elementi vengono inseriti all’inizio delle liste ?
Riepiloghiamo...
Se usiamo doubly linked lists per le
catene e se inseriamo i nuovi elementi
in testa alle liste abbiamo
Ricerca
Cancellazione
Inserimento
O(1) operazioni
in media
Funzioni hash: progettazione
Pr(k) = probabilità della chiave k
Sj={ k  U tali che h(k)=j }
Vogliamo uniform hashing ovvero
 Pr(k) = 1/m
kSj
(m=dimensione della tabella)
Esempio
U = { x R: 0≤x<1 }
x preso a caso da U.
Definiamo h(x)=xm
Dimostrare per esercizio che h() è una buona
hash function.
(suggerimento: definire Sj esplicitamente)
Se Pr(•) è sconosciuta
Usiamo euristiche
IDEA:
 h deve dipendere da tutti i bit di k
 deve essere indipendente da eventuali
pattern che possono essere presenti nelle chiavi
Supponiamo per semplicità che le chiavi siano numeri
naturali.
Metodo della divisione
h(k) = k mod m
Esempio: m=12, k=100, h(100) = 100 mod 12 = 4
Per controllare se uno ha scelto un buon m
è consigliabile usare un “benchmark” reale.
Metodo della moltiplicazione


h(k) = m(kA mod m)
Esempio:
A = (51/2-1)/2 = 0.618...,
k = 123456,
m = 10000
h(123456) = conti conti conti = 41
Risoluzione collisioni:
open addressing
h(,0)
h(,0)
h(,2)
h(,0) =4
h(,0)
h(,1)
h(,1)
h(,1)
=2
=5
=1


1
2
3


4
5
Open addressing
Nessun puntatore: spazio risparmiato!
 ≤ 1 sempre. Nessuna lista per gestire
le collisioni
Hash function più complessa.
<h(k,0), h(k,1), ... , h(k,m-1)> deve
essere una permutazione di <1, ... , m>
h(k,i) = posizione della
tabella in cui inserire la
chiave k quando tutte le
posizioni h(k,0), ... , h(k,i-1)
sono già occupate.
Open addressing: uniform hashing
Se gestiamo le collisioni con il metodo open
addressing, la funzione hash restituisce una
permutazione degli indici <1, ... ,m>.
Invece di simple uniform hashing parliamo di
uniform hashing.
Uniform hashing: tutte le permutazioni
devono apparire con la stessa probabilità
Open addressing: inserimento
Hash-insert(T,k)
i=0
repeat j=h(k,i)
if T[j]=nil
then T[j]=k
return j
else i=i+1
until i=m
error “hash table overflow”
Open addressing: ricerca
Hash-search(T,k)
i=0
repeat j=h(k,i)
if T[j]=k
then return j
else i=i+1
until (T[j]=nil) or (i=m)
return nil
Open addressing: cancellazione
Hash-delete(T,k)
i=Hash-search(T,k)
if inil then T[i]=nil
NON FUNZIONA
0 3
1 7
2 8
3
0
1
2
3
3
7
8
6
0 3
1 7
2
3 6
Inseriamo 6
h(6,0)=0
h(6,1)=1
h(6,2)=2
h(6,3)=3
Cancelliamo 8
ricerchiamo 6
Risposta:
6 non c’è
Esercizio:
Modificare Hash-search e Hash-delete
per risolvere il problema illustrato nel lucido
precedente.
Suggerimento: usare un
carattere con il quale
contrassegnare gli elementi
cancellati.
0
1
2
3
3
7
D
6
Open addressing: linear probing
Sia h’ una funzione hash “ordinaria”.
Definiamo h(k,i)=(h’(k) + i) mod m
Esempio di linear probing: m=5, k=3, h’(3)=4
h(k,0)
h(k,1)
h(k,2)
h(k,3)
h(k,4)
h(k,5)
=
=
=
=
=
=
4
5
0
1
2
3
= probe
Linear probing: primary clustering
Tempo medio di
accesso per una ricerca
senza successo: 1.5
Perche’?
Tempo medio di
accesso per una ricerca
senza successo: 2.5
Perche’?
Clustering primario
Usando linear probing il clustering primario si forma
con alta probabilità.
i slot
pieni
Pr = (i+1)/m
i slot
vuoti
Pr = 1/m
i+1 slot
pieni
Quadratic probing
h(k,i) = (h’(k) + c1i + c2i2) mod m
con c20
Cosa si può dire sul clustering primario ?
Double hashing
h(k,i) = (h1(k) + ih2(k)) mod m
Cosa succede se MCD(m,h2(k)) = d > 1 ???
Quante permutazioni distinte
produce il double hashing ???
Open addressing: ricerca
Teorema:
Data una hash table con open
addressing e load factor
 = n/m < 1
la lunghezza media di una “probe” in
una ricerca senza successo è 1/(1- ).
(Ipotesi: uniform hashing)
 = (m-1)/m
1/(1- ) = m
(valore massimo di )
 = 1/m
1/(1- ) = m/(m-1)
(valore minimo di )
 = 1/2
1/(1- ) = 2
Dimostrazione:
IDEA: cosa succede quando facciamo una
ricerca senza successo ???
X = lunghezza probe =
quante volte devo calcolare
h(k,i) prima di trovare uno slot
vuoto = elemento non trovato
Empty
Dobbiamo valutare E[X] = media di X
Lemma:
0 --> p0
X variabile aleatoria discreta
E[X] =
=
=
∞

i=0
X=
∞
∞
i(Pr(X≥i) - Pr(X≥i+1))

i=0
..........
i --> pi
..........
ipi
iPr(X=i)

i=0
1 --> p1
ESERCIZIO !!!
=
∞
Pr(X ≥i)

i=1
E[X] =
≤
≤
∞
Pr(X≥i)

i=1
∞

i=1
∞

i=1
(n/m)i
Costo per la ricerca:
1 + E[X] =
i
1+
∞

i=1
i =
1 +  + 2 + 3 + ..........
1 / (1-)
Open addressing: inserimento
Teorema:
Data una hash table con open
addressing e load factor  = n/m < 1,
la lunghezza media di una “probe” è
1/(1- ).
(Ipotesi: uniform hashing)
Dimostrazione:
Nota:  deve essere < 1.
Per inserire un elemento abbiamo
bisogno di determinare la posizione
nella tabella dove inserirlo.
Ricerca: costo 1/(1-).
Per inserire nella tabella nella posizione
appena determinata: (1).
Alberi Binari di
Ricerca
Alberi di ricerca binari
8
5
18
6
15
9
17
16
Alberi di ricerca binari
8
5
18
6
15
9
17
16
BST: definizione formale
Sia x un nodo dell’albero:
Se y è un nodo nel sottoalbero sinistro di x allora
key[y]≤key[x]
Se y è un nodo nel sottoalbero destro di x allora
key[y]>key[x]
Nota che un BST può essere molto sbilanciato !!!
bilanciato
sbilanciato
Inorder-tree-walk(x)
if x ≠ nil
then Inorder-tree-walk(left[x])
print key[x]
Inorder-tree-walk(right[x])
8
5
18
7
6
15
9
ORDINAMENTO
17
16
Ricerca
ricerchiamo il 16
8
5
18
7
6
15
9
17
16
(h) confronti
h=altezza albero
Ricerca
Tree-search(x,k)
if x=nil or k=key[x]
then return x
if k<key[x]
then return Tree-search(left[x],k)
else return Tree-search(right[x],k)
Esercizio: dimostrare che il costo computazionale
di Tree-search è (h)
Successore
15
6
3
2
18
7
4
17
13
9
20
x ha il figlio destro.
successore(x)=minimo
nel sottoalbero di destra
Dimostrazione: Esercizio.
Successore
15
6
3
2
18
7
4
17
13
9
20
x non ha il figlio destro.
successore(x) = il più basso avo di x
il cui figlio sinistro è avo di x
Dimostrazione: Esercizio.
Operazioni su BST
Ricerca
Minimo
Massimo
Predecessore
Successore
(h)
confronti
Inserimento
5
15
6
3
2
18
7
4
17
13
5
9
20
99
99
Cancellazione
3 casi:
x non ha figli: elimina x
x ha un figlio:
x ha 2 figli:
Lemma: il successore di x sta nel sotto albero
destro e ha al massimo 1
figlio.
Dimostrazione: esercizio.
eliminiamo
15
6
3
2
18
7
4
17
20
13
9
successore di 15
17
6
3
2
18
7
4
20
13
9
17 ha preso il posto di 15
Cancellazione di un nodo x con 2 figli:
1. sia y = successore di x.
y ha un solo figlio (al massimo)
2. sostituisci x con y.
3. rimuovi y.
Problema
Tutte le operazioni su BST hanno un costo
lineare nell’altezza dell’albero.
Purtroppo, quando l’albero è sbilanciato,
h = n-1
Le operazioni hanno un costo lineare invece
che logaritmico come speravamo !!!
Soluzione ???
Soluzione
Introduciamo alcune proprietà addizionali sui
BST per mantenerli bilanciati.
Paghiamo in termini di una maggiore
complessità delle operazioni dinamiche
sull’albero.
Tali operazioni devono infatti preservare le
proprietà introdotte per mantenere il
bilanciamento.
Red-Black Trees
Red Black Trees =
BST + alcune proprietà aggiuntive
26
17
41
14
21
10
7
3
16
12
15
19
30
23
20
28
47
38
35
39
Proprietà A
ogni nodo è rosso o nero
26
17
41
14
21
10
7
3
16
12
15
19
30
23
20
28
47
38
35
39
Proprietà B
ogni foglia è nera
(ne aggiungiamo un livello fittiziamente)
26
17
41
14
21
10
7
3
16
12
15
19
30
23
20
28
47
38
35
39
Proprietà C
un nodo rosso ha figli neri
26
17
41
14
21
10
7
3
16
12
15
19
30
23
20
28
47
38
35
39
Proprietà D
tutti i cammini da un nodo x alle foglie
ha lo stesso numero di nodi neri
26
17
41
14
21
10
7
16
12
15
19
30
23
20
28
47
38
35
39
3
4 nodi neri
4 nodi neri
Idea di base
Proprietà D: se un RB tree non ha nodi rossi è
completo.
Possiamo immaginarci un RB tree come un
albero nero completo a cui abbiamo
aggiunto “non troppi” nodi rossi (Proprietà
C).
Ciò rende l’albero “quasi bilanciato”
Black-height di un RB tree
bh(x) = numero di nodi neri (senza
contare x) nel cammino da x a una
foglia)
bh(root) = black-height dell’albero
bh(x)
3
26
3
2
3
2
21
16
12
41
14
10
7
17
15
19
23
20
30
28
47
38
35
39
Teorema: Un RBT con n nodi interni è
alto al massimo 2log2(n+1)
Lemma: Il numero di nodi interni di un
sotto albero radicato in x è maggiore
o uguale a 2bh(x)-1
Dimostrazione del lemma.
Per induzione sull’altezza di x.
CASO BASE: h(x)=0.
Se h(x)=0 allora bh(x)=0 inoltre x è una foglia
quindi il numero di nodi interni è 0.
INDUZIONE: h(x)>0.
x ha 2 figli: L e R. Abbiamo 2 casi:
L è rosso: bh(L) = bh(x)
L è nero: bh(L) = bh(x) - 1
R viene trattato in modo analogo
Visto che h(L) < h(x) e h(R) < h(x)
applichiamo l’ipotesi induttiva.
Numero di nodi interni dell’albero radicato in L
maggioreo o uguale a 2bh(L) - 1.
Stesso discorso per R.
Inoltre 2bh(L)-1  2bh(x)-1 - 1 e 2bh(R) - 1  2bh(x)-1 - 1
Quindi: numero di nodi interni dell’albero radicato in x
maggiore o uguale a 2bh(x)-1 - 1 + 2bh(x)-1 - 1 + 1
che è uguale a 2bh(x) - 1.
Dimostrazione del teorema.
Sia h l’altezza dell’albero.
Qualsiasi cammino dalla radice --> foglia contiene
almeno metà nodi neri.
Il cammino radice --> foglia che determina l’altezza
dell’albero contiene almeno h/2 nodi neri.
bh(T) = bh(root)  h/2
Lemma --> n  2bh(root) - 1
quindi: n  2bh(root) - 1  2h/2 - 1.
concludiamo: log2(n+1)  log2(2h/2 ) = h/2.
Rotazioni
Operazioni di ristrutturazione locale dell’albero
che mantengono soddisfatte le proprietà A,B,C,D
esercizio: scrivere
il pseudo codice per
le rotazioni
Y
A
X
B
C
X
B
destra
sinistra
Y
C
A
Inserimento
Idea:
inseriamo x ---> T
color[x]=red
qualche rotazione + ricolorazione
41
30
28
38
35
nodo inserito --->
32
47
39
---> !!!!!! il figlio di
un nodo rosso deve
essere nero
11
2
1
p
15
7
5
4
14
8
11
rotazione sinistra --->
2
1
p
7
5
4
14
8
15
rotazione destra --->
11
7
2
p
14
8
1
5
4
15
7
2
11
1
5
4
FINE...
8
14
15
metodo generale: zio(x) è rosso
p
C
A
x
A
D
up
w
z
C
D
p
B
y
rosso
s
x
B
y
w
z
s
metodo generale: zio(x) è rosso
p
C
p
rosso
B
C
D
B
D
up
A
x
z
y
w
s
A
x
z
y
w
s
metodo generale: zio(x) è nero
p
C
p
A
nero
C
D
B
D
left(A)
x
B
y
A
z
x
z
y
... e poi ...
C
p
B
A
x
D
z
y
B
right(C)
A
x
C
y
z
D
Ci sono un certo numero di casi
analoghi riconducibili
a quelli esaminati:
esercizio.
Cancellazione di un nodo da un
RB-tree
Come nel caso dei BST, possiamo sempre assumere
di eliminare un nodo che ha al massimo un figlio.
Infatti se dobbiamo cancellare un nodo x con due figli,
spostiamo la chiave del successore y di x in x e poi
rimuoviamo y dall’albero.
Il successore di un nodo con due figli ha sempre al massimo un
figlio.
Cancellazione di un nodo
con ≤ 1 figlio
Sia x il nodo da cancellare.
Sia y il figlio di x e sia z il padre di x.
1. rimuoviamo x collegando z con y.
2. se x era rosso allora y e z sono neri e
non dobbiamo fare altro.
3. se x era nero e y è rosso allora coloriamo y di nero.
Se x era nero e y è nero allora ricoloriamo y con un
colore nero “doppio”. Dopodichè svolgiamo alcune altre
operazione descritte nel seguito.
Esempio
z
z
z
x
x
y
y
z
z
x
y
y
doppio nero che va poi ridistribuito su un
nodo rosso annerendolo.
y
ricolora
Cancellazione di un nodo
con ≤ 1 figlio
Idea: cercare di far salire il doppio nero nell’albero fino a
trovare un nodo rosso sul quale scaricare una parte del nero
del nodo
“doppio nero” e riottenere una colorazione legale.
Per far ciò operiamo operazioni di ristrutturazioni locali
dell’albero e ricolorazioni che possono propagare
il doppio nero due livelli più in alto
Caso 1: fratello nero con almeno un figlio rosso
y
x
s
rotazione sinistra
y
s
t
x
t
y
x
t
s
rotazione derstra e poi
sinistra
t
y
s
x
a
a
b
b
Caso 2: fratello nero con figli neri
y
y
x
s
x
y
x
s
y
s
x
s
Caso 3: fratello rosso
y
x
s
rotazione sinistra
s
y
b
x
a
b
a
Programmazione
Dinamica
Programmazione Dinamica
Divide et impera: si suddivide il problema in sotto problemi
indipendenti, si calcola ricorsivamente una soluzione per i
sottoproblemi e poi si fondono le soluzioni così trovate per
calcolare la soluzione globale per il problema originale.
Programmazione dinamica: simile all’approccio divide et impera,
ma in questo caso si tiene traccia (in una tabella) delle
soluzioni dei sottoproblemi perchè può capitare di dover
risolvere il medesimo sottoproblema per più di una volta.
Prodotto di una sequenza di matrici
Dobbiamo calcolare A=A1•A2• ••• •Am
dove Ai sono matrici di opportune
dimensioni (righe x colonne).
In che ordine conviene effettuare le
moltiplicazioni ?
Moltiplicazioni di matrici
Matrix-multiply(A,B)
if columns(A) rows(B)
then “error”
else for i=1 to rows(A)
do for j=1 to columns(B)
do C[i,j]=0
for k=1 to columns(A)
do C[i,j]=C[i,j]+A[i,k]B[k,j]
(rows(A) columns(B) columns(C))
moltiplicazioni.
Esempio:
A=MNQ
M = 10 righe, 100 colonne
N = 100 righe, 5 colonne
Q = 5 righe, 50 colonne
Primo metodo A=((MN)Q).
Numero di moltiplicazioni:
10•100•5 per calcolare A’=MN
10•15•50 per calcolare A=A’Q
Totale 7500
Secondo metodo A=(M(NQ)).
Numero di moltiplicazioni:
100•5•50 per calcolare A’=NQ
10•100•50 per calcolare A=MA’
Totale 75000
Numero di possibili
parentesizzazioni
Il numero di possibili parentesizzazioni P(n) può essere ottenuto
come segue:
1
P(n) =
n-1
 P(k)P(n-k)
k=1
numero di
parentesizzazioni
delle prime k matrici
n=1
n>1
numero di
parentesizzazioni delle
altre n-k matrici
P(n) risulta essere W(4n/n3/2)
Quindi esponenziale in n.
L’algoritmo di enumerazione non può essere usato !!!
Osservazione chiave:
Supponiamo che la soluzione ottima sia ottenuta
1. moltiplicando le prime k matrici tra loro in qualche modo
2. moltiplicando le altre n-k matrici tra loro in qualche modo
3. moltiplicando le due matrici ottenute ai primi due passi
Le parentesizzazioni dei passi 1 e 2 sono ottime
Soluzione ricorsiva
m[i,j] = costo minimo per moltiplicare
le matrici Ai,...,Aj
0
m[i,j] =
min{ m[i,k] + m[k+1,j] + pi-1pkpj }
i≤k<j
i=j
i<j
Costo della soluzione ricorsiva
Esercizio:
Determinare il costo computazionale
dell’algoritmo ricorsivo progettato a
partire dall’equazione ricorsiva del
lucido precedente.
Esponenziale o polinomiale ???
Numero di sottoproblemi
Quanti sottoproblemi abbiamo?
uno per ogni coppia di indici i,j nel range
1,...n ovvero (n2) (pochi !).
Quindi l’algoritmo ricorsivo deve
risolvere più volte lo stesso
sottoproblema altrimenti non si spiega
il costo esponenziale !
A1
A2
A3
A4
A5
A6
30x35
35x15
15x5
5x10
10x20
20x25
m=
A1
A2
A3
A4
A5
A6
s=
s[i,j] contiene l’indice ottmo per spezzare
la moltiplicazione Ai•••Aj in due:
Ai•••As[i,j]
e
As[i,j] +1•••Aj
Pseudo codice
Matrix-chain-order(p)
n=length(p)-1
for i=1 to n
do m[i,i]=0
for l=2 to n
do for i=1 to n-l+1
do j=i+l-1
m[i,j]=∞
for k=i to j-1
do q=m[i,k]+m[k+1,j]+pi-1pkpj
if q<m[i,j]
then m[i,j]=q
s[i,j]=k
return m,s
Pseudo codice
Matrix-chain-multiply(A,s,i,j)
if j>i then X = Matrix-chain-multiply(A,s,i,s[i,j])
Y = Matrix-chain-multiply(A,s,s[i,j]+1,j)
return Matrix-multiply(X,Y)
else return Ai
Parentesizzazione ottima dell’esempio = ((A1(A2A3))((A4A5)A6))
Costo computazionale:
Matrix-chain-order
Matrix-chain-multiply
tempo (n3)
tempo esercizio
spazio (n2)
spazio esercizio
Passi fondamentali della
programmazione dinamica
1.
2.
3.
4.
Caratterizzazione della struttura di una
soluzione ottima
Definizione ricorsiva del valore di una soluzione
ottima
Calcolo del valore di una soluzione ottima con
strategia bottom-up
Costruzione di una soluzione ottima a partire
dalle informazioni già calcolate.
nell’esempio della moltiplicazione di
matrici.....
1.
2.
3.
4.
Una parentesizzazione ottima associata a una lista
A1,A2,...,Am di matrici da moltiplicare può essere sempre
suddivisa in due parentesizzazioni ottime associate alle
liste A1,...,Ak e Ak+1,...,Am per un opportuno valore di k.
Una parentesizzazione ottima costa quanto la somma dei
costi delle due sotto parentesizzazioni ottime più il costo
dovuto alla moltiplicazione delle matrici associate alle due
sotto parentesizzazioni
vedi come procedere con la matrice m nei lucidi precedenti
vedi come procedere con la matrice s nei lucidi precedenti
Caratteristiche del problema per
applicare la programmazione dinamica
Sottostruttura ottima.
Una soluzione ottima per il problema contiene al suo interno le
soluzioni ottime dei sottoproblemi
Sottoproblemi comuni.
Un problema di ottimizzazione ha sottoproblemi comuni quando un
algoritmo ricorsivo richiede di risolvere più di una volta lo stesso
sottoproblema
Versione ricorsiva con memorizzazione dei
risultati parziali in una tabella
Mem-matrix-chain(p)
n=length(p)-1
for i=1 to n
do for j=1 to n
do m[i,j]=∞
return Lookup-chain(p,1,n)
Lookup-chain(p,i,j)
if m[i,j]<∞ then return m[i,j]
if i=j then m[i,j]=0
else for k=1 to j-1
do q=Lookup-chain(p,i,k)+
Lookupchain(p,k+1,j) + pi-1pkpj
if q<m[i,j] then m[i,j]=q
return m[i,j]
Esercizi
Determinare il costo computazionale di:
Mem-matrix-chain ???
Lookup-chain ???
Sottosequenza comune più lunga
X = ABCDBDAB
Y = BDCABA
Z = BCBA
è LCS(X,Y)
Problema di ottimizzazione.
Possiamo applicare la programmazione
dinamica ??
Sotto struttura ottima
Siano X=<x1,...,xm> e Y=<y1,...,yn>
Sia Z=<z1,...,zk> una qualunque LCS di X e Y.
1.
2.
3.
Se xm=yn, e zk=xm=yn allora Zk-1 è LCS di Xm-1 e Yn-1
Se xmyn, e zk  xm allora Zk-1 è LCS di Xm-1 e Y
Se xmyn, e zk  yn allora Zk-1 è LCS di X e Yn-1
0
0
1
A
2
B
3
C
4
B
5
D
6
A
7
B
1
2
3
4
5
6
B
D
C
A
B
A
0
0
0
0
0
0
0
0
0
0
0
1
1
1
0
1
1
1
1
2
2
0
1
1
2
2
2
2
0
1
1
2
2
3
3
0
1
2
2
2
3
3
0
1
2
2
3
3
4
0
1
2
2
3
4
4
LCS-length(X,Y)
m=length(X)
n=length(Y)
for i=1 to m
do c[i,0]=0
for j=1 to n
do c[0,j]=0
for i=1 to m
do for j=1 to n
do if xi=yj
then c[i,j]=c[i-1,j-1] + 1
b[i,j]= 
else if c[i-1,j]≥c[i,j-1]
then c[i,j]=c[i,j-1]
b[i,j]= 
else c[i,j]=c[i,j-1]
b[i,j]= 
return b,c
Costruzione di una LCS
Print-LCS(b,X,i,j)
if i=0 or j=0 then return
if b[i,j] =  then Print-LCS(b,X,i-1,j-1)
print xi
else if b[i,j] = 
then Print-LCS(b,X,i-1,j)
else Print-LCS(b,X,i,j-1)
Algoritmi Greedy
Selezione di attività
S={1,...,n} insieme di attività. Ogni attività ha
un tempo di inizio si e un tempo di fine fi.
Problema: Selezionare un sottoinsieme S’ di
attività in modo tale che:
1.
se i e j appartengono a S’ allora:
si≥fj oppure sj≥fi.
2. La cardinalita di S’ è massimizzato.
11
10
9
8
7
6
5
4
3
2
1
0
1
2
3
4
5
6
7
8
9
10
11
12
Attività ordinate in base a fi
13
14
11
10
9
8
7
6
5
4
3
2
1
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Pseudocodice
Greedy-activity-selector(s,f)
n=length(s)
A={1}
j=1
for i=2 to n
do if si≥fj then A=A{i}
j=i
return A
(n)
Dimostrazione di correttezza
Assumiamo che le attività siano ordinate per tempi di fine in modo
crescente.
Dimostriamo che esiste una soluzione ottima che contiene l’attività 1
che è quella che termina per prima (con il tempo di fine più piccolo).
Supponiamo per assurdo che esista una soluzione S’’migliore di S’ (S’
contiene l’attività 1 ed è stata costruita con l’algoritmo greedy).
Assumiamo che sia S’ che S’’ siano ordinate per tempi di fine crescenti.
S’={1,.....} e S’’={k,......} dove 1k.
Sia T=S’’-{k}{1}. Non è difficile dimostrare (esercizio) che T è una
soluzione ottima e che ovviamente contiene l’attività 1.
Adesso eliminiamo da S tutte le attività che sono incompatibili con
l’attività 1 e ripetiamo la dimostrazione da capo.
A volte gli algoritmi greedy non
trovano la soluzione ottima...
Problema del commesso viaggiatore.
Dato un insieme di n città occorre trovare
la strada più breve per visitarle tutte una
ed una sola volta e tornare alla città di
partenza.
Nessun algoritmo greedy può funzionare...
Algoritmo greedy: parto dalla città 1 e poi procedo visitando
la città più vicina non ancora visitata.
Quando tutte le città sono state visitate torno alla città 1.
2
4
1
Soluzione con
algoritmo greedy
3
2
4
1
3
Soluzione ottima
Non sempre una soluzione di ottimo
“globale” si ottiene facendo una serie
di scelte “localmente” ottime.
Per il problema del commesso
viaggiatore nessuna politica
decisionale greedy fornisce una
soluzione finale ottima.
Proprietà della scelta greedy.
Ad ogni passo l’algoritmo compie una scelta in base ad
una certa politica ed alle scelte compiute fino a
quel momento. Cosi facendo ci si riduce ad un
sottoproblema di dimensioni più piccole. Ad ogni
passo si calcola un pezzo della soluzione.
Sottostruttura ottima.
Come nel caso della programmazione dinamica, anche
per applicare un algoritmo greedy occorre che la
soluzione ottima contenga le soluzioni ottime dei
sottoproblemi.
Programmazione dinamica Vs
Algoritmi greedy
Non sempre è possibile risolvere un problema con
programmazione dinamica o con algoritmi greedy (commesso
viaggiatore).
Non sempre i problemi che soddisfano la proprietà della
sottostruttura ottima possono essere risolti con entrambi i
metodi.
Ci sono problemi che non possono essere risolti con algoritmi
greedy ma possono essere risolti con programmazione
dinamica
Se un problema può essere risolto con algoritmi greedy è inutile
scomodare la programmazione dinamica.
Problema dello zaino: 2 versioni
Knapsack 0-1:
Un ladro durante una rapina si trova
davanti a n oggetti. Ogni oggetto ha un valore vi e un peso wi
(numeri interi). Il ladro ha uno zaino che può contenere fino a
W (numero intero) chilogrammi di refurtiva. Il ladro deve
scegliere quali oggetti rubare per massimizzare il valore
complessivo degli oggetti rubati.
Knapsack: In questo caso il ladro può anche prendere una
parte frazionaria degli oggetti. Non è costretto a “prendere
o lasciare” un oggetto, può decidere di prenderne un pezzo
grande a suo piacimento.
Nota: Knapsack è una generalizzazione di
Knapsack 0-1
Proprietà della sottostruttura
ottima
Entrambe le versioni del knapsack soddisfano
tale proprietà.
Supponiamo infatti che il ladro possa rubare refurtiva avente peso W’
non maggiore di W di valore massimo V.
Se togliamo dallo zaino l’oggetto j otteniamo la soluzione ottima del
sottoproblema in cui lo zaino può contenere al massimo W-wj
chilogrammi ottenuti mettendo insieme oggetti da un insieme di n-1
(abbiamo eliminato l’oggetto j).
Knapsack: soluzione greedy
Idea: il ladro prende la quantità più
grande possibile dell’oggetto i tale per
cui vi/wi (valore per unità di peso) è
massimo. Dopodiche’, se nello zaino c’è
ancora posto, ripete l’operazione.
Esercizio. Dimostrare che l’algoritmo è
greedy ed è corretto.
Knapsack: soluzione greedy
€ 60
€ 100
€ 120
6 € al chilo
10
Zaino da 50 chili
5 € al chilo
20
30
4 € al chilo
Soluzione ottima di knapsack con
algoritmo greedy
10
20
20 = 2/3 di 30
€ 240
Knapsack 0-1: nessuna soluzione
greedy
€ 60
€ 100
€ 120
6 € al chilo
10
Zaino da 50 chili
5 € al chilo
20
30
4 € al chilo
Soluzione ottima di knapsack 0-1
30
20
€ 220
Soluzione di knapsack 0-1
scegliendo per primo l’oggetto da
6 € al chilo (valore per unità di peso
massimo)
10
30
€ 180
Knapsack 0-1: Soluzione con
programmazione dinamica
Prima di decidere se inserire nello zaino l’oggetto i
bisogna controllare il valore della sottosoluzione ottima di
due altri sottoproblemi su n-1 oggetti:
il sottoproblema nel quale l’oggetto i è inserito nello zaino
e lo zaino ha capienza W-wi
il sottoproblema nel quale l’oggetto i non è inserito nello zaino e lo
zaino ha ancora peso W
Esercizio: risolvere knapsack 0-1 con la programmazione dinamica
Scarica