Progetto di algoritmi sequenziali (un solo “esecutore”)
• Divide et Impera
“Per regnare occorre tenere divisi i nemici
e trarne vantaggio”
• Greedy
“fai ad ogni passo la scelta più conveniente”
Buoni risultati immediati possono dare
buoni risultati anche a lungo termine
• Backtrack
“Prova a fare qualche cosa; se non funziona,
disfala e prova qualcos’altro”
• Programmazione dinamica
Filosofia Divide et Impera portata all’estremo: se non si sa quali
sottoproblemi risolvere, si può provare a risolverli tutti e conservare i
risultati per poterli usare successivamente per risolvere il problema di
dimensioni più grandi
“Pianifica il metodo di soluzione del problema
in modo che sia applicabile dinamicamente
a dati di dimensione via via crescente”
• Ricerca locale
“Non guardare più in là del proprio orto”
Algoritmi di approssimazione
Per molti problemi di ottimizzazione c’è poca speranza di
progettare un algoritmo che trovi la soluzione ottima con
complessità polinomiale.
In tali casi, comunque, ci si può spesso accontentare di una
soluzione che sia “abbastanza vicina” a quella ottima.
Algoritmi probabilistici
La computazione dipende dai valori prodotti da un generatore di
numeri casuali
Algoritmi euristici
Forniscono una soluzione ammissibile, non necessariamente
ottima nè approssimata. Spesso basati su tecniche greedy o di
ricerca locale
Algoritmi paralleli (più di un “esecutore”)
Divide et impera (Divide and Conquer)
Dividi il problema in sottoproblemi piu` semplici e
risolvili ricorsivamente
Divide et impera - Schema generale
Divide-et-impera (P, n)
if n ≤ k then “risolvi direttamente”
else “dividi P in sottoproblemi P1, P2,…,Ph
di dimensione n1, n2,…, nh risp.”
for i ← 1 to h do
Divide-et-impera (Pi, ni)
“combina i risultati di P 1,…, Ph
per ottenere quello di P”
Divide et impera - Complessità
Il costo dell’algoritmo è di solito costante per i casi in cui la
soluzione viene costruita direttamente; per i casi in cui non è
immediata, il costo può essere espresso come somma di h + 2 costi:
• il costo di dividere il problema nei sottoproblemi
• il costo di combinare i risultati dei sottoproblemi per ottenere
il risultato del problema
• gli h costi delle soluzioni dei sottoproblemi
T(n) = c
= D(n) + C(n) + Σi=1..h T(ni)
n≤k
n>k
Problema: è relativamente semplice esprimere la complessità con
una relazione di ricorrenza, è però piuttosto complicato
risolvere la relazione di ricorrenza per trovare la
complessità asintotica.
Ci sono tre modi per trovare la soluzione:
• metodo di sostituzione
• metodo iterativo
• metodo principale
Il metodo di sostituzione
• Ipotizza un limite asintotico
• verifica l’ipotesi con una dimostrazione per induzione
esempio:
T(n) = 4T(n/2) + n
possiamo provare T(n) = O(n3)
mostriamo che esistono c ed n0 :
T(n) ≤ c n3 per tutti gli n ≥ n0
Supponendo T(k) ≤ c k3 per tutti i k < n, si ottiene
T(n) = 4T(n/2) + n
≤ 4c(n/2)3 + n
= cn3 - (c/2 n3 - n)
≤ cn3 per c ≥ 2 e n ≥ 1
Anche T(1) deve essere ≤ c 13
un tentativo migliore: T(n) = O(n2)
Cerchiamo di dimostrare che T(n) ≤ c n2 per tutti gli n ≥ n0
Supponendo T(k) ≤ c k2 per tutti i k < n, si ottiene
T(n) = 4T(n/2) + n
≤ 4c(n/2)2 + n
= cn2 + n
≤ cn2
per nessun c ≥ 0
Dobbiamo modificare l’ipotesi:
T(n) ≤ c n2 - d n, d ≥ 1
T(n) = 4T(n/2) + n
≤ 4 (c(n/2)2 - d(n/2)) + n
= cn2 - 2dn + n = cn2 - dn - (dn - n)
Si può scegliere d = 1.
≤ cn2 - dn
Bisogna fare attenzione alle condizioni al contorno.
Non è sempre scontato che esse siano limitate da c f(n).
Ad esempio supponiamo di aver dimostrato per la ricorrenza
T(n) = 2 T(n/2) + n che T(n) ≤ c n lgn e sia T(1) = 1
Allora non si ottiene T(1) ≤ c 1 lg1 per nessun c, essendo
lg1 = 0
La difficoltà può essere superata ricordando che la notazione
asintotica richiede di dimostrare T(n) ≤ c n lgn per gli n ≥ n0,
dove n0 è una costante.
Consideriamo allora T(2) e T(3), in quanto per n > 3, la relazione
non dipende più da T(1).
T(2) = 4 ≤ c 2 lg2
T(3) = 5 ≤ c 3 lg3
Basta scegliere c ≥ 2
Un inconveniente del metodo di sostituzione è che non esiste una
regola per trovare la soluzione corretta per ogni ricorrenza.
Di solito bisogna provare più tentativi, ma se la ricorrenza è simile
ad una di cui si conosce la soluzione, si può provare la stessa
soluzione.
Ad esempio:
T(n) = 2T(n/2 + 15) + n
Intuitivamente per n grande la differenza tra n/2 e n/2 + 15
non è molto significativa.
Si può perciò tentare con la soluzione T(n) = O(n lgn), che
sappiamo risolvere la ricorrenza T(n) = 2T(n/2) + n.
Dimostriamo il risultato anticipato prima che la ricorrenza
T(n) = 2T (n/2) + n
appartiene all’insieme O(n lgn)
Proviamo T(n) ≤ c n lgn
T(n) = 2T (n/2) + n
( n/2 ≤ n/2 )
≤ 2 (c (n/2) lg(n/2)) + n
= c n lgn - c n lg2 + n
= c n lgn - c n + n
≤ c n lgn
per c ≥ 1
Il metodo iterativo
• Sviluppare la ricorrenza ed esprimerla come somma di termini
che dipendono solo da n e dalle condizioni al contorno.
Per risolvere la ricorrenza si applicano le tecniche per limitare
le sommatorie.
Consideriamo di nuovo l’equazione T(n) = 4T(n/2) + n
T(n) = n + 4T(n/2)
= n + 4 ( n/2 + 4 T(n/4))
= n + 2n + 42(n/4 + 4 T(n/8) )
= n + 2n + 4n + 43(n/8 + 4 T(n/16) )
= n + 2n + … + 2lgn-1 n + 2lgnT(1)
= n (1 + 2 + … + 2lgn-1 ) + n Θ(1)
= n (2lgn - 1) + Θ(n)
= n2 - n + Θ(n)
= Θ(n2)
Alberi di ricorsione
Disegnamo l’unfolding della ricorrenza
T(n) = n + 4T(n/2)
T(n)
n
T(n/2)
T(n/2)
T(n/2)
T(n/2)
n
n/2
n/2
n
n/2
n/2
2n
lgn
n/4 n/4 n/4 n/4
n/4 n/4 n/4 n/4
n/4 n/4 n/4 n/4
n/4 n/4 n/4 n/4
4n
2lgn Θ(1)
lgn-1
Totale:
n Σ 2i + 2lgn Θ(1) = n (n - 1) + n Θ(1)
i=0
= Θ(n2)
Insertion-sort
{length[A] ≥ (A)
1}
1Insertion-sort
for j ← 2 to length[A]
(A) do
2
key ← A[j]
{A[1..j-1]
è ordinato}
3
{inserisci A[j] nella sequenza A[1. .j-1]
for jspostando
← 2 toa length[A]
do
destra gli elementi > di A[j]}
← A[j]
4
i key
← j-1
{inserisci
sequenza
5
while
i > 0A[j]
andnella
A[i] >
key do A[1. .j-1]
6
7
8
spostando
a destra
A[i+1] ←
A[i] gli elementi > di A[j]}
← i-1
i ←i j-1
A[i+1]
while←
i >key
0 and A[i] > key do
A[i+1] ← A[i]
i ← i-1
A[i+1] ← key
{A[1..length[A]] è ordinato}
Al j-esimo passo:
{A[1..j-1] e` ordinato}
A
1 2 3
3 5 10 ...
j j+1
25 26 6 29 3
length[A]
...
8 4 21
...
8 4 21
6
key
A
1 2 3
3 5 10 ...
i j j+1
25 26 26 29 3
.
.
.
A
1 2 3 4
3 5 10 10 ...
j j+1
25 26 29 3
...
8 4 21
A
1 2 3 4
3 5 10 10 ...
j j+1
25 26 29 3
...
8 4 21
j
25 26 29 3
...
8 4 21
length[A]
i=2
6
key
A
1 2 3 4
3 5 6 10 ...
{A[1..j] e` ordinato e l’insieme degli elementi è ricostruito}
Insertion-sort (A)
{ A[1..j-1] è ordinato }
for j ← 2 to n do
key ← A[j]
{inserisci A[j] nella sequenza A[1. .j-1]
spostando a destra gli elementi > di A[j]}
i ← j-1
{A[1..j-1] è ordinato & A[i+1..j-1] > key}
while i > 0 and A[i] > key do
A[i+1] ← A[i]
i ← i-1
{A[1..j-1] è ordinato & A[i+1..j-1] > key &
A[i] ≤ key}
A[i+1] ← key
{A[1..n] è ordinato}
O(n2)
Insertion-sort = O(n2)
n = length[A]
Un algoritmo di ordinamento Divide et Impera
Merge-Sort (A, p, q)
if p < q then
r ← (p + q)/2
Merge-Sort (A, p, r)
Merge-Sort (A, r+1, q)
Merge (A, p, r, q)
T(n) = T(n/2) + T(n/2) + Θ(n)
n=q-p+1
per n ≥ 2
T(n) soddisfa T(n) = Θ(1)
T(n) = 2T(n/2) + Θ(n)
per n = 1
per n ≥ 2
n
n
n
n/2
n/2
lgn
n/4
n/4
n/4
n/4
n
2lgn Θ(1)
Totale: (lg n - 1) n + 2lgn Θ(1) = Θ(n lgn)
T(n) = Θ(n lgn)
L’algoritmo Merge-Sort è perciò asintoticamente migliore
dell’algoritmo Insertion-sort
Si può inoltre dimostrare che Θ(n lgn) confronti sono necessari
nel caso peggiore per ordinare n numeri per qualunque algoritmo
basato sui confronti.
Quindi l’algoritmo Merge-Sort è (asintoticamente) ottimo.
Il metodo principale
Teorema principale - versione semplificata
Siano a ≥ 1, b > 1 e c ≥ 0 delle costanti.
T(n) sia definita dalla seguente ricorrenza:
T(n) = a T(n/b) + Θ(nc)
dove n/b rappresenta n/b oppure n/b.
Allora T(n) è asintoticamente limitato nel modo seguente:
• se c < logba
allora
T(n) = Θ(nlogba)
• se c = logba
allora
T(n) = Θ(nc lgn)
• se c > logba
allora
T(n) = Θ(nc)
Esempi di uso del teorema principale
Il tempo di calcolo per l’algoritmo DI-Massimo-ricorsivo
soddisfa la ricorrenza:
T(n) = 2 T(n/2) + Θ(1)
a=b=2
c = 0 < 1 = logba
Caso 1 del teorema principale:
T(n) = Θ(nlog22) = Θ(n)
Il tempo di calcolo per l’algoritmo Merge-Sort soddisfa
la ricorrenza:
T(n) = 2 T(n/2) + Θ(n)
a=b=2
c = 1 = logba
Caso 2 del teorema principale:
T(n) = Θ(nc lgn) = Θ(n lgn)
Ancora qualche esempio:
1.
T(n) = 8 T(n/2) + n2
a=8 b=2
c = 2 < 3 = logba
2.
Caso 1 del teorema principale:
T(n) = Θ(nlog28) = Θ(n3)
T(n) = 7 T(n/2) + Θ(n2)
a=7 b=2
c = 2 < logba ≈ 2.801
Caso 1 del teorema principale:
T(n) = Θ(nlog27) = O(n3)
Per le equazioni di ricorrenza:
1.
T1(n) = 4 T(n/2) + n
2.
T2(n) = 4 T(n/2) + n2
3.
T3(n) = 4 T(n/2) + n3
Si applicano rispettivamente i casi 1, 2 e 3 del teorema
principale:
a=4 b=2
c = i logba = 2
Si ha allora
1.
2.
3.
T1(n) = Θ(n2)
T2(n) = Θ(n2lgn)
T3(n) = Θ(n3)
Come possiamo “leggere” le complessità ottenute?
Caso 1.
T1(n) = 4 T(n/2) + n = Θ(n2)
si applica se il costo della divisione in sottoproblemi e della costruzione
della soluzione a partire da quella dei sottoproblemi (nc) è trascurabile
rispetto al costo della soluzione dei sottoproblemi stessi (a T(n/b)).
Caso 2.
T2(n) = 4 T(n/2) + n2 = Θ(n2lgn)
si applica se il costo della divisione in sottoproblemi e della costruzione
della soluzione a partire da quella dei sottoproblemi (n c) è analogo al
costo della soluzione dei sottoproblemi stessi (a T(n/b)).
Caso 3.
T3(n) = 4 T(n/2) + n3 = Θ(n3)
si applica se il costo della divisione in sottoproblemi e della costruzione
della soluzione a partire da quella dei sottoproblemi (n c) è il fattore
dominante