Algoritmi 1
Algoritmi 1
Fabio Irimie
1° Semestre 2024/2025
Indice
1 Introduzione 3
1.1 Confronto tra algoritmi . . . . . . . . . . . . . . . . . . . . . . . . 3
1.2 Rappresentazione dei dati . . . . . . . . . . . . . . . . . . . . . . . 3
4 Algoritmi di selezione 24
4.1 Ricerca del minimo o del massimo . . . . . . . . . . . . . . . . . . 24
4.1.1 Ricerca del minimo e del massimo contemporaneamente . . 25
4.2 Randomized select . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
5 Strutture dati 29
5.1 Stack . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
5.2 Queue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
5.3 Lista doppiamente puntata . . . . . . . . . . . . . . . . . . . . . . 30
5.4 Albero binario . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
5.4.1 Albero binario di ricerca . . . . . . . . . . . . . . . . . . . . 31
5.4.2 RB-Tree (Red-Black Tree) . . . . . . . . . . . . . . . . . . 32
5.5 Campi aggiuntivi . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
5.6 Albero Binomiale . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
5.7 Heap Binomiale . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
5.7.1 Creazione di un heap binomiale . . . . . . . . . . . . . . . . 49
5.7.2 Unione di due heap binomiali . . . . . . . . . . . . . . . . . 49
5.7.3 Inserimento . . . . . . . . . . . . . . . . . . . . . . . . . . 50
5.7.4 Rimozione . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
5.7.5 Riduzione . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
5.7.6 Divisione . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52
1
5.8 Struttura dati facilmente partizionabile . . . . . . . . . . . . . . . . 52
5.8.1 Complessità . . . . . . . . . . . . . . . . . . . . . . . . . . 52
5.9 Tabelle Hash . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
5.9.1 Risoluzione dei conflitti . . . . . . . . . . . . . . . . . . . . 56
5.9.2 Funzioni di hash . . . . . . . . . . . . . . . . . . . . . . . . 58
6 Tecniche di programmazione 58
6.1 Programmazione dinamica . . . . . . . . . . . . . . . . . . . . . . 58
6.2 Programmazione greedy . . . . . . . . . . . . . . . . . . . . . . . . 61
2
1 Introduzione
Un’algoritmo è una sequenza finita di istruzioni volta a risolvere un problema.
Per implementarlo nel pratico si scrive un programma, cioè l’applicazione di un
linguaggio di programmazione, oppure si può descrivere in modo informale attraverso
del pseudocodice che non lo implementa in modo preciso, ma spiega i passi per
farlo.
Ogni algoritmo può essere implementato in modi diversi, sta al programmatore
capire qual’è l’opzione migliore e scegliere in base alle proprie necessità.
3
Questi sono i blocchi base della programmazione e se riusciamo a calcolare la com-
plessità di ognuno di questi blocchi possiamo calcolare più facilmente la complessità
di un intero algoritmo.
X
l
ci (n)
i=1
A volte la condizione è un test sulla dimensione del problema e in quel caso si può
scrivere una complessità più precisa.
4
2.2 Esempio
L’algoritmo è il seguente:
1
2 for i <- 1 to n // n ( 5 ml + 4 l + 2) + n + 1
3 for j <- 1 to l // l (5 m + 2 + 1) + 1 + l
4 c [ i ][ j ] <- 0
5 for k <- 1 to m // ( m + 1 + m (4) )
6 // 3 ( moltiplicazione , somma e assegnamento )
7 // 1 ( incremento for )
8 c [ i ][ j ] + = a [ i ][ k ] * b [ k ][ j ]
9
Partiamo calcolando la complessità del ciclo for più interno. Non ha senso
tenere in considerazione tutti i dati, ma solo quelli rilevanti. In questo caso
avremo:
(m + 1 + m(4)) = 5m + 1
Questa complessità contiene informazioni poco rilevanti perchè possono far
riferimento alla velocità della cpu e un millisecondo in più o in meno non
cambia nulla se teniamo in considerazione solo l’incognita abbiamo:
Questo semplifica molto i calcoli, rendendo meno probabili gli errori. Siccome
la complessità si calcola su numeri molto grandi, le costanti piccole prima o
poi verranno tolte perchè poco influenti.
5nml + 4ml + 2n + n + 1
5nml + 4ml + 2n + n + 1
nml
f ∈ O(g)
5
∃c > 0 ∃n̄ ∀n ⩾ n̄ f(n) ⩽ cg(n)
y cg
t
n̄
f ∈ Ω(g)
∃c > 0 ∃n̄ ∀n ⩾ n̄ f(n) ⩾ cg(n)
f ∈ Θ(g)
f ∈ O(g) ∧ f ∈ Ω(g)
6
Definizione 2.1.
A ∈ O(f)
So che l’algoritmo A termina entro il tempo definito dalla funzione f. Di
conseguenza se un algoritmo termina entro un tempo f allora sicuramente
termina entro un tempo g più grande. Ad esempio:
A ∈ O(n) ⇒ A ∈ O(n2 )
A ∈ Ω(f)
Significa che esiste uno schema di input tale che se g(n) è il numero di passi
necessari per risolvere l’istanza n allora:
g ∈ Ω(f)
g ∈ O(f)
g ∈ Θ(f)
Teorema 2.1 (Teorema di Skolem). Se c’è una formula che vale coi quanti-
ficatori esistenziali, allora nel linguaggio si possono aggiungere delle costanti
al posto delle costanti quantificate e assumere che la formula sia valida con
quelle costanti.
7
2.3.1 Esempi di dimostrazioni
n ⩽ c2n
Quindi è vero
2n ⩽ 2n
Quindi è vero
8
Esempio 2.4. È vero che f ∈ O(g) ⇐⇒ g ∈ Ω(f)?
Dimostro l’implicazione da entrambe le parti:
• →: Usando il teorema di Skolem:
∀n ⩾ n̄ f(n) ⩽ cg(n)
Trasformo la disequazione:
f(n)
∀n ⩾ n̄ ⩽ g(n)
c
f(n)
∀n ⩾ n̄ g(n) ⩾
c
1
∀n ⩾ n̄ g(n) ⩾ f(n) □
c
Se la definizione di Ω(g) è:
sappiamo che:
1
c′ =
c
• ←: Usando il teorema di Skolem:
∀n ⩾ n̄ ′ g(n) ⩾ c ′ f(n)
Trasformo la disequazione:
g(n)
∀n ⩾ n̄ ′ ⩾ f(n)
c′
1
∀n ⩾ n̄ ′ f(n) ⩽ g(n) □
c′
9
Esempio 2.5.
f1 ∈ O(g) f2 ∈ O(g) ⇒ f1 + f2 ∈ O(g)
Dimostrazione:
Ipotesi
n̄1 c1 ∀n > n1 f1 (n) ⩽ c1 g(n)
n̄1 c2 ∀n > n2 f2 (n) ⩽ c2 g(n)
Esempio 2.6. Se
f1 ∈ O(g1 ) f2 ∈ O(g2 )
è vero che:
f1 · f2 ∈ O(g1 · g2 )
Dimostrazione:
Ipotesi
n̄1 c1 ∀n > n̄1 f1 (n) ⩽ c1 g1 (n)
n̄2 c2 ∀n > n̄2 f2 (n) ⩽ c2 g2 (n)
10
3.1 Insertion sort
Divide la sequenza in due parti:
• Parte ordinata: Sequenza di elementi ordinati
• Parte non ordinata: Sequenza di elementi non ordinati
i j
Pseudocodice:
1 ins ertion _sort ( A )
2 for j <- 2 to length [ A ] // A sinistra di j e tutto ordinato -
3 key <- A [ j ] // |
4 i <- j - 1 // | O(n)
5 while i > 0 and A [ i ] > key // -- |
6 A [ i + 1] <- A [ i ] // | O ( n ) |
7 i-- // -- -----
8 A [ i + 1] <- key
O(n2 )
Per capirlo è sufficiente guardare il numero di cicli nidificati e quante volte eseguono
il codice all’interno.
Ω(n)
Ω(n2 )
Θ(n2 )
11
• Ordinamento in loco: se la quantità di memoria extra che deve usare non
dipende dalla dimensione del problema allora si dice che l’algoritmo è in loco.
• Ordinamento non in loco: se la quantità di memoria extra che deve usare
dipende dalla dimensione del problema allora si dice che l’algoritmo è non in
loco.
• Stabilità: La posizione relativa di elementi uguali non viene modificata
3.2 Fattoriale
1 Fatt ( n )
2 if n = 0
3 ret 1
4 else
5 ret n * Fatt ( n - 1)
Con problemi ricorsivi si avrà una complessità con funzioni definite ricorsivamente.
Questo si risolve induttivamente:
T (n) = 1 + T (n − 1)
= 1 + 1 + T (n − 2)
= 1 + 1 + 1 + T (n − 3)
= 1| + 1 +{z. . . + 1} +T (n − i)
i
La condizione di uscita è: n − i = 0 n = i
= |1 + 1 +{z. . . + 1} +T (n − n)
n
= n + 1 = Θ(n)
Esempio 3.1. j n k
T (n) = 2T +n
2
Questa funzione si può riscrivere come:
Costante se n < a
T (n) =
2T 2 + n se n ⩾ a
n
12
Usando il metodo di sostituzione:
T (n) = cn log n
n 1
c⩾ =
n log 2 log 2
Il metodo di sostituzione dice che quando si arriva ad avere una disequazione
corrispondente all’ipotesi, allora la soluzione è corretta se soddisfa una certa
ipotesi.
Esempio 3.2.
j n k l n m
T (n) = T +T +1 ∈ O(n)
2 2
T (n) ⩽ cn
j n k l n m
T (n) = T +T +1
j n2 k l n2m
⩽c +c +1
2 k l m 2
j n n
=c + +1
2 2
?
= cn + 1 ⩽ cn
Il metodo utilizzato non funziona perchè rimane l’1 e non si può togliere in
alcun modo. Per risolvere questo problema bisogna risolverne uno più forte:
T (n) ⩽ cn − b
13
j n k l n m
T (n) = T +T +1
j n2 k 2l m
n
⩽c −b+c −b+1
2
j n k l n m 2
=c + − 2b + 1
2 2
?
= cn − 2b + 1 ⩽ cn − b
= cn − b} + 1| {z
| {z − b} ⩽ cn − b se b ⩾ 1
⩽0
Se la proprietà vale per questo problema allora vale anche per il problema
iniziale perchè è meno forte.
Esempio 3.3.
j n k
T (n) = 3T +n
4 j k
n
= n + 3T
4 $ %!!
jnk n
4
=n+3 + 3T
4 4
jnk j n k
=n+3 + 32 T
4 42 $ %!!
jnk jnk n
42
⩽n+3 + 32 + 3T
4 42 4
jnk jnk j n k
=n+3 + 32 2 + 33 T 3
4
jnk 4 j n k4 j n k
i−1
=n+3 + ... + 3 + 3i T
4 4 i−1 4i
Per trovare il caso base poniamo l’argomento di T molto piccolo:
n
<1
4i
4i > n
i > log4 n
L’equazione diventa:
jnk j n k
⩽n+3 + . . . + 3log4 n−1 + 3log4 n c
4 4log4 n−1
14
Si può togliere l’approssimazione per difetto per ottenere un maggiorante:
2 log4 n−1 !
3 3 3
⩽n 1+ + + ... + + 3log4 n c
4 4 4
∞ i
X
!
3
⩽n + c3log4 n
4
i=0
Quindi la complessità è:
= O(n) + O(nlog4 3 )
Si ha che una funzione è uguale al termine noto della funzione originale e l’altra
che è uguale al logaritmo dei termini noti. Se usassimo delle variabili uscirebbe:
n
T (n) = aT + f(n)
b
= O(f(n)) + O(nlogb a )
a=9
b=3
f(n) = n
nlogb a = nlog3 9 = n2
15
Verifico se esiste un ε tale che:
n ∈ O(n2−ε )
prendo ε = − 12 e verifico:
1
n ∈ O(n2 · n− 2 )
T (n) ∈ Θ(n2 )
Esempio 3.5.
2n
T (n) = T +1
3
Applico il teorema dell’esperto:
a=1
3
b=
2
f(n) = n0
log 3 1
nlogb a = n 2 = n0
Si nota che le due funzioni hanno lo stesso ordine di grandezza, quindi siamo
nel secondo caso del teorema dell’esperto.
T (n) ∈ Θ(log n)
a=3
b=4
f(n) = n log n
nlogb a = nlog4 3
n log n ∈ Ω(nlog4 3 )
Esiste un ε tale che:
n log n ∈ Ω(nlog4 3+ε )
perchè basta che sia compreso tra log4 3 e 1.
16
Quindi siamo nel terzo caso del teorema dell’esperto.
a=2
b=2
f(n) = n log n
nlogb a = nlog2 2 = n
n log n ∈ Ω(n)
Verifico se esiiste un ε, quindi divido per n:
log n ∈ Ω(nε )
Quindi si nota che questa proprietà non è verificata, quindi non si può applicare
il teorema dell’esperto.
a=2
b=2
f(n) = n
nlogb a = n
n ∈ Θ(n)
17
Quindi siamo nel secondo caso del teorema dell’esperto:
1 // A : Array da ordinare
2 // P : Indice di partenza
3 // q : Indice di mezzo
4 // r : Indice di arrivo
5 merge (A , p , q , r )
6 i <- 1
7 j <- p
8 k <- q + 1
9 // Ordina gli elementi di A in B
10 while ( j <= q and k <= r ) // --
11 if j <= q and ( k > r or A [ j ] <= A [ k ]) // |
12 B [ i ] <- A [ j ] // |
13 j ++ // |
14 else // | O ( n )
15 B [ i ] <- A [ k ] // |
16 k ++ // |
17 i ++ // --
18
19 // Copia gli elementi di B in A
20 for i <- 1 to r - p + 1 // -|
21 A [ p + i - 1] <- B [ i ] // -| O ( n )
L’algoritmo è stablie perchè non vengono scambiati elementi uguali e non è in loco
perchè utilizza un array di appoggio.
3.5 Heap
È un albero semicompleto (ogni nodo ha 2 figli ad ogni livello tranne l’ultimo che è
completo solo fino ad un certo punto) in cui i nodi contengono oggetti con relazioni
di ordinamento.
18
1
2 3
4 5 6 7
8 9 10
3.5.1 Proprietà
∀ nodo il contenuto del nodo è ⩾ del contenuto dei figli. Per calcolare il numero di
nodi di un albero binario si usa la formula:
1 − 2h
N = 20 + 21 + 22 + . . . + 2h−1 = = 2h − 1
1−2
dove h è l’altezza dell’albero. Il numero di foglie di un albero sono la metà dei nodi.
Definiamo una funzione che "aggiusta" i figli di un nodo per mantenere la proprietà
di heap:
1 heapify (A , i ) // O ( n )
2 l <- left [ i ] // Indice del figlio sinistro (2 i )
3 r <- right [ i ] // Indice del figlio destro (2 i +1)
4 if l < H . heap_size and H [ l ] > H [ i ]
5 largest <- l
6 else
7 largest <- i
8
9 if r < H . heap_size and H [ r ] > H [ largest ]
10 largest <- r
11 if largest ! = i
12 swap ( H [ i ] , H [ largest ])
13 heapify (H , largest )
Una volta definito un heap si possono fare diverse operazioni, come ad esempio
estrarre il nodo massimo:
1 extract_max ( A )
2 H [1] <- H [ H . heap_size ]
3 H . heap_size <- H . heap_size - 1
4 heapify (H ,1)
19
1 heap_sort ( A ) // O ( n log n )
2 build_heap ( A ) // n
3 for i <- length [ A ] downto 2
4 scambia ( A [1] , A [ i ])
5 heapsize ( A ) --
6 heapify (A , 1) // log i
X
n Y
n
log i = log i = log n! = Θ(log nn ) = Θ(n log n)
i=1 i=1
Il caso pessimo è un array ordinato al contrario (O(n log n)) e il caso migliore è un
array già ordinato (Ω(n log n)), quindi la complessità è:
Θ(n log n)
1 partition (A , p , r )
2 x <- A [ p ] // Elemento perno ( o pivot )
3 i <- p - 1
4 j <- r + 1
5 while true
6 repeat // Ripete finche ’ la condizione non e ’ soddisfatta
7 j - - // n /2
8 until A [ j ] <= x // Trova un elemento che non puo ’ stare a
destra
9 repeat
10 i ++ // n /2
11 until A [ i ] >= x // Trova un elemento che non puo ’ stare a
sinistra
12 if i < j
13 swap ( A [ i ] , A [ j ]) // n /2
14 else
15 return j // alla fine j puntera ’ all ’ ultimo elemento di
sinistra
20
Questo algoritmo è in loco e non è stabile. La sua complessità nel caso peggiore è:
T (n) = T (partition) + T (q) + T (n − q)
= n + T (q) + T (n − q)
= n + + T (n − 1)
T (1)
= n + T (n − 1)
= Θ(n2 )
Il caso peggiore è un array già ordinato.
Mediamente ci si aspetta che l’array venga diviso in 2 parti molto simili, quindi la
complessità è O(n log n) perchè:
0<c<1
T (n) = n + T (cn) + T ((1 − c)n)
1 rand _parti tion (A , p , r )
2 i <- random (p , r )
3 swap ( A [ i ] , A [ p ])
4 return partition (A , p , r )
1
La complessità è la media di tutte le complessità con probabilità n
1 n−1
T (n) = n + (T (1) + T (n − 1)) + (T (2) + T (n − 2))
n n
1
+ . . . + (T (n − 1) + T (1))
n
1X
T (n) = n + (T (i) + T (n − i))
n
i
2X
=n+ T (i)
n
i
Qualsiasi algoritmo che ordina per confronti deve fare almeno n log n confronti nel
caso pessimo
x → n!
x1 x2
21
3.7.1 Counting sort
Si vogliono ordinare n numeri con valori da 1 a k. L’idea di questo algoritmo è
quella di creare un’array che contiene il numero di occorrenze di un certo valore
(rappresentato dall’indice).
1 counting_sort (A , k )
2 for i <- 1 to k // k
3 C [ i ] <- 0 // I n i z i a l i z z a z i o n e di un array a 0
4
5 for j <- 1 to length [ A ] // n
6 C [ A [ j ]]++ // Conteggio delle occorrenze
7
8 // k
9 for i <- 2 to k // In ogni indice metto il numero di
10 C [ i ] <- C [ i ] + C [i -1] // elementi minori o uguali
11 // al numero dell ’ indice
12 // Alla fine l ’ array C conterra ’ l ’ ultima posizione di occorrenza
per ogni elemento
13
14 for j <- length [ A ] downto 1 // n
15 B [ C [ A [ j ]]] <- A [ j ] // Inserimento dell ’ elemento in posizione
16 C [ A [ j ]] - - // Decremento della posizione di occorrenza
Θ(l(n + k))
dove:
l = numero di cifre = logk n
n = numero di elementi
k = numero di valori possibili
Se rappresentiamo i numeri in base n, allora si avrà la seguente rappresentazione:
. . . n2 n1 n0
22
Il caso peggiore è quello in cui tutti gli elementi finiscono in un singolo bucket, la
probabilità che questo accada è molto bassa:
1 1 1 1
· · · · · · = n−1
|n n {z n} n
n−1
Nel caso medio si ha che per creare i bucket si ha una complessità O(n) e per
assegnare gli elementi ai bucket si ha una complessità O(n). Per ogni bucket
ci si aspetta che il numero di elementi al suo interno sia una costante, quindi
indipendente dal valore di n. Per ordinare un bucket si ha una complessità O(1)
siccome il numero di elementi è costante. La complessità totale è quindi:
Θ(n)
Formalizzando si ha:
1 se l’elemento i va nel bucket j
Sia Xij la variabile casuale che vale:
0 altrimenti
Per esprimere il numero di elementi nel bucket j si può scrivere:
X
Nj = Xij
i
X
n X
n
1
E[Nj ] = E[Xij ] = =1
n
j j
e la varianza è:
X
n X
n
1
1
1
Var[Nj ] = Var[Xij ] = 1− =1−
n n n
j j
23
La complessità diventa:
X 1
E[C] = 1− −1
n
j
X 1
= 2−
n
j
= 2n − 1
4 Algoritmi di selezione
Dato in input un array A di oggetti su cui è definita una relazione di ordinamento
e un indice i compreso tra 1 e n (n è il numero di oggetti nell’array), l’output
dell’algoritmo è l’oggetto che si trova in posizione i nell’array ordinato.
1 selezione (A , i )
2 ordina ( A ) // O ( n log n )
3 return A [ i ]
Quindi la complessità di questo algoritmo nel caso peggiore è O(n log n) (limite
superiore). È possibile selezionare un elemento in tempo lineare? Analizziamo
un caso particolare dell’algoritmo di selezione, ovvero la ricerca del minimo (o del
massimo).
24
corretto, dimostrando che non esiste un algoritmo che trova il massimo in meno di
n − 1 confronti.
Abbiamo quindi trovato che la complessità del massimo (o minimo) nel caso mi-
gliore è Ω(n) (limite inferiore) e nel caso peggiore è O(n) (limite superiore). Di
conseguenza la complessità è Θ(n).
a<b
Due oggetti che sono potenzialmente sia minimi che massimi esistono se
m + M > n + 1 perchè se bisogna distribuire n potenzialità ne avanzano due
che devono essere assegnate a due oggetti che hanno già una potenzialità.
Quindi fino a quando m + M continua ad essere almeno n + 2 si riesce a far
diminuire m + M di 2 ad ogni confronto.
25
Questa diminuzione si può fare n2 volte, successivamente m + M potrà
– n dispari: jnk
n+1−2+
j n2k
=n−1+
2
3
= n −1
2
3
= n −2
2
– n pari: jnk
n−2+
2
n
=n−2+
2
3
= n−2
2
3
= n −2
2
così non si può fare, ma non è detto che esista un algoritmo che raggiunga
questo limite inferiore.
a1 b1
a2 b2
.. ..
. .
a⌊ n ⌋ b⌈ n ⌉
| {z2 } | {z2 }
|Potenziali minimi {zPotenziali massimi}
Potenziali sia minimi che massimi
26
4.2 Randomized select
Si può implementare un algoritmo che divide l’array in 2 parti allo stesso modo in
cui viene effettuata la partition di quick sort:
1 // A : Array
2 // p : Indice di partenza
3 // r : Indice di arrivo
4 // i : Indice che stiamo cercando ( compreso tra 1 e r - p +1)
5 r a n d o m i z e d _ s e l e c t (A , p , r , i )
6 if p = r
7 return A [ p ]
8 q <- r a n d o m i z e d _ p a r t i t i o n (A , p , r )
9 k <- q - p + 1 // Numero di elementi a sinistra
10 // Controlla se l ’ elemento cercato e ’ a sinistra o a destra
11 if i <= k
12 return r a n d o m i z e d _ s e l e c t (A , p , q , i ) // Cerca a sinistra
13 else
14 return r a n d o m i z e d _ s e l e c t (A , q +1 , r , i - k ) // Cerca a destra
• Mediamente:
1 1
T (n) = n + T (max(1, n − 1)) + T (max(2, n − 2)) + . . .
n n
2 X
n−1
=n+ T (i)
n n
i= 2
Esiste un algoritmo che esegue la ricerca in tempo lineare anche nel caso peggiore?
Si potrebbe cercare un elemento perno più ottimale, cioè che divida l’array in
parti proporzionali:
meno di 5 elementi.
2. Calcola il mediano di ogni gruppo di 5 elementi (si ordina e si prende l’elemento
centrale). Θ(n)
27
piccolo, e deve risultare un proporzione di n, quindi ad esempio dividere in
gruppi da 3 elementi non funzionerebbe.
T (?)
m1 → m2 → m3 → m4 → m 5 → m6 → m7
x
↓ ↓ ↓
m5,4 m6,4 m7,4
↓ ↓
m5,5 m6,5
Gli elementi verdi sono maggiori dell’elemento x e ogni elemento verde avrà 2
elementi maggiori di esso (tranne nel caso del gruppo con meno di 5 elementi
rappresentato in blu).
1 n 7
l m
#left ⩽ 3 · − 2 = n+6
2 5 |{z}
10
| {z } rosso + blu
verdi + blu + rosso
1 n 7
l m
#right ⩾ 3 · − 2 = n+6
2 5 |{z}
10
| {z } rosso + blu
verdi + blu + rosso
7
Da ogni parte si hanno almeno 10 n + 6 elementi.
7
l n m
T (n) = Θ(n) + T +T n+6
5 10
7
n+6⩽n
10
7n + 60 ⩽ 10n
3n ⩾ 60
n ⩾ 20
28
Quindi per valori di n ⩽ 20 la disequazione non è vera. Consideriamo quindi
n̄ > 20 e n > n̄. Togliendo l’approssimazione per eccesso si ha:
c 7
T (n) ⩽ n + c + n + n + 6c
5 10
9
⩽ cn + 7c + n
10
?
⩽ cn
1
= cn + − cn + 7c + n ⩽ cn quando
10
1
n + 7c − cn ⩽ cn
10
Quindi T (n) ⩽ cn e quindi T (n) = O(n). Abbiamo trovato un limi-
te superiore e un limite inferiore, quindi la complessità è T (n) = Θ(n) .
Il problema è che le costanti sono così alte che nella pratica è meglio il
randomized_select.
Esiste un modo per strutturare meglio le informazioni nel calcolatore per trovare
l’elemento cercato in tempo log n? Si possono implementare delle Strutture dati
che permettono di fare ricerche in tempo logaritmico.
5 Strutture dati
Una struttura dati è un modo per organizzare i dati in modo da poterli manipolare
in modo efficiente. Bisogna avere un modo per comunicare con le strutture dati,
senza dover sapere come sono implementate.
5.1 Stack
Ad esempio se consideriamo uno stack, si possono individuare le seguenti operazioni:
• new(): Crea uno stack vuoto
• push(S,x): Inserisce un elemento x nello stack S
• top(S): Restituisce l’elemento in cima allo stack S
• pop(S): Rimuove l’elemento in cima allo stack S
• is_empty(): Restituisce vero se lo stack è vuoto
Da queste operazioni si possono definire certe proprietà dello stack:
• top(push(S,x)) = x
• pop(push(S,x)) = S
Questo ci dice che lo stack è LIFO (Last In First Out).
Abbiamo quindi definito un’algebra dei termini da cui possono definire tutte le
operazioni possibili, ad esempio uno stack è definito come una sequenza di push:
push(push(push(empty(),1),2),3)
29
5.2 Queue
Una coda è una struttura dati in cui si possono inserire elementi in fondo e rimuoverli
dall’inizio. Le operazioni possibili sono:
• new(): Crea una coda vuota
• enqueue(Q,x): Inserisce un elemento x in fondo alla coda Q
• dequeue(Q): Rimuove l’elemento in cima alla coda Q
• front(Q): Restituisce l’elemento in cima alla coda Q
• is_empty(): Restituisce vero se la coda è vuota
y v
z u t
30
• right(T): Restituisce il figlio destro dell’albero T
• value(T): Restituisce il valore del nodo dell’albero T
Un albero è bilanciato quando la differenza tra l’altezza del sottoalbero sinistro e
di quello destro è al massimo 1. Un albero è completo quando tutti i livelli sono
completi, cioè tutti i nodi sono presenti, tranne l’ultimo, che può essere incompleto.
25
17 30
2 20 27 40
31
1. Si parte dalla radice e si scende fino a trovare il nodo in cui inserire il
nuovo nodo rispettando le proprietà dell’albero binario di ricerca
2. Si inserisce il nodo in quel punto
• Rimozione Per rimuovere un elemento da un albero binario di ricerca si
possono avere 3 casi:
1. Il nodo da rimuovere è una foglia: si può rimuovere direttamente
2. Il nodo da rimuovere ha un solo figlio: si può sostituire il nodo con il
figlio
3. Il nodo da rimuovere ha due figli: si può sostituire il nodo con il minimo
del sottoalbero destro o con il massimo del sottoalbero sinistro
Una volta che un nodo viene aggiunto o rimosso non si può più essere certi che l’al-
bero sia ancora bilanciato, quindi bisogna fare in modo che l’albero venga bilanciato
ogni volta che viene modificato e la complessità non sarà più logaritmica.
4. Ogni cammino dalla radice ad una foglia contiene lo stesso numero di nodi
neri
Esempio 5.1. Abbiamo un albero con l nodi neri nel sotto albero destro,
quindi per la proprietà 4, il sotto albero sinistro deve avere l nodi neri:
32
Figura 7: Albero rosso-nero
26
17 41
14 21 30 47
10 16 19 23 28 38 nil nil
3 nil nil nil nil nil nil nil nil nil nil
nil nil
Tutte le proprietà sono verificate, però tutte le foglie sono dei nil (nero) e
questo è uno spreco di memoria, perchè metà dei nodi di un albero sono foglie
e quindi metà dei nodi sono utilizzati per la sentinella.
Questo si può risolvere facendo puntare tutte le sentinelle allo stesso valore
nil, però facendo così non si riesce più a risalire all’elemento padre di una
foglia.
Il concetto principale di questo tipo di alberi è quello della black height, cioè il
numero di nodi neri che si incontrano lungo un cammino dalla radice ad una foglia.
Prendendo in considerazione l’esempio 5.2 i nodi:
33
• nil: Hanno black height 0
• 3: Ha black height 1
• ecc...
Lemma:
Per ogni nodo x dell’albero, il sottoalbero radicato in x contiene almeno 2bh(x) − 1
nodi interni (nodi che non sono una foglia).
Dimostrazione:
Dimostriamo per induzione su bh(x):
• Caso base: Se x è una foglia, allora bh(x) = 0 e il sottoalbero radicato in x
contiene 0 nodi interni.
2bh(x) − 1 = 20 − 1 = 0
a b
h
bh(x) ⩾
2
34
h
Il numero di nodi n (per il lemma) vale 2 2 , si ha quindi che l’altezza di un RB
albero è almeno il doppio dell’altezza di un albero binario:
h
n ⩾ 2 2 −1
h
22 ⩽n+1
h
⩽ log2 (n + 1)
2
h ⩽ 2 log2 (n + 1)
x γ
α β
α y
β γ
35
7 y <- right ( parent ( parent ( x ) )
8 if color ( y ) = = red
9 color ( parent ( parent ( x ) ) ) <- red
10 color ( parent ( x ) ) <- black
11 color ( y ) <- black
12 x <- parent ( parent ( x ) )
13 else
14 if x = = right ( parent ( x ) )
15 x <- parent ( x )
16 left_rotate ( x )
17
18 color ( parent ( x ) ) <- black
19 color ( parent ( parent ( x ) ) ) <- red
20 right_rotate ( parent ( parent ( x ) ) )
21 x <- root
22
23 // Se il padre e ’ figlio sinistro del nonno
24 // Si fanno le stesse operazioni con le direzioni invertite
11
2 14
1 7
5 8
y
4
x
36
11
2 14
y
1 7
x
5 8
11
7 14
y
2 8
x
1 5
2 11
1 5 8 14
37
1. Il nodo da rimuovere è una foglia: si può rimuovere direttamente e
salviamo in un puntatore il nodo sentinella che sostituirà il nodo rimosso
2. Il nodo da rimuovere ha un solo figlio: si può sostituire il nodo con
il figlio e si salva in un puntatore il figlio
3. Il nodo da rimuovere ha due figli: si può sostituire il nodo con il
minimo del sottoalbero destro o con il massimo del sottoalbero sinistro
L’anomalia sarà un nodo rosso che dovrà essere nero per mantenere la pro-
prietà 4, oppure un nodo nero che dovrà valere come due neri per mantenere
la proprietà 4. Questa è un anomalia locale che si può risolvere propagandola
verso la radice dell’albero lavorando su un sottoinsieme di nodi.
Visto che ci sono i nodi sentinella i primi due casi sono risolti dallo stesso
codice. L’algoritmo è il seguente:
A D
x w
C E
A D
x w
C E
Per forza il padre di x deve essere nero (per mantenere la proprietà 3),
di conseguenza lo sono anche i figli di w. Si scambia il colore tra il padre
di x e w e si fa una rotazione a sinistra sul padre di x.
B E
A C
x
38
Questa trasformazione non viola nessuna proprietà dell’RB-albero, tran-
ne per l’anomalia di x.
Non siamo più nel caso in cui il fratello di x è rosso.
– Se w è nero:
?B
B
A D
x w
C E
Se w è nero non si può dire nulla sul colore del padre di x. Anche adesso
si distinguono due casi:
∗ Se i figli di w sono neri: Si toglie 1 nero dal livello di x e w e si
aggiunge 1 nero al padre di x. Si propaga l’anomalia verso l’alto:
?B
B
x
A D
C E
39
?B
B
A D
x w
α β C E
γ δ ε ζ
?B
B
A C
x
α β γ D
δ E
ε ζ
?B
B
A D
x w
?C
C E
40
?B
D
B E
?C
A C
41
radice si può salvare la grandezza del sottoalbero radicato nel nodo x e
usare la seguente formula:
size(left(x)) + 1
Questo funziona perchè il campo size è la somma dei campi size dei
figli più 1, cioè viene ricalcolato da capo utilizzando il valore salvato
nei figli.
In ogni caso dopo una rotazione gli unici nodi che cambiano il campo
size sono i nodi alla radice che si scambiano il valore.
Abbiamo dimostrato che si può mantenere il campo size mantenendo la
complessità logaritmica.
– Dato un nodo da trovare: Lo pseudocodice dell’algoritmo è il seguente:
42
9 count <- count + size ( left ( parent ( x ) ) + 1
10
11 x <- parent ( x )
12
13 return count
Oppure si potrebbe prima ordinare l’array e poi usare l’algoritmo di visita in-order
per inserire tutti gli elementi in un RB-albero, per fare ciò però bisogna creare un
"impalcatura", cioè un RB-albero con tutti i nodi nil, e poi inserire i nodi ordinati.
Si fanno tutte le foglie rosse e tutti gli altri nodi neri. In pseudocodice l’algoritmo
è il seguente:
43
1 // Crea un albero bilanciato con n nodi nil
2 build_empty_tree (n)
3 if n = = 0
4 return nil
5 else
6 x <- new_node ( nil )
7 left ( x ) <- b u i l d _ e m p t y _ t r e e ( floor (( n -1) /2) )
8 right ( x ) <- b u i l d _ e m p t y _ t r e e ( ceil (( n -1) /2) )
9 return x
Dimostrazione:
Supponiamo che array_to_rb abbia complessità f < n log n e consideriamo:
1 sort ( A )
2 T <- array_to_rb ( A )
3 A <- rb_to_array ( T )
f + n < n log n
e ciò vorrebbe dire che è stato creato un algoritmo di ordinamento più veloce di
n log n, cosa impossibile, quindi l’algoritmo array_to_rb non può avere comples-
sità f < n log n.
Se visitiamo un albero con gli algoritmi in-visit e pre-visit ottieniamo una stringa di
nodi, è possibile ricostruire l’albero originale partendo dalla radice?
Radice Sinistra Destra
z}|{ z }| { z }| {
• Pre: A B D E F C G H I
• In: E
| D{z B F} A
|{z} G
| C{z H }I
Sinistra Radice Destra
Dalle stringhe si possono individuare quali sono i sottoalberi sinistro e destro e qual’è
la radice. L’algoritmo per ricostruire l’albero è il seguente:
1 // P : Stringa di visita pre - ordine
2 // I : Stringa di visita in - ordine
3 // Supponiamo che le stringhe abbiano la stessa lunghezza
4 str_to_tree (P , I )
5 if length ( I ) = = 0
6 return nil
7 else
8 x <- new_node ( P [1])
9 i <- find (I , P [1])
10 left ( x ) <- str_to_tree ( P [2.. i ] , I [1.. i -1])
11 right ( x ) <- str_to_tree ( P [ i +1.. length ( P ) ] , I [ i +1.. length ( I ) ])
12 return x
44
A
B C
D F G H
E I
45
Teorema 5.1. Sia F un campo aggiuntivo, se
B0
Bi
Bi
B0
• B1 :
B1
B0
• B2 :
B1
B1 B0
B0
46
• B3 :
B1
B1 B1 B0
B1 B0 B0
B0
– Dimostrazione:
Bk−1 , Bk−2 , . . . , B0
47
– Dimostrazione:
• I contenuti dei nodi sono oggetti su cui è definita una relazione di ordinamento
• Per ogni dimensione c’è al più un albero binomiale
• I vari nodi soddisfano la proprietà di uno heap, cioè:
1 key ( x ) <= keys ( children ( x ) )
10 1 6
12 25 8 14 29
15 11 25 38
27
2k > 2log2 n = n
Per questa struttura non esiste un algoritmo di visita in ordine, perchè se ci fosse
vorrebbe dire che esisterebbe un algoritmo di ordinamento in tempo logaritmico e
sappiamo che non esiste.
48
5.7.1 Creazione di un heap binomiale
Per costruire un heap binomiale di dimensione n si può trasformare n in binario e
considerare la notazione polinomiale:
25 24 23 22 21 20
n =1 0 0 1 0 1
5 9 12 7 4 27 25
11 B2 10 B4 B5
next(x) sibling(x)
x
5 7 9 4 12 27 25
11 10 B2 B4 B5
L1 = 111 L2 = 110011
L’unione delle due liste si fa sommando i numeri binari: La nuova lista diventa:
1. Passo 1:
x next(x) sibling(x)
5 9 4 12 27 25
7 11 10 B2 B4 B5
49
1 1 1
1 1 0 0 1 1
0
2. Passo 2:
x next(x) sibling(x)
5 4 12 27 25
7 9 10 B2 B4 B5
11
1 11 1
1 1 0 0 1 1
1 0
3. Passo 3:
x next(x) sibling(x)
5 4 27 25
7 12 9 10 B4 B5
B2 11
1
11 11 1
1 1 0 0 1 1
1 1 1 0 1 0
5.7.3 Inserimento
L’inserimento si può vedere come l’unione tra un heap binomiale e un heap binomiale
con un solo nodo.
50
1 1 1 1 1 1
1
1 0 0 0 0 0
5.7.4 Rimozione
• Se si vuole rimuovere il minimo elemento di un heap binomiale bisogna rimuo-
vere una radice. Sappiamo che i figli di una radice sono tutti heap binomiali
di dimensione minore.
Bk−2 Bk−1 B0
Per rimuovere un nodo arbitrario si usa la reduce diminuendo il valore della chiave
del nodo e facendolo salire fino a che non arriva alla radice eliminando il nodo.
5.7.5 Riduzione
La riduzione prende un nodo, lo abbassa (diminuendo il valore della chiave) e gli
assegna la chiave a, mantenendo le proprietà di un heap.
La proprietà dello heap viene mantenuta rispetto ai figli, ma non rispetto al
padre. Se la chiave del padre è maggiore di quella del figlio, si scambiano le chiavi
e si fa salire il nodo fino a che non si rispettano le proprietà dello heap.
x a
51
La complessità della riduzione è O(log n).
5.7.6 Divisione
Per dividere un heap binomiale mantenendo la differenza di nodi tra le due parti al
più uno bisogna mantenere da parte il nodo di dimensione 0 e dividere il resto. Il
nodo da parte verrà poi riaggiunto ad una delle due lista utilizzando la union.
5.8.1 Complessità
Un modo per rappresentare un insieme è utilizzare una lista concatenata.
a b
/
• find_set(x): O(1) perchè basta restituire il valore del puntatore che punta
al rappresentante
• union(x,y): O(n) perchè bisogna aggiornare tutti i rappresentanti degli
insiemi. Se viene aggiunto un puntatore all’elemento in fondo alla lista, la
complessità diventa O(1).
52
limite superiore migliore si può ottenere calcolando la complessità media di tutte le
2
n2
operazioni e quindi la complessità finale diventa: O( m+nm ) = O(1 + m ).
Per fare meglio di Θ(n2 ) bisogna ottimizzare la union, cioè l’aggiornamento di tutti
i puntatori. Si potrebbe unire l’insieme più piccolo a quello più grande, al posto
di unire quello più grande a quello più piccolo e a questo punto bisogna cambiare
soltanto un puntatore, nel caso in cui l’elemento più piccolo abbia un elemento.
Questo metodo è chiamato unione per rango. Il numero massimo di volte che si
può modificare il puntatore al rappresentante dopo questa tecnica diventa log2 n
perchè:
1. Se ad un insieme di un elemento viene cambiato il puntatore al rappresentante
allora sappiamo che l’insieme risultante avrà minimo 2 elementi:
⩾2
⩾ 4 = 22
3. ...
⩾ 2i
Per fare meglio si può rappresentare in un modo alternativo gli insiemi, quindi con
degli alberi.
53
a f
b c g
d e h
O(1)
O(n)
O(n)
Come prima si può usare l’unione per rango per migliorare la complessità. Ogni
volta che un nodo aumenta di profondità (grazie all’unione ad un altro albero), il
numero di nodi raddoppia. Facendo l’unione per rango sappiamo che la complessità
della ricerca del rappresentante è O(log n) e la complessità dell’unione è O(log n).
Siccome la find_set è O(log n) siamo comunque peggio di prima, quindi si può far
puntare ogni nodo direttamente al rappresentante, ma questo viene fatto soltanto
quando viene richiamata la find_set, im modo da non farlo più alle chiamate
successive. L’algoritmo è il seguente:
1 find_set ( x ) :
2 if parent ( x ) = = x
3 return x
4 else
5 parent ( x ) = find_set ( parent ( x ) )
6 return parent ( x )
a b c ... g h
54
Di conseguenza la complessità della find_set diventa costante.
• Rimuovere un appuntamento
• Dato un intervallo di tempo trovare tutti gli appuntamenti in conflitto
con questo intervallo di tempo
Questo può essere rappresentato come un RB-albero in cui i nodi rappresentano
gli appuntamenti.
Quando sappiamo che non ci sono intervalli che intersecano a sinistra sappiamo
anche che non ce ne sono a destra perchè se l’intervallo da controllare fosse
all’interno dell’intervallo alla radice, allora sarebbe stato a sinistra. Ciò vuol
dire che questo intervallo si trova almeno alla fine dell’intervallo alla radice e
55
di conseguenza tutti gli altri intervalli si troveranno a destra della fine della
radice e quidi non ci sono intersezioni.
Chiavi
Chiavi Array
array
Questa funzione deve essere tale che due stringhe diverse abbiano un hash diverso
e che due stringhe uguali abbiano lo stesso hash. Per ottenere l’elemento associato
ad una stringa si avrà:
A(stringa) ⇒ A ′ (H (stringa))
Mappare uno spazio grande in uno più piccolo crea conflitti, cioè due stringhe diverse
possono essere associate allo stesso valore e quindi bisogna trovare un meccanismo
per risolvere i conflitti.
Chiavi
Chiavi Array "Pippo": 5 "Pluto": 7
array
56
La complessità di questa struttura dati è O(n) perchè bisogna scorrere tutta la
lista per trovare l’elemento ricercato. Se però si riesce a trovare una funzione
hash che riesce a distribuire in maniera uniforme gli input nella lista di dati,
allora il fattore di carico, cioè il rapporto tra il numero di elementi e il numero
di celle allocate, sarà:
n
m
dove n è il numero di elementi e m è il numero di celle allocate.
• Un altro approccio è quello di mantenere tutti i dati nell’array A ′ e non più
in una lista concatenata. Se in una cella è presente un elemento e si aggiunge
un elemento diverso che viene mappato nella stessa cella si può rilevare che
la cella è piena e quindi si inserisce l’elemento in una cella successiva. Per la
ricerca si guarda nella cella mappata e se non si trova l’elemento si guarda
nella cella successiva e così via fino a trovare l’elemento o una cella vuota.
Questo metodo funziona finchè l’array non è pieno, ma questo non viene mai
raggiunto perchè il fattore di carico è 21 .
In questo modo l’offset per fare le ricerche non è più costante, ma dipende
dalla chiave. In questa funzione di hash m e h2 (k) sono sempre relativamente
primi tra di loro.
Per eliminare un elemento bisogna scorrere tutta la lista per trovare l’elemento
e dopo averlo eliminato bisogna far scorrere tutti gli elementi successivi per
riempire il buco lasciato.
Data la funzione di hash si possono trovare subito dei conflitti, quindi per evitare
ciò bisogna introdurre un metodo di crittografia.
57
5.9.2 Funzioni di hash
Alcune funzioni di hash sono:
6 Tecniche di programmazione
Fin’ora abbiamo utilizzato la tecnica del divide et impera, cioè dividere il problema
in parti più piccole e risolverle con lo stesso algoritmo per poi unirle per ottenere il
risultato. Questa non è l’unica tecnica di programmazione esistente, ce ne sono mol-
te altre, ad esempio la programmazione greedy, cioè prendere le decisioni il prima
possibile e sperare che siano le migliori, oppure la programmazione dinamica, cioè
creare un’infrastruttura prima di poter prendere una decisione.
A1 : 10 × 100
A2 : 100 × 5
A3 : 5 × 50
(A1 · A2 ) · A3 = A1 · (A2 · A3 )
Solo che il numero di operazioni nei due casi è diverso, quindi per rendere minimo
il numero di operazioni è più conveniente fare: (A1 · A2 ) · A3 perchè:
58
C’è per forza una moltiplicazione che dovrà essere eseguita per ultima, quindi
distinguiamo k come il punto in cui verrà fatta l’ultima moltiplicazione.
(A1 · · · Ak ) · (Ak+1 · · · An )
Il numero modi per moltiplicare le matrici è dato dal numero di modi per moltiplicare
le matrici A1 . . . Ak e le matrici Ak+1 . . . An .
X
n−1
4n
P(n) = P(k) + P(n − k) ∈Ω 3
k=1
n2
Supponiamo che qualcuno ci dica il valore ottimo di k, allora è sicuro che il modo
in cui vengono moltiplicate le matrici (A1 · · · Ak ) e (Ak+1 · · · An ) è ottimale:
(A1 · · · Ak ) · (Ak+1 · · · An )
| {z } | {z }
Ottimo Ottimo
N (A1 · · · An ) =
59
Cerchiamo di migliorare l’algoritmo, implementando l’albero di ricorrenza, cioè un
alberi i cui nodi rappresentano le chiamate ricorsive dell’algoritmo, identificate dai
parametri i e j.
(1,n)
(2,2)(2,2) (3,3)(4,n)
Notiamo che ci sono molte chiamate ricorsive ripetute, ad esempio (4, n), e questo
aumenta la complessità dell’algoritmo inserendo istanze del problema che abbiamo
già affrontato. Al massimo ci saranno n2 istanze di un problema.
Anzichè risolvere il problema ogni volta che lo incontriamo, possiamo risolvere
ogni singolo problema una sola volta e salvare il risultato:
1 matrix_chain_order (P)
2 n <- length ( p ) - 1
3 for i <- 1 to n // n
4 // La mo lt i pl ic a zi on e di una sola matrice costa 0 perche ’ non c
’e ’ nulla da moltiplicare
5 M [i , i ] <- 0 // Matrice che salva tutte le soluzioni (i , j )
6
7 for l <- 2 to n // n - -* Problemi composti da n matrici
8 for i <- 1 to n - l + 1 // n - -*
9 j <- i + l - 1 // | n ^3
10 M [i , j ] <- + inf // |
11 for k <- i to j - 1 // n - -*
12 M [i , j ] <- min ( M [i , j ] ,
13 M [i , k ] + M [ k +1 , j ] + P [i -1]* P [ k ]* P [ j ])
14
15 return m [1 , n ]
Questo ci dice quante moltiplicazioni sono necessarie per moltiplicare tutte le ma-
trici, però per sapere la posizione dell’ultima moltiplicazione da fare invece si può
riscrivere l’algoritmo precedente come:
1 matrix_chain_order (P)
2 ...
3 for k <- i to j - 1
4 m <- M [i , k ] + M [ k +1 , j ] + P [i -1]* P [ k ]* P [ j ]
5 if m < M [i , j ]
6 M [i , j ] <- m // Numero m o lt ip l ic a zi on i
7 S [i , j ] <- k // Indice dell ’ ultima mo lt i pl ic a zi on e
8 ...
60
1 matrix_chain_order (P)
2 for i <- 1 to n
3 for j <- 1 to n
4 M [i , j ] <- + inf
5
6 m a t r i x _ c h a i n _ o r d e r _ a u x (P ,1 , n )
7
8
9 m a t r i x _ c h a i n _ o r d e r (P ,i , j )
10 if M [i , j ] ! = + inf
11 return M [i , j ]
12 else
13 if i = = j
14 M [i , j ] <- 0
15 else
16 m <- + inf
17 for k <- i to j -1
18 m <- min (m ,
19 m a t r i x _ c h a i n _ o r d e r (P ,i , k ) +
20 m a t r i x _ c h a i n _ o r d e r (P , k +1 , j ) +
21 P [i -1]* P [ k ]* P [ j ])
22 M [i , j ] <- m
23
24 return M [i , j ]
Per ogni istanza del problema si calcola una sola volta il risultato, quindi la com-
plessità è O(n3 ).
L’algoritmo che data la matrice S, che contiene gli indici delle parentesizzazio-
ni ottimal e la matrice A, che contiene le matrici da moltiplicare, restituisce la
moltiplicazione ottimale è il seguente:
1 o p t i m a l _ m u l t i p l y (S , A , i , j )
2 if i = = j
3 return A [ i ]
4
5 return o p t i m a l _ m u l t i p l y (S , A , i , S [i , j ]) *
6 o p t i m a l _ m u l t i p l y (S , A , S [i , j ] + 1 , j )
61
2 1: [8..12]
3 2: [3..5]
4 3: [8..11]
5 4: [3..8]
6 5: [0..6]
7 6: [1..4]
8 7: [5..9]
9 8: [2..13]
10 9: [6..10]
11 10: [5..7]
12 11: [12..14]
62
4 4: [5..7] // V
5 5: [3..8] // X
6 6: [5..9] // X
7 7: [6..10] // V
8 8: [8..11] // V
9 9: [8..12] // V
10 10: [2..13] // V
11 11: [12..14] // V
1 1: [1..4] // --
2 2: [3..5] // X
3 3: [0..6] // X
4 4: [5..7] // --
5 5: [3..8] // X
6 6: [5..9] // X
7 7: [6..10] // X
8 8: [8..11] // <-
9 9: [8..12] // X
10 10: [2..13] // X
11 11: [12..14] // V
1 1: [1..4] // --
2 2: [3..5] // X
3 3: [0..6] // X
4 4: [5..7] // --
5 5: [3..8] // X
6 6: [5..9] // X
7 7: [6..10] // X
8 8: [8..11] // --
9 9: [8..12] // X
10 10: [2..13] // X
11 11: [12..14] // --
{1, 4, 8, 11}
63
di fine di 1 è minore del tempo di fine di k
f1 ⩽ fk
perchè la lista è ordinata. Per questo si sa che ogni attività che si trova nell’insieme
A ha tempo di fine maggiore di fk perchè il tempo di fine di k non è mai minore del
tempo di fine di 1, quindi tutte le altre attività avranno tempo di fine maggiore di
fk e quindi non si sovrappongono con 1. Di conseguenza A ′ è un insieme di attività
compatibili e il primo elemento fa sempre parte dell’insieme di attività compatibili
di cardinalità massima.
Esempio 6.2. Ci sono 3 tipi di sostanze che hanno un prezzo diverso in base
al peso:
1 1: 20 Kg -> 100 $
2 2: 30 Kg -> 120 $
3 3: 10 Kg -> 60 $
Bisogna inserirle in uno zaino che può contenere al massimo 50Kg. Si vuole
massimizzare il valore dello zaino, quindi bisogna vedere quanto costa ogni
sostanza per 1Kg
1 1: 20 Kg -> 100 $ -> 5 $ / Kg
2 2: 30 Kg -> 120 $ -> 4 $ / Kg
3 3: 10 Kg -> 60 $ -> 6 $ / Kg
Per massimizzare il costo si può prendere 20Kg della prima sostanza, 20Kg
della seconda e 10Kg della terza. Si da quindi priorità alla sostanza che vale
di più per 1Kg e si riempie lo zaino con quella.
Si può ordinare la lista in base al valore al chilo e si prende il massimo della prima
risorsa della lista ordinata ripetendo il procedimento sullo stesso problema, ma
con uno zaino con capacità minore, cioè si è ridotto il problema ad uno dello
stesso tipo, ma più piccolo.
Se queste sostanze non fossero divisibili allora il problema non ha nè una so-
luzione greedy, nè una soluzione dinamica. Questo tipo di problema si chiama
64
problema di tipo NP-completo, cioè non è stato ancora trovato un algoritmo
che risolve il problema in tempo polinomiale.
65
1 USA & USSR
2 |
3 V
4 USASR
5 USSAR
6 USSRA
66