Il 0% ha trovato utile questo documento (0 voti)
8 visualizzazioni67 pagine

Algoritmi 1

Il documento tratta degli algoritmi, della loro complessità e delle strutture dati, fornendo una panoramica su come implementare e valutare algoritmi attraverso vari linguaggi di programmazione. Viene analizzato il calcolo della complessità, con esempi pratici e spiegazioni sui blocchi iterativi e condizionali. Inoltre, il documento esplora diverse tecniche di programmazione e algoritmi di ordinamento, evidenziando l'importanza della scelta dell'algoritmo più adatto in base alle necessità.

Caricato da

massdade
Copyright
© © All Rights Reserved
Per noi i diritti sui contenuti sono una cosa seria. Se sospetti che questo contenuto sia tuo, rivendicalo qui.
Formati disponibili
Scarica in formato PDF, TXT o leggi online su Scribd
Il 0% ha trovato utile questo documento (0 voti)
8 visualizzazioni67 pagine

Algoritmi 1

Il documento tratta degli algoritmi, della loro complessità e delle strutture dati, fornendo una panoramica su come implementare e valutare algoritmi attraverso vari linguaggi di programmazione. Viene analizzato il calcolo della complessità, con esempi pratici e spiegazioni sui blocchi iterativi e condizionali. Inoltre, il documento esplora diverse tecniche di programmazione e algoritmi di ordinamento, evidenziando l'importanza della scelta dell'algoritmo più adatto in base alle necessità.

Caricato da

massdade
Copyright
© © All Rights Reserved
Per noi i diritti sui contenuti sono una cosa seria. Se sospetti che questo contenuto sia tuo, rivendicalo qui.
Formati disponibili
Scarica in formato PDF, TXT o leggi online su Scribd
Sei sulla pagina 1/ 67

Algoritmi

UniVR - Dipartimento di Informatica

Fabio Irimie

1° Semestre 2024/2025
Indice
1 Introduzione 3
1.1 Confronto tra algoritmi . . . . . . . . . . . . . . . . . . . . . . . . 3
1.2 Rappresentazione dei dati . . . . . . . . . . . . . . . . . . . . . . . 3

2 Calcolo della complessità 3


2.1 Linguaggi di programmazione . . . . . . . . . . . . . . . . . . . . . 3
2.1.1 Blocchi iterativi . . . . . . . . . . . . . . . . . . . . . . . . 4
2.1.2 Blocchi condizionali . . . . . . . . . . . . . . . . . . . . . . 4
2.1.3 Blocchi iterativi . . . . . . . . . . . . . . . . . . . . . . . . 4
2.2 Esempio . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
2.3 Ordine di grandezza . . . . . . . . . . . . . . . . . . . . . . . . . . 5
2.3.1 Esempi di dimostrazioni . . . . . . . . . . . . . . . . . . . . 8

3 Studio degli algoritmi 10


3.1 Insertion sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
3.2 Fattoriale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
3.3 Teorema dell’esperto . . . . . . . . . . . . . . . . . . . . . . . . . . 15
3.4 Merge sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
3.5 Heap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
3.5.1 Proprietà . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
3.5.2 Heap sort . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
3.6 Quick sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
3.7 Algoritmi di ordinamento non per confronti . . . . . . . . . . . . . 21
3.7.1 Counting sort . . . . . . . . . . . . . . . . . . . . . . . . . 22
3.7.2 Radix sort . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
3.7.3 Bucket sort . . . . . . . . . . . . . . . . . . . . . . . . . . 22

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à.

1.1 Confronto tra algoritmi


Ogni algoritmo si può confrontare con gli altri in base a tanti fattori, come:
• Complessità: quanto ci vuole ad eseguire l’algoritmo
• Memoria: quanto spazio in memoria occupa l’algoritmo

1.2 Rappresentazione dei dati


Per implementare un algoritmo bisogna riuscire a strutturare i dati in maniera tale
da riuscire a manipolarli in modo efficiente.

2 Calcolo della complessità


La complessità di un algoritmo mette in relazione il numero di istruzioni da ese-
guire con la dimensione del problema, e quindi è una funzione che dipende dalla
dimensione del problema.

La dimensione del problema è un insieme di oggetti adeguato a dare un idea chiara


di quanto è grande il problema da risolvere, ma sta a noi decidere come misurare il
problema.
Ad esempio una matrice è più comoda da misurare come il numero di righe e il
numero di colonne, al posto di misurarla come il numero di elementi totali.

La complessità di solito si calcola come il caso peggiore, cioè il limite superiore di


esecuzione dell’algoritmo.

2.1 Linguaggi di programmazione


Ogni linguaggio di programmazione è formato da diversi blocchi:
1. Blocco iterativo: un tipico blocco di codice eseguito sequenzialmente e
tipicamente finisce con un punto e virgola.
2. Blocco condizionale: un blocco di codice che viene eseguito solo se una
condizione è vera.
3. Blocco iterativo: un blocco di codice che viene eseguito ripetutamente finché
una condizione è vera.

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.

2.1.1 Blocchi iterativi


I1 c1 (n)
I2 c2 (n)
.. ..
. .
Il cl (n)
Se ogni blocco ha complessità c1 (n), allora la complessità totale è data da:

X
l
ci (n)
i=1

2.1.2 Blocchi condizionali


IF cond ccond (n)
I1 c1 (n)
ELSE
I2 c2 (n)
La complessità totale è data da:

c(n) = ccond (n) + max(c1 (n), c2 (n))

A volte la condizione è un test sulla dimensione del problema e in quel caso si può
scrivere una complessità più precisa.

2.1.3 Blocchi iterativi


WHILE cond ccond (n)
I c0 (n)
Si cerca di trovare un limite superiore m al limite di iterazioni.

Di conseguenza la complessità totale è data da:

ccond (n) + m(ccond (n) + c0 (n))

4
2.2 Esempio

Esempio 2.1. Calcoliamo la complessità della moltiplicazione tra 2 matrici:

An×m · Bm×l = Cn×l

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.

La complessità totale alla fine sarebbe stata:

5nml + 4ml + 2n + n + 1

Ma ciò che ci interessa veramente è:

5nml + 4ml + 2n + n + 1

Se non consideriamo le costanti inutili, la complessità finale è:

nml

Nella maggior perte dei casi ci si concentra soltanto sull’ordine di grandezza


della complessità, e non sulle costanti.

2.3 Ordine di grandezza


L’ordine di grandezza è una funzione che approssima la complessità di un algoritmo:

f ∈ O(g)

5
∃c > 0 ∃n̄ ∀n ⩾ n̄ f(n) ⩽ cg(n)

y cg

t

Figura 1: Esempio di funzione f ∈ O(g)

f ∈ Ω(g)
∃c > 0 ∃n̄ ∀n ⩾ n̄ f(n) ⩾ cg(n)

f ∈ Θ(g)
f ∈ O(g) ∧ f ∈ Ω(g)

Per gli algoritmi:

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 )

Questa affermazione è corretta, ma non accurata.

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)

Quindi l’algoritmo non termina in un tempo minore di f.

Calcolando la complessità si troverà lo schema di input tale che:

g ∈ O(f)

cioè il limite superiore di esecuzione dell’algoritmo.


Successivamente ci si chiede se esistono algoritmi migliori e si troverà lo schema
di input tale che:
g ∈ Ω(f)
cioè il limite inferiore di esecuzione dell’algoritmo.
Se i due limiti coincidono allora:

g ∈ Θ(f)

abbiamo trovato il tempo di esecuzione dell’algoritmo.

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

Esempio 2.2. È vero che n ∈ O(2n)?


Se prendiamo c = 1 e n̄ = 1 allora:

n ⩽ c2n

Quindi è vero

Esempio 2.3. È vero che 2n ∈ O(n)?


Se prendiamo c = 2 e n̄ = 1 allora:

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) è:

∃c ′ > 0 ∃n̄ ′ ∀n ⩾ n̄ ′ f(n) ⩾ c ′ g(n)

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)

f1 (n) + f2 (n) ⩽ (c1 + c2 )g(n) □


Quindi:
c = (c1 + c2 )
n̄ = max(n̄1 , n̄2 )

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)

f1 (n) · f2 (n) ⩽ (c1 · c2 )(g1 (n) · g2 (n)) □


Quindi:
c = c1 · c2
n̄ = max(n̄1 , n̄2 )

3 Studio degli algoritmi


Il problema dell’ordinamento si definisce stabilendo la relazione che deve esistere tra
input e output del sistema.
• Input: Sequenza (a1 , . . . , an ) di oggetti su cui è definita una relazione di
ordinamento, cioè l’unico modo per capire la differenza tra due oggetti è
confrontarli.
• Output: Permutazione (a1′ , . . . , an′ ) di (a1 , . . . , an ) tale che:
∀i < j ai′ ⩽ aj′

L’obiettivo è trovare un algoritmo che segua la relazione di ordinamento definita e


risolva il problema nel minor tempo possibile.

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

Ordinato Non ordinato

i j

Figura 2: Parte ordinata e non ordinata

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

La complessità di questo algoritmo è:

O(n2 )

Per capirlo è sufficiente guardare il numero di cicli nidificati e quante volte eseguono
il codice all’interno.

Se l’array è già ordinato la complessità è:

Ω(n)

Con l’input peggiore possibile la complessità è:

Ω(n2 )

di conseguenza, visto che vale O(n2 ) e Ω(n2 ) vale:

Θ(n2 )

Quanto spazio in memoria utilizza questo algoritmo?


• Variabile j
• Variabile i
• Variabile key
A prescindere da quanto è grande l’array utilizzato, di conseguenza la memoria
utilizzata è costante.

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

L’insertion sort ordina in loco ed è stabile.

3.2 Fattoriale
1 Fatt ( n )
2 if n = 0
3 ret 1
4 else
5 ret n * Fatt ( n - 1)

L’argomento della funzione ci fa capire la complessità dell’algoritmo:



1 se n = 0
T (n) =
T (n − 1) + 1 se n > 0

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)

Questo si chiama passaggio iterativo.

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 

Se la complessità fosse già data bisognerebbe soltanto verificare se è corretta.

12
Usando il metodo di sostituzione:

T (n) = cn log n

sostituiamo nella funzione di partenza:


j n k
T (n) = 2T +n
j n2 k j n k
⩽ 2c log +n
2 2
n n
⩽ 2c log + n
2
 2
= cn log n − cn log 2 + n
?
⩽ cn log n se n − cn log 2 ⩽ 0

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

Per capire l’ordine di grandezza di 3log4 n si può scrivere come:


log4 n
3log4 n = n(logn 3 ) = nlog4 n·logn 3 = nlog4 3

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 )

3.3 Teorema dell’esperto

Teorema 3.1 (Teorema dell’esperto o Master theorem). Per un’equazione di


ricorrenza del tipo: n
T (n) = aT + f(n)
b
Si distinguono 3 casi:
• f(n) ∈ O(nlogb a−ε ) allora T (n); ∈ Θ(nlogb a )

• f(n) ∈ Θ(nlogb a ) allora T (n) ∈ Θ(f(n) log n)


• f(n) ∈ Ω(nlogb a+ε ) allora T (n) ∈ Θ(f(n))

Esempio 3.4. n


T (n) = 9T +n
3
Applico il teorema dell’esperto:

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 )

Quindi ho trovato il caso 1 del teorema dell’esperto.

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)

Esempio 3.6. n


T (n) = 3T + n log n
4
Applico il teorema dell’esperto:

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.

T (n) ∈ Θ(n log n)

Esempio 3.7. n


T (n) = 2T + n log n
2
Applico il 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.

3.4 Merge sort


Questo algoritmo di ordinamento è basato sulla tecnica divide et impera:

• Divide: Dividi il problema in sottoproblemi più piccoli


• Impera: Risolvi i sottoproblemi in modo ricorsivo
• Combina: Unisci le soluzioni dei sottoproblemi per risolvere il problema
originale

Questo algoritmo divide la sequenza in due parti uguali e le ordina separatamente,


successivamente le unisce in modo ordinato. La complessità, comsiderando il merge
con complessità lineare, risulta:
n
T (n) = 2T +n
2
Applicando il teorema dell’esperto si ottiene:

a=2
b=2
f(n) = n

nlogb a = n
n ∈ Θ(n)

17
Quindi siamo nel secondo caso del teorema dell’esperto:

T (n) ∈ Θ(n log n)

Definizione del merge sort:


1 // A : Array da ordinare
2 // P : Indice di partenza
3 // r : Indice di arrivo
4 merge_sort (A , p , r ) // --
5 if p < r // |
6 q <- floor (( p + r ) / 2) // |
7 merge_sort (A , p , q ) // | O ( n log n )
8 merge_sort (A , q + 1 , r ) // |
9 merge (A , p , q , r ) // --

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

Figura 3: Heap con l’indice di un array associato ai nodi

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 )

Ora si vuole definire una funzione che costruisce un heap da un array:


1 build_heap ( A ) // O ( n )
2 heapsize ( a ) <- length [ A ]
3 for i <- floor ( length [ A ]/2) downto 1
4 heapify (A , i )

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)

3.5.2 Heap sort


Heap sort è un algoritmo di ordinamento basato su heap.

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

La complessità dell’algoritmo è precisamente:

X
n Y
n
log i = log i = log n! = Θ(log nn ) = Θ(n log n)
i=1 i=1

Per la formula di Stirling n! ha ordine di grandezza nn . Questo algoritmo è in loco


e instabile.

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)

3.6 Quick sort


Il concetto di questo algoritmo è quello di mettere prima in disordine l’algoritmo
e poi ordinarlo. L’algoritmo divide l’array in 2 parti e ordina ricorsivamente le due
parti; a quel punto l’array è ordinato.
1 // A : Array da ordinare
2 // p : Indice di partenza
3 // r : Indice di arrivo
4 quick_sort (A , p , r )
5 if p < r // Ordina solo se l ’ array ha piu ’ di un elemento
6 q <- partition (A , p , r ) // Dividi l ’ array in due parti
7 quick_sort (A , p , q ) // Ordina sinistra
8 quick_sort (A , q + 1 , r ) // Ordina destra

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

3.7 Algoritmi di ordinamento non per confronti


Si possono avere algoritmi di ordinamento con complessità < n log n?

Qualsiasi algoritmo che ordina per confronti deve fare almeno n log n confronti nel
caso pessimo

x → n!

x1 x2

x1,1 x1,2 x2,1 x2,2

Figura 4: Heap con l’indice di un array associato ai nodi

Le foglie rappresentano ogni singola combinazione possibile. Il numero di foglie è


n! e l’altezza sarà sempre
h ⩾ log2 n! = n log n

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

La complessità di questo algoritmo è O(n + k) e siccome sappiamo che k è una


costante fissata a priori la complessità è O(n). Non è in loco, ma è stabile

3.7.2 Radix sort


Il radix sort è un ordinamento lessico grafico, cioè si ordinano le cifre partendo da
quella meno significativa e se sono uguali si passa a quella più significativa.
La complessità dell’algoritmo è:

Θ(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

e ad esempio per rappresentare n2 − 1 valori possibili serviranno 2 cifre. cifre.

3.7.3 Bucket sort


Dato un array di numeri con supporto infinito e distribuzione nota, si può dividere
l’array in k parti (bucket) uguali (equiprobabili) e ordinare ricorsivamente. Ogni
coppia di gruppi deve essere totalmente ordinata, cioè ogni elemento del primo
gruppo deve essere minore di ogni elemento del secondo gruppo. Una volta ordinati
i gruppi (con un algoritmo di ordinamento a scelta) si concatenano in modo ordinato.

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

e la sua complessità diventa:


O(n2 )

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

La complessità di questo algoritmo sarà quindi:


X
C= (Nj )2
j

Per ottenere il valore medio della complessità:


 
X
E[C] = E  (Nj )2 
j
X
= E[(Nj )2 ]
j
X
Var[Nj ] + E[Nj ]2

=
j
P
sappiamo che Nj = i Xij , quindi la media è:

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).

4.1 Ricerca del minimo o del massimo

In tempo lineare si può trovare il minimo e il massimo di un array:


1 minimo ( A )
2 min <- A [1]
3 for i <- 2 to length [ A ]
4 if A [ i ] < min
5 min <- A [ i ]
6 return min

trovare il minimo equivale a trovare selezione(A, 1) e trovare il massimo equivale


a trovare selezione(A, n). Si può però andare sotto la complessità lineare?

Per trovare il massimo (o il minimo) elemento n di un array bisogna fare almeno


n − 1 confronti perchè bisogna confrontare ogni elemento con l’elemento massimo
(o minimo) trovato per poter dire se è il massimo (o minimo). Di conseguenza, non
è possibile avere un algoritmo per la ricerca del massimo (o minimo) in cui c’è un
elemento che non "perde" mai ai confronti (cioè risulta sempre il più grande) e non
viene dichiarato essere il più grande (o più piccolo).

Dimostrazione: Per dimostrarlo si può prendere un array in cui l’elemento a non


perde mai ai confronti, ma l’algoritmo dichiara che il massimo è l’elemento b.
Allora si rilancia l’algoritmo sostituendo l’elemento a con a = max(b+1,a) e si
ripete l’algoritmo con questo secondo array in cui a è l’elemento più grande. Si ha
quindi che i confronti in cui a non è coinvolto rimangono gli stessi e i confronti in
cui a è coinvolto non cambiano perchè anche prima a non perdeva mai ai confronti,
di conseguenza l’algoritmo dichiarerà che il massimo è b e quindi l’algoritmo non è

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).

4.1.1 Ricerca del minimo e del massimo contemporaneamente


Si potrebbe implementare unendo i 2 algoritmi precedenti:
1 min_max ( A )
2 min <- A [1]
3 max <- A [1]
4 for i <- 2 to length [ A ]
5 if A [ i ] < min
6 min <- A [ i ]
7 if A [ i ] > max
8 max <- A [ i ]
9 return ( min , max )

Questo algoritmo esegue n − 1 + n − 1 = 2n − 2 confronti.

• Limite inferiore: Potenzialmente ogni oggetto potrebbe essere il minimo o il


massimo. Sia m il numero di oggetti potenzialmente minimi e M il numero
di oggetti potenzialmente massimi. Sia n il numero di oggetti nell’array.
– All’inizio m + M = 2n perchè ogni oggetto può essere sia minimo che
massimo.
– Alla fine m + M = 2 perchè alla fine ci sarà un solo minimo e un solo
massimo.
Quando viene fatto un confronto m + M può diminuire.
– Se si confrontano due oggetti che sono potenzialmente sia minimi che
massimi, allora m + M diminuisce di 2 perchè:

a<b

b non può essere il minimo e a non può essere il massimo e si perdono


2 potenzialità.
– Se si confrontano due potenziali minimi (o massimi), allora m + M
diminuisce di 1 perchè:
a<b
b non può essere il minimo e si perde 1 potenzialità.
Un buon algoritmo dovrebbe scegliere di confrontare sempre 2 oggetti che
sono entrambi potenziali minimi o potenziali massimi.

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à
 

calare solo di 1 ad ogni confronto.

Successivamente il numero di oggetti rimane:



n + 1 se n è dispari
n se n è pari

– 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

Quindi la complessità è Ω( 32 n − 2) = Ω(n) (limite inferiore). Meglio di


 

così non si può fare, ma non è detto che esista un algoritmo che raggiunga
questo limite inferiore.

Un algoritmo che raggiunge il limite inferiore è il seguente:


1. Dividi gli oggetti in 2 gruppi:

a1 b1
a2 b2
.. ..
. .
a⌊ n ⌋ b⌈ n ⌉
| {z2 } | {z2 }
|Potenziali minimi {zPotenziali massimi}
Potenziali sia minimi che massimi

2. Confronta ai con bi , supponendo ai < bi (mette a sinistra i più piccoli e a


destra i più grandi)
3. Cerca il minimo degli ai e cerca il massimo dei bi :
4. Sistema l’eventuale elemento in più se l’array è dispari

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

• Se dividessimo sempre a metà si avrebbe:


n
T (n) = n + T = Θ(n) (terzo caso del teorema dell’esperto)
2

• 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

La complessita media è lineare.


Si esegue un solo ramo, che nel caso pessimo è quello con più elementi. La
risoluzione è la stessa del quick sort.

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:

1. Dividi gli oggetti in n5 gruppi di 5 elementi più un eventuale gruppo con


 

meno di 5 elementi.
2. Calcola il mediano di ogni gruppo di 5 elementi (si ordina e si prende l’elemento
centrale). Θ(n)

3. Calcola ricorsivamente il mediano x dei mediani


l n m
T
5

4. Partiziona con perno x e calcola k (numero di elementi a sinistra). Θ(n)


5. Se i < k cerca a sinistra l’elemento i, altrimenti cerca a destra l’elemento
i−k. La chiamata ricorsiva va fatta su un numero di elementi sufficientemente

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.

Quindi abbiamo trovato T (?):

7
l n m  
T (n) = Θ(n) + T +T n+6
5 10

Dimostriamo con il metodo di sostituzione, supponendo T (n) ⩽ cn, che la


disequazione sia vera:
lnm 7
T (n) ⩽ n + c + c( n + 6)
5 10
7
Non sappiamo se 10 n + 6 è minore di n, quindi lo calcoliamo:

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

5.3 Lista doppiamente puntata


Una lista doppiamente puntata è una struttura dati in cui ogni nodo ha un puntatore
al nodo precedente e al nodo successivo.

Per rimuovere un elemento x dalla lista bisogna:


1. Trovare il nodo x
2. Collegare il nodo precedente a x con il nodo successivo a x
Il problema di questa soluzione è che bisogna anche gestire i casi degli estremi
separatamente. Per evitare di gestire i casi specifici si possono aggiungere dei nodi
sentinella all’inizio e alla fine della lista.

5.4 Albero binario


Un albero binario è una struttura dati in cui ogni nodo ha al massimo 2 figli.

y v

z u t

Figura 5: Albero binario

Le operazioni possibili su un albero binario sono:


• new(): Crea un albero vuoto
• insert(T, x): Crea un nuovo nodo con valore x e lo aggiunge all’albero T
• extract(T, x): Rimuove il nodo con valore x dall’albero T
• is_empty(): Restituisce vero se l’albero è vuoto
• left(T): Restituisce il figlio sinistro dell’albero 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.

La profondità di un albero di n nodi è:


n−1
 
P(n) = 1 + P = Θ(log n)
2

5.4.1 Albero binario di ricerca


Un albero binario di ricerca è un albero binario in cui per ogni nodo x valgono le
seguenti proprietà:
• Tutti i nodi nel sottoalbero sinistro di x hanno valore minore di x
• Tutti i nodi nel sottoalbero destro di x hanno valore maggiore di x

25

17 30

2 20 27 40

Figura 6: Albero binario di ricerca

Le operazioni possibili su un albero binario di ricerca sono:


• insert(T, x): Inserisce un nodo con valore x nell’albero T
• extract(T, x): Rimuove il nodo con valore x dall’albero T
• search(T, x): Restituisce vero se il valore x è presente nell’albero T
• min(T): Restituisce il valore minimo dell’albero T
• max(T): Restituisce il valore massimo dell’albero T

Non è possibile inserire un nodo in un albero binario di ricerca in tempo logaritmico


perchè potrebbe dover essere inserito in modo da sbilanciare l’albero, quindi per
riottenere un albero bilanciato non rimane che spostare tutti gli elementi in una
posizione diversa.
Per ottenere la complessità logaritmica, bisogna utilizzare un albero che si può
sbilanciare, ma non troppo.

• Inserimento Per inserire un nodo in un albero binario di ricerca si può


procedere nel seguente modo:

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.

5.4.2 RB-Tree (Red-Black Tree)


Gli alberi rosso-neri sono alberi binari di ricerca in cui ogni nodo può essere rosso o
nero.
• Nero: Il nodo nero indica che il nodo è a posto
• Rosso: Il nodo rosso è un nodo ausiliario

Questo tipo di albero ha le seguenti proprietà:


1. Ogni nodo è rosso o nero
2. Ogni foglia è nera
3. I figli di un nodo rosso sono neri

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

Il sotto albero sinistro avrà 2l nodi totali.

Esempio 5.2. Prendiamo ad esempio il seguente albero RB:

26

17 41

14 21 30 47

10 16 19 23 28 38 nil nil

7 12 15 20 nil nil nil nil 33 39

3 nil nil nil nil nil nil nil nil nil nil

nil nil

Figura 8: Albero rosso-nero

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

• 19: Ha black height 1


• 14: Ha black height 2
• 26: Ha black height 3

• 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

• Se x è un nodo con un figlio destro b e un figlio sinistro a allora:

a b

bh(a) ⩾ bh(x) − 1 e bh(b) ⩾ bh(x) − 1


#nodi(x) ⩾ #nodi(a) + #nodi(b) + 1

#nodi(x) ⩾ 2bh(a) − 1 + 2bh(b) − 1 + 1
⩾ 2bh(x)−1 − 1 + 2bh(x)−1
−1
+1

= 2 · 2bh(x)−1 − 1
= 2bh(x) − 1
Quindi
#nodi(x) ⩾ 2bh(x) − 1

Complessità: Consideriamo un albero RB di altezza h (distanza tra la radice e la


foglia più lontana, escludendo la radice) e radice x, si ha che bh(x) vale:

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)

• Inserimento: L’inserimento di un nodo in un albero RB si fa allo stesso modo


di un albero binario di ricerca. Per non violare la proprietà 4, si fa di colore
rosso e si salva un puntatore al nuovo oggetto, questo perchè il nuovo albero
è un RB albero, tranne per l’oggetto puntato dal puntatore perchè potrebbe
avere un padre rosso. Si propaga l’anomalia verso la radice dell’albero per po-
ter cambiare il colore della radice mantenendo tutte le proprietà. Per risolvere
il problema si può fare una serie di rotazioni a destra o a sinistra.

Prendiamo in considerazione il seguente albero:

x γ

α β

La rotazione a destra della coppia x e y è la seguente:

α y

β γ

Questa operazione mantiene tutte le proprietà dell’albero binario di ricerca.


L’operazione opposta è la rotazione a sinistra. Il tempo di esecuzione di una
rotazione è costante.

Lo pseudocodice dell’algoritmo per risistemare l’albero è il seguente:


1 // Albero con radice sicuramente nera
2 // x : Nodo da cui si propaga l ’ anomalia
3
4 while x ! = root and color ( parent ( x ) ) = = red
5 // Se il padre e ’ figlio sinistro del nonno
6 if parent ( x ) = = left ( parent ( parent ( x ) ) )

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

La complessità di questo algoritmo è O(log n) perchè è un inserimento in un


albero binario di ricerca con delle rotazioni per sistemare l’anomalia, ma il
numero di rotazioni è costante.

Esempio 5.3. Un esempio del predcedente algoritmo è il seguente:

11

2 14

1 7

5 8
y

4
x

Si cambiano i colori dei nodi parent(x) e y e si sposta l’anomalia verso


l’alto

36
11

2 14
y

1 7
x

5 8

Prendiamo la coppia 2 e 7 e ruotiamo a sinistra

11

7 14
y

2 8
x

1 5

Si cambiano i colori e si effettua una rotazione a destra

2 11

1 5 8 14

L’albero ora non ha più l’anomalia.

• Rimozione: La rimozione di un nodo da un albero RB si fa allo stesso modo


di un albero binario di ricerca. Bisogna quindi distinguere i 3 casi:

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

Consideriamo un nodo x non radice e doppiamente nero (anomalia), pren-


diamo il fratello w di x e distinguiamo i seguenti casi:
– Se w è rosso:

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

Se il nuovo x è rosso l’anomalia è risolta, altrimenti si applica la


stessa procedura al nuovo x.
Questa trasformazione non viola nessuna proprietà dell’RB-albero;
anche se x non ha un colore specifico (e se fosse rosso avrebbe un
figlio rosso, di conseguenza violerebbe una proprietà dell’RB-albero),
l’anomalia si è spostata sul nuovo x, di conseguenza se fosse rosso
diventerebbe nero (anomalia risolta) e se fosse nero diventerebbe
doppiamente nero.
∗ Se i figli di w non sono entrambi neri:
· Se il figlio destro di w è nero: Visto che entrambi i figli di
w non sono neri e il destro è nero, allora il sinistro è per forza
rosso. (le lettere greche sono i sottoalberi delle foglie che non
vengono considerati)

39
?B
B

A D
x w

α β C E

γ δ ε ζ

Si scambia il colore tra w e il figlio sinistro di w e si fa una


rotazione a destra su w:

?B
B

A C
x

α β γ D

δ E

ε ζ

Questa trasformazione preserva l’RB-albero. Ora il figlio destro


del fratello di x è rosso e si applica il caso successivo.
· Se il figlio destro di w è rosso: Visto che entrambi i figli di
w non sono neri e il destro è rosso, allora il sinistro potrebbe
essere sia rosso che nero.

?B
B

A D
x w

?C
C E

Si assegna a w il colore del padre di x, al padre di x e al figlio


destro di w il colore nero. Infine si fa una rotazione a sinistra
sul padre di x e si porta l’anomalia alla radice.

40
?B
D

B E

?C
A C

Questa trasformazione preserva l’RB-albero.


L’anomalia punta alla radice dell’albero, in questo caso non
punta a niente perchè non sappiamo se il nodo più in alto è la
radice, quindi l’anomalia è risolta.
L’algoritmo è finito quando l’anomalia punta alla radice dell’albero. Lo pseu-
docodice dell’algoritmo è il seguente:
1 // x : Nodo da cui si propaga l ’ anomalia
2 while x ! = root and color ( x ) = = black
3 w <- right ( parent ( x ) )
4 // Se x e ’ figlio sinistro del padre
5 if x = = left ( parent ( x ) )
6 // Se w e ’ rosso ( caso 1)
7 if color ( w ) = = red
8 color ( w ) <- black
9 color ( parent ( x ) ) <- red
10 left_rotate ( parent ( x ) )
11 else // Se w e ’ nero
12 // Se i figli del w sono neri ( caso 2 , propaga x in alto
)
13 if color ( left ( w ) ) = = black and color ( right ( w ) ) = = black
14 color ( w ) <- red
15 x <- parent ( x )
16 else
17 // Se il figlio destro di w e ’ nero ( caso 3 , termina )
18 if color ( right ( w ) ) = = black
19 color ( left ( w ) ) <- black
20 color ( w ) <- red
21 right_rotate ( w ) )
22
23 // Se il figlio destro di w e ’ rosso ( caso 4 , termina )
24 color ( w ) <- color ( parent ( x ) )
25 color ( parent ( x ) ) <- black
26 color ( right ( w ) ) <- black
27 left_rotate ( parent ( x ) )
28 x <- root
29 // Se x e ’ figlio destro del padre
30 // Si fanno le stesse operazioni con le direzioni invertite

La complessità di questo algoritmo è Θ(log n) si fa un massimo di 3 rotazioni.


• Selezione: La selezione di un nodo in un albero RB si fa allo stesso modo di
un albero binario di ricerca. Si parte dalla radice e si scende fino a trovare il
nodo desiderato. La complessità di questo algoritmo è O(log n). Se riesco a
trovare un modo per decidere se andare a sinistra o a destra in tempo costante
allora è sufficiente a far diventare la complessità logaritmica.

– Dato l’indice da trovare: Per sapere la posizione dell’elemento della

41
radice si può salvare la grandezza del sottoalbero radicato nel nodo x e
usare la seguente formula:

size(left(x)) + 1

Usando questa formula si può decidere se andare a sinistra o a destra in


tempo costante e lo pseudocodice dell’algoritmo è il seguente:
1 // x : Nodo da cui si parte la selezione
2 // i : Indice dell ’ elemento da selezionare
3 select (x , i )
4 r <- size ( left ( x ) ) + 1
5 if i = = r
6 return x
7 else if i < r
8 return select ( left ( x ) ,i )
9 else
10 return select ( right ( x ) ,i - r )
11

La complessità di questo algoritmo è O(log n) perchè la decisione di


andare a sinistra o a destra è in tempo costante.

Questo crea il problema di dover aggiornare la grandezza del sottoalbero


ad ogni inserimento e rimozione di un nodo.
∗ In un inserimento, il valore size del nodo aggiunto vlae 1, mentre il
campo size di tutti i nodi del percorso seguito per inserire il nodo
aumenta di 1.
∗ In un estrazione, il campo size di tutti i nodi del percorso seguito
per rimuovere il nodo diminuisce di 1.
∗ Nella sistemazione dell’albero, i campi del colore non hanno alcuna
influenza sul campo size, mentre se si fa una rotazione il valore di
size deve cambiare:
1 size ( x ) <- size ( left ( x ) ) + size ( right ( x ) ) + 1
2

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:

1 // x : Nodo da cui si parte la selezione


2 // y : Nodo da trovare
3 rank ( x )
4 count <- size ( left ( x ) ) + 1
5 while x ! = root
6 // Se x e ’ figlio destro del padre
7 if x = = right ( parent ( x ) )
8 // Si aggiunge la grandezza del sottoalbero sinistro
del padre

42
9 count <- count + size ( left ( parent ( x ) ) + 1
10
11 x <- parent ( x )
12
13 return count

La complessità di questo algoritmo è O(log n) perchè la decisione di


andare a sinistra o a destra è in tempo costante.

Se si volessero inserire tutti gli elementi di un RB-albero in un array ordinato,


si potrebbe usare un algoritmo di visita in-order dell’albero. Lo pseudocodice
dell’algoritmo è il seguente:
1 // x : Nodo da cui si parte la visita
2 // A : Array in cui inserire x
3 // i : Indice dell ’ array in cui inserire x ( passato per riferimento )
4 in_visit (x ,A , i )
5 if x ! = nil
6 in_visit ( left ( x ) ,A , i )
7 visit (x ,A , i ) // Inserisce x nell ’ array
8 in_visit ( right ( x ) ,A , i )
9
10
11 // x : Nodo da inserire nell ’ array
12 // A : Array in cui inserire x
13 // i : Indice dell ’ array in cui inserire x ( passato per riferimento )
14 visit (x ,A , i )
15 A [ i ] <- value ( x )
16 i ++

Questo algoritmo ha complessità Θ(n) perchè visita tutti i nodi dell’albero.


T (n) = T (nleft ) + 1 + T (nright ) = nleft + 1 + nright = n

Ci sono anche le seguenti alternative:


1 pre_visit (x ,A , i )
2 if x ! = nil
3 visit (x ,A , i ) // Inserisce x nell ’ array
4 pre_visit ( left ( x ) ,A , i )
5 pre_visit ( right ( x ) ,A , i )
6
7 post_visit (x ,A , i )
8 if x ! = nil
9 post_visit ( left ( x ) ,A , i )
10 post_visit ( right ( x ) ,A , i )
11 visit (x ,A , i ) // Inserisce x nell ’ array

Se si volesse creare un RB-albero partendo da un array (non necessariamente or-


dinato) si potrebbero fare n inserimenti in un RB-albero vuoto. La complessità di
questo algoritmo è Θ(n log n).

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

Questo algoritmo non si può fare con complessità minore di n log n.

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 )

La complessità di questo algoritmo sarebbe:

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

L’albero sarà quindi:

44
A

B C

D F G H

E I

Figura 9: Albero ricostruito

È possibile implementare un algoritmo che unisce 2 RB-alberi in un unico RB-albero


in tempo log n?
1 A1 <- rb_to_array ( T1 )
2 A2 <- rb_to_array ( T2 )
3 A <- merge ( A1 , A2 )
4 T <- array_to_rb ( A )

Questo ha complessità O(n) perchè è l’unione di più algoritmi con complessità


lineare.
Questo problema è simile al concetto del merge sort, cioè prendere 2 metà,
risolverle ed unire il risultato.
1 array_to_rb (A ,p , q )
2 if p > q
3 return nil
4 else
5 r <- ( p + q ) /2
6 T1 <- array_to_rb (A ,p , r )
7 T2 <- array_to_rb (A , r +1 , q )
8 return union ( T1 , T2 )

Questo algoritmo ha complessità:

T (n) = 2T (n/2) + C(union)

Supponiamo che C(union) = log n, quindi T (n) = Θ(n), ma sappiamo che in


tempo minore di n log n non si può costruire un RB-albero, di conseguenza non è
possibile che la union abbia complessità log n.

5.5 Campi aggiuntivi


Si possono aggiungere dei campi aggiuntivi alle strutture dati per velocizzare alcuni
algoritmi, ma bisogna stare attenti a mantenere la complessità delle operazioni
inalterata.

45
Teorema 5.1. Sia F un campo aggiuntivo, se

∃f.∀xF(x) = f(key(x), F(left(x)), F(right(x)), key(left(x)), key(right(x)))

allora F è mantenibile in log n.

Cioè se il campo aggiuntivo è calcolabile utilizzando le operazioni definite in f


allora la complessità di F è mantenibile.

5.6 Albero Binomiale


Gli alberi binomiali sono definiti ricorsivamente rispetto ad un valore chiamato valore
binomiale. Un albero di dimensione 0 sarà da un solo nodo:

B0

Per costruire un albero binomiale di dimensione i + 1 si fa diventare il secondo


albero un sottoalbero del primo albero:

Bi

Bi

Quindi un albero di dimensione:


• B0 :

B0

• B1 :

B1

B0

• B2 :

B1

B1 B0

B0

46
• B3 :

B1

B1 B1 B0

B1 B0 B0

B0

Teorema 5.2. • Il numero di nodi è 2k


– Dimostrazione:

Faccio vedere che la proprietà vale per 0 (caso base). Il numero


totale di nodi in un albero binomiale di dimensione 0 è 1, cioè 20 .
Supponiamo per induzione che in Bk ci siano 2k nodi. Per dimo-
strare che in Bk+1 ci sono 2k+1 nodi, utilizziamo la costruzione
dell’albero binomiale Bk e aggiungiamo un altro albero binomiale
Bk come figlio sinistro della radice di Bk . Quindi il numero di nodi
in Bk+1 è 2k + 2k = 2k+1 .
• L’altezza dell’albero è k
– Dimostrazione:

L’altezza di un albero binomiale di dimensione 0 è 0.


Supponiamo per induzione che la dimensione di un albero binomiale
Bk sia k. Per dimostrare che la dimensione di un albero binomiale
Bk+1 sia k + 1, sappiamo che l’altezza di un albero è il cammino
più lungo radice-foglia, quindi il cammino più lungo dalla radice al
figlio sinistro è k più un arco, quindi l’altezza è k + 1.

• A profondità i ci sono ki nodi: (1 + i)k




– Dimostrazione:

Se consideriamo il triangolo di targaglia, abbiamo che l’elemento


alla riga k e colonna i è ki . Quindi il numero di nodi alla profondità
i è:
k+1
     
k k
+ =
i i−1 i
• I figli della radice da sinistra a destra sono radici di:

Bk−1 , Bk−2 , . . . , B0

47
– Dimostrazione:

Un albero di dimensione 0 non ha figli, quindi la radice di Bk ha co-


me figli le radici di Bk−1 , Bk−2 , . . . , B0 . Per k + 1 basta aggiungere
un sottoalbero k come figlio sinistro della radice

k k-1 k-2 ... 0

5.7 Heap Binomiale


Un heap binomiale è una lista di alberi binomiali, dove:

• 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

Figura 10: Esempio di heap binomiale

La complessità per cercare il minimo è data dal numero di radici.


In un heap di n nodi il numero di alberi binomiali è log n.

2k > 2log2 n = n

Di conseguenza la complessità per trovare il minimo è O(log 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

Si può esprimere un qualsiasi numero n come somma di potenze di 2. Se la cifra


è 1 allora si crea un albero binomiale di quella dimensione, altrimenti si passa alla
cifra successiva.

5.7.2 Unione di due heap binomiali


Si vogliono unire i seguenti heap binomiali:

5 9 12 7 4 27 25

11 B2 10 B4 B5

Si scrive un unica lista che è il merge delle due liste:

next(x) sibling(x)
x
5 7 9 4 12 27 25

11 10 B2 B4 B5

Le due liste rappresentate in binario sono:

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

L’unione di due heap binomiali ha complessità O(log n).

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

Anche l’inserimento ha complessità O(log n).

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

La complessità per rimuovere il minimo elemento è O(log n).

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

Figura 11: Riduzione della chiave di un nodo

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 Struttura dati facilmente partizionabile


Farebbe comodo avere una struttura dati che permetta di partizionare una collezione
di oggetti in complessità costante. Si vogliono implementare i seguenti algoritmi:
• make_set(x) crea un insieme di un solo elemento

• union(x,y) unisce due insiemi a cui appertengono due oggetti


• find_set(x) restituisce il rappresentante dell’insieme a cui appartiene x.
Sappiamo soltanto quello che vogliamo fare, non sappiamo come implementarlo.

5.8.1 Complessità
Un modo per rappresentare un insieme è utilizzare una lista concatenata.

a b
/

Figura 12: Esempio di una lista concatenata

Si potrebbe dire che il rappresentante dell’insieme è il primo elemento della lista.


Con queste informazioni possiamo dire che le complessità degli algoritmi saranno:
• make_set(x): O(1) perchè basta creare un nodo che punta a se stesso perchè
è il rappresentante e non ha altri elementi

• 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).

Se vengono effettuate m operazioni di cui n sono make_set ( cioè il numero di


oggetti, di conseguenza il numero di union possibili), la complessità nel caso pessimo
è O(m ∗ n). Di tutte queste operazioni, le union saranno al massimo n − 1 perchè
non si possono unire più di n insiemi. Tutte le rimanenti saranno operazioni di tempo
costante, di conseguenza un altro limite superiore corretto sarebbe O(m + n2 ). Un

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 capire se questa complessità è accurata bisogna trovare un insieme di operazioni


da fare che portino ad avere quella complessità.

1. Si fanno n make_set creando n insiemi


2. Si uniscono le coppie di insiemi. Per la prima coppia il costo è 1, per la
seconda 2 e così via. Il costo totale è:
n(n + 1)
1 + 2 + 3 + ... + n = = Θ(n2 )
2

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

2. Se ad un insieme di due elementi viene cambiato il puntatore al rappresentan-


te allora sappiamo che l’insieme risultante avrà al minimo 4 elementi (perchè
sappiamo che bisogna unire l’insieme più piccolo ad un altro insieme, di con-
seguenza se sappiamo che l’insieme di due elementi è il più piccolo, l’altro
insieme avrà al massimo 2 elementi):

⩾ 4 = 22

3. ...

4. Se ad un insieme di i elementi viene cambiato il puntatore al rappresentante


allora sappiamo che l’insieme risultante avrà al minimo 2i elementi:

⩾ 2i

Questa costruzione si chiama iterative squaring. La complessità con l’unione per


rango diventa:
m + n log n n log n
=1+ ⩽ 1 + log n
m m

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

Figura 13: Esempio di alberi come insiemi

• make_set(x): si crea un albero con un solo nodo

O(1)

• find_set(x): si risale l’albero fino alla radice

O(n)

• union(x,y): si fa diventare la radice di un albero il figlio dell’altra

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 )

oppure più compatto:


1 find_set ( x ) :
2 if parent ( x ) ! = x
3 x <- find_set ( parent ( x ) )
4 return parent ( x )

Man mano che vengono eseguite le find_set la struttura si comprime (tecnica di


compressione dei cammini):

a b c ... g h

54
Di conseguenza la complessità della find_set diventa costante.

La nuova complessità sapendo di avere m operazioni di cui n make_set diventa:


O(mα(m, n))
dove α è l’inversa della funzione di Ackermann:
 jmk 
α(m, n) = m, n i ⩾ 1, A i, > log n
n
.. 2
22
.i
A(i, 1) = 22
Siccome la funzione di Ackermann cresce molto velocemente, la sua inversa cresce
molto lentamente. Di conseguenza non si riuscirà mai a superare una certa costante,
quindi la complessità diventa costante.

Esercizio 5.1. Si vuole gestire un’agenda che contiene appuntamenti con un


orario di inizio e un orario di fine, si vuole:
• Inserire un appuntamento (un intervallo di tempo [mint , maxt ])

• 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.

Il tempo di fine è un dato importante per capire se è presente un’intersezione,


se ordinassimo per il tempo di inizio, ciò che ci dice se c’è stata un intersezione
con la radice è se il tempo di fine dei nodi a sinistra è maggiore del tempo di
inizio della radice.

Si può aggiungere un campo aggiuntivo che memorizza il massimo tempo di


fine del sottoalbero di un nodo e questo si può mantenere in tempo logaritmico
perchè dipende solo dal nodo stesso e dai propri figli

L’algoritmo che cerca l’eventuale intersezione è il seguente


1 // x e ’ il nodo radice
2 // I e ’ l ’ intervallo di tempo
3 search (x , I )
4 if x = = nil && I . intersects ( x . interval )
5 return x
6 if left ( x ) ! = nil && left ( x ) . max_end > I . start
7 return search ( left ( x ) ,I )
8 else
9 return search ( right ( x ) ,I )

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.

5.9 Tabelle Hash


Le tabelle hash sono strutture dati che permettono di memorizzare elementi asso-
ciandoli ad un valore univoco. Si vuole accedere ad un elemento inserendo una
chiave per ottenere il valore associato. Questa tabella si può creare idealmente
utilizzando un array indicizzato da caratteri:
1 T [ " element " ]

Se si utilizza un array indicizzato da una stringa lunga massimo 256, si dovranno


allocare 26256 celle di memoria. Si può allora creare una struttura che abbia la
stessa funzione, ma allocando meno memoria? L’idea è quella di creare uno spazio
più piccolo associato all’array dei dati e poi creare una funzione chiamata hash che
associa lo spazio più grande, cioè quello delle stringhe, a quello più piccolo.

Chiavi
Chiavi Array
array

Figura 14: Struttura di una tabella hash

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.

5.9.1 Risoluzione dei conflitti


• L’array A ′ è una lista concatenata di coppie chiave valore.

Chiavi
Chiavi Array "Pippo": 5 "Pluto": 7
array

Figura 15: Risoluzione dei conflitti

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 .

Per implementare questo concetto si usa la seguente funzione di hash che


prende in input la chiave e il numero di tentativi fatti per trovare la cella
giusta:
H(k, i) = (h(k) + i) modm
dove h(k) è una funzione di hash qualsiasi.

Con questa funzione si verifica il problema di aggregazione primaria, cioè


una sezione dell’array viene riempita prima delle altre. Per risolvere questo
problema si può scalare l’offset i con una costante l per fare salti più grandi
e lasciare quindi più buchi vuoti. Bisogna però scegliere l in maniera tale
da non dover arrivare al punto di partenza senza prima aver passato tutti gli
elementi, cioè l e m devono essere relativamente primi tra di loro, cioè
non devono avere divisori comuni. Nonostante ci siano più buchi, la lista
della ricerca rimane sempre lunga quanto gli elementi in conflitto, quindi per
ridurre la lista di ricerca si può usare la tecnica dell’hashing doppio:

H(k, i) = (h1 (k) + i · h2 (k)) modm

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.

Se il numero di celle allocate è il doppio del numero di elementi, allora il fattore di


carico sarà 21 e quindi la complessità sarà O(1) perchè si avrà metà elemento per
ogni cella.

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:

1. H(k) = kmodm = k%m. Questa funzione è molto semplice, ma non è molto


buona. Ad esempio se m = 2l tutte le chiavi che hanno gli ultimi l bit uguali
avranno lo stesso hash. Gli m che funzionano bene sono i numeri primi.
2. H(k) = ⌊m (k · Amod1)⌋ dove A ∈ R. Questo rappresenta un numero
compreso tra 0 e 1 moltiplicato per m, quindi un numero tra 0 e m − 1. Il
valore più critico in questa funzione è il valore di A. Il valore ottimale di A è:

5−1
A= ≈ 0.6180339887 = ϕ − 1 = Golden ratio − 1
2

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.

6.1 Programmazione dinamica


Prendiamo ad esempio il problema della moltiplicazione di matrici. Sappiamo che
se abbiamo due matrici A di dimensione n × m e B di dimensione m × l, il prodotto
tra le due matrici avrà complessità Θ(nml).
Se volessimo moltiplicare 3 matrici: A1 · A2 · A3 di dimensione:

A1 : 10 × 100
A2 : 100 × 5
A3 : 5 × 50

possiamo sfruttare la proprietà associativa:

(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è:

(A1 · A2 ) · A3 10 × 100 × 5 + 10 × 5 × 50 = 5000 + 2500 = 7500


A1 · (A2 · A3 ) 100 × 5 × 50 + 10 × 100 × 50 = 25000 + 50000 = 75000

Supponiamo di avere un insieme di matrici A1 , A2 , . . . , An , vogliamo trovare la


parentesizzazione ottimale per minimizzare il numero di operazioni.

Un modo per affrontare il problema è quello di provare tutte le combinazioni di


parentesi e vedere quale è la migliore.

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

La complessità è troppo alta, quindi provare tutte le combinazioni possibili non è


efficace.

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

Questa tecnica si chiama Sottostruttura ottimale, un problema si può dividere in


problemi della stessa natura, ma più piccoli.

Il numero di moltiplicazioni effettuate è dato da:

N (A1 · · · An ) =

= N (A1 · · · Ak ) + N (Ak+1 · · · An ) + Costo ultima moltiplcazione


= N (A1 · · · Ak ) + N (Ak+1 · · · An ) + rows(A1 ) · cols(Ak ) · cols(An )
Se k è ottimo, allora N (A1 · · · Ak ) e N (Ak+1 · · · An ) sono ottimi perchè il risultato
viene sommato e se non fossero ottimi il risultato finale non sarebbe ottimo. Il
problema è che non sappiamo quale sia il valore di k ottimo.

Un possibile algoritmo per trovare k è il seguente:


1 // P e ’ un vettore che descrive le matrici come :
2 // - P [0] = rows ( A1 )
3 // - P [ i ] = cols ( Ai )
4 // Quindi Ai ha dimensione P [i -1] x P [ i ]
5 // i e ’ l ’ indice di partenza
6 // j e ’ l ’ indice di fine
7 m a t r i x _ c h a i n _ o r d e r (P ,i , j )
8 if i = = j
9 return 0
10
11 m <- + inf
12 for k <- i to j -1
13 m <- min (m ,
14 m a t r i x _ c h a i n _ o r d e r (P ,i , k ) +
15 m a t r i x _ c h a i n _ o r d e r (P , k +1 , j ) +
16 P [i -1]* P [ k ]* P [ j ])
17 return m

Questo algoritmo ha una complessità esponenziale.

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)

(1,1)(2,n) (1,2)(3,n) (1,3)(4,n) ... (1,n-1)(n,n)

(1,1)(1,1) (2,2)(3,n) (1,1)(2,2) (3,3)(4,n)

(2,2)(2,2) (3,3)(4,n)

Figura 16: Albero di ricorrenza

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 ...

La complessità di questo algoritmo è O(n3 ).

L’algoritmo precedente scritto in modo iterativo si potrebbe anche scrivere ricor-


sivamente e l’idea è che prima di calcolare il risultato si controlla se è già stato
calcolato e si utilizza quello, altrimenti si calcola e si salva il risultato.

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 ).

Definizione 6.1. La tecnica di memorizzare i risultati già calcolati si chiama


memoizzazione.

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 )

6.2 Programmazione greedy


La programmazione greedy è una tecnica che prende decisioni il prima possibile e
spera che siano le migliori.

Esempio 6.1. Prendiamo in considerazione un insieme di attività che sono


caratterizzate dal tempo di inizio e dal tempo di fine (con tempo discreto). In
ogni istante di tempo c’è solo un’attività che può essere svolta, quindi i periodi
di attività devono essere disgiunti. Una lista di attività è la seguente:
1 // Questa lista puo ’ essere rappresentata come un array

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]

Ad esempio l’attività 5 e l’attività 10 non possono essere svolte contemporanea-


mente perchè si sovrappongono. L’attività 5 e l’attività 11 invece si possono
svolgere contemporaneamente.

Obiettivo: Bisogna trovare un insieme di attività compatibili di cardinalità


massima.

Consideriamo che qualcuno ci dica che l’attività 3 fa parte dell’insieme di


attività compatibili di cardinalità massima, allora possiamo cancellare tutte le
attività compatibili:
1 1: [8..12] // X
2 2: [3..5] // V
3 3: [8..11] // <-
4 4: [3..8] // X
5 5: [0..6] // V
6 6: [1..4] // V
7 7: [5..9] // X
8 8: [2..13] // X
9 9: [6..10] // X
10 10: [5..7] // V
11 11: [12..14] // V

Verificare la compatibilità per tutte le combinazioni ha un numero di istanze


troppo alto e quindi bisogna trovare un altro metodo. Si può ordinare la lista
di attività in base al tempo di fine in modo crescente:
1 1: [1..4]
2 2: [3..5]
3 3: [0..6]
4 4: [5..7]
5 5: [3..8]
6 6: [5..9]
7 7: [6..10]
8 8: [8..11]
9 9: [8..12]
10 10: [2..13]
11 11: [12..14]

Ora è più facile trovare un insieme di attività compatibili di cardinalità maggiore


di 3. C’è un teorema che dice chce tra tutte le soluzioni di cardinalità massima
ce n’è una che contiene la prima attività della lista ordinata. Si prende la prima
attività e si cancellano tutte le attività incompatibili:
1 1: [1..4] // <-
2 2: [3..5] // X
3 3: [0..6] // X

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

Ora si può ripetere il procedimento con le attività rimanenti, perchè il problema


si è ridotto ad un problema dello stesso tipo, ma più piccolo.
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] // V
9 9: [8..12] // V
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] // 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] // --

Abbiamo trovato un insieme di attività compatibili di cardinalità massima:

{1, 4, 8, 11}

facendo una scelta greedy.

Per migliorare l’algoritmo si può evitare di controllare tutte le attività maggiori


della prima attività compatibile trovata.

Il teorema menzionato sopra si può dimostrare come: Sia A un insieme di attività


compatibili, sia k = min(A) e definiamo A ′ = (A − {k}) ∪ {1}. Si sa che il tempo

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.

L’algoritmo mostrato nell’esempio 6.1 è il seguente:


1 // S e ’ un array che contiene tutti i tempi di inizio delle
attivita ’
2 // F e ’ un array che contiene tutti i tempi di fine delle attivita ’
3 a c t i v i t y _ s e l e c t o r (s , f )
4 sort s , f by f
5 A <- {1}
6 j <- 1
7 for i <- 2 to length ( s )
8 if s [ i ] >= f [ j ]
9 A <- A U { i }
10 j <- i
11
12 return A

Questo algoritmo ha complessità O(n log n) perchè viene fatto un ordinamento,


ma se le attività sono già ordinate allora la complessità è O(n). La complessità di
questo algoritmo dipende anche dalle condizioni in cui viene utilizzato.

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.

Esercizio 6.1. Data una stringa si modifichi inserendo il minimo numero di


caratteri per renderla palindroma:
1 AAB ABC BACB
2 | | |
3 V V V
4 BAAB ABCBA BACAB

Si parte dall’esterno e si va verso l’interno, se i caratteri sono uguali si passa ad


un problema dello stesso tipo, ma più piccolo. Se i caratteri non sono uguali
bisogna capire quale dei due caratteri inserire per rendere la stringa palindroma.
In questo caso bisogna risolvere entrambi i problemi e vedere quale dei due ha
il minor numero di caratteri da inserire.
1 // s e ’ la stringa
2 // i e j sono gli indici di inizio e fine della stringa
3 m i n i m u m _ p a l i n d r o m e (s ,i , j )
4 if j <= i
5 return 0
6 if s [ i ] = = s [ j ]
7 return m i n i m u m _ p a l i n d r o m e (s , i +1 ,j -1)
8 else
9 return 1 + min (
10 m i n i m u m _ p a l i n d r o m e (s , i +1 , j ) ,
11 m i n i m u m _ p a l i n d r o m e (s ,i ,j -1)
12 )

Implementando la memoization la soluzione diventa:


1 m i n i m u m _ p a l i n d r o m e (s ,i , j )
2 if j <= i
3 return 0
4 if M [i , j ] ! = + inf
5 return M [i , j ]
6 if s [ i ] = = s [ j ]
7 M [i , j ] <- m i n i m u m _ p a l i n d r o m e (s , i +1 ,j -1)
8 else
9 M [i , j ] <- 1 + min (
10 m i n i m u m _ p a l i n d r o m e (s , i +1 , j ) ,
11 m i n i m u m _ p a l i n d r o m e (s ,i ,j -1)
12 )
13 return M [i , j ]

Completare creando la matrice Z che contiene i caratteri da inserire per rendere


la stringa palindroma. E implementare l’algoritmo in modo iterativo.

Esercizio 6.2 (Love calculator). L’affinità tra due persone è proporzionale


alla stringa più piccola tra le stringhe che contengono i due nomi come sot-
tostringhe. Dati due nomi trovare la stringa più corta che li contiene come
sottostringhe e in quanti modi si può scrivere una stringa partendo dai due
nomi. Ad esempio:

65
1 USA & USSR
2 |
3 V
4 USASR
5 USSAR
6 USSRA

66

Potrebbero piacerti anche