Documenti di Didattica
Documenti di Professioni
Documenti di Cultura
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
Un algoritmo corretto se termina sempre e fornisce un output corretto per ogni possibile istanza
(rispetto al problema computazionale).
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:
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 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.
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
Cicli: for, while, do while (con il loro uso corretto, si evitino ad esempio istruzioni come break).
Commenti tra /* */
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.
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)
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 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.
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).
Se valgono sia il limite inferiore che il limite inferiore allora diciamo in generale che la funzione
limitata (si indica con theta).
E se considerassimo SelectionSort?
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.
transitivi
riflessivi (O grande, theta grande, omega grande se usati nel modo appropriato).
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.
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;
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
for i = 1 to N - 1
for j = i + 1 to N
for K = 1 to J
R = R + 1;
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.
Il caso migliore quando i while vengono eseguiti il minimo numero di volte possibili, cio mi
accorgo subito che la matrice non simmetrica.
For i=1 to N
b[i] = 0
j = N
while j >= 1
b[i] = b[i] + a[j]
j--
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.
int fattoriale(N)
ris = 1
for i = N down to 1
ris = i * ris
return ris
int fattoriale_ricorsivo(N)
if N == 0
return 1
else
parziale = fattoriale_ricorsivo(N-1)
totale = N * parziale
return totale
Dimostrare che ogni cifra partendo dagli 8 centesimi ottenibile attraverso l'utilizzo delle sole
monete da 3 e 5 centesimi.
FunzioneRicorsiva
if casobase
...
else
...
FunzioneRicorsiva
...
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.
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.
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)
Ragionamento sul caso base. La scelta migliore (ie quella pi semplice) considerare l'array con
zero elementi rimanenti, che ovviamente contiene 0 cinque.
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.
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.
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.
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
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
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).
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.
Osserviamo ora un esempio di applicazione del "metodo principale", che si pu usare solo per
equazioni di ricorrenza di questo tipo:
Esempio.
Esercizio.
Un tale epsilon non esiste. Per quanto piccolo sia, dopo un po' . Formalmente:
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.
tempo medio di esecuzione NlogN (ma con costanti nascoste dal limite asintotico pi piccole di
MergeSort).
Mostriamo con un esempio il funzionamento del QuickSort (scegliendo il primo elemento come
elemento pivot).
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.
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 ora alcuni ragionamenti che confermano che QuickSort sta tra NlogN e N^2.
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 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.
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.
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
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
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
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].
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
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)
if I > F
return false
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)
Complessit computazionale.
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.
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!.
Vediamo uno degli algoritmi che non si basano su confronti: CountingSort. Non un algoritmo di
ordinamento in loco. un algortimo di ordinamento stabile.
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.
Azzero l'array C.
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:
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.
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.
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
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.
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.
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.
una chiave, ovvero un'informazione che consente di identificare in modo univoco un record
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.
successore(S, k)/predecessore(S, k)
In caso occorrano pi liste, si soliti creare un array che contiene le teste delle diverse liste.
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.
Vediamo ora come implementare le varie operazioni. Assumiamo, se non ci sono altri dettagli, che
le liste siano doppiamente collegate.
List_search(L, K)
p = head[L]
while p != null && key(p) != K
p = next(p)
return p
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).
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)
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
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
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]
head[L3] = head[L1]
head[L1] = null
head[L2] = null
return head[L3]
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:
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
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)
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.
top(P) che, analogamente a pop, restituisce il valore dell'ultimo elemento ma senza toglierlo.
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').
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.
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".
<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
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)
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).
boolean trova(P, K)
trovato = false
while not(stackempty(P)) && not(trovato)
R = pop(P)
if R == K
trovato = true
return trovato
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)
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
Consideriamo il caso quando il primo if interrompe tutto, ci accade quando il dollaro non c'.
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.
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)
Se il primo e l'ultimo carattere sono diversi, allora Tw1 = 0. Quindi il caso migliore vale
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).
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)
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.
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 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 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)
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.
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.
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.
Per applicare questo le liste devono per essere ordinate. Valutiamo i tempi considerando anche
l'ordinamento delle liste, ad esempio tramite con QuickSort.
Le operazioni di unione si traducono in operazioni binarie (Unione OR, intersezione AND, ecc).
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.
Union(A, B)
for i = 1 to length(A)
C[i] = A[i] or B[i]
ALBERI
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.
Nodi senza figli vengono chiamati foglie (ovvero i cui puntatori ai figli sono tutti null).
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.
nodi.
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))
Ricorsivamente complesso, ma iterativamente basta sostituire una pila con una coda.
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!
Un albero pu essere bilanciato o sbilanciato. Un albero detto bilanciato se tutti i livelli sono
completi tranne l'ultimo.
Il vero caso peggiore , in caso di albero sbilanciato, una situazione di questo tipo.
visita in profondit
Visita in ampiezza (per livelli, prima tutti i nodi del livello N, poi quelli del livello N + 1)
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))
Post order:
Simmetrica:
Non un caso che i valori nella visita simmetrica siano stati visitati in ordine crescente.
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:
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).
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.
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
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)
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)
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. 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.
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)
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.
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
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.
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.
Ad esempio.
Possiamo dire con certezza che il primo elemento (la radice) il massimo.
Heapsize dinamico e indica quanti elementi fanno parte dello heap. Heapsize varia in fase di
esecuzione.
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
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
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:
BuildHeap(A)
heapsize(A) = length(A)
for i = floor(N/2) down to 1 // evito le foglie
heapify(A, i)
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.
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).
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:
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
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.
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.
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!)
liste concatenate
indirizzamento aperto
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.
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".
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).