Sei sulla pagina 1di 87

Zandron@disco.unimib.

it - U14 stanza 1010

Libro di testo: CLRS

Algoritmo: sequenza di istruzioni elementari (i.e. capibili da chi deve eseguirle). Dato un input
produce un output. In generale l'obiettivo risolvere un problema di tipo computazione.

Problema computazionale: problema dove dato in input mi aspetto una risposta. (Condizioni che
devono rispettare l'input e output). Ad esempio ordinamento di una sequenza di interi, ben definita:
in output mi serve una permutazione dei numeri iniziali tali che a1 < a2 < ... < an

Istanza di un problema: ottenuta specificando valori precisi per l'input di un problema.

Un algoritmo corretto se termina sempre e fornisce un output corretto per ogni possibile istanza
(rispetto al problema computazionale).

Dato un problema esistono infiniti algoritmi che lo risolvono.

Dato un input di dimensione N, ci interessa capire qual il caso migliore, peggiore medio.

Consideriamo questo algoritmo che calcola la divisione intera fra due numeri:

sottrai B da A finch possible.

Non funziona sempre, scriviamo:

sottrai B da A finch non vai sotto zero.

Questo algoritmo non corretto, infatti se B vale zero esso non termina.

Per questo algoritmo il caso migliore quando B>A. Il caso peggiore quando B=1. In generale
esiste un insieme di casi migliori e un insieme di casi peggiori.

Sia N la dimensione dell'input e T(N) il tempo impiegato.

Sia dato un vettore che contiene N interi e sia dato un intero K. Trovare K. Assumiamo che tutti gli
elementi nel vettore siano distinti tra loro. (Algoritmo di ricerca di un vettore).

int Trova(V[], K)
P=1
while (p <= N && V[p] != K)
p++
if (p>n)
return(-1)
else
return(p)
Caso peggiore: il while viene eseguito il maggior numero di volte possibile. Ovvero quando la
condizione verificata. Ovvero quando V[p] sempre diverso da K. Ovvero quando K non
presente nell'array. In questo caso Tw = N, Tif = 1 e Fif =0.

Caso migliore: il while viene eseguito il numero minore di volte possibile. Ovvero quando Tw=0.
Ovvero quando K nella prima posizione del vettore.

Caso medio: assumiamo una distribuzione dell'input equiprobabile. Quindi Tw= N/2.

Assumiamo ora che il vettore sia ordinato. Restiamo su una ricerca sequenziale, ma miglioriamo
l'algoritmo. Quando troviamo un elemento pi grande fermiamo la ricerca, visto che non c' pi
speranza di trovarlo.

int Trova(V[], K)
P=1
while (p <= N AND V[p] != K)
p++
if (p>n OR V[p] > K)
return(-1)
else
return(p)

Il caso peggiore ora K non presente e K maggiore di tutti gli elementi. Abbiamo quindi ridotto di
molto il numero di casi in cui assume il caso peggiore. Tuttavia resta sempre circa N.

Il caso migliore non cambia.

Quale il tempo medio? Mi aspetto che a met si accorge che non c' pi. Quindi che diventi un
quarto.

Possiamo inoltre usare la ricerca binaria o dicotomica per cambiare algoritmo e migliorare i tempi.

Ricercabinaria(V[], int K)
i=1, F=N, M = (i+F)/2
while (V[m] != K AND i < F)
if (v[m] > K)
F=M-1
else
i=M+1
M= (i+F)/2
if v[m] == K
return M
else
return -1

Mostriamo che la ricerca binaria richiede veramente tempo logaritmico.

Uso dello pseudocodice:

Cicli: for, while, do while (con il loro uso corretto, si evitino ad esempio istruzioni come break).

Condizione: if, (then), else

Indentazione corretta e uso delle parentesi grae

Commenti tra /* */

Assegnamento con =, :=, <- (dierenza con test ==)

Le variabili sono sempre locali (ad eccezione degli array)

Gli array sono indicizzati da 1 a N. Per la lunghezza, length(A)

Le procedure prevedono il passaggio per valore.

Supponiamo che gli algoritmi vengano eseguiti su una macchina RAM (Random Access Machine),
ovvero che il tempo di reperimento dei valori in memoria costante, indipendentemente da dove
sono stati messi in memoria.
Non ci sono limiti alla memoria disponibile (non si va mai su disco). Supponiamo che la macchina
abbia un solo processore (niente parallelizzazione) e che le istruzioni vengano sempre eseguite in
ordine (senza scambi n ottimizzazioni).

Il problema dell'ordinamento

Pensiamo ad un algoritmo che cerca il minimo e lo mette in prima posizione, quindi cerca il minimo
sui restanti e lo mette in seconda posizione e cos via.

Questo algoritmo si chiama Selection Sort

SelectionSort(A[])
for i=1 to N-1
posminimo = 1
for j=i+1 to N
if A[j] < A[posminimo]
posminimo = j
scambia(A, i, posminimo)

L'occupazione di memoria N (sostanzialmente c' solo l'array che viene ordinato).

Il ciclo esterno viene eseguito N volte.

Bubble Sort

Confronta due elementi vicini e li scambia se opportuno, scorre tutto l'array e riparte da capo ogni
volta che necessario.

Osserviamo che ad ogni passata il massimo viene messo in fondo (rispetto alla sotto sequenza,
nelle passate successive alla prima)

Nel caso migliore (array gi ordinato) impiega N.

Nel caso peggiore di fatto si comporta come il SelectionSort, impiegando quindi N^2.

Nel caso medio possiamo dire che impiega circa la met del caso peggiore, ma l'ordine di
grandezza resta uguale: N^2.

Algoritmo di ordinamento InsertionSort


Simile a come un essere umano ordina un mazzo di carte. Presa la seconda carta, la sistemo al
posto giusto (prima o dopo l'unica carta che c'era prima). Presa un'altra carta, la sistemo nel posto
giusto (considerato che quelle gi presenti sono gi ordinate) partendo dall'ultima e tornando
indietro.

Non serve scambiare tutte le volte: suciente tenere da parte il valore da inserire ordinato e
spostare di un posto a destra (in un'unica operazione) tutti i valori maggiori del valore da inserire.

InsertionSort (A[])
for i = 2 to N
val = A[i]
j = i - 1
while j > 0 && A[j] > val
A[j + 1] = A[j]
j--
A[j + 1] = val
Nel caso migliore InsertionSort pi intelligente di SelectionSort. In generale anche in media lo .

Sia SelectionSort che InsertionSort sono algoritmi in loco, ovvero hanno una occupazione di
memoria costante oltre alla memoria impiegata per il vettore dell'array da ordinare. In altre parole
l'occupazione di memoria aggiuntiva non dipende da N.

Un algoritmo di ordinamento si definisce stabile se dati due numeri A e B uguali tra loro, il reciproco
ordine che avevano prima dell'ordinamento viene mantenuto anche al termine dell'ordinamento.

Cerchiamo ora di semplificare tutti i calcoli che stiamo facendo, trascurando costanti moltiplicative
e additive nei calcoli.

Trascuriamo anche le considerazioni fatte sulla diversa complessit delle singole istruzioni. vero
che esistono istruzioni pi complicate di altre, ma dieriscono per una costante. Diremo perci che
ogni istruzione ha costo C.

Trascuriamo anche il fatto che nei cicli il test viene eseguito una volta in pi che il corpo del ciclo.

Limiti asintotici
Consideriamo funzioni f(N) asintoticamente positive (cio >0 da un certo N in poi).

Limite asintotico superiore

Limite asintotico inferiore

Se valgono sia il limite inferiore che il limite inferiore allora diciamo in generale che la funzione
limitata (si indica con theta).

Riprendiamo l'esempio dei tempi di esecuzione di InsertionSort.

E se considerassimo SelectionSort?

Altri limiti asintotici.

Stiamo limitando la funzione F con un'altra funzione G di un ordine di grandezza superiore,


altrimenti il gioco non funziona.

Che dierenza c' allora tra O grande e o piccolo? La possibilit di scegliere la costante. Entrambe
limitano superiormente la funzione.

Nel nostro uso necessario usare O grande, perch non ci interessa stabilire gli ordini di grandezza
superiori. A noi interessa la funzione pi "bassa" (in termini di grafico) possibile.

Le funzioni che indicano i tempi di esecuzione sono solitamente polinomi.

Consideriamo alcuni tipici tempi di esecuzione di algoritmi.

Propriet dei limiti asintotici:

transitivi

riflessivi (O grande, theta grande, omega grande se usati nel modo appropriato).

Esempio di esercizio d'esame.


Scrivere un algoritmo che dati due vettori V1, V2 di interi dica quanti elementi di V2 compaiono in
V1.

Compara(V1[], V2[])
trovati = 0
for i = 1 to N
j = 1
while j <= N && V2[i] != V1[j]
j++
if (j <= N)
trovati++
return trovati

Per completezza sarebbe necessario aggiungere anche il numero di volte in cui viene testato il while
e risulta subito falso (costo CN).

Consideriamo lo stesso algoritmo ma aggiungiamo il vincolo ai dati di input che non ci siano
elementi ripetuti nel vettore.

Il caso peggiore resta uguale, ma cambia il caso migliore. Ora il caso migliore quando un array
una permutazione di un altro.

Essendo il caso peggiore dello stesso ordine di grandezza del caso migliore, allora in generale
diciamo che l'algoritmo

Consideriamo ora un ulteriore variazione, assumendo che i vettori dati in input siano ordinati e
diversi tra loro. Il caso migliore quando gli elementi del secondo vettore sono tutti pi piccoli del
primo elemento del primo vettore. Il caso peggiore l'opposto, ovvero quando tutti quelli del
secondo sono maggiori del primo. Chiaramente si possono fare semplici modifiche all'algoritmo per
renderlo migliore.

Esempi
Esempi di tempi di calcolo di algoritmi iterativi

Dati due array di binari A e B di N bit calcolare la somma in un terzo array C. Assumiamo che il bit
pi significativo sia in posizione N.

Somma(A[], B[], C[])


riporto = 0
for i = N down to 1
C[i + 1] = A[i] + B[i] + riporto
if C[i + 1] <= 1
riporto = 0
else
riporto = 1
C[i + 1] = C[i + 1] - 2
C[1] = 1

Esercizio: calcolare il tempo del seguente algoritmo.

Int funzione(N)
Z = N;
T = 0;
while Z > 0:
X = Z mod 2;
Z = Z div 2;
if X == 0:
for i = 1 to N
T = T + 1;
return T;

possibile migliorare l'algoritmo?

Int funzione(N)
Z = N
T = 0
while Z > 0
X = Z mod 2
Z = Z div 2
if X == 0
T = T + N
return T

Calcolare i tempi di esecuzione del seguente spezzone di codice.

for i = 1 to N - 1

for j = i + 1 to N

for K = 1 to J

R = R + 1;

Intuitivamente possiamo dire circa N^3.

Esercizio: data una matrice NxN determinare se simmetrica.

A un primo ragionamento possiamo dire che non utile controllare la diagonale principale. Inoltre
essendo l'uguaglianza simmetrica, se Aij uguale ad Aji vale anche il contrario (e vale ovviamente
anche se sono diversi).

Inoltre si osserva subito che se anche solo un elemento non simmetrico, allora l'intera matrice
non simmetrica.

Boolean IsMatriceSimmetrica (M[][])


r = 1
simmetrica = true
while (simmetrica == true) AND (r <= N)
c = R + 1
while (M[r][c] == M[c][r]) AND (c <= N)
C++
if (c >= N)
r++
else
simmetrica = false
return simmetrica
Il caso peggiore quello in cui i due while vengono eseguiti il maggior numero di volte, quindi
quando la matrice simmetrica.

Il caso migliore quando i while vengono eseguiti il minimo numero di volte possibili, cio mi
accorgo subito che la matrice non simmetrica.

Esercizio: valutare i tempi di esecuzione del seguente spezzone di codice.

For i=1 to N
b[i] = 0
j = N
while j >= 1
b[i] = b[i] + a[j]
j--

Esercizio: valutare i tempi di esecuzione del seguente spezzone di codice.

For i = 1 to N
For j = 1 to N
for k = 1 to J-1
istruzione
Programmazione ricorsiva
Esemplificazione di classici calcoli ricorsivi e principio di induzione matematica.

Calcolo iterativo del fattoriale di N

int fattoriale(N)
ris = 1
for i = N down to 1
ris = i * ris
return ris

Calcolo ricorsivo del fattoriale

int fattoriale_ricorsivo(N)
if N == 0
return 1
else
parziale = fattoriale_ricorsivo(N-1)
totale = N * parziale
return totale

Esiste anche una versione "forte" del principio di induzione matematica.

A scopo illustrativo dimostriamo la somma dei primi N numeri.

Dimostriamo un'altra sommatoria.

Dimostriamo un'altra propriet.

Dimostrare che ogni cifra partendo dagli 8 centesimi ottenibile attraverso l'utilizzo delle sole
monete da 3 e 5 centesimi.

necessario utilizzare il principio di induzione forte!

Individuare l'errore nella seguente dimostrazione.

Ritorniamo a uno schema generale di una funzione ricorsiva ben scritta.

FunzioneRicorsiva
if casobase
...
else
...
FunzioneRicorsiva
...

Scrivere un algoritmo che calcoli ricorsivamente l'ennesima potenza di A.

int potenza(A, N)
if N == 0
return 1
else
ris = potenza(A, N - 1)

tot = A * ris
return tot
La prima parte della lezione in coda.

Consideriamo il seguente algoritmo ricorsivo.

Stampa(A[], int i)
if i <= A.length
print(A[i])
stampa(A, i + 1)

Main
...
Stampa(A[], 1)

Il caso base quando I maggiore di A.length, quindi quando il secondo parametro supera la
lunghezza dell'array.

Osserviamo che la funzione equivalente a

If casobase:
*non fare niente*
Else:
istruzioni

Ma poich il costrutto "if" senza istruzioni non ammesso, dobbiamo negare la condizione e
"rovesciare" il programma.

Attenzione: proviamo a invertire le istruzioni print e chiamata ricorsiva nel modo seguente

Stampa(A[], int i)
if i <= A.length
stampa(A, i + 1)
print(A[i])

Main
...
Stampa(A[], 1)

L'eetto stampare il vettore a partire dall'ultima posizione "tornando indietro".

Quanto tempo impiega questo algoritmo?

Questo metodo si chiama "metodo iterativo".

Esercizio: contare quante volte compare il numero 5 in un array.

Ragionamento sul caso base. La scelta migliore (ie quella pi semplice) considerare l'array con
zero elementi rimanenti, che ovviamente contiene 0 cinque.

int conta5(A[], int i)


if i > A.length
return 0
else
r = conta5(A, i + 1)
if A[i] == 5
r++
return r

Main
...
Conta5(A, 1)
...

Esercizio: contare quanti numeri in un array sono seguiti dal proprio successore.

Caso base quando ho solo un elemento, perch esso non seguito da niente e la risposta quindi
in quel caso e zero.

Esercizio: sommare ricorsivamente i numeri in un array. Proviamo a implementarlo in una forma pi


originale del solito, sommando primo e ultimo pi tutti i rimanenti.

Int somma(A[], int i, int f)


if i > f
return 0
if i == f
return A[i]
else
r = somma(A, i + 1, f - 1)
r = r + A[i] + A[f]
return r

Main
...
Somma(A, 1, A.length)

Osserviamo che in questo caso abbiamo previsto pi di un caso base. Calcoliamo quanto tempo
impiega l'algoritmo.

Osserviamo che a seconda che N sia pari o dispari finiamo nel caso base 0 o 1, ma essi
dieriscono solo per una costante. Quindi ci irrilevante ai fini della valutazione dell'ordine di
grandezza. Assumiamo quindi N pari.

Esercizio: determinare se un array di caratteri una parola palindroma.

Boolean IsPalindroma (A[], int i, int f)


if i >= f
return true
else
if A[i] != A[f]
return false
else
r = IsPalindroma(A, i + 1, f - 1)
return r

Quale il tempo di esecuzione di questo algoritmo?

Ora non possiamo scrivere un'unica equazione di ricorrenza. Dobbiamo scrivere i casi estremi: il
numero minimo e il numero massimo di chiamate ricorsive che vengono eettuate.
Esercizio: trovare il massimo in un array (in modo ricorsivo). L'idea : il massimo tra il primo e il
massimo di tutti gli altri. Caso base: array di un elemento rimanente, quello il massimo.

Obiettivo: arrivare a scrivere una funzione che calcoli l'n-esimo numero di fibonacci in modo
ricorsivo. Osserviamo che il numero N dato dalla somma dell' N-1 e N-2.

fibonacci(N)
if N == 1
return 0
if N == 2
return 1
else
R = fibonacci(N-1) + fibonacci(N-2)
return R

Osserviamo che per come abbiamo scritto la funzione ricorsiva, stiamo calcolando tante volte gli
stessi numeri di fibonacci.

Calcolo dell'MCD
Scrivere una funzione che calcoli l'MCD tra due numeri seguendo le seguenti regole.

int MCD(int N, int M)


if M == 0
return N
if N == 0
return M
else
if M > N
scambia(N, M)
R = MCD(M, N - M)

Calcoliamo i tempi di esecuzione.

Svolgiamo ora alcuni esercizi sui tempi di esecuzione di funzioni ricorsive.

Possiamo calcolare anche il tempo di fibonacci

Abbiamo visto in precedenza algoritmi iterativi (ad esempio InsertionSort, SelectionSort dove ad
ogni marco iterazione sistemo un elemento). Questi metodi sono detti incrementali: ad ogni marco
iterazioni incremento di uno "il numero di elementi"; dopo N iterazioni ho finito. Questi algoritmi
hanno tipicamente tempi di esecuzione quindi nell'ordine di N^2.

Introduciamo la tecnica divide et impera. Il problema viene diviso in sottoproblemi in cui la quantit
di elementi sempre una frazione del totale. Si hanno tipicamente tre fasi:

divide: preso il problema lo divido in sottoproblemi tali che la quantit di elementi sempre una
frazione del totale

Impera risolve ciascun sottoproblema


Combina: prende la soluzione dei sottoproblemi e li combina per ottenere la soluzione al
problema di partenza

La fase impera ricorsiva; le fasi divide e combina sono iterative.

Applichiamo la tecnica divide et impera al problema dell'ordinamento, guardando l'algoritmo


MergeSort. Funziona con i seguenti passi:

Divide: divide il problema in due sottoproblemi (floor N/2 e ceil N/2)

Impera: ordina le due met indipendentemente

Combina: merge (fonde i due sottoarray ordinati)

Mostriamo l'esecuzione su un esempio.

Implementato correttamente (a parit di valore prendo l'elemento dal sottoarray di sinistra),


MergeSort un algortimo stabile.

MergeSort(A[], int I, int F)


if not I == F
M = (I + F) / 2;
MergeSort(A, I, M);
MergeSort(A, M + 1, F);
Merge(A, I, M, F);

Nel main la chiamata sar del tipo MergeSort(A, 1, N).

Scriviamo ora la funzione che fonde i due sottoarray ordinati.

Merge(A[], I, M, F)
I1 = I; I2 = M + 1; IB = I;
do
if A[I1] <= A[I2]
B[IB] = A[I1]
I1++
else
B[IB] = A[I2]
I2++
IB++
while I1 <= M AND I2 <= F

while I1 <= M
B[IB] = A[I1]
I1++
IB++
while I2 <= F
B[IB] = A[I2]
I2++
IB++

for IB = I to F
A[IB] = B[IB]

Ragioniamo sui tempi di esecuzione della funzione merge, forti del fatto che

In totale ci sono circa

Calcoliamo ora i tempi di esecuzione dell'intero algoritmo di ordinamento, tenendo conto del fatto
che un algortimo ricorsivo.

Cos i conti non vanno bene, perch stiamo commettendo errori grossolani nello sviluppo del
l'equazione di ricorrenza.

Attenzione che stiamo pagando il vantaggio prestazionale con un consumo di memoria (serve infatti
un array di appoggio di lunghezza N).

Mostriamo in generale il tempo di esecuzione di un algoritmo divide et impera.

Riprendendo il calcolo del tempo di MergeSort.

Consideriamo ora questo esercizio.

Parliamo di metodi di risoluzioni delle equazioni di ricorrenza. Le equazioni di ricorrenza si


presentano in questa forma:

Esistono tre metodi principali per risolvere le equazioni di ricorrenza:

Iterativo / albero di ricorsione

Sostituzione

Esperto / principale

Il primo metodo si pu usare sia per algoritmi ricorsivi che divide et impera. Ad ogni passo si
prende il termine a destra dell'uguaglianza e si risostituisce. Bisogna quindi dedurre la formula
generale dopo K passi. Si ragiona quindi sul K che fa ottenere il caso base.

Si chiama anche "albero di ricorsione" perch si pu disegnare l'albero delle chiamate ricorsive. Alla
fine si guarda quanto alto l'albero e quanto impiega ogni livello.

Il secondo metodo per sostituzione: si ipotizza un tempo di calcolo e si dimostra che corretto
mediante una dimostrazione per induzione. L'ipotesi si pu fare sulle stime asintotiche (omega e o
grande).

Il terzo caso funziona solo con alcuni casi del divide et impera. Si basa sul confronto tra il tempo
che costa di pi tra quello ricorsivo e quello iterativo.

Mostriamo ora alcuni esempi di applicazione dei metodi.

Esempio: metodo iterativo.

Vediamo ora un altro esempio leggermente pi complesso.

Mostriamo ora un esempio con albero.

Proviamo ora a risolvere un esercizio per sostituzione.

necessaria una dimostrazione per induzione forte.

Osserviamo ora un esempio di applicazione del "metodo principale", che si pu usare solo per
equazioni di ricorrenza di questo tipo:

Esercizio. Risolvere usando il metodo dell'esperto.

Se posso applicare il teorema dell'esperto, ricadiamo nel primo caso.

Quindi il teorema applicabile (nel caso 1). Quindi:

Esercizio. applicabile il teorema dell'esperto?

Il metodo applicabile (siamo nel secondo caso).

Vediamo questo metodo applicato sull'equazione di ricorrenza di MergeSort.

Il teorema quindi applicabile e siamo nel secondo caso.

Esempio.

Quindi si pu applicare il teorema dell'esperto nel caso 3.

Esercizio.

Un tale epsilon non esiste. Per quanto piccolo sia, dopo un po' . Formalmente:

Serve quindi applicare un altro metodo per risolvere l'equazione di ricorrenza.

Algoritmo di ordinamento QuickSort

Usa una logica in qualche modo opposta a quella di MergeSort.

Divide un array in due sottoarray (non a met), informalmente "piccoli" e "grandi". La impera ordina
ricorsivamente il sottoarray. La fase "combina" non deve far niente: quando i piccoli e i grandi sono
gi ordinati, allora l'array intero gi ordinato.

La scelta dei piccoli e dei grandi fatta rispetto a un elemento pivot, scelto casualmente tra i valori
dell'array. Tutti i pi piccoli di esso sono nel vettore dei "piccoli", tutti i pi grandi sono nel vettore
dei "grandi". La scelta del pivot quindi determinante.

Si arriva a mostrare che ha queste caratteristiche:

tempo medio di esecuzione NlogN (ma con costanti nascoste dal limite asintotico pi piccole di
MergeSort).

tempo peggiore di esecuzione N^2.

Non un algoritmo di ordinamento stabile.

Ordina in loco: eciente in termini di memoria.

La divide, cuore dell'ordinamento, costituita dalla funzione Partition. Esiste un metodo


"originario", detto metodo di Hoare dal nome dell'inventore.

Mostriamo con un esempio il funzionamento del QuickSort (scegliendo il primo elemento come
elemento pivot).

void QuickSort(A[], Inizio, Fine)


if Inizio < Fine
M = Partition(A, Inizio, Fine)
QuickSort(A, Inizio, M)
QuickSort(A, M + 1, Fine)

int Partition(A[], I, F)
// Sceglie pivot, ad esempio il primo elemento
pivot = A[I]
sx = I - 1
dx = F + 1
while sx < dx
do
sx++
while (A[sx] < pivot)
do
dx--
while (A[dx] > pivot)
if sx < dx
scambia(A, sx, dx)
return dx

Osserviamo che:

non serve controllare che dx e sx vadano out of bound per come costruito

La scelta del pivot pu seguire altri criteri: posso fare la media tra il primo, il centrale e l'ultimo.
Una scelta ideale sarebbe la media di tutti gli elementi, ma calcolare la media costa O(N): troppo!
Importante non scegliere l'ultimissimo elemento come pivot, perch in tal caso si potrebbe
entrare in un loop infinito.

Calcoliamo quanto tempo richiede Partition: osserviamo che ad ogni ciclo accade almeno un
incremento di sx e un decremento di dx.

In generale, posso incrementare N/2 volte l'indice sinistro e decrementare N/2 volte l'indice destro.
Se invece il sinistro si incrementasse tutto, allora accade solo un decremento di dx.

In generale: non so se fa un po sinistro e un po' destro. In totale il numero degli incrementi


sommato al numero dei decrementi vale N. Sempre quindi si ha che Partition richiede

Calcoliamo ora quanto impiega QuickSort scrivendone l'equazione di ricorrenza.

Osserviamo che l'equazione uguale a quella di MergeSort.

E se tagliasse malissimo?

Quali insiemi di input danno luogo al primo caso? Quando il pivot ad ogni passo la media di tutti
gli elementi.

Il secondo caso invece quando il pivot ad ogni passo il pi piccolo dell'array. Questo significa
che l'array gi ordinato.

Mostriamo il caso anche attraverso l'albero di ricorsione.

Mostriamo ora alcuni ragionamenti che confermano che QuickSort sta tra NlogN e N^2.

Scriviamo la vera equazione di ricorrenza di QuickSort:

Si pu dimostrare per induzione che N^2 limita tutti i tempi di esecuzione

Stiamo di fatto cercando un Q che massimizza T(N).

Proviamo a ragionare sul caso medio. Il conto corretto andrebbe fatto valutando la distribuzione di
probabilit dell'input. Vogliamo almeno domandarci: quasi sempre impiega NlogN o N^2.
Ipotizziamo questo albero.

Tutta la valutazione si riduce al valutare quanto profondo il ramo pi a destra. Il caso base

Il tempo totale

Questo mostra che anche se i tagli sono sbilanciati (ma restano una frazione) il tempo tende a
restare NlogN. In generale abbiamo un po' di esecuzioni con tempo N, ma il tempo rimanente sar
NlogN. Questo porta l'algoritmo ad essere asintotico a NlogN.

Il vero QuickSort in realt un QuickSort randomizzato. Il problema che attualmente, in caso di


array ordinato, scegliamo un array pi piccolo.

Il QuickSort randomizzato sceglie un elemento casuale come pivot. Le modifiche sono banali.

Partition(A[], I, F)
Q = random(I, F)
scambia(A, I, Q)
... // resto della Partition

Vediamo ora il funzionamento della Partition alternativa, cos come proposto dal libro di testo.

Dopo la Partition l'elemento pivot gi sistemato. La Partition ritorna sx + 1.

Le due chiamate ricorsive saranno:

QuickSort(A, I, r - 1)
QuickSort(A, I, r + 1)

Un tipico esercizio chiede di mostrare passo passo l'esecuzione di QuickSort (incluse le chiamate a
Partition), dato un input.

Esercizi di programmazione divide et impera

Esercizio. Scrivere un algoritmo che determini il numero di coppie di caratteri consecutivi in


una stringa S. Valutare i tempi di esecuzione dell'algoritmo.

Esempio:

Il caso base array con un solo elemento (con due elementi ci si riconduce a questo). Ad ogni certo
passo dividiamo a met, contiamo ricorsivamente le coppie in ciascuna delle due sottomet e
sommiamo.

int conta(A[], I, F)
if I == F
return 0
else
M = (I + F) / 2
r1 = conta(A, I, M)
r2 = conta(A, M + 1, F)
if A[M] == A[M + 1]
c = 1
else
c = 0
tot = r1 + r2 + c
return tot

Analizziamo il tempo di esecuzione dell'algoritmo.

Quindi applicabile il teorema dell'esperto nel caso 1, che implica

Modifichiamo l'algoritmo precedente per contare le triple contigue. Ad esempio:

int conta(A[], I, F)
if I == F or I == F - 1
return 0
else
M = (I + F) / 2
r1 = conta(A, I, M)
r2 = conta(A, M + 1, F)
if M + 2 <= length(A) and A[M] == A[M + 1] == A[M + 2]
c = 1
else
c = 0
if M - 1 >= 1 and A[M] == A[M + 1] == A[M - 1]
c++
tot = r1 + r2 + c
return tot

Il tempo di esecuzione non varia rispetto all'algoritmo precedente.

Esercizio. Scrivere un algortimo divide et impera che trovi il massimo in un array.


suciente calcolare il massimo tra il massimo delle due sottoparti. Caso base: array di un
elemento, l'unico elemento il massimo.

Esercizio. Scrivere un algortimo divide et impera che implementi la ricerca dicotomica.

int binarySearch(A[], I, F, X)
if I == F
if A[I] == X
return I
else
return -1
else
M = (I + F) / 2
if A[M] == X
return M
else
if A[M] < X
R = binarySearch(A, M + 1, F, X)
else
R = binarySearch(A, I, M - 1, X)
return R

Calcoliamo il tempo di esecuzione di questo algortimo.

Osserviamo che, pur con solo una chiamata ricorsiva, un divide et impera. Proviamo a risolvere
l'equazione di ricorrenza tramite il metodo dell'esperto.

Questa per l'equazione di ricorrenza se l'elemento non c' nell'array. Questo di fatto il caso
peggiore. L'algoritmo quindi sicuramente limitato superiormente da logN.

Non l'equazione se l'elemento presente! Il caso migliore quando X = A[(1 + length(A)) / 2].

In questo caso il tempo di esecuzione

Dell'algoritmo in generale possiamo dire:

Esercizio. Calcolare la seguente equazione attraverso un algoritmo divide et impera.

int valore(A[], I, F)
if I == F
R = A[i] + 2
return R
else
M = (I + F) / 2
R1 = valore(A, I, M)
R2 = valore(A, M + 1, F)
tot = R1 + R2
return tot

Esercizio. Dato un array di interi ordinato senza duplicati, scrivere un algoritmo divide et
impera che dica se esiste almeno una posizione i per cui i = A[i].

L'osservazione chiave che, essendo l'array ordinato e senza numeri ripetuti, allora quando sono in
una certa posizione, a seconda del valore dell'array in quella posizione, posso dire con certezza che
da una parte non c' speranza che la risposta sia true.

boolean trova(A[], I, F)
if I == F
if A[I] == I
return true
else
return false
else
M = (I + F) / 2
if A[M] == M
return true
else
if A[M] < M
R = trova(A, M + 1, F)
else
R = trova(A, I, M - 1)
return R

I tempi di esecuzione dell'algoritmo sono analoghi a quelli della ricerca dicotomica.

Se fosse consentita la presenza dei duplicati bisogna modificare il seguente spezzone di codice:

if A[M] < M
R = trova(A, M + 1, F)
if R == true
return R
else
R = trova(A, I, A[M])
return R
...
(caso simmerico)

necessario ora estendere il caso base.

if I > F
return false

Esercizio. Determinare se un vettore contiene un numero pari di vocali, attraverso un


algortimo divide et impera.

boolean pari(A[], I, F)
if I == F
if isVocale(A[I])
return false // una vocale, dispari
else
return true // zero vocali, pari
else
M = (I + F) / 2
R1 = pari(A, I, M)
R2 = pari(A, M + 1, F)
ris = R1 xor R2
return not(ris)

Esercizio. Divide et impera per risolvere la seguente equazione.


Analogo al precedente (prodotto nel caso base, somma nell'altro caso).

Complessit computazionale.

Riferiamoci ai tempi degli algortimi di ordinamento.

Esiste un algoritmo che ordina un array in tempo N? Per mostrare che non esiste, devo dimostrare
che tutti i possibili algoritmi di ordinamento richiedono almeno NlogN.

Questo significa porre un limite inferiore al problema dell'ordinamento.

Dimostriamo che qualunque algoritmo che si basa sui confronti richiede almeno NlogN per ordinare.
Si pu dimostrare usando gli alberi di decisione (albero binario dove ogni etichetta rappresenta una
domanda a cui pu essere data risposta vera o falsa).

Su ogni foglia dell'albero c' uno dei possibili ordinamenti. Con K domande io ho 2^K foglie.

Quanti sono i possibili ordinamenti di un array? Il primo lo posso scegliere in N modi, il secondo in
N - 1, e cos via. I possibili ordinamenti diversi sono quindi N!.

Servono quindi almeno N! foglie.

Vediamo uno degli algoritmi che non si basano su confronti: CountingSort. Non un algoritmo di
ordinamento in loco. un algortimo di ordinamento stabile.

Il tempo di esecuzione O(N + K). K la dierenza tra il pi grande elemento possibile e il pi


piccolo elemento possibile che sia presente nell'array.

Non possiamo dire che lineare, perch K non esattamente costante: se il range estremamente
ampio (rispetto alla quantit di elementi da ordinare) il tempo di esecuzione finale peggio di
NlogN.

Se il range piccolo, rispetto alla quantit di elementi da ordinare, allora l'algoritmo lineare in N.

L'algoritmo cerca di tenere conto del numero di occorrenze di tutti i possibili valori da ordinare.

L'algoritmo ha bisogno di due array, oltre all'input A[1..N], B[1..N] e C [1..K]. B usato per ordinare
eettivamente, mentre C viene usato per contare.

L'algoritmo funziona in quattro macro parti:

Azzero l'array C.

Scorro A contando in C il numero di occorrenze di ogni elemento di A. Al termine di questa fase


C[i] rappresenta quante volte comprare in A il valore i.

Per ogni i (2 <= i <= K), imposto C[i] = C[i] + C[i-1].

Passa tutti gli elementi di A dalla fine verso l'inizio (in questo modo l'algoritmo stabile) e li
posiziono in B sulla base del contenuto di C.

Esempio:

CountingSort(A[], B[], C[])


// fase 1
for i = 1 to K
C[i] = 0
// fase 2
for i = 1 to N
pos = A[i]
C[pos]++
// fase 3
for i = 2 to K
C[i] += C[i - 1]
// fase 4
for i = N down to 1
posC = A[i]
posB = C[posC]
B[posB] = A[i]
C[posC]--

Quant' il tempo di esecuzione dell'algoritmo?

Esiste un altro algoritmo di ordinamento, chiamato RadixSort. Usato per ordinamenti su pi chiavi
diverse. Ad esempio: ordinare un insieme di studenti in base all'et, a parit di et in base al
cognome, a parit di cognome in base al nome.

Controintuitivamente si procede in senso inverso: ordino rispetto al nome, quindi ordino rispetto al
cognome e quindi rispetto all'et. Questo prevede che l'algoritmo di ordinamento usato ogni volta
sia stabile.

Esempio:

RadixSort(d)
for i = 1 to d
ordinamento stabile da meno significativo a pi significativo.

Uso MergeSort, che un algortimo stabile in NlogN. Stiamo sostanzialmente lanciando D volte
MergeSort.

Esiste un altro algoritmo di ordinamento non basato su confronti, detto BucketSort.

Questo funziona assumendo che i valori nel range sono equiprobabili.

Se va male impiego N^2 (per seguire gli elementi nella lista prima di inserire), se va bene N. Se
vero che i numeri sono equamente distribuiti, il tempo atteso N.

Anche se non tutte le caselle fossero piene e ce ne fossero alcune con 2 elementi in coda, il tempo
non si discosta troppo da N.

In un array ordinato, il minimo e il massimo sono primo e ultimo elemento.

Questi si chiamano statistiche d'ordine: l'i-esima statistica dice quale l'i-esimo elemento pi
piccolo.

Chiaramente non conviene ordinare prima se poi ho bisogno solo del minimo o del massimo.
Riesco a cercare il minimo e il massimo in meno di 2(N - 1)? Un modo banale il seguente: prendo
una coppia di numeri. Fatto un confronto su di loro, ha senso chiedersi solo se il pi piccolo
minore del minimo e se il pi grande maggiore del massimo.

Ci sono N/2 coppie e per ciascuna faccio 3 confronti. In totale quindi 3N/2.

Per determinare l'i-esimo minimo, la cosa pi semplice lanciare la Partition pi volte per cercare
l'i-esimo minimo. Ogni volta ci concentriamo solo su una parte. Il tempo di esecuzione quindi
circa

L'equazione di ricorrenza facilmente risolvibile tramite il metodo dell'esperto, da cui risulta un


tempo complessivo O(N).

Determinare se le stime asintotiche sono vere o false.

Determinare la funzione definitivamente pi grande tra le seguenti coppie.

Valutare i tempi di esecuzione dei seguenti algoritmi iterativi.

A = 0, B = 0
For i = 1 to N
A++
for i = 1 to N
j = N
while j > i
B++
j--

for i = 1 to N
for j = 1 to i
for K = 2 to j - 1
A++

Scrivere un algoritmo iterativo che dica se in un array di N elementi ci sono almeno due elementi
uguali in posizioni diverse.

boolean trova(A[])
i = 1, trovato = false
while trovato == false && i <= N
j = i + 1
while j <= N && (A[i] != A[j])
j++
if j <= N
trovato = true
else
i++
return trovato
Dato un array ordinato, scrivere un algortimo ricorsivo che inverta l'array in modo che al termine
risulti ordinato in senso contrario. Valutarne i tempi di esecuzione.

void inverti(A[], i, f)
if i < f
// scambio elementi
appoggio = A[i]
A[i] = A[f]
A[f] = appoggio
// ricorsione
inverti(A, i + 1, f - 1)

Assumiamo N pari.

Dato un array, scrivere un algoritmo ricorsivo che determini se l'array ordinato o meno. Valutarne i
tempi di esecuzione.

boolean ordinato(A[], i)
if i >= length(A)
return true
else
if A[i] > A[i + 1]
return false
else
r = ordinato(A, i + 1)
return r

Scrivere un algoritmo divide et impera che determini la somma degli elementi di un array il cui
valore compreso tra e1 e e2.

int somma(A[], e1, e2, i, f)


if i == f
if A[i] >= e1 && A[i] <= e2
return A[i]
else
return 0
else
m = (i + f) / 2
r1 = somma(A, e1, e2, i, m)
r2 = somma(A, e1, e2, m + 1, f)
return r1 + r2

Simulare l'esecuzione di MergeSort sul seguente input.

Strutture dati

Nella seconda parte del corso parliamo di strutture dati. Una struttura dati una organizzazione di
informazioni in modo che vengano aggregate e gestite secondo certe regole.

Vedremo liste dinamiche. Di base confronteremo array e liste dinamiche.

Strutture dati pi approfondite utilizzano come base liste o array.

La principale dierenza che gli array sono statici, mentre le liste sono dinamiche. Le liste hanno
quindi il vantaggio di poter aumentare o diminuire la memoria occupata al momento, esattamente
come mi serve.

ADT: tipo di dato astratto, ovvero un dato a cui si ha accesso tramite una certa interfaccia.

Ogni struttura dati funziona seguendo certe regole e un set di operazioni. Oggetto del corso
discutere le regole e un set di operazioni, definiremo quindi una implementazione per queste cose.
In tal modo il programmatore pu usare queste funzioni per interagire con la struttura dati, come se
fosse nativamente disponibile.

Considereremo informazioni di questo tipo:

una chiave, ovvero un'informazione che consente di identificare in modo univoco un record

Informazioni aggiuntive associate a ogni chiave

Le operazioni che faremo sono quelle standard che si fanno anche sugli array.

Esistono operazioni base e alcune pi avanzate che si basano su combinazione di quelle base.

Vediamo quelle base

search(S, x) per cercare un'informazione nella struttura

insert(S, x) per inserire un'informazione nella struttura

delete(S, x) per cancellare un'informazione nella struttura

min/max(S) per sapere il minimo e il massimo

successore(S, k)/predecessore(S, k)

In caso occorrano pi liste, si soliti creare un array che contiene le teste delle diverse liste.

Esistono due operazioni fondamentali:

allocazione: chiede di riservare memoria per N byte e ritorna l'indirizzo da cui stata riservata la
memoria. L'operazione solitamente realizzata in questo modo:

x = alloc(100)
opportuno anche verificare che x non sia null.

Di solito esiste anche qualcosa del tipo x = alloc(sizeof(record) * 100) dove sizeof
ritorna la dimensione su quella macchina e su quel sistema operativo di quanti byte occupa un
record.

liberare memoria: ad esempio l'istruzione free(x) che informa il sistema operativo che la
memoria puntata da x ora non serve pi ed libera.

Esistono anche liste doppiamente collegate.

Vediamo ora come implementare le varie operazioni. Assumiamo, se non ci sono altri dettagli, che
le liste siano doppiamente collegate.

Partiamo dalla ricerca di un elemento.

List_search(L, K)
p = head[L]
while p != null && key(p) != K
p = next(p)
return p

L'algoritmo funziona in modo identico anche nel caso di lista singola.

Vediamo ora l'operazione di inserimento con liste doppie, considerando che x sia gi il risultato
dell'allocazione di memoria e contentente le informazioni necessarie (key ed eventualmente altri).

Ovviamente conveniente inserirla all'inizio (dando per scontato che la lista disordinata e non
siamo interessati quindi all'ordinamento).

List_insert(L, x)
next(x) = head[L]
prev(x) = null
prev(head[L]) = x
head[L] = x

Chiaramente il codice deve essere adattato per una lista semplice: suciente rimuovere la
seconda e la terza operazione (che coinvolgono l'inesistente prev nelle liste singole).

Vediamo ora l'operazione di cancellazione.

List_delete(L, x)
if prev(x) != null
next(prev(x)) = next(x)
else
head[L] = next(x)
if next(x) != null
prev(next(x)) = prev(x)

// ricordarsi liberare la memoria: free(x)


Attenzione che per cancellare un elemento da una lista semplice potrebbe richiedere O(N): non c'
modo di capire chi il predecessore.

Ci pu essere mitigato quando non ho un indirizzo da cancellare, ma una chiave da cercare e da


cancellare: in questo caso, scorrendo la lista, posso sempre tenermi il puntatore del nodo da cui
provengo; evitando di dover riscorrere tutta la lista.

Vediamo ora l'operazione di ricerca del minimo.

List_min(L)
p_min = head[L]
p_att = head[L]
while p_att != null
if key(p_att) < key(p_min)
p_min = p_att
p_att = next(p_att)
return p_min

Quanto tempo richiede questo algortimo? A occhio si vede che il tempo

Non accedendo al campo prev, l'algoritmo funziona anche per liste singole.

Vediamo la funzione per trovare il successore (ovvero il minimo di tutti quelli che hanno chiave
maggiore di quella dell'elemento).

List_succ(L, x)
// Cerco un minimo ragionevole, se non c' ritorno null
p_min = head[L]
while p_min != null && key(p_min) <= key(x)
p_min = next(p_min)
if p_min == null
return null
// Ora procedo in modo simile al minimo
p_att = p_min
while p_att != null
if key(p_att) < key(p_min) && key(p_att) > key(x)
p_min = p_att
p_att = next(p_att)
return p_min

Quanto il tempo di esecuzione? Sicuramente asintoticamente N. Non comunque 2N perch i


due cicli non scorrono entrambi la lista, ma uno riparte da dove arrivato l'altro.

Sostanzialmente stiamo dicendo che TW1 + TW2 = N.

Il predecessore si fa seguendo analogo ragionamento.

Definiamo sentinella in una lista come un valore fittizio: occupa uno spazio in pi, ma semplifica
molto alcune operazioni. In tal modo la lista non mai vuota ed esiste sempre un primo ed un
ultimo elemento.

Si utilizzano solitamente sentinelle con le liste circolari, dove l'ultimo nodo collegato con il primo.
L'ultima si riconosce perch il suo next uguale alla testa della lista.

In alcuni casi, pu essere comodo tenere un puntatore tail che punti all'ultima casella.
Ovviamente tale puntatore deve essere tenuto aggiornato in tutte le operazioni.

Esercizio. Date due liste, creare una terza lista contenente gli elementi delle prime due.

Unione(L1, L2)
// Se una delle due liste vuota restituisco l'altra.
if head[L1] == null
head[L3] = head[L2]
head[L2] = null
return head[L3]
if head[L2] == null
head[L3] = head[L1]
head[L1] = null
return head[L3]
// Altrimenti le unisco.
P = head[L1]
while next(P) != null
P = next(P)
next(P) = head[L2]

// Se la lista fosse doppia sarebbe necessaria l'istruzione seguente.


prev(head[L2]) = P

head[L3] = head[L1]
head[L1] = null
head[L2] = null
return head[L3]

Valutiamo i tempi di esecuzione considerando L1 con N elementi e L2 con M elementi.

Esercizio. Date due liste, creare una terza lista contenete solo gli elementi in comune tra le
due, eliminando da L1 e L2 tali elementi.

Intersezione(L1, L2)
if head[L1] == null || head[L2] == null
head[L3] = null
return head[L3]
p1 = head[L1]
while p1 != null
p2 = head[L2]
while p2 != null && key(p2) != key(p1)
p2 = next(p2)
if p2 != null // vuol dire che ho trovato due valori uguali
if prev(p2) != null // i.e. se non la prima
next(prev(p2)) = next(p2)
else
head[L2] = next(p2)
if next(p2) != null // i.e. se non l'ultima
prev(next(p2)) = prev(p2)
insert(L3, p2)
p1 = next(p1)

Il caso migliore quando L1 e L2 sono identiche, ovvero gli elementi sono rispettivamente nella
stessa posizione. Eliminando gli elementi, quello da eliminare sar sempre il primo di L2. In questo
caso vale:

Il caso peggiore quando L1 e L2 non hanno elementi in comune. In tal caso:

Rispetto agli array abbiamo quindi ottimizzato il caso migliore.

Esercizio. Data una lista doppia circolare, creare un'altra con ordine inverso degli elementi.

Inverti(L1)
p1 = head[L1]
while p1 != null
if next(p1) != p1
head[L1] = next(p1)
prev(next(p1)) = prev(p1)
next(prev(p1)) = next(p1)
insert(L2, p1)
p1 = head[L1]
else
head[L1] = null
insert(L2, p1)
p1 = null

Il tempo dell'algoritmo chiaramente

Esercizio. Dato un array A disordinato, inserire i suoi elementi in una lista in modo che questa
risulti ordinata.
Ordina(A[], L)
insert(L, A[1]) // inserisco il primo elemento a prescindere
for i = 2 to N
Patt = head[L1]
Pprev = null
while Patt != null && key(Patt) < A[i]
Pprev = Patt
Patt = next(Patt)
X = malloc(sizeof(cella))
key(X) = A[i]
next(X) = Patt
if Pprev != null // non deve essere messo in prima posizione
next(Pprev) = X
else
head[L] = X

Esercizio. Data una lista, cancellare tutti gli elementi con valore uguale a K e il loro
successivo. Non esistono elementi uguali successivi.

Del_m(L, K)
Patt = head[L]
Pprev = null
while Patt != null
if key(Patt) == K
if Pprev != null // non la prima casella
next(Pprev) = next(next(Patt))
else
head[L] = next(next(Patt))
free(next(Patt))
free(Patt))
if Pprev != null
Patt = next(Pprev)
else
Patt = head[L]
else
Pprev = Patt
Patt = next(Patt)

Se trovassi K in ultima posizione, il codice andrebbe modificato. Il tempo di esecuzione

Esercizio. Scrivere una procedura ricorsiva che conti quanti elementi di una lista semplice
hanno chiave uguale a K.
int ContaOccorrenze(P, K)
if P == null
return 0
else
if key(P) == K
C = 1
else
C = 0
tot = ContaOccorrenze(next(P), K)
return (C + tot)

Esercizio. Contare il numero di coppie di valori uguali in una lista in modo ricorsivo.
Potrebbe essere realizzato anche tramite divide et impera? Sarebbe forzato.

Possiamo domandarci se gli algoritmi di ordinamento che abbiamo visto per gli array sono
utilizzabili anche con le liste. SelectionSort, InsertionSort sono facilmente realizzabili. Il MergeSort
sarebbe troppo forzato. QuickSort implementabile aggiungendo tail e utilizzando una lista
doppia. CountingSort non invece fattibile.

Vediamo un'altra struttura dati detta stack o pila. una struttura dati di tipo dinamico gestita con
politica LIFO (Last In First Out). Posso prelevare elementi solo a partire dall'ultimo che ho inserito.

Sono previste le seguenti operazioni:

push(P, K) che inserisce un elemento

pop(P) che preleva dalla pila l'ultimo elemento

stackempty(P) che ritorna vero sse lo stack vuoto

top(P) che, analogamente a pop, restituisce il valore dell'ultimo elemento ma senza toglierlo.

Se lo stack vuoto e tento di eseguire una pop si verifica un errore di underflow.

Se inserisco un elemento in uno stack pieno si verifica un errore di overflow (che in generale non
posso controllare perch dipende da quanta memoria disponibile c').

Lo stack pu essere implementato tramite array o tramite liste dinamiche.

Proviamo a vederne l'implementazione tramite array. Chiaramente ci sono alcuni problemi: devo
shiftare tutti gli elementi sia quando inserisco che quando tolgo (tempo lineare); non semplice
capire quando vuoto.

Per ovviare a questo problema posso usare una variabile t[S] che indica la posizione dell'ultimo
valore inserito. In tal modo ho tempo costante sia per inserire che per prelevare.

Durante l'operazione di pop non devo cancellare il valore, ma semplicemente decrementare la


variabile. Controllando che la variabile non sia a 0, posso anche evitare errori di underflow.

int push(S, K)
if t[S] == length(S)
return error(-1)
else
t[S]++
S[t[S]] = K

<tipo> pop(S)
if t[S] == 0 // underflow
error(underflow)
else
R = S[t[S]]
t[S]--
return R

boolean stackempty(S)
if T[S] == 0
return true
else
return false

Discutiamo ora l'implementazione dello stack attraverso una lista. suciente una lista semplice,
con una gestione "in testa".

push(L, X) // consideriamo X gi pronto


next(x) = head[L]
head[L] = X

<tipo> pop(P)
if head[P] == null
error(underflow)
else
R = key(head[P])
p_t = head[P]
head[P] = next(head[P])
// free(p_t)
return R

boolean stackempty(P)
if head[P] == null
return true
else
return false

Esercizio. Data una pila P e una chiave K, eliminare da P tutte le occorrenze di K.

Elimina(P, K)
while not(stackempty(P))
R = pop(P)
if R != K
push(Papp, R)
while not(stackempty(Papp))
R = pop(Papp)
push(P, R)

Il caso peggiore quindi quando nessun valore di P uguale a K.

Il caso migliore quando ogni valore di P uguale a K.

In generale diciamo quindi che l'algoritmo ha complessit

Esercizio. Realizzare due pile utilizzando solo un array. Non devo avere overflow. Non devo
dire che pieno se non ho pi di N elementi in tutto.
Diamo un'idea della soluzione. Una pila punta all'inizio, una alla fine. Ho overflow quando i due
indici si accavallano. Chiaramente sono da sistemare le operazioni per la pila che punta in fondo (e
che deve quindi "crescere" al contrario).

Esercizio. Data una pila stabilire se K presente nella pila.

boolean trova(P, K)
trovato = false
while not(stackempty(P)) && not(trovato)
R = pop(P)
if R == K
trovato = true
return trovato

Prestiamo attenzione perch abbiamo scritto una soluzione distruttiva!

Il caso migliore quando K in testa allo stack. In tal caso:

Il caso peggiore quando nessun elemento di P uguale a K.

Esercizio. Invertire una stringa contenuta in un array usando una pila.


Variante: Data una pila contente una stringa, ribaltarla.

Inverti(P)
while not(stackempty(P))
R = pop(P)
push(Papp1, R)
while not(stackempty(Papp1))
R = pop(Papp1)
push(Papp2, R)
while not(stackempty(Papp2))
R = pop(Papp2)
push(P, R)
Esercizio. Data una stringa, verificare che le parentesi siano annidate correttamente.

boolean check(S[])
i = 1
ok = true
while i <= length(S) && ok
if S[i] == '(' || S[i] == '['
push(P, S[i])
if S[i] == ')'
if stackempty(P) || pop(P) != '('
ok = false
if S[i] == ']'
if stackempty(P) || pop(P) != '['
ok = false
i++
if not(stackempty(P))
ok = false
return ok

Il caso migliore si verifica quando il primo carattere una parentesi chiusa di qualunque tipo.

Il caso peggiore invece un'espressione (scritta bene o male) con pi parentesi aperte che chiuse.

Esercizio. Scrivere un algoritmo che dati in ingresso due pile contententi ciascuna valori
crescenti e una pila vuota P. Inserire in modo ordinato in P.

Unisci(P1, P2)
while not(stackempty(P1)) && not(stackempty(P2))
if top(P1) <= top(P2)
R = top(P1)
else
R = top(P2)
push(Papp, R)

while not(stackempty(P1))
R = pop(P1)
push(Papp, R)

while not(stackempty(P2))
R = pop(P2)
push(Papp, R)

// Ribalto
while not(stackempty(Papp))
R = pop(Papp)
push(P, R)

Calcoliamo il tempo di esecuzione dell'algoritmo.

Sappiamo per che

La dierenza tra il caso migliore e peggiore minima. Trascuriamola.

Esercizio. Dato una pila con lettere dell'alfabeto, dire se contiene una sequenza del tipo "w
$wR" dove w una stringa. W non contiene $.
boolean IsReverse(P)
// Estraggo finch non trovo il dollaro
while not(stackempty(P)) && top(P) != '$'
R = pop(P)
push(Papp, R)
// Tolgo il dollaro se non vuota
if stackempty(P)
return false
else
pop(P)
// Controllo che i rimanenti in P siano uguali a quelli in Papp
while not(stackempty(P)) && not(stackempty(Papp))
&& top(P) == top(Papp)
pop(P)
pop(Papp)
if stackempty(P) && stackempty(Papp)
return true
else
return false

Valutiamo il tempo di questo algoritmo.

Consideriamo il caso quando il primo if interrompe tutto, ci accade quando il dollaro non c'.

Oppure quando c' il dollaro in testa.

Quindi il vero caso migliore quest'ultimo, ovvero quando il dollaro il primo elemento.

Possiamo chiederci qual il caso peggiore. quando P si svuota. Accade in due casi: quando P
non contiene il dollaro, oppure quando P contiene una forma del tipo W$W' con i W' i cui primi
caratteri sono i primi di W.

In questi casi vale:

Esercizio. Leggere caratteri da tastiera fino al carattere nullo. Dire se la sequenza inserita
palindroma utilizzando la pila. Non consentito contare i caratteri inseriti (in tal caso
l'esercizio analogo al precedente).
boolean IsPalindroma
do
R = read(C)
if R != ' '
push(P, R)
push(Papp, R)
while R != ' ';

while not(stackempty(Papp))
R = pop(Papp)
push(Papp2, R)

while not(stackempty(P)) && top(P) == top(Papp2)


pop(P)
pop(Papp2)
if stackempty(P) && stackempty(Papp)
return true
else
return false

Valutiamo il tempo di esecuzione.

Se il primo e l'ultimo carattere sono diversi, allora Tw1 = 0. Quindi il caso migliore vale

Il caso peggiore quando la stringa inserita palindroma. In tal caso:

In generale il tempo di esecuzione dell'algoritmo

Code
Supponiamo di dover gestire la coda di stampa attraverso la pila. evidente che non adeguato
per gestire la coda: potremmo anche ignorare il problema della correttezza (e stampare per prima
l'ultimo arrivato), ma potrebbe anche verificarsi che su un sistema sotto carico alcune stampe non
vengano mai eettuate.

Potremmo, per aggirare il problema, svuotare tutta la pila e prendere l'ultimo. Questo esempio
rende evidente come le pile non sono la struttura dati dinamica adeguata a risolvere tutti i problemi.

Vogliamo gestire la situazione con una politica FiFo (First In First Out) al posto di una LiFo (Last In
First Out).

La struttura dati detta coda o queue. Sono previste le seguenti operazioni:

Enqueue (Q, X): aggiunge un elemento in fondo alla coda

Dequeue (Q): prende il primo elemento dalla coda, lo toglie e lo restituisce

EmptyQueue (Q): ritorna vero se la coda vuota, falso altrimenti.

Proviamo a implementare una coda attraverso una lista. suciente una lista semplice.

Dequeue(L)
if head[L] == null
return error(underflow) // potrebbe essere return null
else
X = head[L]
head[L] = next(head[L])
return X

Per rendere eciente la funzione Enqueue, usiamo una lista semplice con un puntatore tail alla
coda della lista. In questo modo l'inserimento avviene in tempo costante.

Enqueue(L, X)
if (tail[L]) != null)
next(tail[L]) = X
else
head[L] = X
tail[L] = X

EmptyQueue(L)
if head[L] == null
return true
else
return false

Proviamo ora a implementare la coda con un array. Uso un array e due indici, che indicano
rispettivamente il primo elemento e la prossima casella libera. Quando i due indici sono sovrapposti
la coda vuota. Teniamo una casella libera per capire quando la coda piena.

EmptyQueue(A[])
if H_A == T_A
return true
else
return false

Dequeue(A[])
if H_A == T_A // vuota
return error(underflow)
else
R = A[H_A]
H_A++ modulo N // se il totale supera la lunghezza, riporto a 1
return R

Enqueue(A[], K)
if T_A == H_A - 1 modulo N // se non c' pi posto
return error(overflow)
else
A[T_A] = K
T_A++ modulo N

In questo modo tutte le operazioni sulle code possono essere fatte in tempo costante.
Esercizio. Realizzare una coda utilizzando due pile. Valutare i tempi di esecuzione delle
operazioni.

EmptyQueue(Q)
if stackempty(Q)
return true
else
return false

Enqueue(Q)
push(Q, X)

Dequeue(Q)
if stackempty(Q)
return error(underflow)
else
while not(stackempty(Q))
R = pop(Q)
push(Papp, R)
X = pop(Papp)
while not(stackempty(Papp))
R = pop(Papp)
push(Q, R)

Il problema di questa implementazione che dequeue non pi costante, ma ora lineare.


Si pu modificare in modo che Enqueue mantenga ordinata la pila e la Dequeue faccia
semplicemente una pop.

La scelta di una o l'altra soluzione dipende dall'applicazione. Nell'esempio della stampante,


meglio implementare la Enqueue lenta (tanto comunque la stampante sta eseguendo una lenta
elaborazione, la stampa) e la Dequeue veloce (per sapere subito chi il prossimo da stampare).

Un miglioramento possibile non rigirarli alla fine. Tenendo una pila di appoggio, creata una volta
sola, possiamo fare tutte le successive Dequeue direttamente da questa pila finch non vuota.

Quando non ci sono pi elementi, ribaltiamo la pila originale.

Esercizio. Implementare una funzione che restituisca il Kesimo elemento della coda. Non
consentito l'uso di strutture dati diverse dalla coda. Se la coda contiene meno di K elementi,
errore di underflow. Il contenuto di Q deve essere lasciato inalterato.

L'algoritmo funziona solo utilizzando un valore particolare che sappiamo che non pu comparire
nella lista (ad esempio -1 in una lista di interi positivi)

Extract(Q, K)
enqueue(Q, -1)
i = 1
do
R = dequeue(Q)
if i != K && R != -1
// accodo alla coda stessa
Enqueue(Q, R)
i++
while R != -1;
if i < K
return error(underflow)

Esercizio. Scrivere una funzione che cancelli da una coda tutte le occorrenze di un valore A.
Si possono usare come appoggio solo altre code.

Supponiamo di non poter usare un valore come -1, ovvero come fatto in precedenza.

Del_m(Q, A)
while not(emptyqueue(Q))
R = dequeue(Q)
if R != A
enqueue(Qapp, R)
// Ricopio nella coda originale
while not(emptyqueue(Q))
R = dequeue(Qapp)
enqueue(Q, R)

Il tempo di esecuzione circa

Il caso peggiore quando A non compare mai nella coda. In tal caso

Il caso migliore quando la coda contiene tutti elementi uguali a A. In tal caso

Esercizio. Data due code ordinate, fonderle in una terza coda ordinata.

Fondi(Q1, Q2)
if emptyqueue(Q1)
copia(Q, Q2)
return
if emptyqueue(Q2)
copia(Q, Q1)
return
// Se sono qui entrambe le code hanno elementi
R1 = dequeue(Q1)
R2 = dequeue(Q2)
while not(emptyqueue(Q1)) && not(emptyqueue(Q2))
if R1 <= R2
enqueue(Q, R1)
R1 = dequeue(Q1)
else
enqueue(Q, R2)
R2 = dequeue(Q2)
// Ora ho elementi solo da una coda + R1 o R2
...

Esercizio. Invertire una stringa, fornita in un array, attraverso una (o pi) code.
Banalmente potremmo scorrere l'array dal fondo e inserire in una coda.

Esercizio. Stabilire se in un'espressione matematica le parentesi sono accoppiate


correttamente. Utilizzare le code.

Esercizio. Stabilire se in un array contenente lettere dell'alfabeto presente una sequenza del
tipo W$Wr (con Wr stringa ribaltata rispetto a W). Utilizzare code.

Esercizio. Scrivere un algortimo che dato uno stack a elementi unici e una coda a elementi
(possibilmente) ripetuti, eliminare dalla coda gli elementi presenti nello stack.

Delete(Q, S)
flag = 0
while not(stackempty(S))
R = pop(S)
// Mi fermo anche quando la coda (ovvero le code) vuota. In tal
// caso non c' pi nulla da cancellare e non ha senso scorrere
// ulteriormente lo stack.
while (not(emptyqueue(Q)) && not(emptyqueue(Q) && emptyqueue(Qapp)
&& flag == 0)
H = dequeue(Q)
if H != R // i.e. non da cancellare
enqueue(Qapp, H)
while not(emptyqueue(Qapp)) && flag == 1
H = dequeue(Qapp)
if H != R
enqueue(Qapp, H)
flag = not(flag) // commuta il flag
push(Sapp, R)
// Ribalto la pila d'appoggio in quella originale.
while not(stackempty(Sapp))
R = pop(Sapp)
push(S, R)
// Copio eventualmente Qapp in Q, se alla fine i valori sono in Qapp
while not(emptyqueue(Qapp))
H = dequeue(Qapp)
enqueue(Q, H)

Valutiamo i tempi di esecuzione dell'algoritmo.

Osserviamo che:

Ci sono tanti casi, cerchiamo di capire quali sono i casi migliori e peggiori.

Consideriamo il caso in cui tutti gli elementi della coda sono uguali tra loro e sono uguali al primo
elemento della pila (quello in cima e che viene estratto per primo). Questo il caso migliore. In
questo caso:

Analizziamo il caso peggiore: non ci sono elementi in comune tra S e Q. In pi N dispari. In questo
caso:

Realizzazione di insiemi.
Un insieme una collezione di elementi distinti.
Le operazioni base sono: appartenenza di un elemento a un insieme, unione, intersezione e
dierenza tra insiemi.

Unisci (La, Lb)


if head[Lb] == null
return head[La]
if head[La] == null
return head[Lb]
// Scorro A, ogni elemento lo confronto con quelli di B. Se non c'
// lo aggiungo in U. Al termine aggiungo tutto B in U.
Pa = head[La]
while Pa != null
Pb = head[Lb]
while Pb != null && key(Pa) != key(Pb)
Pb = next(Pb)
// Se sono arrivato in fondo a B, l'elemento di A non c'. Lo
// aggiungo a U.
if Pb == null
insert(Lu, Pa)
// Aggiungo B ad U "velocemente".
next(tail[Lb]) = head[Lu]
head[Lu] = head[Lb]
head[La] = null
head[Lb] = null

Il caso migliore quando A e B sono uguali (contengono gli stessi valori). Il caso peggiore quando
sono completamente diversi.

Discutiamo velocemente delle altre operazioni senza implementarle. L'intersezione banale nel
caso in cui uno dei due insiemi sia vuoto, altrimenti simmetrico a quanto abbiamo appena scritto.

Il caso peggiore quando l'intersezione vuota, migliore quando A e B contengono gli stessi valori.

La dierenza tra le due liste si realizza in modo simile.

Potrebbe essere conveniente osservare la lunghezza delle due liste.

Se le due liste fossero ordinate allora possiamo migliorare. Consideriamo ad esempio l'operazione
di unione. Si pu scrivere una procedura ricorsiva.

Union(L1, L2)
if head[L1] == null
return L2
if head[L2] == null
return L1
if key(head[L1]) < key(head(L2))
assembla(head[L1], union(next(head[L1]), head[L2]))
else if key(head[L1]) > key(head[L2])
assembla(head[L2], union(head[L1], next(head[L2])))
else
assembla(head[L1], union(next(head[L1]), next(head[L2])))

Il caso migliore quando la lista pi corta ha elementi sempre inferiori di tutti quelli dell'altra.
Oppure chiaramente le due liste sono uguali. In questi casi il tempo

Il caso peggiore quando gli ultimi due valori di L1 e L2 sono i pi grandi. Intuitivamente ci sono N
+ M chiamate ricorsive.

Scriviamo formalmente l'equazione di ricorrenza:

Per applicare questo le liste devono per essere ordinate. Valutiamo i tempi considerando anche
l'ordinamento delle liste, ad esempio tramite con QuickSort.

Questo mostra un miglioramento rispetto a O(N^2), tempo richiesto dall'algoritmo classico.

Analogo ragionamento vale anche per le operazioni di intersezione e dierenza.

Vediamo ora la realizzazione di insiemi tramite array.

interessante l'implementazione tramite vettore caratteristico. Ad esempio, date 52 carte,


possiamo creare un array di bit contentente uno all'i-esimo posto sse c' quella carta.

Le operazioni di unione si traducono in operazioni binarie (Unione OR, intersezione AND, ecc).

Le operazioni richiedono dove U l'universo.

Questa rappresentazione particolarmente comoda quando il numero di elementi eettivi che ho


dello stesso ordine di grandezza dell'universo.

I vettori sono ecienti quindi sia nello spazio che nel tempo, ma a condizione che il numero di
elementi da memorizzare sono circa dello stesso ordine di grandezza dell'universo.

Ad esempio, non ha senso rappresentare pochi biglietti della lotteria acquistati su un monte totale
di biglietti estremamente alto.

Mostriamo ad esempio l'implementazione della Union di due vettori caratteristici.

Union(A, B)
for i = 1 to length(A)
C[i] = A[i] or B[i]

ALBERI

Per definizione un albero un grafo non orientato, connesso e aciclico.

Un grafo aciclico, non orientato ma non connesso detto foresta.

Un albero radicato un albero in cui un vertice particolare detto root di T.

I figli di un vertice sono tutti i vertici ad esso direttamente collegati e che si trovano ad un livello
inferiore nell'albero.

Il livello definito come profondit di un vertice che la lunghezza del cammino che lo separa dalla
radice.

L'altezza di un albero il livello massimo di profondit raggiunto dai suoi vertici.

Nodi senza figli vengono chiamati foglie (ovvero i cui puntatori ai figli sono tutti null).

Il grado di un vertice la quantit di figli che ha un vertice.

Vediamo ora una possibile implementazione di un albero

Di solito si fissa un massimo numero di figli per un nodo. Se il massimo due, allora si ha quindi un
albero binario radicato.

Se ogni elemento ha due figli (tranne le foglie) l'albero binario si dice completo.

Diamo una definizione ricorsiva di albero binario.

Un albero binario completo formato da

nodi.

Dato un albero binario, stampare tutte le chiavi di quell'albero.

stampa(Pt)
if Pt != null
printscreen(key(Pt))
stampa(left(Pt))
stampa(right(Pt))

Proviamo, usando una pila di appoggio, a non usare un approccio ricorsivo ma iterativo.

stampa(root[T] = P)
if P == null
return
else
push(S, P)
while not(stackempty(S))
P = pop(S)
print(key(P))
if right(P) != null
push(S, right(P))
if left(P) != null
push(S, left(P))

Come posso far stampare livello per livello i nodi?

Ricorsivamente complesso, ma iterativamente basta sostituire una pila con una coda.

Albero binario di ricerca (detto ABR o SBT)


Vengono distribuiti gli elementi nell'albero in un certo modo anch sia pi veloce eettuare certe
operazioni.

L'obiettivo arrivare a fare operazioni in tempo logaritmico.

I nodi contengono valori in modo tale che a sinistra di ogni nodo, in tutto il sottoalbero sinistro, ci
sono solo valori minori o uguali del nodo stesso (e nel sottoalbero destro maggiori o uguali). Questo
vale per ogni nodo!

Quanto tempo impiega a cercare?

Un albero pu essere bilanciato o sbilanciato. Un albero detto bilanciato se tutti i livelli sono
completi tranne l'ultimo.

Se l'albero bilanciato, l'altezza di un albero con N nodi logN.

Il vero caso peggiore , in caso di albero sbilanciato, una situazione di questo tipo.

L'altezza in questo caso N.

Ricordiamo la definizione di un albero binario di ricerca.

Visite di un albero binario


Due tipi:

visita in profondit

Visita in ampiezza (per livelli, prima tutti i nodi del livello N, poi quelli del livello N + 1)

Concentriamoci sulla visita in profondit

visita in ordine anticipato (pre order)

Visita in ordine simmetrico (in order)

Visita in ordine posticipato (post order)

(rispetto alla radice di ogni sottoalbero, quindi anche dell'albero intero).

Mostriamo l'esempio su questo albero

anticipata(T)
if T != null
print(key(T))
anticipata(left(T))
anticipata(right(T))

simmetrico(T)
if T != null
simmetrico(left(T))
print(key(T))
simmetrico(right(T))

posticipato(T)
if T != null
posticipato(left(T))
posticipato(right(T))
print(key(T))

Vediamo ora un albero di ricerca.

Simuliamo una vista in pre order:

Post order:

Simmetrica:

Non un caso che i valori nella visita simmetrica siano stati visitati in ordine crescente.

Mostriamo il tempo di esecuzione.

Proviamo a rendere bilanciato l'albero.

In generale non detto che l'albero bilanciato sia unico. Se la quantit di elementi esattamente
2^N - 1 allora la soluzione unica; altrimenti no.

Avendo N elementi, l'albero ben bilanciato ha altezza logN. L'albero pi alto possibile alto N - 1 (di
fatto l'albero una lista). Ad esempio:

La radice il minimo o il massimo.

Vediamo le operazioni definire su un albero binario di ricerca, in cui tutte le operazioni richiedono
tempo che dipende dall'altezza dell'albero. Se l'albero viene mantenuto piuttosto bilanciato, tutte le
operazioni richiedono tempo logaritmico.

SBT_Search(X, K)
if X == null || key(X) == K)
return X
else
if key(X) > K
R = SBT_Search(left(X), K)
else
R = SBT_Search(right(X), K)

return R

L'algoritmo richiede

Vediamo ora l'algoritmo per determinare il minimo. l'elemento che trovo scendendo sempre a
sinistra e che non ha figlio sinistro.

SBT_min(X)
if X == null
return X
else
while left(X) != null
X = left(X)
return X

Cercare il minimo richiede quindi tanto quanto profondo il ramo. Il caso migliore quando la
radice non ha figlio sinistro: tempo costante. Il caso peggiore quando stiamo percorrendo il ramo
che determina l'altezza dell'albero: O(h).

Cercare il massimo analogo scendendo a destra anzich a sinistra.

Vediamo il successore (cio il pi piccolo tra tutti quelli pi grandi di lui).

SBT_Succ(X)
if right(X) != null // se esiste il sottoalbero di destra
R = SBT_min(right(X))
return R
else // altrimenti risalgo finch trovo il primo nodo
while parent(X) != null && X != left(parent(X))
X = parent(X)
return parent(X)

Tempo di esecuzione. Stiamo percorrendo un ramo dell'albero, quindi O(h). Analogo e simmetrico
ragionamento vale per il predecessore.

Esercizio. una sequenza valida per cercare 363 in un albero di ricerca?

924, 220, 911, 244, 898, 258, 362, 363.

925, 202, 911, 240, 912, 245, 363.

No perch 912 maggiore di 911. Da 911 vado a sinistra e mi aspetto quindi che tutti i successivi
valori siano pi piccoli di lui.

Inserimento

Vediamo ora l'inserimento di un elemento in un albero di ricerca.

SBT_insert(T, X)
if root[T] == null
root[T] = X
else
P = root[T]
Pa = null
while P != null
Pa = P
if key(P) > key(X)
P = left(P)
else
P = right(P)

if key(Pa) > key(X)


left(Pa) = X
else
right(Pa) = X

Il tempo sempre O(h) nel caso peggiore.

Cancellazione di un elemento da un albero di ricerca. Distinguiamo tre casi:

1. elemento foglia (pi semplice: elimino e basta).

2. elemento con un solo figlio (sostituiamo con il sottoalbero avente come radice l'unico figlio; tale
operazione detta "contrazione").

3. elemento con entrambi i figli. Trovo il minimo del sottoalbero destro (oppure il massimo del
sottoalbero sinistro) per ottenere il successore (rispettivamente, il predecessore); scambio i due
valori. A quel punto il problema si riduce a cancellare il nodo dove c'era il successore che ricade
obbligatoriamente nei due casi precedenti.

SBT_Delete(T, X)
if left(X) == null && right(X) == null // non ha figli, caso 1
if parent(X) == null // radice foglia
root[T] = null
else
if X == left(parent(X)) // il figlio sinistro
left(parent(X)) = null
else
right(parent(X)) = null
else if left(X) == null XOR right(X) == null // 1 figlio, caso 2
contrazione(T, X)
else
S = succ(X)
key(X) = key(S)
SBT_Delete(T, S)

Il caso 3 dipende dal costo della ricerca del successore ed quindi potenzialmente O(h).

La contrazione, il cui codice sotto riportato, richiede tempo costante. Non dicile, ma
dobbiamo considerare tutta una serie di casi.

contrazione(T, X)
if parent(X) == null
if left(X) != null
root[T] = left(x)
parent(left(X)) = null
else
root[T] = right(X)
parent(right(X)) = null
else if X == left(parent(X))
if left(X) != null
left(parent(X)) = left(X)
parent(left(X)) = parent(X)
else
left(parent(X)) = right(X)
parent(right(X)) = parent(X)
else
if left(X) != null
right(parent(X)) = left(X)
parent(left(X)) = parent(X)
else
right(parent(X)) = right(X)
parent(right(X)) = parent(X)

Cenni sul bilanciamento degli alberi


Come faccio a tenere un albero binario bilanciato?

Lo sbilanciamento si riferisce a ogni nodo e ha i seguenti valori: 0 se bilanciato, valori negativi se il


sottoalbero di sx ha altezza maggiore di quello di destra (e viceversa).

Quando lo sbilanciamento raggiunge il valore 2, posso fare una serie di rotazioni che ribilanciano
l'albero. Ci sono due rotazioni semplici e una pi complessa.

Esercizio. Data la seguente struttura, inserire valori anch risulti un BST.


10 75 30 42 47 81 38 31 76 53
Inserire quindi uno alla volta i valori 40 60 90

Cancellare quindi uno alla volta i valori 76, 53, 30

Stampare i nodi seguendo le diverse visite.

Esercizio. Dato un albero binario a valori interi, scrivere un algoritmo divide et impera che
conti i valori nell'albero sono compresi tra N1 e N2.

int conta(X, N1, N2)


if X == null
return 0
else
sx = conta(left(X), N1, N2)
dx = conta(right(X), N1, N2)
if key(X) >= N1 && key(X) <= N2
c = 1
else
c = 0
return sx + dx + c

Valutare i tempi di esecuzione considerando in input un albero completo.

Esercizio. Costruire un BST con i valori in sequenza 21 7 9 72 27 3 15 45 32 28 1 42 50

Esercizio. Dato un SBT con N interi distinti e un vettore ordinato con M interi distinti,
stampare i valori in ordine crescente. Implementare il tipo di dati insieme.

StampaUnione(T, A[])
i = 1
X = SBT_min(T)
while i <= length(A) && X != null
if A[i] < key(X)
print(A[i])
i++
else if key(X) < A[i]
X = succ(X)
else // sono uguali e ne stampo solo uno
print(A[i])
i++
X = succ(X)
while i <= length(A)
print(A[i])
i++
while X != null
print(key(X))
X = succ(X)

Valutiamo i tempi di esecuzione.

In realt il tempo calcolato inesatto. Il tempo per cercare il successore di una foglia in realt 1.
Ma in un albero di N elementi ci sono N/2 nodi all'ultimo livello. Al penultimo livello (N/4 nodi)
cercare il successore in realt 2.

Solo la radice richiede logN per cercare il successore.

Esercizio. Contare il numero di nodi di un albero binario (rispettivamente, di foglie, figli destri,
altezza )

int nodi_BT(X)
if X == null
return 0
else
S = nodi_BT(left(X))
D = nodi_BT(right(X))
return S + D + 1

int foglie_BT(X)
if X == null
return 0
else if right(X) == null && left(X) == null
return 1
else
S = foglie_BT(left(X))
D = foglie_BT(right(X))
return S + D

int figlidestri_BT(X)
if X == null
return 0
else
S = figlidestri_BT(left(X))
D = figlidestri_BT(right(X))
if right(X) != null
C = 1
else
C = 0
return S + D + C

int altezza_BT(X)
if X == null // l'altezza di un albero vuoto -1
return -1
else
S = altezza_BT(left(X))
D = altezza_BT(right(X))
M = max(S, D)
return M + 1
Verificare se un BT un SBT.

boolean is_SBT(X)
if X == null
return true
else
S = is_SBT(left(X))
if S == false
return false
S_M = SBT_max(left(X)) // restituisce null se albero vuoto
if S_M != null && key(S_M) > key(X)
return false
D = is_SBT(right(X))
if D == false
return false
D_m = SBT_min(right(X))
if D_m != null && key(D_m) < key(X)
return false
// Tutte le condizioni OK.
return true

Scriviamo l'equazione di ricorrenza per valutare il tempo di esecuzione.

Il caso peggiore quando un BST.

HEAP
Ad esempio uno degli usi degli heap per implementare code di priorit, oppure per l'algoritmo di
ordinamento HeapSort.

Uno heap un array che pu essere visto come un albero binario (di fatto viene interpretato
logicamente).

un albero binario completo a meno dell'ultimo livello, riempito comunque a partire da sinistra.

Il nodo I, ha figli i nodi 2I e 2I + 1.

Osserviamo che la moltiplicazione e la divisone per due sono realizzabili hardware tramite shift a
sinistra e a destra. Sono quindi eseguite in modo particolarmente veloce.

Uno heap ha questa caratteristica:

Ad esempio.

Possiamo dire con certezza che il primo elemento (la radice) il massimo.

Mostriamo un'altra propriet degli heap.

Heapsize dinamico e indica quanti elementi fanno parte dello heap. Heapsize varia in fase di
esecuzione.

In generale vale che gli elementi dello heap vanno da 1 a heapsize(A).

Lo heap ci garantisce che il max in prima posizione, il secondo pi grande in seconda o terza
posizione, e cos via. I numeri piccoli sono nelle foglie ma non sappiamo esattamente dove.

L'algoritmo di ordinamento HeapSort sfrutta le propriet dello heap. Esso si compone di diverse
fasi:

BuildHeap

Da un array disordinato ottengo uno heap

Sposto il massimo in fondo

Decremento heapsize

Richiamo BuildHeap

Heapify: crea uno heap senza considerare tutto l'array. Parte dal principio che il sottoalbero di
destra e di sinistra sono gi degli heap, quindi a partire da K trasforma l'albero in uno heap.

Vediamo un esempio

Lancio heapify: confronta 4, 14 e 7; scambia 4 con 14.

Lancio heapify: confronta 4, 2, 8; scambia 4 con 8.

Ora uno heap. Sostanzialmente, ogni volta tralascio un sottoalbero.

L'altezza H ragionevolmente logN e determina il tempo peggiore. Se non necessario trasformare


l'albero perch gi uno heap, il tempo costante.

Heapify(A, K)
largest = K
if left(K) <= hs(A) && A[left(K)] > A[largest]
largest = left(K)
if right(K) <= hs(A) && A[right(K)] > A[largest]
largest = right(K)
if largest != K
scambia(A, K, largest)
heapify(A, largest)

Tempo di esecuzione:

Applichiamo il teorema dell'esperto.

Esercizio. Dato il seguente array simulare HeapSort.


Creiamo lo heap dal basso. Al primo passo heapify confronta 16 con 7, sono a posto e quindi uno
heap. Al secondo passo heapify confronta 2, 14 e 8, scambia 14 con 2 ed a posto.

Lo stack di chiamate heapify nell'esempio il seguente.

Scriviamo la funzione BuildHeap.

BuildHeap(A)
heapsize(A) = length(A)
for i = floor(N/2) down to 1 // evito le foglie
heapify(A, i)

Il tempo sembra essere O(NlogN), ma in realt O(N).

Questo giustificabile dal fatto che le foglie richiedono tempo 0, il livello superiore 1 e via via
crescendo. Il conto esatto diventa quindi

Maggioriamo la sommatoria con la serie infinita corrispondente, di cui sappiamo scrivere il valore
esatto.

L'HeapSort completo quindi il seguente.

HeapSort(A[])
BuildHeap(A)
for i = 1 to N - 1
scambia(A, 1, heapsize(A))
heapsize--
heapify(A, 1)
// Al termine andrebbe decrementato ancora una volta heapsize.
// heapsize-- per farlo arrivare a 0.

Il tempo O(NlogN).

L'algoritmo di ordinamento in loco

Non un algoritmo stabile, informalmente perch non so cosa "succede" agli elementi.

Tramite gli heap si realizzano anche code con priorit su cui sono definite le seguenti operazioni:

insert(C, X) in tempo logaritmico

Maximum(C) in tempo costante

Extract_max(C) in tempo logaritmico

Parliamo di tabelle hash.

L'obbiettivo riuscire a ridurre il tempo di ricerca di un elemento; l'idea che il tempo sia O(1),
ovvero costante.

Introduciamo prima tabelle ad indirizzamento diretto: struttura dati in cui ogni elemento da
memorizzare viene memorizzato in una posizione univoca determinata dalla chiave stessa.

Nell'array, per inserire un elemento basta settore il bit alla posizione della chiave a 1. Inserimento e
cancellazione richiedono chiaramente

Anche cercare un elemento richiede

Proviamo a implementare una

Insert(H, K)
H[K] = 1

Come trovo il massimo? Parto dal fondo e cerco il primo bit a 1. Il tempo O(N).

Massimo, minimo, predecessore e successore non sono operazioni che sono implementate in
maniera eciente.

C' anche un problema di occupazione di memoria: vero che stiamo memorizzando solo bit, ma
ci servono tante posizioni quanto il range dei valori da memorizzare.

In generale, serve una funzione P che prende in input la chiave e restituisce una posizione. Se
l'indirizzamento diretto, la funzione P restituisce sempre valori diversi per chiavi diverse.

Esercizio. Estendere le tabelle per chiavi ripetute.


suciente cambiare i bit in interi. Le operazioni cambiano nel modo seguente:

Insert(H, K)
H[K]++

Delete(H, K)
if H[K] >= 1
H[K]--
else
underflow error

Search(H, K)
if H[K] >= 1
return true
else
return false

Il problema che con queste tabelle, se la quantit dei dati da memorizzare inferiore alla
dimensione dell'universo si spreca un sacco di spazio.

Diciamo in generale che la tabella ha dimensione M = |U|. Se mi accorgo di star sprecando troppo
spazio, decido di porre M < |U|. Questo comporta che non pi vero che ogni chiave ha posizione
univoca.

Supponiamo U = 1000, M = 200 e devo memorizzare 100 numeri. M influisce su quanti conflitti si
vanno a generare: in breve, cambia la probabilit che la chiave abbia posizione univoca.

Quello che si cerca di fare distribuire equamente le chiavi sulle caselle. Il modo in cui scegliere la
funzione hash(K), chiamata in precedenza P, arbitrario, ma fa cambiare i tempi di esecuzione.

Obiettivo che la funzioni generi probabilit in questo modo:

Un conflitto la situazione per cui due chiavi distinte devono essere memorizzare in quella cella.
Rendendo uguali le probabilit, la probabilit che avvenga un conflitto la minima possibile (anche
se non nulla!)

Gestione dei conflitti:

liste concatenate

indirizzamento aperto

Sia data una funzione hash(chiave) = posizione.

Gestire i conflitti con la lista concatenata significa operare in questo modo:

Insert(H, K)
pos = hash(x)
list_insert_head(H[pos], K)

Il tempo costante.

Search(H, K)
pos = hash(K)
list_search(H[pos], K)

La ricerca di un elemento in una lista richiede tempo lineare con N il numero degli elementi
memorizzati veramente. Noi stiamo cercando in una sola lista, ma se tutti gli elementi inseriti finora
fossero nella lista, allora il tempo O(N). Supponiamo di avere molti numeri memorizzati, ci
aspettiamo O(5) (nell'esempio 5 era il numero massimo di elementi della lista). In generale il tempo
generale O(C), dove C il numero massimo di collisioni per una casella.

Il tempo medio ci aspettiamo per che sia Theta(N/M) dove M la dimensione della tabella.

La cancellazione ha tempo analogo ( di fatto una ricerca e una cancellazione).

M legata a tempo e spazio: all'aumentare di M aumenta lo spazio richiesto ma diminuisce il


tempo.

Alpha detto fattore di carico della lista. il numero di collisioni che ci aspettiamo per ogni casella.

Esercizio. Data una tabella hash con risoluzioni collisioni con liste concatenate, inserire i
seguenti valori. La tabella ha 9 posizioni (M = 9) e la funzione hash(K) = K mod 9.

Se la lista viene tenuta ordinata, peggioro il tempo dell'inserimento, la ricerca diminuisce in media
ma resta uguale nel caso peggiore, la cancellazione segue lo stesso ragionamento.

Date M caselle, come posso riempirle in modo che presa una chiave qualunque riempia una casella
in modo equiprobabile? 1/M per ciascuna.

Una buona scelta di M un numero tendenzialmente primo (rispetto a una certa potenza di due
presa in un certo modo).

Ragioniamo un momento su una buona scelta per la funzione hash: deve essere semplice da
calcolare e distribuire bene le chiavi. Una scelta semplice, comune e ragionevole

hash(K) = K mod M.

Mostriamo ora il secondo metodo di risoluzione dei conflitti. Il problema del metodo delle liste
concatenate che, all'estremo, tutte le chiavi sono in conflitto sulla stessa posizione. In questo
modo si spreca spazio (puntatori a null inutilizzati) e costa spaio (elementi della lista).

Questo metodo detto indirizzamento aperto. In caso di collisione, cerco un'altra posizione della
tabella libera.

Dobbiamo chiaramente cambiare la search, perch ora un elemento pu essere fuori posto.

Durante la ricerca, calcolo la sua posizione prevista. Se vuota, allora il numero non c'. Altrimenti,
devo fare una scansione lineare finch non trov una casella vuota.

Attenzione alla cancellazione: dobbiamo impostare un "flag" deleted per non interrompere la
"catena".

Dopo un po' di operazioni, i tempi diventano piuttosto alti.

Il tempo di ricerca O(N), per quanto sia dicile che si verifichi. Mediamente per ci aspettiamo un
tempo costante O(1).

Pi tendo a mettere numeri vicini all'altro, pi tendo a creare agglomerati (sto accumulando
probabilit su una casella).

La soluzione possibilmente creare funzioni hash con salti diversi.

Tabella riassuntiva delle complessit computazionali.