Concetti di base di
complessità degli algoritmi
1
Problemi, algoritmi, programmi
•
•
•
•
Problema: il compito da svolgere
– quali output vogliamo ottenere a fronte di certi input
– cioè quale funzione vogliamo realizzare
Algoritmo: i passi (il processo) da seguire per risolvere un
problema
– un algoritmo prende gli input in ingresso ad un problema e
li trasforma in opportuni output
Come al solito, un problema può essere risolto da tanti
algoritmi
Un algoritmo è una sequenza di operazioni concrete
– deve essere eseguibile da una “macchina”
•
Un algoritmo deve essere corretto
– deve calcolare la funzione giusta
– sappiamo che determinare la correttezza di un algoritmo è
un problema indecidibile...
– ... questo però non vuole dire che non si possa fare niente
per cercare di capire se un algoritmo è corretto o no
•
Un algoritmo può essere descritto in diversi linguaggi
– se usiamo un linguaggio di programmazione (C, C++, Java, C#,
ecc.) abbiamo un programma
•
Come linguaggio noi usiamo... lo pseudocodice
– non è un vero linguaggio di programmazione, ma ci assomiglia
molto
– facile da tradurre in codice di un linguaggio di programmazione
quale C o Java (o Python)
– il particolare linguaggio di programmazione con cui un algoritmo
è implementato è, dal punto di vista della complessità, un po'
come l'hardware: cambia solo le costanti moltiplicative
2
pseudocodice
•
assegnamento: i := j
– assegnamento multiplo: i := j := e
• applicato da destra a sinistra
• cioè è la stessa cosa che scrivere j := e; i := j
•
•
•
while, for, if-then-else come in C
// inizia un commento, che termina alla fine della riga
la struttura a blocchi è data dalla indentazione
while i > 0 and A[i] > key
while (i > 0 and A[i] > key)
{
A[i + 1] := A[i]
i := i − 1
A[i + 1] := key
A[i + 1] := A[i]
=
i := i − 1
}
A[i + 1] := key
•
•
Le variabili sono locali alla procedura
Agli elementi degli array si accede come in C
– A[j] è l'elemento di indice j dell'array A
– il primo elemento può avere un indice diverso da 0
•
C'è una nozione di sottoarray
– A[i..j] è il sottoarray che inizia dall'elemento i-esimo e
termina all'elemento j-esimo
• e.g. A[1..5] è il sottoarray con i primi 5 elemento dell'array A
3
pseudocodice (2)
•
•
Dati composti sono organizzati in oggetti
Gli oggetti hanno degli attributi (detti anche campi)
– per indicare il valore di un attributo attr di un oggetto x,
scriviamo x.attr
– gli array rappresentano dati composti, quindi sono oggetti
• ogni array ha un attributo length, che contiene la lunghezza dell'array
–
•
A.length è la lunghezza dell'array A
Una variabile che corrisponde ad un oggetto (es. un array) è un
puntatore all'oggetto
– molto simile ai puntatori in C e, sopratutto, al concetto di
reference in Java
– per esempio, se abbiamo due variabili x and y, e x punta ad un
oggetto con un attributo f, dopo le seguenti istruzioni
y := x
x.f := 3
si ha che x.f = y.f = 3, in quanto, grazie all'assegnamento y :=
x, x e y puntano allo stesso oggetto
•
Un puntatore che non fa riferimento ad alcun oggetto ha valore
NIL
•
I parametri sono passati per valore
– la procedura invocata riceve una copia dei parametri passati
– se una procedura PROC ha un parametro x e dentro a PROC il
parametro x riceve il valore di y (x := y), la modifica non è
visibile al di fuori della procedura (per esempio al chiamante)
•
Quando un oggetto è passato come parametro, ciò che viene
passato è il puntatore all'oggetto
– degli attributi non viene fatta una copia, e modifiche a questi
sono visibili al chiamante
– se x è un parametro che è un oggetto con attributo f, gli effetti
dell'assegnamento x.f:=3 (il fatto che l'attributo f valga 3) sono
visibili al di fuori della procedura
4
– questo è il funzionamento di Java...
Modello di computazione
•
•
•
Quale è la “macchina” sulla quale vengono eseguiti gli
algoritmi scritti in pseudocodice?
La macchina RAM!
Assunzione di base: ogni istruzione semplice di pseudocodice
è tradotta in un numero finito di istruzioni RAM
– per esempio x := y diventa, se ax e ay sono gli l'indirizzi in
memoria delle variabili x e y (ax e ay sono delle costanti):
LOAD
ay
STORE
ax
•
Da ora in poi adottiamo il criterio di costo costante
– adatto per gli algoritmi che scriveremo, che non manipoleranno
mai numeri né richiederanno quantità di memoria molto più
grandi della dimensione dei dati in ingresso
•
•
•
In conseguenza di ciò abbiamo che ogni istruzione i di
pseudocodice viene eseguita in un tempo costante ci
Grazie a questa assunzione, da adesso in poi possiamo
“dimenticarci” che il modello computazionale dello
pseudocodice è la macchina RAM
Inoltre, da ora in poi ci concentriamo sulla complessità
temporale, più che su quella spaziale
5
Per chi volesse divertirsi ..
• … A fare girare un po’ di codice (non tutto!)
• Usando un linguaggio molto simile a quello
adottato dal testo: Javascript
• (ma anche il C va benissimo!)
• http://home.deib.polimi.it/mandriol/Didattica/
MaterialeAlgPrincipi/MaterialeJavascript/Java
Script-API.pdf
• http://home.deib.polimi.it/pradella/ex.js
6
Primo esempio di problema/algoritmo
•
Problema: ordinamento
– Input: una sequenza A di n numeri a1, a2, ... an
– Output: una permutazione b1, b2, ... bn della sequenza di input
tale che b1  b2  ...  bn
•
Algoritmo: insertion sort
INSERTION-SORT(A)
1 for j := 2 to A.length
2
key := A[j]
3
//Inserisce A[j] nella sequenza ordinata A[1..j−1]
4
i := j − 1
5
while i > 0 and A[i] > key
6
A[i + 1] := A[i]
7
i := i − 1
8
A[i + 1] := key
7
Costo di esecuzione per INSERTION-SORT
INSERTION-SORT(A)
costo
1 for j := 2 to A.length
c1
c2
2
key := A[j]
3
//Inserisce A[j] nella sequenza A[1..j−1]
4
i := j − 1
5
while i > 0 and A[i] > key
6
A[i + 1] := A[i]
7
i := i − 1
8
A[i + 1] := key
0
c4
c5
c6
c7
c8
numero
di volte
n
n-1
n-1
n-1
∑ j= 2 t j
n
 t
j
1
 t
j
1
n-1
•
Note:
– n = A.length = dimensione dei dati in ingresso
– t2, t3 ... tn = numero di volte che la condizione del ciclo
while viene eseguita quando j = 2, 3, ... n
•
Tempo di esecuzione di INSERTION-SORT:
T n = c1n + c2 n 1+ c4 n 1+ c5  t j
+ c6  t j 1+ c7  t j 1+ c8 n 1
•
•
Se l'array A è già ordinato, t2 = ... = tn = 1
– T(n) = an+b, cioè T(n) = (n)
• questo è il caso ottimo
Se A è ordinato, ma in ordine decrescente, t2=2, t3=3, ... tn=n
– T(n) = an2+bn+c, cioè T(n) = (n2)
• questo è il caso pessimo
8
Un classico problema: l'ordinamento
•
•
•
•
L'ordinamento degli elementi di una sequenza è un esempio
classico di problema risolto mediante algoritmi
C'è un gran numero di algoritmi di ordinamento disponibili:
insertion sort, bubblesort, quicksort, merge sort, counting sort,
...
Ne abbiamo appena visto uno: insertion sort
Abbiamo visto che nel caso pessimo TINSERTION-SORT(n) è
(n2)
– possiamo anche scrivere che TINSERTION-SORT(n) = O(n2) (usando
la notazione O, senza specificare “nel caso pessimo”), in quanto
il limite superiore (che è raggiunto nel caso pessimo) è una
funzione in (n2)
– è anche TINSERTION-SORT(n)=(n), in quanto il limite inferiore
(raggiunto nel caso ottimo) è (n)
•
Possiamo fare di meglio?
– possiamo cioè scrivere un algoritmo con un limite superiore
migliore?
•
Sì!
9
Merge sort
•
Idea dell'algoritmo:
– se l'array da ordinare ha meno di 2 elementi, è ordinato per
definizione
– altrimenti:
• si divide l'array in 2 sottoarray, ognuno con la metà degli elementi di
quello originario
• si ordinano i 2 sottoarray ri-applicando l'algoritmo
• si fondono (merge) i 2 sottoarray (che ora sono ordinati)
•
MERGE-SORT è un algoritmo ricorsivo
•
Un esempio di funzionamento:
42
16
28
36
26
78
84
8
42
16
28
36
26
78
84
8
42
16
28
36
26
78
84
8
16
42
28
36
26
78
8
84
16
28
36
42
8
26
78
84
8
16
26
28
36
42
78
84
10
pseudocodice di MERGE-SORT
MERGE-SORT(A, p, r)
1
if p < r
2
q := ⎣(p + r)/2⎦
3
MERGE-SORT(A, p, q)
4
MERGE-SORT(A, q+1, r)
5
MERGE(A, p, q, r)
•
Per ordinare un array A = A[1], A[2], ... A[n] invochiamo
MERGE-SORT(A, 1, A.length)
•
MERGE-SORT adotta una tecnica algoritmica classica: divide
et ìmpera
Se il problema da risolvere è grosso:
•
– dividilo in problemi più piccoli della stessa natura
– risolvi (domina) i problemi più piccoli
– combina le soluzioni
•
Dopo un po' che dividiamo il problema in altri più piccoli, ad
un certo punto arriviamo ad ottenere problemi “piccoli a
sufficienza” per poterli risolvere senza dividerli ulteriormente
– è una tecnica naturalmente ricorsiva in quanto, per risolvere i
“problemi più piccoli”, applichiamo lo stesso algoritmo del
problema più grosso
•
Per completare l'algoritmo dobbiamo definire un sottoalgoritmo MERGE che "combina" le soluzioni dei problemi più
piccoli
11
Fusione (merge) di sottoarray ordinati
•
Definizione del problema (input/output)
– Input: 2 array ordinati A[p..q] e A[q+1..r] di un array A
– Output: l'array ordinato A[p..r] ottenuto dalla fusione degli
elementi dei 2 array iniziali
•
Idea dell'algoritmo:
1. si va all'inizio dei 2 sottoarray
2. si prende il minimo dei 2 elementi correnti
3. si inserisce tale minimo all’inizio dell'array da restituire
4. si avanza di uno nell'array da cui si è preso il minimo
5. si ripete dal passo 2
•
pseudocodice:
MERGE (A, p, q, r)
1 n1 := q – p + 1
2 n2 := r – q
3 crea (alloca) 2 nuovi array L[1..n1+1] e R[1..n2+1]
4 for i := 1 to n1
5
L[i] := A[p + i - 1]
6 for j := 1 to n2
7
R[j] := A[q + j]
8 L[n1 + 1] := ∞
9 R[n2 + 1] := ∞
10 i := 1
11 j := 1
12 for k := p to r
13
if L[i] ≤ R[j]
14
A[k] := L[i]
15
i := i + 1
16
else A[k] := R[j]
17
j := j + 1
12
Analisi dell'algoritmo MERGE
•
•
•
•
Nell'algoritmo MERGE prima si copiano gli elementi dei 2
sottoarray A[p..q] e A[q+1..r] in 2 array temporanei L e R,
quindi si fondono L e R in A[p..r]
Escamotage: per non dover controllare se L e R sono vuoti si
usa una “sentinella”, un valore particolare (), più grande di
ogni possibile valore, messo in fondo agli array (linee 8-9)
Dimensione dei dati in input: n = r – p + 1
L'algoritmo è fatto di 3 cicli for:
– 2 cicli di inizializzazione (l. 4-7), per assegnare i valori a L e R
• il primo è eseguito n1 volte, il secondo n2 volte, con
(n1) = (q-p+1) = (n/2) = (n)
(n2) = (r-q) = (n/2) = (n)
–
•
•
si poteva giungere allo stesso risultato notando che n1 + n2 = n, quindi
(n1 + n2) = (n)
Il ciclo principale (l. 12-17) è eseguito n volte, e ogni linea ha
costo costante
In totale TMERGE(n) = (n)
MERGE (A, p, q, r)
costo
1 n1 := q – p + 1
c
2 n2 := r – q
c
3 //crea 2 nuovi array L[1..n1+1] e R[1..n2+1](n)
4 for i := 1 to n1
(n1) per tutto il ciclo
5
L[i] := A[p + i - 1]
6 for j := 1 to n2
(n2) = (n/2) = (n)
7
R[j] := A[q + j]
8 L[n1 + 1] := ∞
c
9 R[n2 + 1] := ∞
c
10 i := 1
c
11 j := 1
c
12 for k := p to r
(n) per il ciclo
13
if L[i] ≤ R[j]
c
14
A[k] := L[i]
c
15
i := i + 1
c
16
else A[k] := R[j]
c
17
j := j + 1
c
13
Più in generale:
Complessità di un algoritmo divide et impera
•
In generale, un algoritmo divide et impera ha le caratteristiche
seguenti:
– si divide il problema in sottoproblemi, ognuno di dimensione 1/b
di quello originale
– se il sottoproblema ha dimensione n piccola a sufficienza (n<c,
con c una costante caratteristica del problema), esso può essere
risolto in tempo costante (cioè (1))
– indichiamo con D(n) il costo di dividere il problema, e C(n) il
costo di ricombinare le soluzioni dei sottoproblemi
– T(n) è il costo per risolvere il problema totale
•
Possiamo esprimere il costo T(n) tramite la seguente
equazione di ricorrenza (o ricorrenza):
Tn  = Θ1
se n < c

Dn + aTn / b + Cn  altrimenti
•
Ricorrenza per l'algoritmo MERGE-SORT:
a = b = c = 2, D(n) = (1), C(n) = (n)
Tn  = Θ1
se n < 2

2T n / 2 + Θn  altrimenti
– in realtà dovrebbe essere T(n/2) + T(n/2) invece di 2T(n/2),
ma l'approssimazione non influisce sul comportamento asintotico
della funzione T(n)
•
Come risolviamo le ricorrenze?
Vedremo tra poco...
... per ora:
14
Complessità di MERGE-SORT
•
Riscriviamo la ricorrenza di MERGE-SORT:
Tn  = c

2T n / 2+ cn
•
se n < 2
altrimenti
Possiamo disegnare l'albero di ricorsione (consideriamo per
semplicità il caso in cui la lunghezza n dell'array è una potenza
di 2)
cn
cn
cn/2
log 2 n
cn/4
cn
cn/2
cn/4
cn/4
cn/4
c c c c c c c c c c c c c
cn
cn
Totale: cnlog n + cn
•
Sommando i costi dei vari livelli otteniamo
T(n) = cn log(n) + cn, cioè TMERGE-SORT(n) = (n log(n))
15
Un inciso: MERGE-SORT non ricorsivo
•
•
•
La complessità spaziale della versione ricorsiva di MERGESORT è TMERGE-SORT(n) = (n log(n))
Perché?
(Come tutti gli algoritmi) MERGE-SORT può essere
codificato anche in versione non ricorsiva:
42
16
28
36
26
78
84
8
F1
16
42
28
36
26
78
8
84
F2
16
28
36
42
8
26
78
84
F1
8
16
26
28
36
42
78
84
F2
• La complessità spaziale della versione
ricorsiva di MERGE-SORT è SMERGE-SORT(n)
= (n log(n))
• La complessità spaziale della versione non
ricorsiva di MERGE-SORT è
• SMERGE-SORT(n) = (n)
• La codifica della versione non ricorsiva di
MERGE-SORT è …un po’ più
complicata
16
Risoluzione di ricorrenze (1)
• In generale la funzione di complessità viene
ricavata mediante equazioni che mettono in
relazione (sotto)programmi e relative (sotto)
complessità:
• P
• {P1; for i := 1 to n {P2}}
• T = T1 + n.T2
• Quando P è ricorsivo T dipende da se stessa
• Tre tecniche principali:
– sostituzione
– albero di ricorsione
– teorema dell'esperto (master theorem)
• Metodo della sostituzione:
– formulare un'ipotesi di soluzione
– sostituire la soluzione nella ricorrenza, e dimostrare
(per induzione) che è in effetti una soluzione
17
Risoluzione di ricorrenze (2)
•
Metodo della sostituzione:
• Congetturo e verifico
• Però se cerco O(n) o (n) … semplifico ma … con cautela
• (disequazioni invece di equazioni)
• Esempio: cerchiamo un limite superiore per la seguente T(n):
T(n) = 2T(n/2) + n
– supponiamo T(n) = O(n log2(n))
– dobbiamo mostrare che T(n) ≤ cn log2(n) per una opportuna
costante c>0
– supponiamo che ciò valga per T(n/2), cioè
T(n/2) ≤ cn/2 log2(n/2)
– allora, sostituendo in T(n) abbiamo
T(n) ≤ 2cn/2 log2(n/2) + n ≤ cn log2(n/2) + n =
= cn log2(n) -cn log2(2) + n = cn log2(n) -cn + n ≤ cn log2(n)
• basta che c ≥ 1
– dobbiamo però mostrare che la disuguaglianza vale per n = 1
(condizione al contorno); supponiamo che sia T(1) = 1, allora
T(1) =1 ≤ c.1.log2(1) = 0? No!
– però T(n) ≤ cn log2(n) deve valere solo da un certo n0 in poi, che
possiamo scegliere arbitrariamente; prendiamo n0 = 2, e notiamo
che, se T(1) = 1, allora, dalla ricorrenza, T(2) = 4 e T(3) = 5
• inoltre, per n > 3 la ricorrenza non dipende più dal
problematico T(1)
– ci basta determinare una costante c tale che
T(2) = 4 ≤ c.2. log2(2) e T(3) = 5 ≤ c3 log2(3)
– per ciò basta prendere c ≥ 2
– Le costanti contano!
18
Osservazioni sul metodo di sostituzione (1)
• Consideriamo il seguente caso:
T(n) = T(n/2)+T(n/2)+1
• Proviamo a vedere se T(n) = O(n): ipotizziamo T(n) ≤ c.n:
– T(n) ≤ cn/2+cn/2+1 = cn+1
– basta prendere c=1 e siamo a posto?
– No! perché non abbiamo dimostrato la forma esatta della
disuguaglianza!
• Dall’ipotesi T(n/2) ≤ c (n/2) abbiamo dedotto solo
T(n) ≤ c.n +1:
• L’induzione non è dimostrata!
• Potremmo prendere un limite più alto, e dimostrare che T(n) è
O(n2) (cosa che è vera), ma in effetti si può anche dimostrare
che T(n) = O(n), dobbiamo solo avere un'accortezza:
• Mostriamo che T(n) ≤ cn-b, con b un'opportuna costante
– se fosse così, allora T(n)= O(n)
– T(n) ≤ cn/2-b+cn/2-b+1 = cn-2b+1 ≤ cn - b
• basta prendere b  1
• Attenzione però:
– T(n) = 2T(n/2) + n è O(n)?
– Ipotesi: T(n) ≤ cn; ricavo
– T(n) ≤ cn + n = O(n), quindi C.V.D.?
– No!
– Dobbiamo mostrare la forma esatta della disuguaglianza, e
(c+1)n non è ≤ cn
19
Osservazioni sul metodo di sostituzione (2)



 
• Altro esempio: T n = 2T  n  + log 2 n
– poniamo m = log2(n), quindi n = 2m, otteniamo
– T(2m) = 2T(2m/2)+m
– Ponendo S(m) = T(2m) abbiamo
S(m) = 2S(m/2) + m
quindi S(m) = O(m log2(m))
– Quindi, sostituendo all'indietro:
– T(n) = O(log2(n) log2log2(n))
20
Metodo dell'albero di ricorsione
•
•
•
Un metodo non molto preciso, ma utile per fare una congettura
da verificare poi con il metodo di sostituzione
Idea: a partire dalla ricorrenza, sviluppiamo l'albero delle
chiamate, indicando per ogni chiamata la sua complessità
Esempio: T(n) = T(n/3) + T(2n/3)+O(n)
– Prima chiamata:
cn
T n / 3
– Espandiamo:
T 2n / 3
cn
cn /3
T n / 9
2cn/3
T 2n / 9
T 2n / 9 T 4n / 9
– fino in fondo:
cn
cn
cn /3
log3/ 2 n
cn/9
cn
2cn/3
2cn/9
2cn/9
4cn/9
cn
– Se l'albero fosse completo, sommando i costi livello per livello, a
ogni livello avremmo un costo cn, ed il numero di livelli k
sarebbe tale che n(2/3)k=1, cioè k = log3/2n ...
21
Albero di ricorsione (2)
• ... però l'albero non è completo
– il ramo più a destra è sì tale che alla fine n(2/3)k=1, ma quello più
a sinistra è tale che n(1/3)k'=1, cioè k' = log3n
• non fa niente, per ora ci accontentiamo di una congettura anche
abbastanza grossolana, tanto poi la andiamo a controllare con il
metodo di sostituzione
• In definitiva, abbiamo la congettura che T(n)=O(n log2n)
– verifichiamola, mostrando che T(n) ≤ dn log2n:
– T(n) ≤ (d/3)n log2(n/3)+(2d/3)n log2(2n/3)+cn
= (d/3)n log2n –(d/3)n log23+ (2d/3)n log2n –(2/3)dn log23
+(2d/3)n+cn
= dn log2n -dn log23+(2d/3)n+cn
= dn log2n -dn(log23-2/3)+cn
≤ dn log2n
se d>c/(log23-2/3)
22
Albero di ricorsione (3)
• Congetturiamo (e verifichiamo) anche che T(n)=(n log2n), cioè che
T(n) ≥ kn log2n
– T(n) ≥ (k/3)n log2(n/3)+(2k/3)n log2(2n/3)+cn
= kn log2n -kn log23+(2k/3)n+cn
= kn log2n -kn(log23-2/3)+cn
≥ kn log2n
se 0<k<c/(log23-2/3)
• Notiamo che in entrambe le sostituzioni sopra c'è anche da
considerare il caso base per determinare se opportuni d e k esistono
– lasciato per casa come esercizio...
• Quindi in effetti T(n)=(n log2n)
23
Teorema dell'esperto (Master Theorem) (1)
• Data la ricorrenza:
•
T(n) = aT(n/b) + f(n)
(in cui a  1, b >1, e n/b è o n/b o n/b)
1. se f(n) = O(nlogba-) per qualche >0, allora T(n)
= (nlogba)
2. se f(n) = (nlogba), allora T(n) = (nlogbalog(n))
3. se f(n) = (nlogba+) per qualche >0, e
a.f(n/b)  c.f(n) per qualche c < 1 e
per tutti gli n grandi a sufficienza,
allora T(n) = (f(n))
24
Teorema dell'esperto (Master Theorem) (2)
• Alcune osservazioni...
• La soluzione è data dal più grande tra nlogba e f(n)
– se nlogba è il più grande, T(n) è (nlogba)
– se f(n) è il più grande, T(n) è (f(n))
– se sono nella stessa classe secondo la relazione ,
T(n) è (f(n)log(n))
• “Più grande” o “più piccolo” in effetti è
"polinomialmente più grande" e "polinomialmente più
piccolo"
– n è polinomialmente più piccolo di n2
– n log(n) è polinomialmente più grande di n½
• Il teorema dell'esperto non copre tutti i casi!
– se una delle due funzioni è più grande, ma non
polinomialmente più grande...
– n log(n) è più grande di n, ma non polinomialmente
più grande
• Applichiamo il teorema dell'esperto a MERGE-SORT:
– T(n) = 2T(n/2) + (n)
• a=b=2
• f(n) = n
• nlogba = n1 = n
– siamo nel caso 2: TMERGE-SORT(n) = (n log(n))
25
Un caso particolare
• Notiamo che l'enunciato del teorema
dell'esperto si semplifica un po' se f(n) è una
funzione (nk), con k una qualche costante:
1. se k < logba, allora T(n) = (nlogba)
2. se k = logba, allora T(n) = (nklog(n))
3. se k > logba, allora T(n) = (nk)
– nel caso 3 la condizione aggiuntiva è
automaticamente verificata
– (a/bk) < 1
26
Un ulteriore risultato
• Un altro teorema utile per risolvere certi tipi di
ricorrenze:
• Data la ricorrenza (in cui i coefficienti ai sono
interi ≥ 0)
(1)

T (n)   a T (n  i)  cn k

i

1ih
in cui poniamo
a=
se n  m  h
se n  m
ai
∑
1≤ i≤ h
allora abbiamo che:
1. se a=1, allora T(n)=O(nk+1)
2. se a ≥ 2, allora T(n)=O(annk)
• Per esempio, data la ricorrenza
T(n) = T(n-1) + (n), otteniamo che
T(n)=O(n2)
– questa è la ricorrenza che otterremmo con
una versione ricorsiva di INSERTIONSORT
27
Grafi (richiamo)
•
Un grafo è una coppia (V, E) in cui V è un insieme finito di
nodi (detti anche vertici), e E  VV è una relazione binaria su
V che rappresenta gli archi del grafo
– se u e v sono nodi del grafo, la coppia (u,v) è un arco, ed è
rappresentata graficamente come:
u
v
in questo caso l'arco è orientato, in quanto c'è un ordine tra i
vertici, prima u, poi v
– se non c'è un ordine tra i nodi (che quindi sono solo un insieme,
{u,v} allora diciamo che l'arco è non orientato:
u
•
v
Un grafo è orientato se i suoi archi lo sono, non orientato
altrimenti
– esempio di grafo non orientato:
•
1
2
3
4
Un cammino è una sequenza di nodi [v0, v1, v2, … vn] tali che
tra ogni coppia di nodi della sequenza (vi, vi+1) c'è un arco
– i nodi v0, … vn appartengono al cammino
– la lunghezza del cammino è data da n (numero di vertici -1)
•
In un grafo non orientato, il cammino forma un ciclo se v0=vn,
e contiene almeno 3 nodi (cioè se ha almeno 3 archi)
– Un grafo che non ha cicli è aciclico
•
Un grafo non orientato è connesso se tra ogni coppia di vertici
esiste un cammino
28
Alberi (richiamo)
•
Un albero è un grafo connesso, aciclico, non orientato
– un albero è radicato se un nodo viene indicato come la radice
radice
profondità 0
nodi interni
profondità 1
altezza = 3
profondità 2
profondità 3
foglie
•
Ogni nodo dell'albero è raggiungibile dalla radice tramite un
cammino (che è unico, in quanto il grafo è aciclico)
•
Chiamiamo:
–
–
–
–
–
foglie: gli ultimi nodi dei cammini dalla radice
nodi interni: tutti i nodi dei cammini tra la radice e le foglie
profondità (di un nodo N): la distanza di N dalla radice
altezza (dell'albero): la distanza massima tra la radice e una foglia
antenato (di un nodo N): ogni nodo che precede N sul cammino
dalla radice a N
– padre (di un nodo N): il nodo che immediatamente precede N
lungo il cammino dalla radice a N
– figlio (di un nodo N): ogni nodo di cui N è padre
– fratelli (di un nodo N): i nodi che hanno lo stesso padre di N
•
Un albero è binario se ogni nodo ha al più 2 figli
29
HEAPSORT
•
MERGE-SORT è efficiente dal punto di vista del tempo di
esecuzione, ma non è ottimale dal punto di vista dell'uso della
memoria (a meno di non usare la versione non ricorsiva):
– ogni MERGE richiede di allocare 2 array, di lunghezza (n)
– usa una quantità di memoria aggiuntiva rispetto all'array da
ordinare che non è costante, cioè non ordina sul posto
•
•
HEAPSORT, invece, non solo è efficiente (ordina in tempo
(n log(n))), ma ordina sul posto
L'idea alla base di HEAPSORT è che un array può essere visto
come un albero binario:
– A[1] è la radice
– per ogni elemento A[i], A[2i] e A[2i+1] sono i suoi figli, e
A[i/2] è il padre
•
Esempio:
1
2
3
4
5
6
7
8
9
10
11
12
a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12
1
2
4
a8
8
a4
a1
a2
3
5
a5
a6
6
a9
a10
a11
9
10
11
a3
7
a7
a12
12
30
Gli heap (mucchi)
•
Uno heap binario è un albero binario quasi completo
– quasi completo = tutti i livelli sono completi, tranne al più
l'ultimo, che potrebbe essere completo solo fino a un certo punto
da sinistra
– l'albero binario che deriva dall'interpretazione di un array come
albero è quasi completo
•
Un max-heap è uno heap tale che, per ogni nodo x dell'albero,
il valore contenuto nel padre di x è ≥ del contenuto di x
– usando la corrispondenza albero-heap, questo vuole dire che
A[i/2]≥A[i]
•
Esempio:
1
2
3
4
5
6
7
8
9
10
11
12
9
8
7
5
7
4
0
4
3
6
1
2
3
7
1
2
5
4
4
8
•
9
8
5
7
4
6
3
6
1
9
10
11
7
0
2
12
Si noti che in un max-heap l'elemento massimo è nella radice
– dove è il minimo?
31
Alcune operazioni sugli heap
•
Operazioni di base:
PARENT(i)
1 return i/2
LEFT(i)
1 return 2*i
RIGHT(i)
1 return 2*i + 1
•
Quindi, in un max-heap abbiamo che A[PARENT(i)] ≥ A[i]
– esistono anche i min-heap, per le quali A[PARENT(i)] ≤ A[i]
•
Per realizzare l'ordinamento usiamo i max-heap
• Ogni array A che rappresenta uno heap ha 2 attributi:
– A.length, che rappresenta il numero totale di
elementi dell'array
– A.heap-size, che rappresenta il numero di elementi
dello heap
• A.heap-size ≤ A.length, e solo gli elementi fino a
A.heap-size hanno la proprietà dello heap
– però l'array potrebbe contenere elementi
dopo l'indice A.heap-size, se A.heapsize<A.length
– NB: Però A.length è il numero effettivo n di
elementi da ordinare, non corrisponde
necessariamente a una potenza di 2 (-1)
32
Algoritmi di supporto
•
Un algoritmo che, dato un elemento di un array tale che i suoi
figli sinistro e destro siano dei max-heap, ma in cui A[i] (la
radice del sottoalbero) potrebbe essere < dei suoi figli,
modifica l'array in modo che tutto l'albero di radice A[i] sia un
max-heap
MAX-HEAPIFY(A, i)
1 l := LEFT(i)
2 r := RIGHT(i)
3
4
5
if l  A.heap-size and A[l] > A[i]
max := l
else max := i
6
7
if r  A.heap-size and A[r] > A[max]
max := r
8 if max  i then
9
swap A[i]  A[max]
10
MAX-HEAPIFY(A, max)
• TMAX-HEAPIFY = O(h), dove h è l'altezza dell'albero, che è
O(log(n)), poiché l'albero è quasi completo
– quindi, TMAX-HEAPIFY = O(log(n))
33
Osservazione
•
Questo si sarebbe anche potuto mostrare usando il teorema
dell'esperto per la seguente ricorrenza, che rappresenta il
tempo di esecuzione di MAX-HEAPIFY nel caso pessimo:
T(n) = T(2n/3) + (1)
– nel caso pessimo l'ultimo livello dell'albero è esattamente pieno a
metà, e l'algoritmo viene applicato ricorsivamente sul sottoalbero
sinistro:
20
21
22
23
23
34
Da array a heap (1)
• Un algoritmo per costruire un max-heap a
partire da un array
– idea: costruiamo il max-heap bottom-up,
dalle foglie, fino ad arrivare alla radice
• osservazione fondamentale: tutti gli
elementi dall'indice A.length/2 in
poi sono delle foglie, quelli prima sono
dei nodi interni
• i sottoalberi fatti solo di foglie sono,
presi singolarmente, già degli heap, in
quanto sono fatti ciascuno di un unico
elemento
35
Da array a heap (2)
BUILD-MAX-HEAP(A)
1 A.heap-size := A.length
//heap-size viene inizializzata a n
2 for i := A.length/2 downto 1
3
MAX-HEAPIFY(A, i)
•
•
•
Costo di BUILD-MAX-HEAP?
– ad occhio, ogni chiamata a MAX-HEAPIFY costa
O(log(n)), e vengono fatte n/2 chiamate (con n che è
A.length), quindi il costo è O(n log(n))
– ma in realtà questo limite non è stretto...
Osserviamo che:
– l'altezza di un albero quasi completo di n nodi è log2(n)
– se definiamo come “altezza di un nodo di uno heap” la
lunghezza del cammino più lungo che porta ad una foglia,
il costo di MAX-HEAPIFY invocato su un nodo di altezza
h è O(h)
– il numero massimo di nodi di altezza h di uno heap è
n/2h+1
Quindi MAX-HEAPIFY viene invocato n/2h+1 volte ad ogni
altezza h, quindi il costo di BUILD-MAX-HEAP è
lgn 

n
h
 

 2 h+1 O(h) = O n  2 h 
h=0
 h=0 
lgn 
cioè O(n), in quanto è noto che

h
1/ 2
=
=2

h
2
(1 1/ 2 )
h=0 2
36
Infatti:

1
x =

( 1  x)
h=0
h
 h
1 

d   x =

(
1

x)
 h=0
  hx h 1 = 1

2
dx
(
1

x)
h=0

x
h
hx =

2
( 1  x)
h=0

h
1/ 2
=
=2

h
2
(1 1/ 2 )
h=0 2
Oppure: esercizio (per induzione)
h
n2
= 2 n

h
2
h=1 2
n
37
HEAPSORT
•
Possiamo a questo punto scrivere l'algoritmo di HEAPSORT:
HEAPSORT(A)
1 BUILD-MAX-HEAP(A)
2 for i := A.length downto 2
3
swap A[1] ↔ A[i]
4
A.heap-size := A.heap-size − 1
5
MAX-HEAPIFY(A,1)
– idea: a ogni ciclo piazziamo l'elemento più grande (che è
il primo dell'array, in quanto questo è un max-heap) in
fondo alla parte di array ancora da ordinare (che è quella
corrispondente allo heap)
• fatto ciò, lo heap si decrementa di 1, e si ricostruisce
il max-heap mettendo come radice l'ultima foglia a
destra dell'ultimo livello, e invocando MAXHEAPIFY
•
La complessità di HEAPSORT è O(n log(n)), in quanto
– BUILD-MAX-HEAP ha costo O(n)
– MAX-HEAPIFY è invocato n volte, e ogni sua chiamata
ha costo O(log(n))
38
Un «corollario» dei MAX-HEAP:
le code con priorità
(Priority queues)
• Sfruttano l’ordinamento parziale dei MAX-HEAP (resp.
MIN-HEAP) per gestire efficientemente ,e
dinamicamente, i massimi (minimi) di vari insiemi: da
cui il nome della struttura:
• Arricchiscono i MAX-HEAP con le seguenti funzioni:
•
Mantenendo la proprietà MAX-HEAP!
• HEAP-INSERT (A, key):
A := A  {key}
• HEAP-MAXIMUM (A)
• HEAP-EXTRACT-MAX (A):
• return MAXIMUM (A)
and
A:= A- {MAXIMUM (A)}
• HEAP-INCREASE-KEY (A, i, key): aumenta il valore
della chiave dell’elemento i-esimo al nuovo valore key,
che deve essere > key(A[i]).
39
Implementazione delle funzioni
delle code con priorità
• HEAP-MAXIMUM (A)
•
return A[1]
• HEAP-EXTRACT-MAX (A):
•
•
•
•
•
•
if A.heap.size < 1 return error «heap-underflow»
max := A[1];
A[1] := A[A.heap.size];
A.heap.size := A.heap.size -1;
MAX-HEAPIFY(A, 1)
return max
• HEAP-INCREASE-KEY (A, i, key)
•
•
•
if key < A[i] return error «wrong key»
A[i] := key:
while i > 1 and A[Parent(i)] < A[i]
• swap A[i] and A[Parent(i)];
• i := Parent(i)
• HEAP-INSERT (A, key)
•
•
•
A.heap.size := A.heap.size +1;
A[A.heap.size] := - ;
HEAP-INCREASE-KEY (A, A.heap.size, key)
40
QUICKSORT
•
QUICKSORT è un algoritimo in stile divide-et-impera
– ordina sul posto
•
•
Nel caso pessimo (vedremo) ha complessità (n2)
Però in media funziona molto bene (in media ha complessità
(n log(n)))
– inoltre ha ottime costanti
•
Idea di base del QUICKSORT: dato un sottoarray A[p..r] da
ordinare:
– (dividi) riorganizza A[p..r] in 2 sottoarray A[p..q-1] e A[q+1..r]
tali che tutti gli elementi di A[p..q-1] siano ≤ A[q] e tutti gli
elementi di A[q+1..r] siano ≥A[q]
– (impera) ordina i sottoarray A[p..q-1] e A[q+1..r] riutilizzando
QUICKSORT
– (combina) nulla! L'array A[p..r] è già ordinato
QUICKSORT(A, p, r)
1 if p < r
2
q := PARTITION(A, p, r)
3
QUICKSORT(A, p, q−1)
4
QUICKSORT(A, q+1, r)
•
Per ordinare un array A: QUICKSORT(A,1,A.length)
41
PARTITION
•
La cosa difficile di QUICKSORT è partizionare l'array in 2
parti:
PARTITION(A, p, r)
1 x := A[r]
2 i := p − 1
3 for j := p to r − 1
4
if A[j] ≤ x
5
i := i + 1
6
swap A[i] ↔ A[j]
7 swap A[i+1] ↔ A[r]
8 return i + 1
– l'elemento x (cioè A[r] in questa implementazione) è il pivot
– Scelta (a priori) nondeterministica
•
Complessità di PARTITION: (n), con n = r-p+1
i
i=j
p
1
5
4
5
4
5
4
5
4
r
j
3
8
6
7
i
j
r
3
p
1
7
6
i
p
1
8
3
x
j
p
1
r
3
7
6
8
7
i
j
r
6
7
8
42
q
Complessità di QUICKSORT (1)
• Il tempo di esecuzione di QUICKSORT
dipende da come viene partizionato l'array
• Se ogni volta uno dei 2 sottoarray è vuoto e
l'altro contiene n-1 elementi si ha il caso
pessimo
– la ricorrenza in questo caso è:
T(n) = T(n-1) + (n)
• abbiamo visto che la soluzione di questa
ricorrenza è O(n2)
• si può anche dimostrare (per esempio
per sostituzione) che è anche (n2)
– un caso in cui si ha sempre questa
situazione completamente sbilanciata è
quando l'array è già ordinato
43
Complessità di QUICKSORT (2)
• Nel caso ottimo, invece, i 2 array in cui il
problema viene suddiviso hanno esattamente la
stessa dimensione n/2
– la ricorrenza in questo caso è:
T(n) = 2T(n/2) + (n)
– è la stessa ricorrenza di MERGE-SORT, ed
ha quindi la stessa soluzione (n log(n))
• Notiamo che se la proporzione di divisione,
invece che essere n/2 ed n/2, fosse n/10 e
9n/10, comunque la complessità sarebbe (n
log(n))
– solo, la costante “nascosta” dalla notazione
 sarebbe più grande
– abbiamo già visto qualcosa di molto simile
per la suddivisione n/3 e 2n/3
44
QUICKSORT nel caso medio (solo intuizione)
• In media ci va un po' bene ed un po' male
– bene = partizione ben bilanciata
– male = partizione molto sbilanciata
• Qualche semplificazione:
– ci va una volta bene ed una volta male
– quando va bene => ottimo
• n/2 e n/2
– quando va male => pessimo
• n-1 e 0
(n)
n
0
n-1
(n-1)/2 - 1
(n-1)/2
45
QUICKSORT nel caso medio (solo intuizione)
• Albero di ricorsione in questo caso (ogni
divisione costa n):
(n)
n
0
n-1
(n-1)/2 - 1
(n-1)/2
– costo di una divisione “cattiva” + una divisione
“buona” = (n)
• è lo stesso costo di una singola divisione
“buona”
– dopo una coppia “divisione cattiva” – “divisione
buona” il risultato è una divisione “buona”
– quindi alla fine il costo di una coppia “cattiva –
buona” è lo stesso di una divisione “buona”, ed il
costo di una catena di tali divisioni è la stessa...
– l’altezza dell’albero è  2.log(n) invece di log(n) …
– ... quindi (n log(n))
• le costanti moltiplicative peggiorano un po', ma
l'ordine di grandezza non cambia!
46
QUICKSORT nel caso medio (in generale .. )
• Assumendo che ogni ripartizione posizioni il
pivot con uguale probabilità in ogni posizione:
1 n 1
T (n)  cn   T (k )  T (n  1  k ),
n k 0
T (0)  T (1)  c
• Che, con un po’ di pazienza, si può dimostrare
essere (n.log(n))
47
Limite inferiore per l'ordinamento (1)
• E' stato dimostrato che l'ordinamento basato su
confronto (come INSERTION-SORT, MERGE-SORT,
o HEAPSORT, per esempio) deve fare (n log(n))
confronti nel caso pessimo:
• Idea della dimostrazione: ogni computazione di un
qualsiasi algoritmo parte da un array e produce un
risultato che è una permutazione dell’array originario.
• Tutte le possibili computazioni devono poter produrre
tutte le possibili permutazioni:
• Qualsiasi algoritmo deve avere almeno n! diverse
possibili computazioni: se le rappresentiamo mediante
un albero:
a1, a2, …
an
a1 >= a2
a1 < a2
a5, a 8, …
a23
a12, a21, …
a6
48
Limite inferiore per l'ordinamento (2)
• Lunghezza della computazione massima = altezza
dell’albero.
• L’altezza minima: quando l’albero è bilanciato:
• (log n): ogni algoritmo è  (log n!).
• Qual è l’ordine di grandezza di log n!
?
n n
n! n(n  1)(n  2)     
2 2
e quindi ,
n n
log( n!)  log  
2 2
n
2
che è (n⋅log n).
D’altra parte, log(n!)  log n  log(n 1)  
cosicché log(n!)  n⋅log(n).
• questo risultato ha come conseguenza che un algoritmo
come MERGE-SORT (o HEAPSORT) è “ottimale” nel
caso pessimo
• tuttavia, questo non significa che l'ordinamento è un
“caso chiuso”, che abbiamo trovato la soluzione ideale,
o che dobbiamo sempre usare MERGE-SORT o
HEAPSORT
– in effetti, alla fin fine l'algoritmo di ordinamento
forse più usato è QUICKSORT, che non è “ottimale”
nel caso pessimo...
• inoltre, possiamo in effetti fare meglio di MERGE-SORT
e HEAPSORT!
– però dobbiamo evitare di fare confronti
49
COUNTING-SORT (1)
• Ipotesi fondamentale: i valori da ordinare non sono più
grandi di una certa costante k
• Idea di base: se nell'array ci sono me valori più piccoli di
un certo elemento e (il cui valore è ve) nell'array
ordinato l'elemento e sarà in posizione me+1
– quindi, basta contare quante "copie" dello stesso
valore ve sono contenute nell'array
– usiamo questa informazione per determinare, per
ogni elemento e (con valore ve tale che 0 ve k),
quanti elementi ci sono più piccoli di e
– dobbiamo anche tenere conto del fatto che nell'array
ci possono essere elementi ripetuti
• es. 2, 7, 2, 5, 1, 1, 9
• pseudocodice
– parametri: A è l'array di input (disordinato), B
conterrà gli elementi ordinati (cioè è l'output), e k è
il massimo tra i valori di A
• A e B devono essere della stessa lunghezza n
50
COUNTING-SORT (2)
COUNTING-SORT (A, B, k)
1 for i := 0 to k
2
C[i] := 0
3 for j := 1 to A.length
4
C[A[j]] := C[A[j]] + 1
5 //C[i] ora contiene il numero di elementi
uguali a i
6 for i := 1 to k
7
C[i] := C[i] + C[i - 1]
8 //C[i] ora contiene il numero di elementi
≤ i
9 for j := A.length downto 1
10
B[C[A[j]]] := A[j]
11
C[A[j]] := C[A[j]] - 1
Se A =  2,5,3,0,2,3,0,3 
– A.length = 8
– B deve avere lunghezza 8
• Se eseguiamo COUNTING-SORT(A, B, 5)
– prima di eseguire la linea 5 (cioè alla fine del loop 3-4)
C =  2,0,2,3,0,1 
– prima di eseguire la linea 8 C =  2,2,4,7,7,8 
– dopo le prime 3 iterazioni del ciclo 9-11 abbiamo
1. B =  _,_,_,_,_,_,3,_ , C =  2,2,4,6,7,8 
2. B =  _,0,_,_,_,_,3,_ , C =  1,2,4,6,7,8 
3. B =  _,0,_,_,_,3,3,_ , C =  1,2,4,5,7,8 
– alla fine dell'algoritmo
B =  0,0,2,2,3,3,3,5 , C =  0,2,2,4,7,7 
•
51
COUNTING-SORT (3)
• La complessità di COUNTING-SORT è data dai 4 cicli
for:
– il ciclo for delle linee 1-2 ha complessità (k)
– il ciclo for delle linee 3-4 ha complessità (n)
– il ciclo for delle linee 6-7 ha complessità (k)
– il ciclo for delle linee 9-11 ha complessità (n)
• La complessità globale è (n + k)
• Se k è O(n), allora il tempo di esecuzione è O(n)
– lineare!
• COUNTING-SORT è "più veloce" (cioè ha complessità
inferiore) di MERGE-SORT e HEAPSORT (se k è O(n))
perché fa delle assunzioni sulla distribuzione dei valori
da ordinare (assume che siano tutti ≤ k)
– sfrutta l'assunzione: è veloce se k è O(n), altrimenti
ha complessità maggiore (anche di molto) di
MERGE-SORT e HEAPSORT
52
Strutture Dati
53
Scopo delle strutture dati
• Le strutture dati sono “aggeggi” usati per contenere
oggetti
– rappresentano collezioni di oggetti
– spesso (ma non sempre) gli oggetti di in una
struttura dati hanno una chiave, che serve per
indicizzare l'oggetto, e dei dati satelliti associati
(che sono i dati di interesse che porta con sé
l'oggetto)
• per esempio, si fa ricerca sulla chiave per
accedere ai dati satelliti, che possono essere
qualunque
• Ci sono 2 tipi di operazioni sulle strutture dati:
– operazioni che modificano la collezione
– operazioni che interrogano la collezione
54
• Alcune operazioni tipiche sulle strutture dati:
– SEARCH(S, k)
• restituisce l'oggetto (o, meglio il suo riferimento) x nella
collezione S con chiave k, NIL se nessun oggetto nella
collezione ha chiave k
• è un'operazione di interrogazione
– INSERT(S, x)
• inserisce l'oggetto x nella collezione S
• è un'operazione che modifica la collezione
– DELETE(S, x)
• cancella l'oggetto x dalla collezione S (op. di modifica)
– MINIMUM(S)
• restituisce l'oggetto nella collezione con la chiave più
piccola (op. di interrogazione)
– MAXIMUM(S)
• restituisce l'oggetto nella collezione con la chiave più
grande (op. di interrogazione)
– SUCCESSOR(S, x)
• restituisce l'oggetto che segue x nella collezione, secondo
una qualche relazione di ordinamento (op. di
interrogazione)
• per esempio, potrebbe essere l'elemento con la prossima
chiave più grande, se c'è un ordinamento sulle chiavi
– potrebbe essere qualcosa d'altro (la sua definizione
dipende dalla specifica struttura dati)
– PREDECESSOR(S,x)
• restituisce l'oggetto che precede x nella collezione, secondo
una qualche relazione di ordinamento (op. di
interrogazione)
55
Pile (Stack)
•
•
•
•
Cominciamo con un esempio semplicissimo di struttura dati:
la pila
Ad un livello astratto, una pila è una collezione di oggetti sulla
quale possiamo fare le seguenti operazioni:
– controllare se è vuota
– inserire un elemento nella collezione (PUSH)
– cancellare un elemento dalla collezione (POP)
• l'operazione di POP restituisce l'elemento cancellato
Una pila è gestita con una politica LIFO (Last In First Out)
– l'elemento che viene cancellato (pop) è quello che è stato
inserito per ultimo (cioè quello che è nella pila da meno
tempo)
• cioè, se viene fatta una PUSH di un oggetto e su una
pila S, seguita immediatamente da una POP su S,
l'elemento restituite dalla POP è lo stesso e di cui era
stata fatta la PUSH
Se la pila può contenere al massimo n elementi, possiamo
implementarla come un array di lunghezza n
– per tenere traccia dell'indice dell'elemento che è stato
inserito per ultimo viene introdotto un attributo, chiamato
top
• cioè, se una pila S è implementata mediante un array,
S.top è l'indice dell'ultimo elemento inserito
– se S.top = t, allora S[1], S[2], ... S[t] contengono
tutti gli elementi, e S[1] è stato inserito prima di
S[2], che è stato inserito prima di S[3], ecc.
• se S.top = 0, la pila è vuota, e nessun elemento può
essere cancellato
• se S.top = S.length = n, la pila è piena, e nessun
elemento vi può essere aggiunto
56
pseudocodice per le operazioni sulle pile
•
Se una pila è implementata tramite un array, lo pesudocodice
per le operazioni su di essa è il seguente:
STACK-EMPTY(S)
1 if S.top = 0
2
return TRUE
3 else return FALSE
PUSH(S, x)
1 if S.top = S.length
2
error “overflow”
3 else S.top := S.top + 1
4
S[S.top] := x
POP(S)
1 if STACK-EMPTY(S)
2
error “underflow”
3 else S.top := S.top - 1
4
return S[S.top + 1]
•
Tutte le operazioni vengono eseguite in tempo T(n) = O(1)
– poiché la pila è limitata, non servono cicli, quindi la
complessità è costante
• Si noti la separazione tra interfaccia astratta e
implementazione concreta:
• Esercizio: implementare la medesima interfaccia per una
pila realizzata mediante lista a puntatori.
57
Code (queue) (1)
• Le code sono simili alle pile, salvo che una
coda è gestita con una politica FIFO (First In
First Out)
• A livello astratto, una coda è una collezione di
oggetti sulla quale si possono fare le seguenti
operazioni:
– (controllare se è vuota)
– inserire un elemento nella collezione
(ENQUEUE)
– cancellare un elemento dalla collezione
(DEQUEUE)
• si noti che l'operazione di DEQUEUE
restituisce l'elemento cancellato
• Una coda è gestita con una politica FIFO
– l'elemento che viene cancellato è quello che
era stato inserito per primo (cioè quello che
è rimasto nella coda per più tempo)
58
Code (queue) (2)
• Se una coda può contenere al più n elementi,
allora, come per le pile, possiamo
implementarla tramite un array di lunghezza n
– ora però dobbiamo tenere traccia di 2
indici:
• l'indice del prossimo elemento da
eliminare (quello che è nella coda da più
tempo),
• l'indice della cella nell'array in cui sarà
memoerizzato il prossimo elemento
inserito nella coda
– utilizziamo 2 attributi, head e tail
• se Q è una coda implementata mediante
un array, Q.head è l'indice dell'elemento
da più tempo nell'array
• Q.tail è l'indice in cui il prossimo
elemento inserito dovrà essere
memorizzato
– cioè, Q.tail-1 è l'indice dell'ultimo
elemento inserito
59
Operazioni sulle code
• Prima di introdurre lo pseudocodice di
ENQUEUE e DEQUEUE, analizziamo come
funziona una coda implementata come array
– gli elementi di una coda Q hanno indici
Q.head, Q.head+1, ... Q.tail-1
– se Q.tail = Q.length e un nuovo elemento è
inserito, il prossimo valore di tail sarà 1
• la coda funziona in modo “circolare”
• per esempio, se la coda ha lunghezza 10,
Q.tail = 10 e noi inseriamo un nuovo
elemento, dopo l'accodamento abbiamo
che Q.tail = 1
– se Q.head = Q.tail la coda è vuota
– se Q.head = Q.tail+1 la coda è piena
• se la coda non è piena, c'è sempre
almento una cella libera tra Q.tail e
Q.head
• quindi, se dobbiamo implementare
mediante un array una coda Q che
contiene al massimo n elementi, l'array
deve avere n+1 celle
60
Pseudocodice per le operazioni sulle code
•
(che non controlla se la coda è
piena/vuota)
tempo di esecuzione:
ENQUEUE(Q, x)
T(n) = O(1)
1 Q[Q.tail] := x
2 if Q.tail = Q.length
3
Q.tail := 1
4 else Q.tail := Q.tail + 1
tempo di esecuzione:
DEQUEUE(Q)
T(n) = O(1)
1 x := Q[Q.head]
2 if Q.head = Q.length
3
Q.head := 1
4 else Q.head := Q.head + 1
5
return x
… e i controlli di coda piena/vuota?
(in realtà c’è un po’ di ridondanza …)
61
Liste (doppiamente) concatenate
• Una lista concatenata è una struttura dati in cui gli elementi
sono sistemati in un ordine lineare, in modo simile ad un array
– l'ordine è dato non dagli indici degli elementi, ma da una
“catena” di puntatori
• Una lista doppiamente concatenata è fatta di oggetti con 3
attributi:
– key, che rappresenta il contenuto dell'oggetto
– next, che è il puntatore all'oggetto seguente
• cioè il successore dell'oggetto nell'ordinamento
lineare
– prev, che è il puntatore all'oggetto precedente
• cioè il predecessore
• Se x è un oggetto nella lista, se x.next = NIL, x non ha
successore
– cioè è l'ultimo elemento della lista
• Se x.prev = NIL, x non ha predecessore
– cioè è il primo elemento della lista, la testa (head)
• ogni lista L ha un attributo L.head, che è il puntatore al primo
elemento della lista (ed eventualmente un L.tail)
• Esempio di lista doppiamente concatenata
prev
head[L]
9
key
16
next
4
1
62
• Altri tipi di liste:
– singolarmente concatenate
• gli elementi non hanno il puntatore prev
– ordinate
• l'ordinamento degli elementi nella lista è
quello delle chiavi
• il primo elemento ha la chiave minima,
l'ultimo la massima
– non ordinate
– circolari
• il puntatore prev di head punta alla coda
(tail), e il puntatore next della coda punta alla
testa
63
Operazioni su una lista doppiamente
concatenata (1)
• Ricerca
– input: la lista L in cui cercare e la chiave k
desiderata
– output: il puntatore ad un elemento che ha k
come chiave, NIL se la chiave non è nella
lista
LIST-SEARCH(L, k)
1 x := L.head
2 while x ≠ NIL and x.key ≠ k
3
x := x.next
4 return x
• Nel caso pessimo (quando la chiave non è
nella lista)
T(n) = (n)
• Anche in questo caso è buona norma separare
l’interfaccia della struttura dati dalla sua
implementazione (un lista sequenziale può
essere concatenata mediante puntatori ma
anche realizzata mediante un array)
64
Operazioni su una lista doppiamente
concatenata (2)
• Inserimento (in testa)
– input: la lista L, e l'oggetto x da aggiungere,
inizializzato con la chiave desiderata
– output: inserisce x all'inizio della lista L
• (anche se un elemento con la stessa
chiave esiste già nella lista)
LIST-INSERT(L, x)
1 x.next := L.head
2 if L.head ≠ NIL
3
L.head.prev := x
4 L.head := x
5 x.prev := NIL
• T(n) = O(1)
• Occhio alla differenza tra oggetto e chiave!
65
Operazioni (3)
• Cancellazione
– input: la lista L, e l'oggetto x da
cancellare
• si noti che non si passa come
argomento la chiave da cancellare,
ma tutto l'oggetto
– output: cancella x dalla lista
LIST-DELETE(L, x)
1 if x.prev ≠ NIL
2
x.prev.next := x.next
3 else L.head := x.next
4 if x.next ≠ NIL
5
x.next.prev := x.prev
//differenze tra C e Java?
• T(n) = O(1)
66
Operazioni (4)
NB
• se cancelliamo tramite la chiave, e non
direttamente tramite l'oggetto, allora la
complessità diventa O(n), perchè dobbiamo
prima cercare l'elemento
– dobbiamo cioè prima chiamare LISTSEARCH, che ha tempo di esecuzione T(n)
= O(n)
– Domanda_1: se L è singolarmente concatenata
T(n) di LIST-DELETE(L, x)è O(?)
– Domanda_2: se voglio ordinare una lista
concatenata?
67
Dizionari e indirizzamento diretto
•
Dizionario: insieme dinamico che supporta solo le operazioni
di INSERT, DELETE, SEARCH
Agli oggetti di un dizionario si accede tramite le loro chiavi
•
•
Se la cardinalità m dell'insieme delle possibili chiavi U
(m=|U|)è ragionevolmente piccola, la maniera più semplice di
realizzare un dizionario è tramite un array di m elementi
– con questo si ha l'indirizzamento diretto
– in questo caso l'array si dice tabella a indirizzamento diretto
•
Ogni elemento T[k] dell'array contiene il riferimento
all'oggetto di chiave k, se un tale oggetto è stato inserito in
tabella, NIL altrimenti
0 NIL
U
18
1
K
4
2
3
1 NIL
7
...
2
T
2
dati
3
dati
3
4 NIL
68
Operazioni su una tabella a indirizzamento diretto
DIRECT-ADDRESS-SEARCH(T, k)
1 return T[k]
DIRECT-ADDRESS-INSERT(T, x)
1 T[x.key] := x
DIRECT-ADDRESS-DELETE(T, x)
1 T[x.key] := NIL
•
Hanno tutte T(n)=O(1)
•
Però, se il numero effettivamente memorizzato di chiavi è
molto più piccolo del numero di chiavi possibili, c'è un sacco
di spreco di spazio... è un po’ come il counting sort.
69
Tabelle hash
• Una tabella hash usa una memoria proporzionale al
numero di chiavi effettivamente memorizzate nel
dizionario
– indipendentemente dalla cardinalità dell'insieme
U di chiavi
• Idea fondamentale: un oggetto di chiave k è
memorizzato in tabella in una cella di indice h(k),
con h una funzione hash
– se m è la dimensione della tabella, h è una
funzione
h: U → {0..m-1}
– la tabella T ha m celle, T[0], T[1], ... , T [m-1]
– h(k) è il valore hash della chiave k
• Problema: ho |U| possibili chiavi ed una funzione
che le deve mappare su un numero m (< |U|, ma
tipicamente << |U|) di slot della tabella
– necessariamente avrò delle chiavi diverse
(tante!) k1, k2 tali che h(k1)=h(k2)
– in questo caso ho delle collisioni
• Ci sono diverse tecniche per risolvere le collisioni
• Una tipica è quella del concatenamento (chaining)
70
Risoluzione di collisioni tramite
concatenamento (1)
• Idea della tecnica del concatenamento: gli
oggetti che vengono mappati sullo stresso slot
vengono messi in una lista concatenata
U
0 NIL
1
K
k2
T
k2
k3
2 NIL
k3
3
k5
k5
4 NIL
5 NIL
71
U
0 NIL
T
1
K
k2
k2
k3
2 NIL
k3
3
k5
k5
4 NIL
5 NIL
• Operazioni sulle tabelle in questo caso:
CHAINED-HASH-INSERT(T, x)
inserisci x in testa alla lista T[h(x.key)]
CHAINED-HASH-SEARCH(T, k)
cerca un elemento con chiave k nella lista T[h(k)]
CHAINED-HASH-DELETE(T, x)
cancella x dalla lista T[h(x.key)]
• INSERT si fa in tempo O(1) (assumendo l'elemento da
inserire non sia già in tabella)
• SEARCH si fa in tempo proporzionale alla lunghezza di
T[h(k)]
• DELETE si fa in tempo O(1) se la lista è doppiamente
concatenata
– in input c'è l'oggetto da eliminare, non solo la chiave
– se singolarmente concatenata, proporzionale alla
lunghezza di T[h(x.key)]
72
Analisi della complessità delle
operazioni (1)
• Nel caso pessimo, in cui tutti gli n
elementi memorizzati finiscono nello
stesso slot la complessità è quella di una
ricerca in una lista di n elementi, cioè O(n)
• In media, però, le cose non vanno così
male...
• Siano:
– m la dimensione della tabella (il numero
di slot disponibili)
–  il fattore di carico,  = n/m
• siccome 0 ≤ n ≤ |U| avremo 0 ≤  ≤
|U|/m
• Ipotesi dell'hashing uniforme semplice:
ogni chiave ha la stessa probabilità 1/m di
finire in una qualsiasi delle m celle di T,
indipendentemente dalle chiavi
precedentemente inserite
73
Analisi della complessità delle
operazioni (2)
• Sotto questa ipotesi, la lunghezza media di una
lista T[j] è
m
E [ n j ]=
1
n
n
=
=α
∑
i
m i= 1
m
quindi il tempo medio per cercare una chiave k
non presente nella lista è (1+)
– 1 è il tempo per calcolare h(k), che si
suppone sia costante
• (1+) è anche il tempo medio per cercare
una chiave k che sia presente nella lista
– la dimostrazione però richiede qualche
calcolo in più...
• In pratica:
– se n = O(m), allora  = n/m = O(m)/m =
O(1)
– quindi in media ci mettiamo un tempo
costante
• Quindi la complessità temporale è O(1) (in
media) per tutte le operazioni (INSERT,
SEARCH, DELETE)
74
Funzioni hash (1)
• Come scelgo una buona funzione hash h?
• In teoria, ne dovrei prendere una che soddisfa l'ipotesi di
hashing uniforme semplice
– per fare ciò, però, dovrei sapere quale è la
distribuzione di probabilità delle chiavi che devo
inserire
• se le chiavi sono tutte “vicine”, la funzione hash
dovrebbe essere tale da riuscire a separarle
• se invece so che le chiavi sono distribuite in
modo uniforme in
[0..K-1] mi basta prendere h(k) = (k/K)m
– tipicamente si usano delle euristiche basate sul
dominio delle chiavi
• Attenzione: tipica assunzione delle funzioni hash: la
chiave k è un intero non-negativo (cioè è in ℕ)
– facile convertire una qualunque informazione
trattata da un calcolatore in un intero non-negativo,
basta per esempio interpretare come tale la sequenza
di bit corrispondente
– Quanto risulterà uniforme?
75
Funzioni hash (2)
• Come scelgo una buona funzione hash h?
• Metodo della divisione:
h(k) = k mod m
– facile da realizzare e veloce (una sola operazione)
– evitare certi valori di m:
• potenze di 2 (m non deve essere della forma 2p)
– se no k mod m sono solo i p bit meno
significativi di k
– meglio rendere h(k) dipendente da tutti i bit
di k
– spesso si prende per m un numero primo non troppo
vicino ad una potenza esatta di 2
• per esempio m=701, che ci darebbe, se n=2000,
in media 3 elementi per lista concatenata
76
Metodo della moltiplicazione (1)
• Moltiplichiamo k per una costante A reale tale
che 0 < A < 1, quindi prendiamo la parte
frazionaria di kA; il risultato lo moltiplichiamo
per m, e ne prendiamo la parte intera
• Cioè:
h(k) = m(kA mod 1)
– in cui x mod 1 = x – x è la parte frazionaria di x
• In questo caso il valore di m non è critico, funziona bene
con qualunque valore di A
– spesso come m si prende una potenza di 2 (cioè
m=2p), che rende semplice fare i conti con un
calcolatore
– in questo caso, è utile prendere come A un valore
che sia della forma s/2w, con w dimensione della
parola di memoria del calcolatore (con 0<s<2w)
• se k sta in una sola parola (k<2w), ks=kA2w è un
numero di 2w bit della forma r12w+r0, ed i suoi
w bit meno significativi (cioè r0) costituiscono
kA mod 1
• il valore di hash cercato (con m=2p) è costituito
dai p bit più significativi di r0
77
Metodo della moltiplicazione (2)
• Un valore di A proposto (da Knuth) che
funziona bene è
A  ( 5 1) / 2
– è l'inverso della sezione aurea
– se si vuole applicare il calcolo precedente,
occorre prendere come A la frazione della
forma s/2w più vicina all'inverso della
sezione aurea
• dipende dalla lunghezza della parola w
78
Indirizzamento aperto
• Un altro modo di evitare collisioni è tramite la tecnica
dell'indirizzamento aperto
• In questo caso la tabella contiene tutte le chiavi, senza
memoria aggiuntiva
– quindi il fattore di carico  non potrà mai essere più
di 1
• L'idea è quella di calcolare l'indice dello slot in cui va
memorizzato l'oggetto; se lo slot è già occupato, si cerca
nella tabella uno slot libero
– la ricerca dello slot libero però non viene fatta in
ordine 0,1,2,...,m-1; la sequenza di ricerca (detta
sequenza di ispezione) è un valore calcolato dalla
funzione hash
• dipende anche dalla chiave da inserire
• la sequenza deve essere esaustiva, deve coprire
tutte le celle
• La funzione hash ora diventa:
h : U {0,1,..., m-1}  {0,1,..., m-1}
– la sequenza di ispezione h(k, 0), h(k, 1),..., h(k, m1) deve essere una permutazione di 0, ... ,m-1
79
Operazioni in caso di indirizzamento aperto (1)
• Inserimento di un oggetto:
HASH-INSERT(T, k)
1 i := 0
2 repeat
3
j := h(k, i)
4
if T[j] = NIL
5
T[j] := k
6
return j
7
else i := i + 1
8 until i = m
9 error “hash table overflow”
80
Operazioni in caso di indirizzamento aperto (2)
Ricerca:
HASH-SEARCH(T, k)
1 i := 0
2 repeat
3
j := h(k, i)
4
if T[j] = k
5
return j
6
else i := i + 1
7 until T[j] = NIL or i = m
8 return NIL
• La cancellazione è più complicata, in quanto
non possiamo limitarci a mettere lo slot
desiderato a NIL, altrimenti non riusciremmo
più a trovare le chiavi inserite dopo quella
cancellata
– una soluzione è quella di mettere nello slot,
invece che NIL, un valore convenzionale
come DELETED
• però così le complessità non dipendono
più (solo) dal fattore di carico
81
Analisi di complessità (1)
• Il tempo impiegato per trovare lo slot
desiderato (quello che contiene la chiave
desiderata, oppure quello libero in cui inserire
l'oggetto) dipende (anche) dalla sequenza di
ispezione restituita dalla funzione h
– quindi dipende da come è implementata h
• Per semplificare un po' il calcolo facciamo
un'ipotesi sulla distribuzione di probabilità con
la quale vengono estratte non solo le chiavi,
ma anche le sequenze di ispezione
• Ipotesi di hashing uniforme:
ognuna delle m! permutazioni di 0, ... ,m-1 è
ugualmente probabile che venga selezionata
come sequenza di ispezione
– è l'estensione dell'hashing uniforme
semplice visto prima al caso in cui
l'immagine sia non più solo lo slot in cui
inserire l'elemento, ma l'intera sequenza di
ispezione
82
Analisi di complessità (2)
• L'analisi viene fatta in funzione del fattore di
carico  = n/m
– siccome abbiamo al massimo un oggetto
per slot della tabella, n≤m, e 0 ≤  ≤ 1
• Sotto l'ipotesi di hashing uniforme valgono i
seguenti risultati (i calcoli sono sul libro):
– il numero medio di ispezioni necessarie per
effettuare l'inserimento di un nuovo oggetto
nella tabella è m se  = 1 (se la tabella è
piena), e non più di 1/(1-) se <1 (se la
tabella cioè ha ancora spazio disponibile)
– il numero medio di ispezioni necessarie per
trovare un elemento presente in tabella è
(m+1)/2 se =1, e non più di
1/ log(1/(1-)) se <1
83
Tecniche di ispezione (1)
• In pratica, costruire funzioni hash che
soddisfino l'ipotesi di hashing uniforme è
molto difficile
• Si accettano quindi delle approssimazioni che,
nella pratica, si rivelano soddisfacenti
• Tre tecniche:
– ispezione lineare
– ispezione quadratica
– doppio hashing
• nessuna di queste tecniche produce le
m! permutazioni che sarebbero
necessarie per soddisfare l'ipotesi di
hashing uniforme
• tuttavia, nella pratica si rivelano “buone
a sufficienza”
• Tutte e 3 le tecniche fanno uso di una (o più)
funzione hash ausiliaria (ordinaria) h': U 
{0,1,..., m-1}
84
Tecniche di ispezione (2)
• Ispezione lineare:
h(k,i) = (h'(k)+i) mod m
– in questo caso l'ispezione inizia dalla cella
h'(k), e prosegue in h'(k)+1, h'(k)+2, ... fino
a che non si arriva a m-1, quindi si
ricomincia da 0 fino a esplorare tutti gli slot
di T
– genera solo m sequenze di ispezione
distinte
• la prima cella ispezionata identifica la
sequenza di ispezione
– soffre del fenomeno dell'addensamento
(clustering) primario
• lunghe sequenze di celle occupate
consecutive, che aumentano il tempo
medio di ricerca
85
Ispezione quadratica
• Nel caso dell'ispezione quadratica:
h(k,i) = (h'(k)+c1i+c2i2) mod m
– c1 e c2 sono costanti ausiliarie (con c2 ≠ 0)
– c1 e c2 non possono essere qualsiasi, ma
devono essere scelte in modo che la
sequenza percorra tutta la tabella
– ancora una volta, la posizione di ispezione
iniziale determina tutta la sequenza, quindi
vengono prodotte m sequenze di ispezione
distinte
– soffre del fenomeno dell'addensamento
secondario: chiavi con la stessa posizione
iniziale danno luogo alla stessa sequenza di
ispezione
86
Doppio hashing
• Doppio hashing:
h(k,i) = (h1(k)+i h2(k)) mod m
– h1 e h2 sono funzioni hash ausiliarie
– perché la sequenza prodotta sia una
permutazione di 0, ... ,m-1 h2(k) deve
essere primo rispetto a m (non deve avere
divisori comuni tranne l'1)
• posso ottenere questo prendendo come
m una potenza di 2, e facendo in modo
che h2 produca sempre un valore dispari
• oppure prendendo come m un numero
primo, e costruendo h2 in modo che
restituisca sempre un valore < m
• esempio:
h1(k) = k mod m
h2(k) = 1 + (k mod m')
– con m' < m (per esempio m' = m-1)
– numero di sequenze generate ora è (m2) in
quanto ogni coppia (h1(k), h2(k)) produce
una sequenza di ispezione distinta
87
Alberi binari
• Un albero binario è fatto di 3 elementi: un
nodo radice; un albero binario che è il
sottoalbero sinistro, ed un albero binario che è
il sottoalbero destro
– è una definizione ricorsiva
– un sottoalbero può essere vuoto (NIL)
• Ad ogni nodo dell'albero associamo un oggetto
con una chiave
• Esempio di albero binario
radice
sottoalbero sinistro
3
2
4
7
1
5
6
sottoalbero destro
88
Rappresentazione di alberi binari (1)
• Tipicamente si rappresentano alberi binari
mediante strutture dati concatenate
– abbiamo però anche visto la rappresentazione
mediante array
• Ogni nodo dell'albero è rappresentato da un
oggetto che ha i seguenti attributi
– key, la chiave del nodo (che per noi ne
rappresenta il contenuto)
• tipicamente ci sono anche i dati satelliti
– p, che è il (puntatore al) nodo padre
– left, che è il (puntatore al) sottoalbero sinistro
• left è la radice del sottoalbero sinistro
– right che è il (puntatore al) sottoalbero destro
• è la radice del sottoalbero destro
• ogni albero T ha un attributo, T.root, che è il
puntatore alla radice dell'albero
89
Rappresentazione di alberi binari (2)
• Si noti che:
– se il sottoalbero sinistro (destro) di un nodo x
è vuoto, allora
x.left = NIL
(x.right = NIL)
– x.p = NIL se e solo se x è la radice (cioè x =
T.root)
• Per esempio:
T.root
3
2
4
7
1
5
6
90
Alberi binari di ricerca (Binary Search Trees-1)
• Un albero binario di ricerca (Binary Search
Tree, BST) è un albero binario che soddisfa la
seguente proprietà:
– per tutti i nodi x del BST, se l è un nodo nel
sottoalbero sinistro, allora l.key  x.key; se r
è un nodo del sottoalbero destro, allora
x.key  r.key
• tutti i nodi l del sottoalbero sinistro di un
nodo x sono tali che, per tutti i nodi r nel
sottoalbero destro di x vale l.key  r.key
• esempio
5
3
2
7
5
8
91
Alberi binari di ricerca (Binary Search Trees-2)
• Una tipica operazione che viene fatta su un albero
è di attraversarlo (walk through)
• Lo scopo dell'attraversamento di un albero è di
produrre (le chiavi associate a) gli elementi
dell'albero
• Ci sono diversi modi di attraversare un albero; un
modo è l'attraversamento simmetrico (inorder
tree walk)
– prima si visita il sottoalbero sinistro e si
restituiscono i suoi nodi
– quindi si restituisce la radice
– quindi si visita il sottoalbero destro e si
restituiscono i suoi nodi
• Si noti che:
– come spesso accade con gli algoritmi sugli
alberi (che è una struttura dati inerentemente
ricorsiva), l'attraversamento simmetrico è un
algoritmo ricorsivo
– con l'attraversamento simmetrico gli elementi
di un albero sono restituiti ordinati
• per esempio, l'attraversamento simmetrico
sull'albero precedente produce le chiavi
seguenti: 2, 3, 5, 5, 7, 8
92
Algoritmi di attraversamento (1)
• Nel dettaglio:
INORDER-TREE-WALK(x)
1 if x ≠ NIL
2
INORDER-TREE-WALK(x.left)
3
print x.key
4
INORDER-TREE-WALK(x.right)
• Se T è un BST, INORDER-TREEWALK(T.root) stampa tutti gli elementi di T
in ordine crescente
• Se n è il numero di nodi nel (sotto)albero, il
tempo di esecuzione per INORDER-TREEWALK è (n)
– se l'albero è vuoto, è eseguito in tempo
costante c
– se l'albero ha 2 sottoalberi di dimensioni k e
n-k-1, T(n) è dato dalla ricorrenza T(n) =
T(k) + T(n-k-1) + d, che ha soluzione
(c+d)n+c
• lo si può vedere sostituendo la soluzione
nell'equazione
93
Algoritmi di attraversamento (2)
• Altre possibili strategie di attraversamento:
anticipato (preorder tree walk), e posticipato
(postorder tree walk)
– in preorder, la radice è restituita prima dei
sottoalberi
– in postorder, la radice è restituita dopo dei
sottoalberi
• Esercizi:
• scrivere lo pseudocodice per PREORDERTREE-WALK e POSTORDER-TREE-WALK
• scrivere lo pseudocodice per Breadthfirst-TREE-WALK (il cui
risultato per l’albero
precedente deve essere:
5, 3, 7, 2, 5, 8)
94
Operazioni sui BST (1)
• Sfruttiamo la proprietà di essere un BST per
realizzare la ricerca:
– confronta la chiave della radice con quella
cercata
– se sono uguali, l'elemento è quello cercato
– se la chiave della radice è più grande, cerca
nel sottoalbero sinistro
– se la chiave della radice è più grande, cerca
nel sottoalbero destro
TREE-SEARCH(x, k)
1 if x = NIL or k = x.key
2
return x
3 if k < x.key
4
return TREE-SEARCH(x.left, k)
5 else return TREE-SEARCH(x.right, k)
• Il tempo di esecuzione è O(h), con h l'altezza
dell'albero
95
Operazioni sui BST (2)
• L'elemento minimo (risp. massimo) in un BST
è quello che è più a sinistra (risp. destra)
• Sfruttiamo questa proprietà per definire il
seguente algoritmo, che semplicemente
“scende” nell'albero
– MINIMUM scende a sinistra, mentre
MAXIMUM scende a destra
– gli algoritmi restituiscono l'oggetto
nell'albero con la chiave minima, non la
chiave stessa
• Entrambi gli algoritmi hanno tempo di
esecuzione che è O(h), con h l'altezza
dell'albero
TREE-MINIMUM(x)
1 while x.left ≠ NIL
2
x := x.left
3 return x
TREE-MAXIMUM(x)
1 while x.right ≠ NIL
2
x := x.right
3 return x
96
Operazioni sui BST (3)
• Il successore (risp. predecessore) di un oggetto x in un
BST è l'elemento y del BST tale che y.key è la più
piccola (risp. più grande) tra le chiavi che sono più
grandi (risp. piccole) di x.key
– di fatto, se il sottoalbero destro di un oggetto x
dell'albero non è vuoto, il successore di x è
l'elemento più piccolo (cioè il minimo) del
sottoalbero destro di x
– invece, se il sottoalbero destro di x è vuoto, il
successore di x è il primo elemento y che si incontra
risalendo nell'albero da x tale che x è nel sottoalbero
sinistro di y
TREE-SUCCESSOR(x)
1 if x.right ≠ NIL
2
return TREE-MINIMUM(x.right)
3 y := x.p
4 while y ≠ NIL and x = y.right
5
x := y
6
y := y.p
7 return y
• Il successore del massimo è NIL
• Il tempo di esecuzione per TREE-SUCCESSOR è O(h)
• Esercizio: scrivere l'algoritmo TREE-PREDECESSOR e
darne la complessità
97
Inserimento (1)
• Idea di base per l'inserimento: scendere
nell'albero fino a che non si raggiunge il posto
in cui il nuovo elemento deve essere inserito,
ed aggiungere questo come foglia
• Supponiamo, per esempio, di volere inserire un
nodo con chiave 7 nell'albero seguente:
5
3
1
8
4
9
98
Inserimento (2)
• eseguiamo i seguenti passi:
– confrontiamo 5 con 7 e decidiamo che il
nuovo elemento deve essere aggiunto al
sottoalbero destro di 5
– confrontiamo 8 con 7 e decidiamo che 7
deve essere aggiunto al sottoalbero sinistro
di 8
– notiamo che il sottoalbero sinistro di 8 è
vuoto, e aggiungiamo 7 come sottoalbero
sinistro di 8
• quindi, otteniamo il nuovo albero:
5
3
1
8
4
7
9
99
Insert: pseudocodice
TREE-INSERT(T, z)
1
y := NIL
2
x := T.root
3
while x ≠ NIL
4
y := x
5
if z.key < x.key
6
x := x.left
7
else x := x.right
8
z.p := y
9
if y = NIL
10
T.root := z
//l'albero T era vuoto
11 elsif z.key < y.key
12
y.left := z
13 else y.right := z
• Si noti che inseriamo un oggetto, z, che
assumiamo sia stato inizializzato con la chiave
desiderata
• Il tempo di esecuzione di TREE-INSERT è
O(h)
– infatti, scendiamo nell'albero nel ciclo
while (che al massimo richiede tante
ripetizioni quanta è l'altezza dell'albero), e
il resto (linee 8-13) si fa in tempo costante
100
Cancellazione (1)
• Quando cancelliamo un oggetto z da un albero, abbiamo
3 possibili casi (a seconda che z sia una foglia o un nodo
interno):
– il nodo z da cancellare non ha sottoalberi
– il nodo z da cancellare ha 1 sottoalbero
– il nodo z da cancellare ha 2 sottoalberi
• Il caso 1 è quello più facile, basta mettere a NIL il
puntatore del padre di z che puntava a z:
5
3
1
5
8
4
3
9
8
1
9
Nel caso 2,dobbiamo spostare l'intero sottoalbero di z su di un
livello:
5
3
1
5
7
4
3
9
8
1
9
4
8
10
10
101
Cancellazione(2)
• Nel caso 3 dobbiamo trovare il successore del nodo da
cancellare z, copiare la chiave del successore in z, quindi
cancellare il successore
– cancellare il successore potrebbe richiedere di
spostare un (il) sottoalbero del successore un livello
su
– si noti che in questo caso l'oggetto originario z non è
cancellato, ma il suo attributo key viene modificato
(l'oggetto effettivamente cancellato è quello con il
successore di z)
5
3
1
7
4
6
12
8
14
9
5
3
1
8
4
6
12
9
14
102
Delete: pseudocodice (1)
TREE-DELETE(T, z)
1 if z.left = NIL or z.right = NIL
2
y := z
3 else y := TREE-SUCCESSOR(z)
4 if y.left ≠ NIL
//y non è il successore di z
5
x := y.left
6 else x := y.right
7 if x ≠ NIL
8
x.p := y.p
9 if y.p = NIL
10
T.root := x
11 elsif y = y.p.left
12
y.p.left := x
13 else y.p.right := x
14 if y ≠ z
15
z.key := y.key
16 return y
103
Delete: pseudocodice (2)
• In TREE-DELETE, y è il nodo effettivamente da
cancellare
• Se z ha non più di un sottoalbero, allora il nodo y
da cancellare è z stesso; altrimenti (se z ha
entrambi i sottoalberi) è il suo successore (linee
1-3)
– Si noti che y non può avere più di un
sottoalbero
• nelle linee 4-6, ad x viene assegnata la radice del
sottoalbero di y se y ne ha uno, NIL se y non ha
sottoalberi
• le linee 7-13 sostituiscono y con il suo
sottoalbero (che ha x come radice)
• nelle linee 14-15, se z ha 2 sottoalberi (che
corrisponde caso in cui il nodo y da cancellare è
il successore di z, non z stesso), la chiave di z è
sostituita con quella del suo successore y
104
Analisi di complessità (1)
• Il tempo di esecuzione per TREE-DELETE è
O(h)
– TREE-SUCCESSOR è O(h), il resto è fatto
in tempo costante
• Tutte le operazioni sui BST (SEARCH,
MINIMUM, MAXIMUM, SUCCESSOR,
PREDECESSOR, INSERT, DELETE) hanno
tempo di esecuzione che è O(h)
– cioè, alla peggio richiedono di scendere
nell'albero
• Quindi... quanto vale l'altezza di un BST
(rispetto al numero dei suoi nodi)?
105
Analisi di complessità (2)
• Per un albero completo, h = (log(n))
– un albero è completo se e solo se, per ogni
nodo x, o x ha 2 figli, o x è una foglia, e
tutte le foglie hanno la stessa profondità
5
3
2
7
5
7
8
• Nel caso pessimo, però, che si ha se tutti i nodi
sono “in linea”, abbiamo h = (n)
2
4
5
9
106
Analisi di complessità (3)
• Tuttavia, un BST non deve per forza essere
completo per avere altezza h tale che h =
(log(n))
– abbiamo per esempio visto che questa proprietà vale
anche per alberi quasi completi
• Abbiamo che h = (log(n)) anche per un
albero bilanciato
– informalmente, diciamo che un albero è bilanciato
se e solo se non ci sono 2 foglie nell'albero tali che
una è “molto più lontana” dalla radice dell'altra (se
si trovano a profondità molto diverse)
• ci potrebbero essere diverse nozioni di "molto
più lontano"
• una possibile definizione di albero bilanciato
(Adelson-Velskii e Landis) è la seguente: un
albero è bilanciato se e solo se, per ogni nodo x
dell'albero, le altezze dei 2 sottoalberi di x
differiscono al massimo di 1
• per esempio
5
2
1
9
4
107
• Ma anche
108
Analisi di complessità (4)
• Ci sono diverse tecniche per mantenere un
albero bilanciato:
– alberi rosso-neri (red-black)
– alberi AVL
– B- trees
– etc.
• Inoltre, si può dimostrare che l'altezza attesa di
un albero è O(log(n)) se le chiavi sono inserite
in modo casuale
109
Alberi rosso-neri (red-black) (1)
• Gli alberi rosso-neri (RB) sono BST
“abbastanza” bilanciati, tali che l'altezza
dell'albero h è O(log(n))
– ed è possibile realizzare tutte le operazioni più
importanti in tempo O(log(n))
• Negli alberi RB non si ha mai che un ramo
dell'albero sia lungo più del doppio di un altro
ramo
– è una nozione di “bilanciamento” diversa da quella
degli alberi AVL, ma dà comunque h = O(log(n))
• Idea alla base degli alberi RB:
– ogni nodo ha un colore, che può essere solo rosso o
nero
– i colori sono distribuiti nell'albero in modo da
garantire che nessun ramo dell'albero sia 2 volte più
lungo di un altro
110
Alberi rosso-neri (red-black) (2)
• Ogni nodo di un albero RB ha 5 attributi: key,
left, right, p, e color
– convenzione: le foglie sono i nodi NIL, tutti i nodi
non NIL (che hanno quindi una chiave associata)
sono nodi interni
• Un BST è un albero RB se soddisfa le seguenti
5 proprietà:
1.
2.
3.
4.
5.
ogni nodo è o rosso o nero
la radice è nera
le foglie (NIL) sono tutte nere.
i figli di un nodo rosso sono entrambi neri
per ogni nodo x tutti i cammini da x alle foglie sue
discendenti contengono lo stesso numero bh(x) di
nodi neri
 bh(x) è la altezza nera (black height) del nodo x
 Il nodo x non è contato in bh(x) anche se nero.
111
Esempi di alberi RB
26
17
41
14
21
10
7
16
12
19
15
30
23
47
28
20
38
35
39
3
•
Per comodità, per rappresentare tutte le foglie NIL, si usa un
unico nodo sentinella T.nil
– è un nodo particolare accessibile come attributo dell'albero T
– tutti i riferimenti a NIL (compreso il padre della radice) sono
sostituiti con riferimenti a T.nil
26
17
41
14
21
10
7
16
12
15
19
30
23
47
28
20
38
35
39
3
T.nil
112
Proprietà degli alberi RB
• Un albero rosso-nero con n nodi interni (n nodi
con chiavi, per la convenzione usata) ha
altezza
h ≤ 2 log2(n+1)
– si dimostra che il numero di nodi interni di
un (sotto)albero con radice x è ≥ 2bh(x)-1
– per la proprietà 4, almeno metà dei nodi
dalla radice x (esclusa) ad una foglia sono
neri, quindi bh(x) ≥ h/2, e n ≥ 2h/2-1, da cui
discende che h ≤ 2 log2(n+1)
113
Proprietà degli alberi RB
• Come conseguenza di questa proprietà,
SEARCH, MINIMUM, MAXIMUM, SUCCESSOR
e PREDECESSOR richiedono tempo O(log(n))
se applicate ad un albero RB con n nodi
– queste operazioni non modificano l'albero,
che viene semplicemente trattato come un
BST
– il loro pseudocodice è come quello visto in
precedenza
• INSERT e DELETE si possono anch'esse fare
con complessità O(log(n)), ma devono essere
modificate rispetto a quelle viste prima
– devono essere tali da mantenere le 5
proprietà degli alberi RB
• Il meccanismo fondamentale per realizzare
INSERT e DELETE è quello delle rotazioni
114
Rotazioni
• Le rotazioni possono essere verso sinistra (LEFTROTATE) o verso destra (RIGHT-ROTATE)
LEFT-ROTATE(T,x)
x

y

RIGHT-ROTATE(T,y)

LEFT-ROTATE(T,x)
1 y := x.right
2 x.right := y.left
3
4
5
6
7
8
9
10
11
12
y

x


//il sottoalbero sinistro di y
//diventa quello destro di x
if y.left ≠ T.nil
y.left.p := x
y.p := x.p
//attacca il padre di x a y
if x.p = T.nil
T.root := y
elsif x = x.p.left
x.p.left := y
else x.p.right := y
y.left := x
//mette x a sinistra di y
x.p := y
• Una rotazione su un BST mantiene la proprietà di essere BST
• Esercizio per casa: scrivere lo pseudocodice per RIGHT-ROTATE
115
RB-INSERT
• L'inserimento è fatto in modo analogo a quello dei BST, ma
alla fine occorre ristabilire le proprietà dagli alberi RB se
queste sono state violate
– per ristabilire le proprietà si usa un algoritmo RB-INSERTFIXUP (che vedremo dopo)
RB-INSERT(T, z)
1
y := T.nil
2
x := T.root
3
while x ≠ T.nil
4
y := x
5
if z.key < x.key
6
x := x.left
7
else x := x.right
8
z.p := y
9
if y = T.nil
10
T.root := z
//l'albero T e' vuoto
11 elsif z.key < y.key
12
y.left := z
13 else y.right := z
14 z.left := T.nil
15 z.right := T.nil
16 z.color := RED
17 RB-INSERT-FIXUP(T, z)
• Uguale a TREE-INSERT, salvo che per l'uso di T.nil al
posto di NIL e l'aggiunta delle righe 14-17
116
RB-INSERT-FIXUP
RB-INSERT-FIXUP(T, z)
1 if z = T.root
2
T.root.color = BLACK
3 else x := z.p
// x e' il padre di z
4
if x.color = RED
5
if x = x.p.left
// se x e' figlio sin.
6
y := x.p.right
// y e' lo zio di z
7
if y.color = RED
8
x.color := BLACK
// Caso 1
9
y.color := BLACK
// Caso 1
10
x.p.color := RED
// Caso 1
11
RB-INSERT-FIXUP(T,x.p)
// Caso 1
12
else if z = x.right
13
z := x
// Caso 2
14
LEFT-ROTATE(T, z)
// Caso 2
15
x := z.p
// Caso 2
16
x.color := BLACK
// Caso 3
17
x.p.color := RED
// Caso 3
18
RIGHT-ROTATE(T, x.p)
// Caso 3
19
else (come 6-18, scambiando “right”↔“left”)
• RB-INSERT-FIXUP è invocato sempre su un nodo z
tale che z.color = RED
– per questo motivo, se la condizione alla linea 4 è
non verificata (cioè se il padre di z è di colore nero)
non ci sono ulteriori modifiche da fare
117
Funzionamento di RB-INSERT-FIXUP
• Caso 1: y rosso
– quindi x.p, che è anche y.p, non può essere rosso o
l'albero originario avrebbe violato la proprietà 4
x.p
x.p
7
x 5
x 5
y
9
7
y
9
z 3
z 3
x.p
x 3
z
x.p
7
9
5
y
x 3
7
9
y
z 5
– quindi ripeto la procedura su x.p, in quanto il padre
di x.p potrebbe essere di colore rosso, nel qual caso
la proprietà 4 degli alberi RB non sarebbe (ancora)
verificata
118
Funzionamento di RB-INSERT-FIXUP (2)
• Caso 2: y nero e z figlio destro di x
x.p
x 3
x' = z
9 y
 z 5 d

7
x.p
7

z' = x 3



9 y
5
d


– a questo punto ci siamo messi nel caso 3
• Caso 3: y nero e z figlio sinistro di x
x.p
x 5

9 y

z 3

x.p
7
d

7
x 5

y
9

z 3
5
d

3
 
7


9
d 
– a questo punto l'albero è a posto, non ci sono più
modifiche da fare
• Ogni volta che RB-INSERT-FIXUP viene invocato
esso può o terminare (casi 2 e 3), o venire applicato
ricorsivamente risalendo 2 livelli nell'albero (caso 1)
– quindi può essere invocato al massimo O(h) volte, cioè
O(log(n))
– si noti che una catena di invocazioni di RB-INSERTFIXUP esegue al massimo 2 rotazioni (l'ultima chiamata)
119
RB-DELETE
RB-DELETE(T, z)
1
if z.left = T.nil or z.right = T.nil
2
y := z
3
else y := TREE-SUCCESSOR(z)
4
if y.left ≠ T.nil
5
x := y.left
6
else x := y.right
7
x.p := y.p
8
if y.p = T.nil
9
T.root := x
10 elsif y = y.p.left
11
y.p.left := x
12 else y.p.right := x
13 if y ≠ z
14
z.key := y.key
15 if y.color = BLACK
16
RB-DELETE-FIXUP(T,x)
17 return y
•
•
Uguale a TREE-DELETE, salvo che per l'uso di T.nil al posto
di NIL (che permette l'eliminazione dell'if alla linea 7), e
l'aggiunta delle righe 15-16
Se viene cancellato un nodo rosso (cioè se y.color = RED)
non c'è bisogno di modificare i colori dei nodi
– per come è fatto RB-DELETE, viene cancellato un nodo (y) che
ha al massimo un figlio – x – diverso da T.nil, e se y.color =
RED il nodo x che prende il posto di y è per forza nero
120
RB-DELETE-FIXUP
RB-DELETE-FIXUP(T, x)
1 if x.color = RED or x.p = T.nil
2
x.color := BLACK
// Caso 0
3 elsif x = x.p.left
// x e' figlio sinistro
4
w := x.p.right
// w e' fratello di x
5
if w.color = RED
6
w.color := BLACK
// Caso 1
7
x.p.color := RED
// Caso 1
8
LEFT-ROTATE(T,x.p)
// Caso 1
9
w := x.p.right
// Caso 1
10
if w.left.color = BLACK and
w.right.color = BLACK
11
w.color := RED
// Caso 2
12
RB-DELETE-FIXUP(T,x.p)
// Caso 2
13
else if w.right.color = BLACK
14
w.left.color := BLACK // Caso 3
15
w.color := RED
// Caso 3
16
RIGHT-ROTATE(T,w)
// Caso 3
17
w := x.p.right
// Caso 3
18
w.color := x.p.color
// Caso 4
19
x.p.color := BLACK
// Caso 4
20
w.right.color := BLACK
// Caso 4
21
LEFT-ROTATE(T,x.p)
// Caso 4
19 else (come 4-21, scambiando “right”↔“left”)
•
Idea: il nodo x passato come argomento si porta dietro un
“nero in più”, che, per fare quadrare i conti, può essere
eliminato solo a certe condizioni
121
Funzionamento di RB-DELETE-FIXUP
•
x è ha preso il posto del nodo eliminato
•
Caso 0: x è un nodo rosso, oppure è la radice;
x 5
•
x 5
x 5
x 5
Caso 1: x è un nodo nero, il suo fratello destro w è rosso, e di
conseguenza il padre x.p è nero
3
3
x 1

7 w

5

x 1

9
d 
7 w

3
9
5


7
d 
5 w
x 1


9
 

d
– diventa o il caso 2, o il caso 3, o il caso 4
•
Caso 2: x è nero, suo fratello destro w è nero con figli entrambi neri
3
x

3
7 w
1

5


9
d 
7 w
1


5

9
d 

– se arriviamo al caso 2 dal caso 1, allora x.p è rosso, e quando RBDELETE-FIXUP viene invocato su di esso termina subito (arriva subito al
caso 0)
122
Funzionamento di RB-DELETE-FIXUP (2)
•
Caso 3: x è nero, suo fratello destro w è nero con figlio sinistro
rosso e figlio destro nero
3
x 1

3
7 w

5
3
x 1

9
7 w

 d  
5
x 1

9
5 w


7
 d  
d
9
 
– diventa il caso 4
•
x

Caso 4: x è nero, suo fratello destro w è nero con figlio destro
rosso
3
3
7
7 w
1

5

•
x

9
d 

7 w
1

5

3
9
d 
1

  
9

5

d
Ogni volta che RB-DELETE-FIXUP viene invocato esso può
o terminare (casi 0, 1, 3 e 4), o venire applicato ricorsivamente
risalendo un livello nell'albero (caso 2 non proveniente da 1)
– quindi può essere invocato al massimo O(h) volte, cioè O(log(n))
– si noti che una catena di invocazioni di RB-DELETE-FIXUP
esegue al massimo 3 rotazioni (se il caso 1 diventa 3 e poi 4)
123
Richiamo sui grafi
•
Un grafo è una coppia G = (V, E), in cui:
– V è un insieme di nodi (detti anche vertici)
– E è un insieme di archi (detti anche lati, o edges)
•
Un arco è una connessione tra 2 vertici
– 2 vertici connessi da un arco sono detti adiacenti
– se un arco e connette 2 vertici u e v, può essere rappresentato
dalla coppia (u, v) di vertici che connette
• quindi, E  V2
•
|V| è il numero di vertici nel grafo, mentre |E| è il numero di
archi
– 0  |E|  |V|2
•
Ci sono 2 tipi di grafi: orientati e non orientati
– in un grafo non orientato, un arco (u, v) è lo stesso di (v, u) (non
c'è nozione di direzione da un nodo all'altro)
– In un grafo orientato(u, v) "va dal" nodo u al nodo v, ed è diverso
da (v, u)
•
Esempio di grafo non orientato
a
d
V = {a, b, c, d, e}
E = {(b, a) (a, c) (b, c) (d, c) (e, d) (b, e)}
c
e
b
– L'ordine dei vertici negli archi è irrilevante
•
Esempio di grafo orientato:
a
d
c
b
e
V = {a, b, c, d, e}
E = {(a, b) (a, d) (d, a) (b, e)
(c, e) (e, d), (e, e)}
124
Rappresentazione di grafi in memoria (1)
• Come possiamo rappresentare un grafo in
memoria?
• 2 tecniche principali:
– liste di adiacenza
– matrice di adiacenza
• Grafo orientato
a
d
c
e
b
a
b
b
e
c
e
d
a
e
e
a
V = {a, b, c, d, e}
E = {(a, b) (a, d) (d, a) (b, e)
(c, e) (e, d), (e, e)}
a
b
c
d
e
d
Liste ad.
d
b c d
e
[ ]
0
0
0
1
0
1
0
0
0
0
0
0
0
0
0
1
0
0
0
1
0
1
1
0
1
Matrice ad.
125
Rappresentazione di grafi in memoria (2)
a
d
c
b
e
a
b
c
d
e
b
e
e
a
e
a
V = {a, b, c, d, e}
E = {(a, b) (a, d) (d, a) (b, e)
(c, e) (e, d), (e, e)}
a
b
c
d
e
d
Liste ad.
d
b c d
e
[ ]
0
0
0
1
0
1
0
0
0
0
0
0
0
0
0
1
0
0
0
1
0
1
1
0
1
Matrice ad.
• Nel caso di liste di adiacenza abbiamo un array
di liste
– c'è una lista per ogni nodo del grafo
– per ogni vertice v, la lista corrispondente
contiene i vertici adiacenti a v
• In una matrice di adiacenza M, l'elemento mij è
1 se c'è un arco dal nodo i al nodo j, 0
altrimenti
• In entrambi i casi, dato un nodo u in un grafo
G, l'attributo u.Adj rappresenta l'insieme di
vertici adiacenti a u
126
Rappresentazione di grafi (3)
• Quanto è grande una rappresentazione con liste
di adiacenza?
– il numero totale di elementi nelle liste è |E|
– il numero di elementi nell'array è |V|
– la complessità spaziale è (|V| + |E|)
• Quanto è grande la matrice di adiacenza?
– la dimensione della matrice è |V|2, quindi la
sua complessità è (|V|2)
• Le liste di adiacenza sono in generale migliori
quando |E|  (|V|2), cioè quando il grafo è
sparso (quando il numero di nodi connessi
“non è tanto grande”)
– si ricordi che |E|  |V|2, cioè |E| = O(|V|2)
• Se il grafo è completo (o quasi), tanto vale
usare una matrice di adiacenza
– un grafo orientato è completo se, per ogni
coppia di nodi u e v, sia l'arco (u, v) che
l'arco (v, u) sono in E
127
Rappresentazione di grafi (4)
• Quale è la complessità temporale per
determinare se un arco (u, v) appartiene al
grafo:
– quando il grafo è rappresentato mediante
liste di adiacenza?
– quando il grafo è rappresentato con una
matrice di adiacenza?
• Quale è la complessità temporale per
determinare il numero di archi che escono da
un nodo u del grafo
– quando il grafo è rappresentato mediante
liste di adiacenza?
– quando il grafo è rappresentato con una
matrice di adiacenza?
128
Rappresentazione di grafi (5)
• Possiamo rappresentare un arco (u, v) di un
grafo non orientato come 2 archi orientati, uno
che va da u a v, ed uno che va da v ad u
• Per esempio:
a
d
c
e
a
b
c
b
c
a
e
c
b
a
d
d
c
e
e
b
d
b
a
a
b
V = {a, b, c, d, e}
E = {(b, a) (a, c) (b, c) (d, c) (e, d) (b, e)}c
d
e
b c
d
e
[ ]
0
1
1
0
0
1
0
1
0
1
1
1
0
1
0
0
0
1
0
1
0
1
0
1
0
– si noti che in questo caso la matrice di adiacenza è
simmetrica, quindi tutta l'informazione che serve per
descrivere il grafo si trova sopra la diagonale
principale
129
Visita in ampiezza (Breadth-First Search) (1)
• Problema:
– input: un grafo G, e un nodo s (la sorgente)
di G
– output: visitare tutti i nodi di G che sono
raggiungibili da s (un nodo u è
raggiungibile da s se c'è un cammino nel
grafo che va da s a u)
• Algoritmo: Breadth-First Search (BFS)
• Idea dell'algoritmo: prima visitiamo tutti i nodi
che sono a distanza 1 da s (cioè che hanno un
cammino di lunghezza 1 da s), quindi visitiamo
quelli a distanza 2, quindi quelli a distanza 3, e
così via
• Quando visitiamo un nodo u, teniamo traccia
della sua distanza da s in un attributo u.dist
130
Visita in ampiezza (Breadth-First Search) (2)
• Inoltre, mentre visitiamo i nodi, li coloriamo
(cioè li marchiamo per tenere traccia della
progressione dell'algoritmo)
– un nodo è bianco se deve essere ancora visitato
– un nodo è grigio se lo abbiamo già visitato, ma
dobbiamo ancora completare la visita dei nodi ad
esso adiacenti
– un nodo è nero dopo che abbiamo visitato tutti i suoi
nodi adiacenti
• L'algortitmo in breve:
– all'inizio tutti i nodi sono bianchi, tranne s (la
sorgente), che è grigio
– manteniamo i nodi di cui dobbiamo ancora visitare i
nodi adiacenti in una coda (che è gestita con politica
FIFO!)
• all'inizio la coda contiene solo s
– a ogni iterazione del ciclo, eliminiamo dalla coda un
elemento u, e ne visitiamo i nodi adiacenti che sono
ancora bianchi (cioè che devono essere ancora
visitati)
• Si noti che, se u.dist è la distanza del nodo u da
s, la distanza dei nodi bianchi adiacenti ad u è
u.dist+1 (a meno che non mettiamo dei pesi agli
archi …)
131
BFS: pseudocodice
BFS(G, s)
1 for each u ∊ G.V – {s}
2
u.color := WHITE
3
u.dist := ∞
4 s.color := GRAY
5 s.dist := 0
6 Q := ∅
7 ENQUEUE(Q, s)
8 while Q ≠ ∅
9
u := DEQUEUE(Q)
10
for each v ∊ u.Adj
11
if v.color = WHITE
12
v.color := GRAY
13
v.dist := u.dist +1
14
ENQUEUE(Q, v)
15
u.color := BLACK
•
•
•
Le linee 1-7 sono la fase di inizializzazione dell'algoritmo (che
ha complessità O(|V|))
Le linee 8-15 sono quelle che effettivamente visitano i nodi;
ogni nodo nel grafo G è accodato (e tolto dalla coda) al
massimo una volta, quindi, nel ciclo for della linea 10, ogni
lato è visitato al massimo una volta; quindi, la complessità del
ciclo while è O(|E|)
La complessità totale di BFS è O(|V| + |E|)
132
Ricerca in profondità (Depth-First Search) (1)
• BFS si basa sull'idea di visitare i nodi con una
politica FIFO (che è realizzata mediante una
coda)
• Come alternativa possiamo usare una politica
LIFO
• Se usiamo una politica LIFO, otteniamo un
algoritmo di visita in profondità (depth-first
search, DFS)
– l'idea in questo caso è che, ogni volta che “mettiamo
un nodo in cima allo stack”, immediatamente
cominciamo a visitare i nodi a lui adiacenti
• cioè continuiamo con la visita dei nodi che sono
adiacenti a quello che da meno tempo è nello
stack
• in BFS non è così: visitiamo i nodi che sono
adiacenti a quello che da più tempo è nella coda
– non è sufficiente, per ottenere un algoritmo di DFS,
cambiare ENQUEUE con PUSH, e DEQUEUE con
POP nell'algoritmo di BFS...
• non appena visitiamo un nodo, dobbiamo
ripetere DFS su di esso...
• DFS è implementato in modo naturale in modo
ricorsivo
133
Ricerca in profondità (Depth-First Search) (2)
• In realtà, l'algoritmo DFS risolve un problema
leggermente diverso da BFS
• Problema risolto dall'algoritmo DFS
– input: un grafo G
– output: visitare tutti i nodi di G
• nell'algoritmo BFS visitiamo solo i nodi
che sono raggiungibili dalla sorgente s!
• DFS è spesso usato come parte (cioè come
sottoalgoritmo) di un algoritmo più complesso
(da cui il problema leggermente diverso da
quello risolto da BFS)
– è spesso usato come “passo preparatorio”
prima di lanciare l'algoritmo “principale”
134
DFS: considerazioni
• Come in BFS, in DFS coloriamo i nodi di bianco, grigio
e nero (con lo stesso significato che in BFS)
• Come detto in precedenza, questa volta usiamo una
politica di visita LIFO, quindi usiamo un meccanismo
analogo a quello dello stack
– in questo caso, il meccanismo a stack viene dal fatto
che l'algoritmo è ricorsivo
• invece di fare push e pop di vertici su uno stack,
facciamo push e pop di chiamate ricorsive
dell'algoritmo sullo stack delle chiamate (vedere
corsi base informatica):
– push = invochiamo l'algoritmo
ricorsivamente
– pop = la chiamata ricorsiva termina
• L'algoritmo DFS tiene traccia di “quando” i nodi sono
messi sullo stack ed anche di “quando” sono tolti da
esso
– c'è una variabile (globale), time, che è messa a 0
nella fase di inizializzazione dell'algoritmo, e che è
incrementata di 1 sia appena dopo un nodo è messo
sullo stack che appena prima di togliere un nodo
dallo stack
– usiamo la variabile time per tenere traccia di 2 altri
valori:
• il “tempo” di quando inizia la “scoperta”
(discovery) di un nodo, ed il “tempo” di quando
la scoperta termina
• l'inizio della scoperta di un nodo u è
memorizzata nell'attributo u.d, mentre la sua fine
nell'attributo u.f
135
DFS: pseudocodice
DFS(G)
1 for each u ∊ G.V
2
u.color := WHITE
3 time := 0
4 for each u ∊ G.V
5
if u.color = WHITE
6
DFS-VISIT(u)
•
in cui l'algoritmo DFS-VISIT è il seguente:
DFS-VISIT(u)
1 u.color := GRAY
2 time := time + 1
3 u.d := time
4 for each v ∊ u.Adj
5
if v.color = WHITE
6
DFS-VISIT(v)
7 u.color := BLACK
8 u.f := time := time + 1
•
•
•
Le linee 1-3 di DFS inizializzano i nodi colorandoli tutti di
bianco, e mette il tempo a 0
– tempo di esecuzione (|V|)
L'algoritmo DFS-VISIT è ripetuto (linee 4-6 di DFS) fino a che
non ci sono più nodi da visitare
– come in BFS, ogni nodo è “messo sullo stack” (che in questo
caso corrisponde ad invocare DFS-VISIT sul nodo) solo
una volta
– quindi, ogni lato è visitato esattamente una volta durante
l'esecuzione del ciclo for delle linee 4-6 di DFS, quindi
136
queste prendono tempo (|E|)
In tutto, la complessità di DFS è (|V| + |E|)
Ordinamento Topologico (1)
• Supponiamo di avere un grafo orientato
aciclico (directed acyclic graph, DAG) che
rappresenta le precedenze tra eventi
• Come questo, per esempio
mutande
calze
orologio
pantaloni
scarpe
camicia
cintura
cravatta
giacca
137
Ordinamento Topologico (2)
• O, se preferite, come questo (che rappresenta
un Network Part Program di un Flexible
Manufacturing System)
begin
O
2
1
4
3
10
9
8
6
5
7
end
138
Ordinamento topologico (3)
• Un ordinamento topologico di un DAG è un
ordinamento lineare dei nodi del grafo tale che, se nel
DAG c'è un arco (u, v), allora il nodo u precede v
nell'ordinamento
• Per esempio, un ordinamento topologico del primo
DAG della slide precedente potrebbe dare il seguente
ordinamento
calze
mutande
pantaloni
scarpe
orologio camicia
cintura cravatta
giacca
– si noti che questo non è l'unico possibile
ordinamento ammissibile...
• Di fatto, un ordinamento topologico restituisce un
ordinamento che rispetta le precedenze tra eventi
– per esempio, nel caso del network part program, un
ordinamento topologico restituisce una sequenza di
operazioni che è compatibile con le precedenze tra
di esse
• cioè tale che, quando eseguiamo Oi, tutte le
operazioni preparatorie necessarie sono state
completate
139
Ordinamento topologico (4)
• Il problema dell'ordinamento topologico di un
DAG è il seguente:
– input: un DAG G
– output: una lista che è un ordinamento
topologico di G
• si ricordi che una lista è un ordinamento
lineare, in cui l'ordine è dato da come gli
oggetti nella lista sono connessi tra loro
• Idea per l'algoritmo:
– visitiamo il DAG con un algoritmo DFS
– quando coloriamo un nodo u di G di nero
(cioè ogni volta che finiamo di visitare un
nodo di G), inseriamo u in testa alla lista
– dopo che abbiamo visitato tutti i nodi di G,
la lista che abbiamo costruito è un
ordinamento topologico di G, e lo
restituiamo
140
Ordinamento topologico: pseudocodice
TOPOLOGICAL-SORT(G)
1 L := ∅
2 for each u ∊ G.V
3
u.color := WHITE
4 for each u ∊ G.V
5
if u.color = WHITE
6
TOPSORT-VISIT(L, u)
7 return L
• in cui TOPSORT-VISIT è:
TOPSORT-VISIT(L, u)
1 u.color := GRAY
2 for each v ∊ u.Adj
3
if v.color = WHITE
4
TOPSORT-VISIT(L, v)
5 crea l'elemento di lista x
6 x.key := u
7 LIST-INSERT(L, x)
8 u.color := BLACK
•
Il tempo di esecuzione di TOPSORT è lo stesso di DFS, cioè
(|V| + |E|)
– le linee 5-7 di TOPSORT-VISIT impiegano tempo (1)
(come le linee 2-3 e 7-8 di DFS-VISIT), ed il resto
dell'algoritmo è come DFS
• tranne la gestione della variabile time, che possiamo
evitare
141
Argomenti Avanzati
142
Programmazione dinamica (1)
(cenni)
• Come la tecnica divide-et-impera si basa
sull'idea di scomporre il problema in
sottoproblemi, risolvere quelli, e ricombinarli
– si applica però quando i problemi non sono
indipendenti, cioè condividono dei
sottoproblemi
– quando si risolve un sottoproblema
comune, si mette la soluzione in una
tabella, per riutilizzarla in seguito
• il termine “programmazione” qui non si
riferisce alla codifica in linguaggi di
programmazione, ma al fatto che è una
tecnica tabulare
• Programmazione dinamica spesso usata per
problemi di ottimizzazione
– la “soluzione” è un ottimo del
sottoproblema
• un problema potrebbe avere più
soluzioni ottime
143
Programmazione dinamica (2)
• Tipici passi nello sviluppo di un algoritmo di
programmazione dinamica:
– caratterizzare la struttura delle soluzioni
ottimali
– definire ricorsivamente il valore di una
soluzione ottimale del problema
– calcolare una soluzione ottimale in modo
bottom-up
• dai sottoproblemi più semplici a quelli
più difficili, fino al problema originario
– costruire una soluzione ottimale del
problema richiesto
144
Problema: taglio delle aste (1)
• Il prezzo di un'asta di acciaio dipende dalla sua
lunghezza
• problema: date delle aste di lunghezza n che
posso tagliare in pezzi più corti, devo trovare il
modo ottimale di tagliare le aste per
massimizzare il ricavo che posso derivare dalla
vendita delle aste
– il ricavo massimo lo potrei avere anche non
tagliando l'asta, e vendendola intera
• Esempio di tabella dei prezzi:
lunghezza i
1
2
3
4
5
6
7
8
9
10
prezzo pi
1
5
8
9
10
17
17
20
24
30
– per esempio, un'asta di lunghezza 4 può
essere tagliata in tutti i modi seguenti (tra
parentesi il prezzo):
[4](9), [1+ 3](9), [2+2](10), [3+1](9),
[1+1+2](7), [1+2+1](7), [2+1+1](7),
[1+1+1+1](4)
• il taglio ottimale in questo caso è unico,
ed è [2,2]
145
Problema: taglio delle aste (2)
• Data un'asta di lunghezza n, ci sono 2n-1 modi
di tagliarla (secondo coordinate intere)
– ho n-1 punti di taglio, se indico con una
sequenza di n-1 0 e 1 la decisione di
tagliare o no ai vari punti di taglio (per
esempio, per un'asta lunghezza 4, la
decisione di non tagliare è data da 000; la
decisione di tagliare solo a metà è data da
010, ecc.), ogni sequenza corrisponde ad un
numero binario, e con n-1 cifre binarie
posso rappresentare fino a 2n-1 valori
• Chiamiamo rn il ricavo massimo ottenibile dal
taglio di un'asta di lunghezza n
– per esempio, dati i prezzi della tabella di
cui sopra abbiamo r4 = 10, mentre r10 = 30
(derivante da nessun taglio)
146
Sottostruttura ottima (1)
• Per un qualunque n, la forma di rn è del tipo
ri+rn-i
– a meno che l'ottimo preveda di non tagliare
l'asta; in questo caso abbiamo rn = pn, il
prezzo dell'asta intera
– in altre parole,
rn = max(pn, r1+rn-1, r2+rn-2, ..., rn-1+r1)
• Quindi, l'ottimo è dato dalla somma dei ricavi
ottimi derivanti dalle 2 semiaste ottenute
tagliando l'asta in 2
– l'ottimo incorpora cioè i 2 ottimi delle
soluzioni dei 2 sottoproblemi
– notiamo che per forza è così: se non fosse
vero che ri ed rn-i sono gli ottimi dei
rispettivi sottoproblemi, allora, sostituendo
per esempio ad ri una soluzione ottima del
taglio di un'asta di lunghezza i otterremmo
un ricavo totale > rn, che non potrebbe
essere più ottimo
147
Sottostruttura ottima (2)
• Quando la soluzione di un problema
incorpora le soluzioni ottime dei suoi
sottoproblemi, che si possono risolvere
indipendentemente, diciamo che il
problema ha una sottostruttura ottima
• Riformulando l'espressione dell'ottimo rn,
inoltre, possiamo fare dipendere rn dall'ottimo
di un solo sottoproblema:
– rn = prezzo del primo pezzo tagliato +
taglio ottimo della restante asta, cioè
rn = pi + rn-i
• ciò vale anche nel caso particolare in cui
l'asta non va tagliata; in questo caso è rn
= pn + r0, con r0 = 0
• Quindi, rn = max1≤i≤n(pi+rn-i)
148
Algoritmo ricorsivo (1)
• Applicando l'espressione ricorsiva appena vista
della soluzione del problema del taglio delle
aste otteniamo il seguente algoritmo
CUT-ROD(p,n)
1 if n = 0
2
return 0
3 q := -∞
4 for i := 1 to n
5
q = max(q, p[i]+CUT-ROD(p,n-i))
6 return q
n 1
 
T j
• Tempo di esecuzione: T(n) = 1 +
– cioè T(n) = 2n
j=0
• lo si può vedere sostituendo la soluzione
nella ricorrenza
149
Algoritmo ricorsivo (2)
• Tempo di esecuzione: 2n
• Il tempo di esecuzione è così alto perché gli
stessi problemi vengono risolti più e più volte:
4
3
1
2
1
1
0
0
0
2
1
0
0
0
0
150
Algoritmo di programmazione dinamica (1)
• Usando un po' di memoria extra, si riesce a
migliorare di molto il tempo di esecuzione
– addirittura diventa polinomiale
– trade-off spazio-temporale: aumento la
complessità spaziale, riducendo quella
temporale
• Idea: memorizzo il risultato dei sottoproblemi
già calcolati, e quando li reincontro, invece di
ricalcolarli, mi limito ad andare a prendere il
risultato dalla tabella
– risolvo ogni problema distinto una volta
sola
– il costo diventa polinomiale se il numero di
problemi distinti da risolvere è polinomiale,
e la risoluzione dei singoli problemi
richiede tempo polinomiale
151
Algoritmo di programmazione dinamica (2)
• 2 tecniche per implementare la
programmazione dinamica: un metodo topdown, ed uno bottom-up
• Nel metodo top-down, comincio a risolvere il
problema di dimensione n, e ricorsivamente
vado a risolvere i sottoproblemi via via più
piccoli; aumento però l'insieme dei parametri
passati con una tabella nella quale memorizzo i
risultati già calcolati
– prima di lanciare la ricorsione sul problema
più piccolo, controllo nella tabella se non
ho già calcolato la soluzione
– questa tecnica va sotto il nome di
memo-ization
• Nel metodo bottom-up, parto dai problemi più
piccoli, e li risolvo andando in ordine crescente
di dimensione; quando arrivo a risolvere un
problema di dimensione i, ho già risolto tutti i
problemi di dimensioni < i
152
Versione memo-ized di CUT-ROD
MEMOIZED-CUT-ROD(p, n)
1 crea un nuovo array r[0..n]
2 for i := 0 to n
3
r[i] := -∞
4 return MEMOIZED-CUT-ROD-AUX(p,n,r)
MEMOIZED-CUT-ROD-AUX(p,n,r)
1 if r[n] ≥ 0
2
return r[n]
3 if n = 0
4
q := 0
5 else q := -∞
6
for i := 1 to n
7
q = max(q,
p[i]+MEMOIZED-CUT-ROD-AUX(p,n-i,r))
8 r[n] = q
9 return q
153
Versione bottom-up di CUT-ROD
BOTTOM-UP-CUT-ROD(p, n)
1 crea un nuovo array r[0..n]
2 r[0] := 0
3 for j := 1 to n
4
q := -∞
5
for i := 1 to j
6
q := max(q,p[i]+r[j-i])
7
r[j] := q
8 return r[n]
• BOTTOM-UP-CUT-ROD è facile vedere che
ha complessità T(n) = Θ(n2) per i 2 cicli
annidati
• Anche MEMOIZED-CUT-ROD ha complessità
T(n) = Θ(n2), in quanto ogni sottoproblema è
risolto da MEMOIZED-CUT-ROD una volta
sola, ed il ciclo 6-7 fa n iterazioni per risolvere
un problema di dimensione n
– quindi si fanno in tutto n + (n-1) + (n-2) +
... + 1 iterazioni
154
Complessità di CUT-ROD con prog. din. (1)
• Gli algoritmi CUT-ROD visti fino ad ora
restituiscono il massimo ricavo, ma non il
modo in cui l'asta va tagliata
• Modifichiamo BOTTOM-UP-CUT-ROD per
tenere traccia non solo del massimo, ma del
modo di effettuare il taglio
EXTENDED-BOTTOM-UP-CUT-ROD(p, n)
1 crea 2 nuovi array r[0..n] e s[0..n]
2 r[0] := 0
3 for j := 1 to n
4
q := -∞
5
for i := 1 to j
6
if q < p[i]+r[j-i]
7
q = p[i]+r[j-i]
8
s[j] = i
9
r[j] := q
10 return (r,s)
– s[j] mi dice quale è la lunghezza del
primo pezzo nel taglio ottimale di un'asta di
lunghezza j
155
Complessità di CUT-ROD con prog. din. (2)
Esempio di risultato di un'esecuzione:
i
0
1
2
3
4
5
6
7
8
9
10
pi
0
1
5
8
9
10
17
17
20
24
30
r[i]
0
1
5
8
10
13
17
18
22
25
30
s[i]
0
1
2
3
2
2
6
1
2
3
10
Per stampare il taglio che mi dà il ricavo massimo
uso il seguente algoritmo
PRINT-CUT-ROD-SOLUTION(p, n)
1 (r,s) := EXTENDED-BOTTOM-UP-CUT-ROD(p,n)
2 while n > 0
3
print s[n]
4
n := n - s[n]
156
Algoritmi golosi (1)
• Per quanto con la programmazione dinamica
un sottoproblema non venga risolto più di una
volta, comunque occorre analizzare diverse
soluzioni per decidere quale è l'ottimo
• A volte però non serve provare tutte le
soluzioni: è dimostrabile che una sola può
essere quella ottima
• Questo è esattamente quel che succede negli
algoritmi “golosi” (greedy)
157
Algoritmi golosi (2)
• Il problema della scelta delle attività:
– n attività a1,a2,..,an usano la stessa risorsa
• es: lezioni da tenere in una stessa aula
– Ogni attività ai ha un tempo di inizio si ed
un tempo di fine fi con si < fi
– ai occupa la risorsa nell’intervallo
temporale semiaperto [si, fi)
– ai ed aj sono compatibili se [si, fi) e [sj, fj)
sono disgiunti
– voglio scegliere il massimo numero di
attività compatibili
• supponiamo che le attività a1,a2,..,an
siano ordinate per tempo di fine non
decrescente f1 ≤ f2 ≤ ... ≤ fn altrimenti le
ordiniamo in tempo O(n log n)
• Esempio
i
1
2
3
4
5
6
7
8
9
10
11
si
1
3
0
5
3
5
6
8
8
2
12
fi
4
5
6
7
8
9
10
11
12
13
14
– insieme di attività compatibili: {a3, a9, a11}
– massimo numero di attività compatibili: 4
• un esempio: {a2, a4, a9, a11}
158
Soluzione del problema (1)
• Possiamo risolvere il problema con un
algoritmo di programmazione dinamica
• Definiamo Sij come l'insieme delle attività che
iniziano dopo la fine di ai e terminano prima
dell'inizio di aj
– quindi che sono compatibili con ai (e quelle
che non terminano dopo ai) e con aj (e
quelle che iniziano non prima di aj)
– Sij = {at ∈ S : fi ≤ st < ft ≤ sj }
• Chiamiamo Aij un insieme massimo di attività
compatibili in Sij
– supponiamo che ak sia una attività di Aij,
allora Aij è della forma:
Aij = Aik  {ak}  Akj
• è fatto dell'ottimo del sottoproblema Sik
più l'ottimo del sottoproblema Akj
– se così non fosse, allora potrei trovare
un insieme di attività più grande di Aik
(resp. Akj), in Sik, il che vorrebbe dire
che Aij non è un ottimo di Sij (assurdo)
• questa è dunque una sottostruttura
ottima
159
Soluzione del problema (2)
• Memorizziamo in una tabella c la dimensione
dell'ottimo del problema Aij, cioè c[i, j] = |Aij|
• Allora abbiamo che c[i, j] = c[i, k] + c[k, j] + 1
• Se non sappiamo che la soluzione ottima include
l'attività ak, dobbiamo provare tutte le attività in
Sij, cioè
c[i, j] = 0
se Sij = 
= maxak Sij {c[i, k] + c[k, j] + 1} se Sij ≠ 
– esercizio per casa: scrivere gli algoritmi di
programmazione dinamica con memoization e
con tecnica bottom-up
160
Algoritmo goloso
• E' inutile però provarle tutte per risolvere il problema
Sij, è sufficiente prendere l'attività a1 che finisce per
prima in Sij, e risolvere il problema Skj, con k prima
attività in Sij che inizia dopo la fine di a1
– se chiamiamo Sk l'insieme di tutte le attività che
iniziano dopo la fine di ak, cioè Sk = {at ∈ S : fk ≤ st},
dopo che abbiamo preso a1, ci rimane da risolvere il
solo problema S1
• Abbiamo il seguente risultato:
dato un sottoproblema Sk, se am è l'attività che finisce
per prima in Sk, am è inclusa in qualche sottoinsieme
massimo di attività mutuamente compatibili di Sk
– supponiamo che Ak sia un sottoinsieme massimo di
Sk, e chiamiamo aj l'attività che finisce per prima in
Ak; allora o aj = am, oppure fm ≤ fj, e se sostituisco aj
con am in Ak ho ancora un sottoinsieme massimo A'k
di Sk
• Quindi, per risolvere il problema di ottimizzazione mi
basta ogni volta scegliere l'attività che finisce prima,
quindi ripetere l'operazione sulle operazioni che
iniziano dopo quella scelta
161
Pseudocodice (1)
• Versione ricorsiva
– s e f sono array con, rispettivamente, i
tempi di inizio e di fine delle attività
– k è l'indice del sottoproblema Sk da
risolvere (cioè l'indice dell'ultima attività
scelta
– n è la dimensione (numero di attività) del
problema originario
RECURSIVE-ACTIVITY-SELECTOR(s,f,k,n)
1 m := k + 1
2 while m ≤ n and s[m] < f[k]
3
m := m + 1
4 if m ≤ n
5 return {am} ∪
RECURSIVE-ACTIVITY-SELECTOR(s,f,m,n)
6 else return ∅
162
Pseudocodice (2)
• Versione iterativa:
GREEDY-ACTIVITY-SELECTOR(s,f)
1 n := s.length
2 A := {a1}
3 k := 1
4 for m := 2 to n
5
if s[m] ≥ f[k]
6
A := A ∪ {am}
7
k := m
8 return A
– entrambe hanno complessità Θ(n), in
quanto considerano ogni attività una volta
sola
163
Alcune domande (e risposte) finali (1)
(con uno sguardo all’indietro)
• Esiste una sorta di “classe universale di
complessità”
– cioè esiste una qualche funzione di
complessità T(n) tale che tutti i
problemi risolvibili impiegano al più
T(n)?
• Il nondeterminismo può cambiare la
complessità di soluzione dei problemi?
– in primis, come si definisce la
complessità di un modello
nondeterministico?
164
Alcune domande (e risposte) finali (2)
• Cominciamo con alcune definizioni/richiami,
per fissare le idee
• Data una funzione T(n), indichiamo con
DTIME(T) l'insieme dei problemi tali che
esiste un algoritmo che li risolve in tempo T(n)
• Più precisamente:
– problema = riconoscimento di un
linguaggio
• per semplicità, consideriamo i linguaggi
ricorsivi
– algoritmo = macchina di Turing
– (Ma quando T è un polinomio …)
• Riformulando: DTIME(T) (risp. DSPACE(T))
è la classe (l'insieme) dei linguaggi (ricorsivi)
riconoscibili in tempo (risp. spazio) T mediante
macchine di Turing deterministiche a k nastri
di memoria
165
Alcune domande (e risposte) finali (3)
• Un primo risultato: data una funzione totale e
computabile T(n), esiste un linguaggio
ricorsivo che non è in DTIME(T)
– c'è quindi una gerarchia di linguaggi
(problemi) definita sulla base della
complessità temporale deterministica
• una cosa analoga vale per DSPACE, e
per le computazioni nondeterministiche
(NTIME ed NSPACE)
• Lo schema di dimostrazione ricalca
quello dell’indecidibilità del problema
della terminazione del calcolo
• a proposito...
166
Computazioni nondeterministiche
(richiami)
• Data una macchina di Turing nondeterministica M,
definiamo la sua complessità temporale TM(x) per
riconoscere la stringa x come la lunghezza della
computazione più breve tra tutte quelle che accettano x
– TM(n) poi è (nel caso pessimo) il massimo tra tutti i
TM(x) con
|x| = n
• Quindi NTIME(T) è la classe (l'insieme) dei linguaggi
(ricorsivi) riconoscibili in tempo T mediante macchine
di Turing nondeterministiche a k nastri di memoria
• Tantissimi problemi si risolvono in modo molto naturale
mediante meccanismi nondeterministici (per esempio,
trovare un cammino in un grafo che tocca tutti i nodi)...
• ... però i meccanismi di computazione reali sono
deterministici
• se riuscissimo a trovare una maniera “poco onerosa” per
passare da una formulazione nondeterminisitca ad una
deterministica, tantissimi problemi interessanti
potrebbero essere risolti (in pratica) in modo
(teoricamente) efficiente
– però spesso abbiamo notato una “esplosione” nel
passaggio da un meccanismo ND ad uno D (quando
i 2 meccanismi sono equipotenti, come è peraltro il
caso delle MT)
• per esempio, esplosione del numero degli stati
nel passare da NDFSA a DFSA
167
Relazione tra DTIME e NTIME (1)
• Sarebbe utile poter determinare, date certe
interessanti famiglie di funzioni di
complessità, se la classe dei problemi
risolvibili non cambia nel passare da
computazioni deterministiche a quelle
nondeterministiche
– in altri termini, se DTIME(ℱ) = NTIME(ℱ)
per certe famiglie ℱ={Ti}, di funzioni
– Ad esempio ….
• Una fondamentale classe di problemi:
P = ∪i≥1 DTIME(ni)
– convenzionalmente, questi sono considerati
i problemi “trattabili”
• Similmente:
NP = ∪i≥1 NTIME(ni)
• Altre classi interessanti di problemi: PSPACE,
NPSPACE, EXPTIME, NEXPTIME,
EXPSPACE, NEXPSPACE
168
Relazione tra DTIME e NTIME (2)
• LA domanda: P = NP?
– boh...
– probabilmente no, ma non si è ancora
riusciti a dimostrarlo
• Più in generale:
LogSpace  P  NP  PSPACE;
LogSpace  PSPACE
Ma …
• Alcuni esempi di problemi della classe NP:
– Soddisfacibilità di formule di logica
proposizionale (SAT): data una formula F
di logica proposizionale, esiste un
assegnamento dei valori alle lettere
proposizionali che compaiono in F tale che
F è vera?
• detto in altro modo: F ammette un
modello?
– Circuito hamiltoniano (HC): dato un grafo
G, esiste un cammino in G tale che tutti i
nodi del grafo sono toccati una ed una sola
volta prima di tornare al nodo di partenza?
169
Riduzione in tempo polinomiale e completezza (1)
• Un linguaggio (problema) L1 è riducibile in
tempo polinomiale ad un altro linguaggio L2 se
e solo se esiste una MT deterministica
(traduttrice) con complessità in P che per ogni
x produce una stringa τ(x) tale che τ(x)  L2 se
e solo se x  L1
• Se ℒ è una classe di linguaggi, diciamo che un
linguaggio L (che non è detto che debba essere
in ℒ) è ℒ-difficile rispetto alle riduzioni in
tempo polinomiale se e solo se, per ogni
L' ℒ, L' è riducibile in tempo polinomiale a L
– cioè se risolvere L (determinare se una
stringa x appartiene ad L o no) è almeno
tanto difficile quanto risolvere un
qualunque linguaggio in ℒ
• Un linguaggio L è ℒ-completo se è ℒ-difficile
ed è in ℒ
• se si trovasse un problema NP-completo che è
risolvibile in tempo polinomiale, allora
avremmo P = NP
• dualmente, se si trovasse un problema NPcompleto che non è risolvibile in tempo
polinomiale, allora avremmo P  NP
170
Riduzione in tempo polinomiale e completezza (2)
• SAT è NP-difficile
– quindi è NP-completo
– si mostra codificando le computazioni di
una generica MT nondeterministica M (con
complessità polinomiale) in SAT, in modo
che M accetti una stringa x se e solo se una
opportuna formula s è soddisfacibile
• HC è anch'esso NP-difficile (e NP-completo)
– NP-completezza di HC si mostra riducendo
SAT a HC
• tantissimi altri problemi sono NP-completi...
• Oggigiorno però …
• … NP-completezza non è più sinonimo di
“intrattabilità pratica”
• Il giochino di ridurre un problema a SAT
(magari “finitizzandolo”) è molto di moda …
• Si apre una nuova frontiera pratico  teorica
• ….
171
SAT è NP-difficile
• Come ridurre un generico problema NP a
SAT in tempo polinomiale
(deterministicamente)
• L’idea base tutto sommato non è
particoramente complicata (come tutte le
grandi idee …):
• Fornire una computazione deterministica che,
per ogni MT non deterministica M con
complessità polinomiale p e per ogni stringa
x sull’alfabeto di M, tale che |x| = n, produca
come uscita una formula proposizionale W, in
tempo polinomiale p’(n), tale che W sia
soddisfacibile se e solo se x  L(M).
172
• Se la MT ha complessità p(n), la formula
complessiva proposizionale ha dimensione
p2(n) e la traduzione da MT a formule
proposizionali avviene in tempo pure
polinomiale.
• Il nocciolo della dimostrazione deriva
dall’osservazione che, se x  L(M), allora
esiste una sequenza di mosse, la cui
lunghezza non eccede p(n), che conduce ad
uno stato di accettazione. Quindi, al più p(n)
+ 1 celle di memoria possono essere
utilizzate da M durante una simile
computazione. Una volta compreso che una
configurazione di una MT ha uno spazio
limitato, la si può descrivere mediante
un’opportuna formula proposizionale.
• Ad esempio, una variabile logica Qt,k può
stabilire se, all’istante t, lo stato di M sia qk.
Un’altra variabile, Ct,i,k, può stabilire se,
all’istante t, la cella i-esima contenga il
simbolo ak e così via. Inoltre, opportuni
connettivi logici possono imporre che,
all’istante t = 0, la formula descriva una
configurazione iniziale, che la configurazione
all’istante t + 1 derivi dalla configurazione
all’istante t e che, all’istante t = p(n), M si
trovi in uno stato di accettazione.
173
Traduzione in SAT di una generica MT ND a
complessità P(n)
• M a nastro singolo, con nastro lmitato a
sinistra senza perdita di generalità. Perché ?
• La formula W viene costruita come
congiunzione ( logico) di diverse
sottoformule, o clausole, secondo le seguenti
regole. Sia t la variabile tempo, con
0  t  p(n), e sia i la posizione di una cella di
memoria, 0  i  p(n).
174
1. Descrizione della configurazione di M.
Si definiscono i seguenti insiemi di variabili
logiche.
{Qt,k| 0  t  p(n), 0  k  |Q| – 1}
Qt,k risulterà vera se e solo se M si troverà nello
stato qk all’istante t.
{Ht,i| 0  t  p(n), 0  i  p(n)}
Ht,i risulterà vera se e solo se la testina M si
troverà nella posizione i all’istante t.
{Ct,i,h| 0  t  p(n), 0  i  p(n), 0  h  |A| – 1,
dove A è l’alfabeto completo di M}
Ct,i,h risulterà vera se e solo se, all’istante t, la
cella i-esima conterrà il simbolo ah.
Abbiamo in questo modo definito un numero
g (n )  (p(n )  1) | Q | (p(n )  1) 2  (p(n )  1) 2 | A |
di variabili logiche, dove g è una funzione
(p2). In breve, diremo che si sono definite
(p2(n)) variabili.
Chiaramente, in ogni istante, M deve trovarsi
esattamente in uno stato, la sua testina deve
essere esattamente sopra una cella e ciascuna
cella deve contenere esattamente un simbolo.
In questo modo si ottiene un primo gruppo di
clausole (clausole di configurazione, CC) che
le variabili logiche devono soddisfare per
descrivere le configurazioni di M.
175
Clausole di configurazione (CC) (1):


  Qt ,k 

0t  p ( n )  0 k |Q|1

{M deve trovarsi in almeno uno stato alla volta}


0t  p ( n )
0 k1  k 2 |Q|1
Q
t , k1
 Qt ,k2

{Ad ogni istante M non può trovarsi in due stati
diversi}



  H t ,i 

0t  p ( n )  0i  p ( n )


 H
0t  p ( n )
0i , j  p ( n )
i j
t ,i
 H t , j 
{La testina di M si trova esattamente su di una
cella alla volta}
176
Clausole di configurazione (CC) (2):


  Ct ,i ,h 

0  t  p ( n )  0  h | A|1

0i  p ( n )

 C
0 t  p ( n )
0i  p ( n )
0 h , k | A|1
h k
t ,i , h
 Ct ,i ,k 

{In ogni istante, ciascuna cella contiene esattamente
un simbolo}
Riassumendo, CC contiene il seguente numero di
clausole:
(p(n) + 1)  |Q| + (p(n) + 1)  |Q|  (|Q| 1) +
+ (p(n) + 1)2 + (p(n) + 1)2  p(n) +
+ (p(n) + 1)2  |A| + (p(n) + 1)2  |A|  (|A| 1)
dove ciascuna clausola è l’“or” logico di un numero
limitato a priori di letterali, e ciascun letterale è o
una variabile logica o la sua negazione. Quindi, CC
contiene (p3(n)) clausole e  (p3(n)) letterali (si
ricordi che |Q| e |A| sono costanti).
177
Descrizione della configurazione iniziale.
All’istante t = 0, M deve trovarsi in q0, la sua testina
deve trovarsi nella posizione 0 e x = ak0ak1...ak(n – 1)
deve essere immagazzinata nelle prime n celle del
nastro. Tutte le restanti celle devono risultare vuote.
Ciò produce un secondo gruppo di clausole
(Clausole della configurazione iniziale, IC), qui
elencate.
Q0,0  H 0,0  C0,0,k0    C0,n1,kn1

n i  p ( n )
C0 ,i , 0
(si suppone che il blank sia a0)
IC ha (p(n)) letterali.
178
Descrizione della configurazione di accettazione.
Si adotti la convenzione per la quale, se M si ferma
all’istante tH < p(n), allora mantiene la
configurazione finale per tutti gli istanti t,
tH  t  p(n). Questa convenzione provoca
conseguenze sulle transizioni della macchina che
verranno descritte fra poco, ma consente di stabilire
che, all’istante t = p(n), M deve trovarsi in uno stato
di accettazione. Ciò produce un’altra clausola
(Clausola di accettazione, AC) :
Q p ( n ),i1   Q p ( n ),iS
dove {qi1,...,qis} = F
AC ha (1) letterali.
179
Descrizione della relazione di transizione.(1)
La funzione di transizione sia del tipo
δ(qk,sh) = {qk,sh,N}, con la convenzione che, ogni
volta che la δ originale di M risulta indefinita per
qualche qk e per qualche sh, si pone δ(qk,sh) =
{qk,sh,S}. Indichiamo con m la cardinalità di
{qk,sh,N}.
Per ogni t e per ogni i, se M si trova nello stato qk, se la sua testina
è nella posizione i e se sta leggendo sh, allora, all’istante t + 1,
M si trova, in modo mutuamente esclusivo, in una
configurazione in cui lo stato è qk’, la posizione i memorizza
sh’ e la sua testina si trova nella posizione i, oppure nella i + 1,
oppure nella i – 1, in dipendenza da N, se e solo se la terna
qk,sh,N appartiene a δ(qk, sh).
 Q
0t  p ( n )
0i  p ( n )
0 k |Q|1
0 h | A|1
t ,k
 H t ,i  Ct ,i ,h   Qt 1,k '  H t 1,i '  Ct 1,i ,h ' 

Qt 1,k ''  H t 1,i''  Ct 1,i,h'' 

...

Qt 1,k m  H t 1,im  Ct 1,i,hm 
 qk ' , sh ' , N ' ,  qk '' , sh '' , N ' ' ,..., qk m , sh m , N m   d (qk , sh )
180
Trasformata in forma disgiuntiva …
… pesantuccio ma utile …
 Q
0t  p ( n )
0i  p ( n )
0 k |Q|1
0 h| A|1
Q
t ,k
Q
t ,k

t ,k
 H t ,i  Ct ,i ,h  Qt 1,k '    Qt 1,k m  
 H t ,i  Ct ,i,h  Qt 1,k '    Qt 1,k m1  H t 1,hm  
 H t ,i  Ct ,i,h  Qt 1,k '    H t 1,hm1  Qt 1,k m  
… (vengono considerate tutte le congiunzioni formate
dalle possibili disgiunzioni di Qt+1, k, Ht+1,h e Ct+1,i,h,
prendendo uno e un solo elemento da ciascun
termine in “or esclusivo”, e vengono combinate con
l’antecedente)
181
Descrizione della relazione di transizione.(2)
Se, all’istante t, la testina si trova nella posizione i,
allora, all’istante t + 1, tutte le celle, esclusa la iesima, hanno lo stesso contenuto che avevano
all’istante t.
 H
0 t  p ( n )
0 i  p ( n )
0 k |Q|1
0 h | A|1
t ,i
 Ct ,i ,h   Ct 1,i ,h
che, ancora, si può riformulare nel modo seguente:
H t ,i  Ct ,i ,h  Ct 1,i ,h '
182
L’insieme TC di clausole relative alla transizione è
definito come la congiunzione delle formule
derivate in 4.1 e 4.2. TC ha (p2(n)) letterali
(si noti che il numero di ‘’ nelle clausole 4.1 è
limitato a priori perché così è la cardinalità degli
insiemi {qk,sh,N} e, di conseguenza, il numero
totale di clausole viene moltiplicato nella
trasformazione in forma normale congiuntiva per un
termine che dipende – pur esponenzialmente – solo
da m).
Si pone, infine, W = CC  IC  AC  TC, che
significa che W è la congiunzione di tutte le
clausole che appartengono agli insiemi precedenti.
W ha quindi (p3(n)) letterali.
183
Osservazioni conclusive
C0 ⊢ CA, dove C0 è la configurazione iniziale e CA è
una configurazione di accettazione per M, se e solo
se W risulta soddisfacibile.
Una formula proposizionale che contiene n occorrenze
di variabili logiche si può codificare come una
stringa di lunghezza (n⋅log n). Quindi, per ogni x
di lunghezza n, la sua traduzione τ(x) in una stringa
che codifica W è certamente non più lunga di
(p4(n)). Una volta compreso che la traduzione τ
può essere eseguita da una MT deterministica
(anche multinastro) in un tempo (|τ(x)|), la
riducibilità in tempo polinomiale è completamente
dimostrata.
Non bisogna effettivamente conoscere una MT M che
risolva il problema originale. L’esistenza di tale
macchina garantisce l’esistenza di una macchina
riducente in tempo polinomiale. Tuttavia, se si
conosce una simile macchina M e se è noto un
limite di tempo polinomiale p per la sua
computazione nondeterministica, allora la
dimostrazione consente effettivamente di costruire
la macchina che esegue la riduzione dei problemi.
Corollario
Il problema di stabilire la soddisfacibilità delle formule
proposizionali in forma normale congiuntiva è NP 184
completo.
Il problema del cammino Hamiltoniano (HC)
è NP-difficile
Ridurremo SAT a HC (non viceversa!): SAT ha fatto da
“apripista” verso la NP-completezza: la sua natura
riflette al meglio la generalità del concetto.
Però vale anche il viceversa.
Ne è quindi una naturale conseguenza il fatto che esso
possa essere ridotto in modo abbastanza “naturale” a
moltissimi altri problemi implicandone la NPdifficoltà (e completezza se …)
Tutto ciò sottolinea la “natura forte” dell’NPcompletezza che in un certo senso ripropone
all’interno della categoria dei problemi decidibili,
l’approccio che ha portato alla semidecidibilità, ossia:
“non so fare di meglio che enumerare le possibili
soluzioni del problema (finite, infinite, esponenziali)
nella dimensione del problema, … e verificare se
effettivamente sono tali.”
Tornando a SAT  HC sfrutteremo il corollario
precedente, ossia partiremo da una versione di SAT in
forma normale:
Per ogni formula proposizionale W in forma normale
congiuntiva, costruiremo un grafo G che ammette un
HC se e solo se W risulta soddisfacibile.
185
Intuizione ed esempio
Il procedimento consiste nel costruire G come
aggregazione di due classi di “pezzi”. I pezzi del
primo tipo saranno associati a ciascuna variabile
logica in W e, in un certo senso, ne mostreranno
tutte le occorrenze. I pezzi del secondo tipo saranno
associati a ciascuna clausola di W e legati ai nodi
che appartengono ai pezzi del primo tipo, in modo
tale che il loro attraversamento garantisca la verità
della clausola associata.
186
W: A1  ( A1  A2)
NB: L21 = A1
187
Più in generale:
188
• pi è il massimo numero fra il numero di occorrenze
di Ai e quelle di Ai in W
• Per ogni r, se Ljr è Ajr, si connetta il primo nodo Fjr,s
di GAjr avente solo due archi uscenti a Ljr, e L jr a
Tjr,s+1. Viceversa se Ljr è Ajr.
(possibilie poiché pi è maggiore o uguale al numero
di occorrenze di Ai in W.)
• Se i GCj vengono ignorati, vi sono esattamente 2m
HC nel grafo restante, . Ciò corrisponde al fatto che,
se W è vuota ogni assegnamento di valori di verità,
banalmente, la soddisfa.
• Un possibile HC entrante in qualche GCj da Ljr deve
lasciarlo da L. jr . Ad esempio, un cammino che
entra in GCj da Lj2, visitando L j 2 , L j1 e
lasciando infine GCj, renderebbe Lj1 inaccessibile.
Ecc. …
• Ciò non implica che un eventuale HC debba visitare
tutti i nodi di ciascun GCj consecutivamente.
189
• Ogni possibile HC deve essere ottenuto da un HC di
sostituendo qualche arco del tipo Fi,s, Ti,s+1 con un
cammino che passa attraverso qualche GCj, se HC
contiene l’arco IAi, Tj0. Viceversa, se HC contiene
l’arco IAi, Fi0 , esso deve essere ottenuto da un HC
di , sostituendo qualche arco del tipo  Ti,s, Fi,s+1 
con un cammino che passa attraverso qualche GCj.
• Ergo, si può entrare in ogni GCj attraverso qualche
Ljr mediante un HC, solo da qualche Tjr,s se  IAjr,
Fjr,0  è in HC e solo da qualche Fjr,s se  IAjr, Tjr,0  è
in HC:
entrare in GCj da qualche Fjr,s significa soddisfarlo
supponendo Ajr = T.
…
190
Riassumendo
• I modelli
• Il calcolo
• L’analisi e la sintesi di algoritmi
• Inventati da capire e analizzare
• Inventati da “catalogare”
• Da “inventare” … o soltanto da
• Applicare/adattare
191
Riassumendo:
a voi la parola
(grazie per la pazienza)
192