Obiettivo: disegnare “buoni” algoritmi e “buone” strutture dati per

Obiettivo: disegnare “buoni” algoritmi e “buone” strutture dati
per “risolvere” problemi.
Algoritmo: procedimento passo passo per eseguire un compito.
• Un programma è una particolare implementazione
di un algoritmo, non è un algoritmo
• Vi sono sempre molti algoritmi diversi per ogni
problema dato
Struttura Dati : modo sistematico di organizzare i dati e
accedere ad essi.
algoritmo e organizzazione dati
validità
semantica
efficienza
Risolve il
problema dato?
codifica
È un “buon”
algoritmo?
Classificare come buoni
avere modi precisi per analizzarli
Misure naturale di bontà:
• spazio utilizzato
• tempo di run degli algoritmi e delle operazioni sulle
strutture dati
Qual è il modo corretto per misurare il tempo di run?
Siamo interessati a determinare la dipendenza del tempo di
calcolo dalla dimensione dell’input.
In generale il tempo aumenta con la dimensione dell’input.
Una tabella rivelatrice
Se un algoritmo è eseguito in tempo T(n), dove n è la dimensione
dell’istanza in input, quanto tempo richiede risolvere un’istanza
del problema di dimensione n se un’istanza di dimensione 1
richiede 1 µs (10-6 s)?
n=10
n=20
n=50
n=100
n=1000
n lgn 33 µs
86 µs
282 µs
664 µs
10 ms
n
2
100 µs
400 µs
3 ms
10 ms
1s
n
5
100 ms
3s
5 min
3 hr
32 yr
2
n
1ms
1s
36 yr
T(n)
n!
4s
77147yr 1051 yr
4x1016yr 10287 yr
10144 yr 102554 yr
10-4 x 2n
T
e
m
p
o
105
104
di
c
a
l
c
o
l
o
10-6 x 2n
1 giorno
1 ora
103
102
10-2 x n3
1 minuto
10 -4 x n3
10
1 secondo
1
5
10 -1
10
15
20
25
30
35
Dimensione dell’istanza
Algorithmics versus Hardware
40
Un’altra tabella rivelatrice
Se un algoritmo è eseguito in tempo T(n), qual è la massima
istanza del problema che può essere risolta in un minuto ?
T(n) Il computer più veloce
del mondo nel 1990
n lgn
2
n
5
n
n
2
n!
Un computer 1000 volte
piu’ veloce
1.5 x 103 miliardi
1 x 106 miliardi
8 milioni
260 milioni
570
2300
46
56
16
18
In quale modo stimiamo il tempo di run?
Approccio empirico (a posteriori)
Approccio teorico (a priori)
Per sviluppare la metodologia dobbiamo precisare:
1.
2.
3.
4.
linguaggio per descrivere gli algoritmi
modello computazionale d’esecuzione
metrica per misurare il tempo di run
modo per caratterizzare i tempi di run anche
per gli algoritmi ricorsivi
1. Il linguaggio di disegno: Pseudo-codice
assegnazione: ←
i ← j ← k equivale alla seq.: j ← k; i ← j
espressioni: simboli matematici standard per espressioni
numeriche e booleane
commento: { }
dichiarazione di metodo: nome (param 1, param 2, ...)
chiamata di un metodo: nome (param 1, param 2, ...)
ritorno da un metodo: return valore
dati composti: - i-esimo elemento array A: A[i]
- A[i . . j] ≡ <A[i], A[i+1], . . . , A[j]> se i ≤ j
sequenza vuota se i > j
- i dati composti sono organizzati in oggetti, che
sono strutturati in attributi o campi: ad es.
length[A]
una variabile che rappresenta un oggetto è trattata come puntatore
un puntatore che non si riferisce a nessun oggetto: nil
parametri alle procedure passati per valore
per gli oggetti una copia del puntatore
struttura di blocco: spaziatura
costrutti iterativi e condizionali : PASCAL-like
if condizione
then azioni
(else azioni)
while condizione do
azioni
repeat azioni
until condizione
for variabile-incremento do
azioni
2. Il modello computazionale
•
•
•
•
•
•
•
assegnazione di un valore ad una variabile
chiamata di un metodo
eseguire un’operazione aritmetica
confronto di due numeri
indicizzazione di un elemento in un array
riferimento a un oggetto
rientro da un metodo
Assunzione implicita: il numero di operazioni primitive è
proporzionale al tempo di esecuzione dell’algoritmo
Questo approccio dà origine al modello computazionale
chiamato Random Access Machine (RAM):
• CPU connessa a un banco di celle di memoria
• ogni cella memorizza una parola (un numero, una stringa,
un indirizzo . . ., in generale il valore di un tipo base)
• la CPU accede ad una cella di memoria arbitraria con una
operazione primitiva
La quantità di tempo (e di spazio) consumato dall’esecuzione
di un programma RAM su un dato input può essere determinato
essenzialmente usando due criteri:
Criterio di costo uniforme:
l’esecuzione di un’istruzione richiede un tempo
indipendente dalla “grandezza” degli operandi
Criterio di costo logaritmico:
il tempo di calcolo richiesto da un’istruzione
dipende dal numero di bit necessari a rappresentare
gli operandi
3. Misurare il tempo di run: Contare le operazioni primitive
Massimo (A, n)
current-max ← A[0]
for i ← 1 to n-1 do
if current-max < A[i]
then current-max ← A[i]
return current-max
2
1+n
4 (n-1) / 6 (n-1)
1
Numero di operazioni primitive: t (n) =
minimo 2 + 1 + n + 4(n-1) + 1 = 5n
massimo 2 + 1 + n + 6(n-1) + 1 = 7n - 2
tem
po
di
run
5 ms
tempo nel caso
peggiore
3 ms
tempo nel caso
migliore
1 ms
A
B
C
D
E
istanze in input
F G
Analisi del caso medio
tempo d’esecuzione dell’algoritmo espresso come
media dei tempi per tutti i possibili input
conoscere la distribuzione di probabilità sull’insieme degli
input: un compito non semplice
Analisi del caso peggiore
• non richiede teoria probabilità
• caso peggiore quasi sempre facile da identificare
• se un algoritmo si comporta bene nel caso peggiore, si
comporta bene su ogni input
4. Come esprimere il tempo d’esecuzione degli
algoritmi ricorsivi ?
Equazione di ricorrenza: una funzione che esprime il tempo
di esecuzione sull’input di dimensione n in funzione del
tempo su input di dimensione inferiore.
Massimo-ricorsivo (A, n)
if n = 1
then return A[0]
return max (Massimo-ricorsivo (A, n-1), A[n-1])
T(n) =
3
T(n-1) + k
T(n) = k (n-1) + 3
se n = 1
altrimenti
Moltiplicazione per “somme successive”
x1
x2
45
19
moltiplicazione (x1, x2)
y ← x1
44
19
prod ← 0
43
19
while y > 0 do
..
..
.
.
prod ← prod + x2
4
19
y ←y-1
3
19
return prod
2
19
1
19
855
Moltiplicazione “alla russa”
x1
45
22
11
5
2
x2
19
38
76
152
304
1
608
19
--76
152
--608
855
molt-russa (x1, x2)
y1 ← x1
y2 ← x2
prod ← 0
while y1 > 0 do
if y1 is odd
then prod ← prod + y2
y1 ← y1 div 2
y2 ← y2 + y2
return prod
moltiplicazione (x1, x2)
y ← x1
prod ← 0
while y > 0 do
prod ← prod + x2
x1 ← x1 - 1
return prod
nel while
molt-russa (x1, x2)
y1 ← x1
y2 ← x2
prod ← 0
while y1 > 0 do
if y1 is odd
then prod ← prod + y2
y1 ← y1 div 2
y2 ← y2 + y2
return prod
moltiplicazione
molt-russa
2m assegnazioni
m somme
m decrementi
m+1 confronti
3 lg m assegnazioni
lg m divisioni
2 lg m somme
2 lg m + 1 confronti
5m+1 + 3 operazioni
8 lg m+1 + 4 operazioni
f(m) = 5m + 4
g(m) = 8 lg m + 5
60
54
50
49
numero di operazioni
44
40
39
34
29
30
27,46
19
25,68
23,58
21
17,68
14
13
9
10
31,58
29
24
20
30,36
5
4
0
0
1
2
3
4
5
6
moltiplicatore
7
8
9
10
11
Ulteriore astrazione :
tasso di crescita o ordine di grandezza
del tempo di esecuzione.
• ogni passo nello pseudo-codice (e ogni statement in un linguaggio
ad alto livello) corrisponde a un piccolo numero di operazioni
primitive che non dipendono dalla dimensione dell’input
• basta considerare il termine principale perchè i termini di ordine
inferiore non sono significativi per n grande .
L’ordine di grandezza del tempo di esecuzione fornisce una
semplice caratterizzazione dell’efficienza e consente di
confrontare algoritmi alternativi.
Efficienza asintotica degli algoritmi: come cresce il tempo di
esecuzione con il crescere al limite della dimensione delle
istanze in input
Notazione asintotica
Consideriamo funzioni dai naturali ai numeri reali non negativi
Notazione O: O (g(n)) e’ l’insieme di tutte le funzioni f(n) per
cui esistono due costanti positive c ed n0 tali che
f(n) ≤ c • g(n) per tutti gli n ≥ n0
c g(n)
f(n)
tem
po
di
run
n0
f(n) ∈ O (g(n))
n
Notazione Ω: Ω(g(n)) e’ l’insieme di tutte le funzioni f(n) per
cui esistono due costanti positive c ed n0 tali che
f(n) ≥ c • g(n) per tutti gli n ≥ n0
f(n)
tem
po
di
run
c g(n)
n0
f(n) ∈ Ω (g(n))
n
Notazione Θ: Θ(g(n)) e’ l’insieme di tutte le funzioni f(n) per
cui esistono tre costanti positive c1, c2 ed n0 tali che
c1• g(n) ≤ f(n) ≤ c2 • g(n) per tutti gli n ≥ n 0
c2 g(n)
f(n)
tem
po
di
run
c1 g(n)
n0
f(n) ∈ Θ (g(n))
n
Proprietà
Transitiva:
f(n) = Θ (g(n)) e g(n) = Θ (h(n))
f(n) = O (g(n)) e g(n) = O (h(n))
f(n) = Ω (g(n)) e g(n) = Ω (h(n))
Riflessiva:
Simmetrica:
f(n) = Θ (h(n))
f(n) = O (h(n))
f(n) = Ω (h(n))
f(n) = Θ (f(n))
f(n) = O (f(n))
f(n) = Ω (f(n))
f(n) = Θ (g(n))
Simmetrica trasposta: f(n) = O (g(n))
g(n) = Θ (f(n))
g(n) = Ω (f(n))
• d(n) = O(f(n))
a . d(n) = O(f(n)), per ogni costante a > 0
• d(n) = O(f(n)) & e(n) = O(g(n))
d(n) + e(n) = O(f(n) + g(n))
• d(n) = O(f(n)) & e(n) = O(g(n))
d(n) . e(n) = O(f(n) . g(n))
• f(n) funzione polinomiale di grado d:
f(n) = a0 + a1n + . . . + adnd
f(n) = O(nd)
Complessità
O(log n)
logaritmica
O(n)
lineare
O(n2)
quadratica
O(nk) (k ≥ 1)
polinomiale
O(an) (a > 1)
esponenziale
trattabili
non trattabili
Funzioni ordinate per velocità di crescita
n
log n
n
n
n log n
n2
nn32
2n
2
1
1,41
2
2
4
8
4
4
2
2
4
8
16
64
16
8
3
2,83
8
24
64
512
256
16
4
4
16
64
256
4.096
65.536
32
5
5,66
32
160
1.024
32.768
4.294.967.296
64
6
8
64
384
4.096
262.144
1,84 x 1019
128
7
11,31
128
896
16.384
2.097.152
3,40 x 1038
256
8
16
256
2.048
65.536
16.777.216
1,15 x 1077
512
9
22,63
512
4.608
262.144
134.217.728
1,34 x 10154
1.024
10
32
1.024 10.240 1.048.576 1.073.741.824
1,79 x 10308
10
10
9
9
8
8
9
8
8
8
7
7
6
6
5
5
4,75
4
4
4
3,17
3
2,81
3
2,32
2
1
logn
radn
n
n logn
n^2
n^3
2^n
2 1,73
1,41
1,58
1
2
3
2,58
2,24 2,45
2,65
3,58
3,32 3,46
3
2,83
3,16
3,32
3,46
2
0
2
3
4
5
6
7
8
9
10
11
12
60
logn
radn
n
n logn
n^2
n^3
2^n
50
40
30
20
10
0
2
3
4
5
6
7
8
9
10
11
12
4500
logn
radn
n
n logn
n^2
n^3
2^n
4000
3500
3000
2500
2000
1500
1000
500
0
2
3
4
5
6
7
8
9
10
11
12
Problema: ordinamento di numeri.
Input: una sequenza di n numeri <a1, a2,…,an>.
Output: una permutazione <a1’, a2’,…,an’> della sequenza di
input tale che a1’≤ a2’ ≤ … ≤ an’.
1
2
3
4
5
3 5 1 8 2
key = 5
1
2
3
4
5
3 5 1 8 2
3 5 5 8 2
key = 1
1 3 5 8 2
1
2
3
4
3 3 5 8 2
5
1 3 5 8 2
1
2
key = 8
3 4 5
1 3 5 8 2
key = 2
1 3 5 8 8
1 3 5 5 8
1 3 3 5 8
1 2 3 5 8
Insertion-sort (A)
1 for j ← 2 to length[A] do
2
key ← A[j]
3
{inserisci A[j] nella sequenza A[1. .j-1]
num. volte
1
n
n-1
spostando a destra gli elementi > di A[j]}
i ← j-1
while i > 0 and A[i] > key do
A[i+1] ← A[i]
i ← i-1
A[i+1] ← key
4
5
6
7
8
n-1
Σ tj
(j=2..n)
Σ(tj-1) (j=2..n)
Σ(tj-1) ( j=2..n)
n-1
T(n) = 1 + a n + b (n-1) + c Σ tj + d Σ(tj-1) =
= (a + b) n + (1- b) + c Σ tj + d Σ (tj-1)
Situazione migliore: A e’ ordinato
T(n) è una funzione lineare di n.
Situazione peggiore: A è ordinato in ordine inverso
T(n) è una funzione quadratica di n.
Situazione media: ogni permutazione e’ ugualmente
probabile
T(n) è una funzione quadratica di n.
Problema: valutazione di polinomi.
Input: una sequenza di n+1 numeri reali A = <a0, a1,…,an> e
una variabile x.
Output: il valore del polinomio di grado n:
P(x) = a0 + a1x+ … + anxn.
Poly-eval (A, x, n)
1 y←1
2 result ← A[0]
3 for i ← 1 to n do
4
y←y•x
5
result ← result +A[i] • y
6 return result
{y = xi}
L’algoritmo esegue 2 n moltiplicazioni n somme e 2n assegnazioni.
Ma si può fare meglio
La regola di Horner:
P(x) = a0 + x (a1+ … + x (an-1+ x an))…)
Horner (A, x, n)
1 result ← A[n]
2 for i ← n - 1 downto 0 do
3
result ← result • x + A[i]
4 return result
L’algoritmo esegue n somme, n moltiplicazioni e n assegnazioni.
Questo algoritmo è sicuramente migliore
L’analisi asintotica non distingue però tra i due algoritmi:
per entrambi si ottiene Θ(n)
Poly-eval (A, x, n)
1 y←1
2 result ← A[0]
3 for i ← 1 to n do
4
y←y•x
5
result ← result +A[i] • y
6 return result
fuori dal for
confronti
nel for
Poly-eval
5
n+1
8n
Poly-eval: 9 n + 6
Horner:
7n+5
Horner (A, x, n)
1 result ← A[n]
2 for i ← n - 1 downto 0 do
3
result ← result • x + A[i]
4 return result
Horner
4
n+1
6n
1200
T(n) = 9 n + 6
1005
1000
T(n) = 7 n + 5
915
825
800
735
782
712
645
600
642
555
572
465
400
432
285
82
12
1
362
292
195
200
0
502
375
222
152
105
15
11
21
31
41
51
61
71
81
91 101 111