Appunti per il Modulo di Algoritmi e Struture Dati Guido Fiorino

Appunti per il Modulo di
Algoritmi e Struture Dati
Guido Fiorino
Ultima Versione: 22 marzo 2011
0
Presentazione
0.1
Programma del corso
• Un esempio introduttivo
• Cenni ai modelli di calcolo e alle metodologie di analisi
• Complessità degli algoritmi di ordinamento
• Complessità degli algoritmi di selezione e statistiche d’ordine
• (polinomi e trasfomata di Fourier)
• Tecnica divide et impera
• Programmazione dinamica
• Tecnica greedy
• (Branch and Bound)
• Grafi e loro visite
• Minimo albero ricoprente
• Cammini minimi
• (Flusso)
0.2
Materiale Didattico
Libro adottato:
C. Demetrescu, I. Finocchi, G. Italiano, Algoritmi e strutture dati, McGraw-Hill, 2004.
Libri consigliati:
T.H. Cormen, C.E. Leiserson, R.L. Rivest, Introduzione agli algoritmi, Jackson Libri, 1999.
R. Sedgewick, Algoritmi in C, Addison-Wesley Italia, 1993.
Dispense:
A. Bertoni, M. Goldwurm, Progetto e analisi di algoritmi, scaricabile dal sito
http://homes.dsi.unimi.it/ goldwurm/algo/
0.3
Esame
Uno scritto di 1h30min. in cui si chiedono algoritmi e dimostrazioni di teoremi visti nel corso, problemi in
cui si chiede di adattare quanto visto a lezione o dimostrazioni di teoremi non visti a lezione, esercizi che
siano applicazione di quanto visto a lezione.
2
0.4
Altro
• Ricevimento: contattarmi via email;
• questi appunti: http://web-nuovo.dimequant.unimib.it/~guidofiorino/
3
1
Un esempio introduttivo
• nel corso gli algoritmi verranno principalmente scritti in pseudocodice. La codifica in un reale linguaggio di programmazione, C o JAVA, sarà quindi in subordine e servirà solo per concretezza.
• Ci occupiamo di progettare algoritmi e di analizzarli matematicamente, cosa diversa dal condurre
analisi sperimentali;
• L’analisi è preferibile perchè ci da risposte su tutti i casi possibili, cioè permette di predire il comportamento di un algoritmo per ogni possibile dato di ingresso e permette di scegliere tra 2 algoritmi
anche senza averli implementati.
• Piuttosto che discutere astrattamente il significato delle affermazioni precedenti, per amore di concretezza consideriamo un problema molto semplice.
1.1
Numeri di Fibonacci
• Come si espande una popolazione di conigli a partire da una singola coppia sotto le seguenti ipotesi
semplificatrici: 1) una coppia di conigli genera una coppia di conigli all’anno; 2) i conigli nel primo
anno non si riproducono; 3) i conigli sono immortali;
• in base a quanto detto possiamo graficamente rappresentare il numero conigli con un albero; è chiaro
che nell’anno t abbiamo tutte le coppie di conigli presenti nell’anno t − 1, infatti nessuno è morto;
inoltre tutte le coppie presenti nell’anno t − 2 hanno figliato una coppia di conigli. Quindi se F (n)
denota il numero di conigli nell’anno n dell’esperimento abbiamo che F (1) = 1, F (2) = 1, F (n) =
F (n − 1) + F (n − 2) per n ≥ 3. Ne segue che F è una funzione definita per casi. E’ una funzione
definita per ricorsione. Possiamo domandarci se esista una funzione analitica equivalente.
• proviamo a vedere se F (n) ha un andamento esponenziale, cioè se per caso F (n) = an , con a ∈ R.
• se F (n) ha andamento esponenziale allora sostituendo nella ricorrenza otteniamo
an = an−1 + an−2
2
Portando tutto a primo membro, raccogliendo a fattor comune an−2 otteniamo
che deve
√
√ essere a −
a − 1 = 0. Risolvendo l’equazione otteniamo due soluzioni per a : a1 = 1+2 5 e a2 = 1−2 5 .
√
√
• Purtroppo anche se ( 1+2 5 )n e ( 1−2 5 )n hanno il medesimo andamento di F (n) nessuna di loro due è
F (n) come si vede ad esempio per n = 2.
• Dov’è il problema? Il problema sta nel fatto che le due funzioni risolvono la ricorrenza ma non
rispettano il passo base di F (n).
4
• Siccome una qualunque combinazione lineare di funzioni che soddisfano la ricorrenza di fibonacci,
soddisfa anch’essa la ricorrenza cerchiamo di ricavare una funzione
√ !n
√ !n
1− 5
1+ 5
+ c2
G(n) = c1
2
2
combinazione lineare delle 2 trovate e che soddisfi anche il passo base, ovvero G(1) = 1, G(2) = 1.
Impostiamo il sistema cosi’ da scoprire quanto devono valere c1 , c2
(
√
√
c1 ( 1+2√5 )1 + c2 ( 1−2√5 )1 = 1
c1 ( 1+2 5 )2 + c2 ( 1−2 5 )2 = 1
Questo è un sistema in 2 equazioni e 2 incognite che risolto dà
1
1
c1 = √ , c2 = − √
5
5
Abbiamo quindi la nostra funzione G(n) = F (n), ovvero abbiamo scoperto l’andamento analitico di F (n):
√ !n
√ !n !
1
1+ 5
1− 5
F (n) = √
−
2
2
5
Questa soluzione immediatamente suggerisce un algoritmo molto facile. Il difetto di questa soluzione è che
lavora con i reali ma un calcolatore non può rappresentarli con una precisione illimitata. Questo produce
errore nei calcoli e quindi un errore nel computo di F (n).
Esercizio. provare per credere: scrivete un programma C per l’algoritmo, scegliete le variabili di tipo
double o long double.
1.2
Algoritmo ricorsivo
• Piuttosto che usare la versione analitica di F (n), usiamo la sua definizione ricorsiva e scriviamo un
algoritmo ricorsivo per calcolare F (n) come quello in figura 1.4, pagina 6 (fibonacci2).
• Ma quanto tempo ci vuole ad eseguire questo algoritmo in funzione del valore di ingresso?
• Prima di tutto stabiliamo cosa è per noi il tempo. Scegliere una grandezza come i secondi non va bene
in quanto con il cambiare della tecnologia il medesimo codice eseguito su una macchina nuova impiega
di meno.
• per noi il tempo impiegato sarà in prima approssimazine il numero di righe di codice eseguite dove
assumeremo che ciascuna riga possa essere eseguita sempre con il medesimo tempo e che questo sia
costante.
5
• Valutiamo il numero di righe T (n) in funzione di n:
– se n = 1 o n = 2 allora T (n) = 1;
– se n ≥ 3, allora T (n) = T (n − 1) + T (n − 2) + 2, ovvero il numero di righe eseguite è 2 più il
numero di righe richiesto per eseguire la chiamata ricorsiva con parametro n − 1 più il numero di
righe richiesto per la chiamata ricorsiva con parametro n − 2.
• Si osservi come la ricorrenza assomigli fortemente a quella della funzione F (n) di fibonacci.
• Valutiamo T (n): sicuramente vale che T (n) > T (n−2)+T (n−2) = 2T (n−2). Srotolando la ricorsione
otteniamo che
T (n) > 2T (n − 2) > 22 T (n − 2 ∗ 2) > 23 T (n − 2 ∗ 3) > ... > 2k T (n − 2 ∗ k) > ...
fino ad ottenere T(2) se n pari,
T (n) > 2T (n − 2) > 22 T (n − 2 ∗ 2) > 23 T (n − 2 ∗ 3) > ... > 2k T (n − 2 ∗ k) > ...
fino ad ottenere T(1) se n dispari. Ora, se n pari, quante iterazioni sono necessarie per raggiungere
T(2)? Basta porre n − 2 ∗ k = 2 ed otteniamo k = n−2
2 , cioè k è il numero di iterazioni in funzione
n−2
di n per arrivare al passo base. Sostituendo k otteniamo che T (n) > 2 2 . Questo ci dice che il
numero di righe eseguito è (almeno) esponenziale in funzione di n, con n pari. Per n dispari otteniamo
n−1
T (n) > 2 2 .
• Possiamo anche limitare superiormente T (n):
T (n) < 2T (n − 1) + 2 < 22 T (n − 2) + 2 ∗ 2 < 23 T (n − 3) + 2 ∗ 3 < ... < 2k T (n − k) + 2 ∗ k < ...
Questa catena termina quando n − k = 2, ovvero dopo k = n − 2 iterazioni, oppure quando n − k = 1.
Nel primo caso, sostituendo n − 2 al posto di k otteniamo
T (n) < 2n−2 + 2 ∗ (n − 2)
possiamo concludere che il numero di istruzioni è esponenziale rispetto a n.
• Il problema dell’algoritmo fibonacci2 è che ricalcola la soluzione al medesimo problema più volte.
Questo lo si vede facilmente analizzando l’albero delle chiamate ricorsive: per calcolare F (8) si ricalcola
più volte il valore F (4).
Albero delle chiamate ricorsive di F(8), pagina 7, Fig 1.5
6
1.3
algoritmo iterativo
• L’algoritmo fibonacci3, figura 1.6 pagina 9 riutilizza le risposte a sottoproblemi già risolti senza ricalcolare la risposta e questo fa risparmiare tempo. L’idea è di mantenere una lista i valori F (1), F (2), . . .
e di accedervi quando serve.
• calcoliamo il numero di righe di codice eseguite in funzione del valore n: le righe 1,2,5 vengono sempre
eseguite;
• la riga 3 viene eseguita:
– 1 volta se n = 1, 2,
– n − 1 volte negli altri casi;
• il passo 4 viene eseguito:
– 0 volte nei casi n = 1, 2,
– n − 2 volte altrimenti.
• riassumendo: la linea 3 viene eseguita:
– n-1 volte per n ≥ 2,
– 1 volta se n = 1;
la linea 4 viene eseguita:
– n − 2 volte se n ≥ 2,
– 0 altrimenti.
Il numero di linee di codice eseguite, ovvero il tempo di esecuzione in funzione di n è:
4
se n ≤ 1;
T (n) =
3 + (n − 2) + (n − 1) = 2n se n ≥ 2
• anche l’occupazione di memoria è un fattore rilevante. L’algoritmo richiede spazio proporzionale a n.
• l’algoritmo può essere modificato in fibonacci4 (figura 1.8, pagina 12) per utilizzare spazio costante.
Il prezzo che si paga è una maggiore lentezza.
1.4
Notazione asintotica
• L’analisi fatta sinora soffre del fatto che contiamo le linee di codice, e quindi il medesimo programma
scritto su linee di codice differenti dà valori differenti pur avendo la medesima velocità;
• aumentare la velocità di un computer dà tempi differenti, ma l’analisi non cambia dato che sempre lo
stesso numero di righe di codice è eseguito.
7
• si può astrarre da questi dettagli mediante la notazione asintotica cioè trascurando le costanti moltiplicative delle funzioni e vedendo come “viaggia” la complessità per n → ∞;
• date due funzioni f (n) g(n) da N a N, diremo che f (n) è “O di g(n)” e scriveremo f (n) ∈ O(g(n))
o con abuso di notazione anche f (n) = O(g(n)) se esistono n0 e c > 0 tali che f (n) <= cg(n) per
n ≥ n0 , cioè f (n) da un certo punto in poi si comporta come g(n) a meno di una costante.
1.5
Un algoritmo basato su potenze ricorsive
1 1
• Sia A =
. Allora
1 0
Lemma 1.1
An−1
=
F (n)
F (n − 1)
, n ≥ 2.
F (n − 1) F (n − 2)
Cosı̀ possiamo definire l’algoritmo iterativo fibonacci5 (figura 1.10, pagina 14) basato sulla moltiplicazione di matrici.
• si vede immediatamente che il tempo di esecuzione è O(n), quindi uguale a finonacci3 e fibonacci4,
ma qui la notazione asintotica nasconde le costanti.
• fibonacci5 usa spazio di memoria costante.
• fibonacci5 può essere ulteriormente migliorato facendo la moltiplicazione di matrici mediante quadrati
successivi, basandosi sul fatto che An = An/2 ∗ An/2 , se n pari.
• otteniamo cosı̀ fibonacci6 (figura 1.11, pagina 15) il cui tempo di esecuzione è in pratica il tempo speso
per la chiamata alla funzione.
• Studiamo il tempo impiegato da potenzamatrice in funzione di n: se n = 1 il tempo è una costante,
T (1) = K ∈ N. Se n > 1, allora T (n) = T ( n2 ) + K1 .
• svolgendo i conti abbiamo che T ( n2 ) = T ( 2n2 ) + K1 , che sostituito in T (n) dà T (n) = T ( 2n2 ) + 2K1 .
Procedendo cosı̀ alla i-esima sostituzione abbiamo che T (n) = T ( 2ni ) + iK1 . Ora, 2ni = 1, quando
i = lg n, quindi dopo i sostituzioni abbiamo T (n) = T (1) + (lg n)K1 ∈ O(lg n).
• il tempo di esecuzione della chiamata ricorsiva per fare la potenza n-esima è logaritmico nel valore di
n.
1.6
Il metodo dei quadrati ripetuti
L’algoritmo basato su potenze ricorsive usa un metodo noto come quadrati ripetuti. Vale la pena di
evidenziarla dato che propone un modo veloce di elevare a potenza un numero o una matrice.
b
Dovendo fare ab , piuttosto che iterare a ∗ a ∗ a ∗ · · · ∗ a si calcola il valore di c = a 2 e quindi il risultato è
8
ab = c ∗ c oppure ab = c ∗ c ∗ a
b
b
a seconda che b sia pari o dispari. Ovviamente, per calcolare c = a 2 possiamo calcolare d = a 4 e quindi
c = d ∗ d oppure c = d ∗ d ∗ a a seconda che 2b sia pari o dispari, idem per d. Ovviamente la base di questo
procedimento è che elevare a zero un numero dà come risultato 1.
Questo modo di procedere è in stile divide et impera, tipico ad esempio di MergeSort. Oltre all’ovvia
versione ricorsiva ne possiamo progettare una iterativa basata sul seguente ragionamento fatto al contrario
cioè partendo da zero piuttosto che da b. Consideriamo di avere calcolato la sequenza
a0 , a1 , a2 , a4 , a8 , . . . , ax
(1)
Domanda: quanto deve essere lunga la sequenza e come mettere assieme i valori della sequenza per ottenere
ab ? La risposta sta nella rappresentazione di b in base 2. Sappiamo che ogni numero della base 10 può essere
espresso in base 2 cioè tramite una sequenza di bit
1bn−1 . . . b0
che inizia per 1 e tali che
b = b0 ∗ 20 + b1 ∗ 21 + b2 ∗ 22 · · · + 1 ∗ 2n
Cosı̀
0 +b
ab = ab0∗2
1
2
n
1 ∗2 +b2 ∗2 ···+1∗2
0
1
2
n
= ab0∗2 ∗ ab1∗2 ∗ ab2∗2 ∗ · · · ∗ a1∗2
i
Nota che se un certo bit bi vale zero, allora il fattore vale 1, altrimenti il fattore vale a2 .
L’espressione appena data ci dice quali elementi della sequenza (1) devono essere moltiplicati tra di loro e
quanto la sequenza deve essere lunga. Quindi l’algoritmo si basa sull’espansione binaria dell’esponente. In
Figura 1 è dato lo pseudolinguaggio.
Algoritmo potenza(intero base,intero esp)->intero
ris=1
pow=base;
while (esp > 0) do
{
if (esp % 2 == 1) then ris=ris*pow
pow=pow*pow
esp=esp/2
}
return ris
Figura 1: potenza basata su quadrati ripetuti
9
Il tempo di esecuzione di questo algoritmo è ovviamente analogo a quello in versione ricorsiva, basta osservare
che la variabile esp viene divisa per due ad ogni iterazione, analogamente a quanto avviene per l’algoritmo
precedente.
Esercizio Scrivere l’algoritmo per fare la potenza di una matrice.
1.7
L’algortimo di Euclide per il MCD
Si tratta di trovare il più grande divisore d tra due numeri a, b ∈ N. Supponiamo a ≥ b. L’algoritmo di
Euclide si basa sulle seguenti osservazioni. Qualsiasi numero d divida sia a che b, in simboli d|a e d|b, deve
anche dividere a − b. Infatti,
se d|a
se d|b
allora
allora
a = q ∗ d, con q ∈ N
b = q 0 ∗ d, con q 0 ∈ N
cosı̀ a − b = q ∗ d − q 0 ∗ d = (q − q 0 ) ∗ d, quindi d divide a − b.
Vale anche che se d|a − b e d|b allora d|a. Infatti
se d|a − b
se d|b
allora
allora
a − b = q ∗ d, con q ∈ N
b = q 0 ∗ d, con q 0 ∈ N
cosı̀ (a − b) + b = q ∗ d + q 0 ∗ d = d ∗ (q + q 0 ), quindi d divide a.
In base a quanto provato sopra
se a ≥ b
se a − b ≥ b
se a − 2b ≥ b
...
mcd(a,b)=mcd(a-b,b)
mcd(a-b,b)=mcd(a-2b,b)
mcd(a-2b,b)=mcd(a-3b,b)
...
Per quanto possiamo andare avanti cosı̀? Per k volte, dove k è tale che
0 ≤ a − kb < b
Quindi nell’espressione (2) k altro non è che il quoziente della divisione
Abbiamo che
mcd(a, b) = mcd(a − kb, b)
(2)
a
b
e a − kb è il resto di tale divisione.
Adesso basta continuare con lo stesso metodo, cioè dividere b per a − kb e prendere il resto. Si va avanti
fin quando non si ottiene resto zero. Infatti per ogni n > 0 vale mcd(n, 0) = n. La Figura 2 presenta
l’algoritmo: Circa il fatto che prima o poi si trovi un resto pari a zero non ci sono dubbi. Si osservi infatti
che la sequenza dei resti ottenuti dalle divisioni è strettamente decrescente (il resto è sempre strettamente
inferiore del divisore). Il tempo dell’algortimo è strettamente dipendente dal numero di volte che vengono
ripetute le istruzioni nel ciclo. Facciamo vedere che nella sequenza dei resti r1 , r2 , . . . , ru che vengono calcolati
10
Algoritmo Euclide(intero a,intero b)->intero
do
r=a%b
a=b;
b=r
while (r <> 0)
return a
Figura 2: Algoritmo di Euclide
durante l’iterazione vale che ri+2 < ri /2 cioè è garantito un dimezzamento ogni due resti. La dimostrazione
ha due casi:
(i) ri+1 ≤ ri /2, allora dato che ri+2 < ri+1 l’affermazione segue banalmente. (ii) se non vale il punto (i)
allora necessariamente ri+1 è compreso nell’intervallo [ r2i + 1, ri − 1]. In base a come procede l’algoritmo,
la quantità ri+2 è il resto della divisione tra ri e ri+1 . Il quoziente della divisione è 1, il resto, cioè ri+2 è
ri − 1 ∗ ri+1 . Dato che il range di ri+1 è [ r2i + 1, ri − 1] segue che il range di ri+2 è [1, r2i − 1], in ogni caso
strettamente inferiore alla metà di ri .
In base al risultato appena ottenuto il numero di iterazioni è ovviamente governato da una legge già
incontrata, infatti:
r0
b
r2 <
2 = 2
r2
b
r4 <
2 = 22
r4
b
r6 <
2 = 23
r6
b
r8 <
2 = 24
... ... ...
b
ru <
u
22
Per avere il valore di u per cui ru = 0, basta sapere per quale u vale che
b
u = 1, che risolta dà 2 lg b = u
22
Quindi il numero di iterazioni è proporzionale al logaritmo in base 2 del valore di b.
Infine vale la pena osservare che ogni qual volta vale il punto (ii) i resti soddisfano la definizione dei numeri
di Fibonacci, infatti
ri+2 = ri − ri+1 implica ri+2 + ri+1 = ri
Quindi se a e b sono due numeri di Fibonacci consecutivi la sequenza di resti ottenuta sempre soddisfa il
punto (ii). Quindi il numero di iterazioni dell’algoritmo è quello massimo.
Esercizio Un algoritmo alternativo per il mcd(a, b) è quello che prevede, partendo da b di provare tutti i
possibili divisori nel range [b, 1]. Implementate questo algoritmo e quello di Euclide ed eseguiteli su numeri
di Fibonacci abbastanza grandi e vedrete la differenza nei tempi!
11
Per ora abbiamo considerato come tempo semplicemente il numero di righe che vengono eseguite. In pratica è come affermare che i comandi contenuti negli algoritmi sono eseguiti sempre in tempo costante,
indipendentemente dal valore assunto dalle variabili.
Questo però potrebbe anche essere un’assunzione troppo forte. Pensiamo alla nostra esperienza: è vero che
per calcolare 99 × 99 ci mettiamo tanto quanto per calcolare 9999 × 9999? E ancora, è vero che per calcolare
99 + 99 ci mettiamo tanto quanto 99999 + 99999? La risposta è no, per sommare e moltiplicare ci vuole
tanto più tempo quanto più grandi, cioè più lunghi, sono i numeri su cui lavoriamo.
Esercizio Assumendo che per sommare due numeri da una cifra ci mettiamo sempre lo stesso tempo (cioè
il tempo è costante), quanto tempo occorre per sommare i due numeri an an−1 . . . a0 bn bn−1 . . . b0 con il noto
algoritmo della somma?
Esercizio Assumendo che per moltiplicare due numeri da una cifra ci mettiamo sempre lo stesso tempo,
quanto tempo occorre per moltiplicare i due numeri an an−1 . . . a0 bn bn−1 . . . b0 con il noto algoritmo della
moltiplicazione?
Nel prossimo paragrafo affrontiamo i problemi che abbiamo aperto in questa introduzione e che come vedremo
derivano dal fatto di non aver ancora ben formalizzato matematicamente le varie nozioni per ora introdotte.
12
2
2.1
Modelli di Calcolo e Metodologie di Analisi
Un quadro generale
Analizzare gli algoritmi significa stabilire come le loro richieste di risorse computazionali aumenta all’aumentare della dimensione dei dati di input.
La pratica insegna che le buone performances dei programmi dipendono anche dalla scelta delle strutture
dati. Può infatti accadere che a fronte dello stesso metodo, differenti scelte di strutture dati portino a
differenti tempi di esecuzione.
Per quanto riguarda i problemi, spesso accade che essi abbiano natura discreta, cioè coinvolgano la ricerca
di una/la soluzione all’interno di un insieme finito di prossibilità combinatoriali (spazio di ricerca).
Ovviamente dato un problema il primo interesse è quello di trovare un algoritmo. Ma immediatamente dopo
nasce quello della efficienza computazionale, in particolare efficienza rispetto al tempo di esecuzione, che
informalmente possiamo associare a “essere veloci”. Anche l’uso della memoria (spazio computazionale) è
un altro aspetto dell’efficienza. Fissiamo le idee sull’efficienza rispetto al tempo di esecuzione.
Una definizione concreta di efficienza non può ridursi all’affermazione: “ un algoritmo è efficiente se la
sua implementazione và velocemente su dati di input reali”. Tale definizione è troppo vaga ed imprecisa. Può accadere che pessimi algoritmi possano essere veloci quando eseguiti su piccoli casi test e/o su
processori potenti. D’altronde buoni metodi risolutivi potrebbero sembrare pessimi a causa di una cattiva
implementazione (es. scelta sbagliata delle strutture dati).
Inoltre, cos’è un input reale?
Infine, la definizione non tiene conto della scalabilità di un algoritmo, cioè di come le performance di un
algoritmo si comportano all’aumentare della dimensione dei dati di input. Può accadere che due algoritmi
diversi possano avere la medesima velocità quando eseguiti su input di una certa dimensione, ma essere l’uno
molto più lento dell’altro quando gli input hanno dimensione 10 volte superiore.
Abbiamo quindi bisogno di una nozione di efficienza indipendente dalla piattaforma, indipendente dall’istanza e che permetta di descrivere come varia la richiesta di risorse computazionali al variare della quantità
(dimensione, lunghezza) dei dati da elaborare.
In pratica, abbiamo bisogno di una visione matematica.
La “dimensione dei dati da elaborare” (dimensione dell’istanza di input) è per definizione il numero di
simboli di cui sono composti i dati da elaborare. Il consumo di risorse (tempo, spazio, ...) da parte di un
algoritmo oggetto di analisi sarà espresso matematicamente mediante una funzione, chiamiamola T , nella
dimensione dei dati di input, quindi T ha come dominio i numeri naturali (N).
Consideriamo x ∈ N. E’ ovvio che il numero di possibili input diversi di lunghezza x è un numero finito
(esempio, se gli input possono essere inseriti usando solo le 26 lettere minuscole dell’alfabeto inglese, allora
i possibili input di dimensione 4 sono 264 ). La funzione T associa ad ogni possibile valore di x un valore che
rappresenta il consumo di tempo.
Dato che fissato x sono molte le istanze di dimensione x, come definiamo T ?
13
Un modo è analizzare l’algoritmo oggetto di studio secondo il caso peggiore (worst case analysis): per ogni
possibile valore di x si considerano tutte le istanze di dimensione x ed il tempo associato all’algoritmo,
cioè il valore di T (x) è il tempo più alto impiegato. Quindi, l’obiettivo è quello di determinare come
varia la funzione T al variare di x. Sebbene sembri troppo penalizzante, catturare l’efficienza pratica di
un algoritmo basandosi sul caso peggiore fornisce un limite superiore alle risorse necessarie ad eseguire
l’algoritmo qualunque sia l’input di una data dimensione.
Sorge adesso la questione: quale andamento deve avere T perchè l’algoritmo oggetto di studio sia considerato
efficiente? L’esperienza mostra che tipici problemi di interesse pratico hanno una natura combinatoriale e
lo spazio in cui cercare la/una soluzione cresce esponenzialmente rispetto ai dati di input. Buona parte
dei problemi possiede un ovvio algoritmo che prevede di analizzare per enumerazione l’intero spazio delle
soluzioni. Tale algoritmo però ha tempo esponenziale rispetto alla dimensione dei dati di ingresso.
Come esempio si pensi all’ordinamento di n dati: ci sono n! modi di organizzare i dati, ma solo uno ne
costituisce l’ordinamento crescente. D’altrone sappiamo che esistono algoritmi che permettono di ordinare
facendo n2 o anche solo n log n confronti e scambi. Tra l’altro questi risultati possono essere determinati senza
bisogno di implementrare gli algoritmi, ma solo mediante ragionamenti matematici sul metodo. L’analisi di
un algoritmo indica anche come possa concretamente essere implementato. Siamo quindi al punto che molti
problemi hanno ovvi algoritmi basati sull’analisi dello spazio di ricerca, il quale cresce esponenzialmente nella
dimensione dei dati. Tali algoritmi sono in pratica inutilizzabili. Quindi il primo punto è che la definizione
di efficienza deve implicare tempi decisamente inferiori rispetto a quelli necessari agli algoritmi a forza bruta.
Dato che crescita esponenziale significa che ad un aumento unitario della dimensione dei dati di input corrisponde un aumento moltiplicativo del tempo di esecuzione, un comportamento desiderabile è che all’aumento
di un fattore costante k della dimensione dei dati di input corrisponda un aumento delle risorse richiste pari
ad un fattore k 0 . Questo è tipico di un andamento polinomiale:
T (x)
= xd , d ∈ N fissato
T (x0 ) = xd0
T (2x0 ) = (2x0 )d = 2d xd0 , dove 2d è il fattore.
Si considera efficiente un consumo di risorse con andamento polinomiale (rispetto alla dimensione dei dati,
d’ora in poi lo considero implicito).
Se per un attimo focalizziamo la nostra attenzione sul tempo di calcolo, si osserva una imperfezione nella
nostra definizione: parliamo di tempo di calcolo, ma abbiamo già detto che non samo necessariamente
interessati ad una implementazione, ma ad uno studio matematico. Questo significa che tempo (tempo di
esecuzione) deve essere definito meglio.
Qui non intendiamo il numero di secondi, ma piuttosto il numero di passi di esecuzione elemetari cioè
comandi che siano assimilabili a quelli in grado di svolgere una CPU reale: assegnamenti, confronti, incrementi e decrementi sono operazioni elementari. Sotto particolari condizioni che specificheremo meglio
tra poco anche le operazioni aritmetiche sono elementari. In verità purchè si descriva l’algoritmo con uno
pseudolinguaggio non troppo sofisticato, passo di esecuzione può essere assimilato all’esecuzione di una riga dell’algoritmo. Perchè possiamo evitare di essere molto precisi circa il concetto di passo di esecuzione
elementare? La risposta risiede nel fatto che nella sostanza l’andamento della funzione T non viene molto
14
influenzato dalle diverse nozioni, il suo comportamento asintotico non cambia: se rispetto ad un fissato
pseudolinguaggio il consumo di tempo ha andamento polinomiale esso lo avrà anche con un differente pseudolinguaggio, purchè ogni istruzione del primo sia traducibile con un numero polinomiale di istruzioni del
secondo (la moltiplicazione di polinomi è un polinomio!).
Anche per questa ragione le funzioni che descrivono i tempi di esecuzione non sono definite in maniera
esatta, ma piuttosto si descrive l’andamento della funzione dando il suo ordine di crescita per mezzo della
notazione O.
Non ha molta utilità contare esattamente il numero di passi di un algoritmo, infatti le funzioni x2 e x2 + 2x
al crescere di x si comportano alla stessa maniera e quindi due algoritmi diversi i cui tempi di esecuzione
siano descritti dalle due funzioni date sono considerati avere la stessa velocità.
La morale è quindi che nel descrivere il consumo di risorse le costanti ed i fattori moltiplicativi non
interessano.
In breve:
Per poter studiare gli algoritmi abbiamo bisogno di un modello di calcolo.
Quello che si usa di solito è la macchina a registri, dove oltre ad un dispositivo di input ed uno di output si
ha a disposizione un numero arbitrario di registri ciascuno dei quali può contenere numeri interi o reali di
grandezza arbitaria.
Queste sono assunzioni non realistiche ma semplificano l’analisi.
Infatti non è vero che ciascuna singola operazione abbia il medesimo tempo e sia indipendente dalla grandezza
dei dati. Quando si adotta tale criterio si parla di misura di costo uniforme.
Il criterio di costo logaritmico tiene conto della dimensione dei dati ed il tempo di ogni singola operazione
è misurato rispetto alla dimensione dei dati coinvolti. Siccome ogni intero n ∈ N è rappresentabile in base
b con blgb bc + 1 cifre, si parla di costo logaritmico.
Ad esempio, dato n quanto costa calcolare 2n , con il seguente algoritmo?
x<-2
for i=1 to n do x<-x*2
Secondo il criterio di costo uniforme tempo O(n) in quanto la moltiplicazione costa 1 ed il for è iterato n
volte. Ma per il costo logaritmico l’analisi è diversa:
all’iterazione i-esima x vale 2i . Il tempo speso per moltiplicare x per 2 è lg 2i dato che la moltiplicazione
per due è uno shift verso sx, mentre l’incremento di i costa lg i. Quindi il tempo è
n
X
i + lg i,
i=1
ovvero compreso tra
n(n+1)
2
e n(n + 1), cioè Θ(n2 ).
Esercizio Valutare l’algortimo di Figura 1 secondo il criterio di costo logaritmico.
15
2.2
La notazione asintotica
• La notazione asintotica consente di semplificare l’analisi nel senso che possiamo trascurare le costanti
ed i termini di ordine inferiore. Considereremo funzioni da N in R+ . Data una funzione f (n) definiamo
O(f (n)) = {g(n)|∃c > 0, ∃n0 >= 0 tale che g(n) ≤ c · f (n), ∀n ≥ n0 }
Ω(f (n)) = {g(n)|∃c > 0, ∃n0 >= 0 tale che g(n) ≥ c · f (n), ∀n ≥ n0 }
Θ(f (n)) = {g(n)|∃c1 > 0, ∃c2 > 0, ∃n0 >= 0 tale che c1 · f (n) ≤ g(n) ≤ c2 · f (n), ∀n ≥ n0 }
• Il fatto che g ∈ O(f ) ci dice che g cresce al max come f e da questa è dominata (modulo la costante
moltiplicativa);
• il fatto che g ∈ Ω(f ) ci dice che g cresce almeno come f e da questa ne è limitata inferiormente (modulo
la costante moltiplicativa);
2.3
Metodi di analisi
• Un algoritmo è un metodo che forniti dei dati di ingresso produce dei risultati in uscita. Per produrre
tali risultati è necessario impiegare delle risorse.
• Le risorse più importanti sono il tempo di esecuzione e la memoria necessaria per svolgere i calcoli.
• Analizzare un algoritmo che risolve un certo problema significa determinare in funzione di ogni possibile
dato di ingresso il numero di passi che compie l’algoritmo o la quantità di memoria usata dall’algoritmo
per produrre l’output.
• Si tratta quindi di scoprire delle funzioni dall’insieme dei dati di ingresso ai naturali.
• Tali funzioni possono essere semplici ed intuitive quando l’insieme dei possibili dati di ingresso è
l’insieme dei naturali come nel caso degli algoritmi che risolvono il problema di fibonacci, ma possono
essere complicate quando l’insieme dei possibili dati sono sequenze di interi, come nel caso degli
algoritmi di ordinamento.
• Funzioni di questo tipo sarebbero di difficile interpretazione circa l’uso di risorse computazionali da
parte degli algoritmi.
• Quello che si preferisce fare è definire delle funzioni di complessità che esprimano l’uso di risorse in
funzione della quantità di informazione fornita in input.
2.4
Quantità di Informazione di una Istanza
• Per valutare la quantità di informazione fornita in input di definisce prima di tutto la nozione di
dimensione di una istanza, la quale a seconda del problema in esame potrà essere il numero di cifre
di cui è costituito un intero, il numero di componenti di un vettore.
16
• Nella sua accezione più stringente per dimensione di una istanza dobbiamo intendere il numero
di simboli che occorrono per scrivere i dati di input. Quindi, a questo punto valuteremo i tempi di
esecuzione di un algoritmo come funzione nella dimensione delle istanze (cioè da interi) a interi.
• Ora qui sorge un problema che può essere visto anche analizzando l’esempio di fibonacci: istanze
diverse, che danno luogo a tempi di esecuzione diversi hanno la medesima dimensione di istanza, si
prenda ad esempio l’istanza n = 120 e n = 999 entrambe di dimensione tre. Come possiamo definire
il tempo o lo spazio di calcolo?
17
3
Valutiamo la complessità di alcuni algoritmi visti in precedenza in
base alla dimesione dell’istanza
Abbiamo visto che l’algoritmo fibonacci2 ha un tempo di esecuzione tale che T (n) > 2
l’argomento per cui si vuole calcolare il valore F (n).
n−1
2
, dove n ∈ N è
La lunghezza l di n è l = log n, da cui n = 10l , se usiamo la base 10 per scrivere i dati di input.
Esprimento T in funzione di l e non di n otteniamo che
T (l) > 2
10l −1
2
Questa analisi già ci dice che l’algoritmo ha un tempo super-esponenziale nella dimensione dell’istanza di
input.
L’unico piccolo difetto che possiamo trovare è di tipo formale: la variabile l è reale e non intera, quindi
la funzione T è funzione reale di variabile reale. Per esprimere T come funzione intera di variabile intera
dobbiamo arrotondare all’intero superiore log n. A questo punto sono numerosi gli input n che coincidono
con dlogne, ciascuno dei quali ha un tempo di esecuzione diverso. Procediamo facendo l’analisi di caso
peggiore per il tempo di esecuzione:
Tw (l) = max{T (n)|10l−1 ≤ n ≤ 10l − 1} = T (10l − 1) > 2
10l −1
2
Analogamente possiamo procedere nell’analisi dell’algoritmo iterativo fibonacci3, la cui complessità in
tempo avevamo stabilito essere T (n) = 2n, dove n è sempre il valore dell’argomento di F . Dal momento che
vale la relazione l = log n, in prima approssimazione segue che
T (l) = 2 ∗ 10l
Se vogliamo esprimere il consumo di risorse mediante una funzione intera di variabile intera allora seguendo
il procedimento fatto sopra possiamo condurre l’analisi di caso peggiore e ottenere
Tw (l) = max{T (n)|10l−1 ≤ n ≤ 10l − 1} = T (10l − 1) = 2 ∗ (10l − 1)
cioè esponenziale in l.
Infine l’algoritmo fibonacci5, basato su potenze ricorsive ha una funzione di complessità di tipo logaritmico:
T (n) = lg n. A questo punto segue che esprimendo T in funzione della lunghezza l di n abbiamo
T (l) = lg 10l = k ∗ l, k ∈ N
Quindi il consumo di risorse da parte dell’ultimo algoritmo è di tipo lineare nella lunghezza dell’input.
18
• Analizziamo il problema della ricerca di un elemento in una lista.
3.1
Ricerca Sequenziale
• Prendiamo l’algoritmo di ricerca sequenziale (figura 2.2, pagina 30) e valutiamo il numero di confronti
che è l’operazione più frequente.
Algoritmo RicercaSequenziale(vettore di interi L, intero x)-> {0,1}
1. sia dim il nro di elementi di L;
2. for i=1 to dim do
3.
if (L[i] == x) return 1;
4. return 0;
Figura 3: Algoritmo di Ricerca Sequenziale
• Vogliamo predire il tempo di esecuzione in funzione della quantità di dati presente nella
lista, piuttosto che in funzione dell’istanza, cioè dei dati che compaiono nella lista e dell’elemento x
da ricercare.
• Denotiamo con tempo la funzione che associa ad ogni possibile istanza I il tempo di esecuzione
dell’algoritmo su I. Ci sono 3 tipi di analisi:
– caso peggiore: fissata la dimensione dell’istanza, quante operazioni al massimo compiamo?
Tworst (n) = max(tempo(I))
|I|=n
– caso migliore: fissata la dimensione dell’istanza, quante operazioni compiamo nel caso più favorevole?
Tbest (n) = min (tempo(I))
|I|=n
– caso medio:
Tavg (n) =
X
P rob(I) · tempo(I)
|I|=n
3.2
ANALISI DI CASO MEDIO
• Facile l’analisi di caso peggiore e migliore, facciamo l’analisi di caso medio: assumiamo che l’elemento
x possa trovarsi in una qualsiasi posizione con la medesima probabilità, quindi
P rob(pos(x) = i) =
19
1
n
Quindi
Tavg (n) =
n
X
P rob(pos(x) = i) ∗ i =
i=1
n
X
n+1
1
∗i=
n
2
i=1
se x ∈ L. Se x 6∈ L allora il numero di confronti atteso è n.
• Possiamo compiere un’altra analisi di caso medio, assumendo come una distribuzione delle istanze tale
che ogni permutazione sia equiprobabile.
• Ci sono n! possibili permutazioni di n oggetti. Fissata la posizione i di x ci sono (n − 1)! possibili
permutazioni aventi x nel posto i, quindi possiamo scrivere:
Pn! 1
Tavg (n) =
π=1 n! ∗ (n.ro confronti sulla permutazione π)
dove
Pn!
π=1
è da leggersi come somma su tutte le possibili permutazioni;
• per ciascuna delle permutazioni il nro di confronti è una quantità compresa tra 1 e n; riscriviamo la
sommatoria raccogliendo i termini rispetto al nro di confronti:
Pn (n−1)!
Tavg (n) =
∗i
i=1
n!
=
1
n
Pn
i=1 i
=
n+1
2
Nota: il risultato è il tempo atteso sotto l’ipotesi che x appartenga a L.
3.3
Ricerca Binaria
Prendiamo lo pseudocodice per la ricerca binaria in Figura 2.4, pagina 34.
Algoritmo ricercaBinaria(vettore di interi L, intero x)->{0,1}
1. a=1
2. b=lunghezza di L
3. while (L[(a+b)/2]<>x) do
4.
m=(a+b)/2
5.
if (L[i]>x) then b=m-1
6.
else a=m+1
7.
if (a>b) then return 0
8. return 1
Figura 4: Algoritmo di ricerca binaria
Consideriamo un array di estremi [a, b], dove b − a + 1 = n.
20
• Caso migliore: x è in posizione
a+b
2 ;
• Caso peggiore: x viene trovato quando a = b. Dato che dopo un confronto
l’array in cui si effettua la ricerca ha dimensione n2 , poi 2n2 e dopo i confronti 2ni ,
affinchè sia 2ni = 1 deve essere i = log2 n. Quindi Tworst (n) = O(log n).
• caso medio: assumiamo che x ∈ L e che possa occupare con la medesima
probabilità una qualsiasi delle n posizioni, allora
n
X
1
∗ (n.ro confronti per x in posizione pos).
Tavg (n) =
n
pos=1
Per valutare questa quantità facciamo una sorta di ragionamento al contrario:
quante posizioni consentono di trovare x con 1 confronto? una, la posizione
centrale. E con 2 confronti? 2, le posizioni 1/4 · n e 3/4 · n. E con 3 confronti?
4, le posizioni 1/8 · n, 3/8 · n, 5/8 · n, 7/8 · n.
21
Albero delle posizioni
Nro Confronti
1
2
3
posizioni
n/2
n/4
n/8
n/8 3/8n
5/8n
7/8n
Possiamo andare avanti cosı̀ fino a che i = lg2 n. La costruzione ci dice che
per trovare x con i confronti, x può occupare 2i−1 posizioni diverse. Nella
sommatoria vi è esattamente un termine per cui il numero di confronti vale
1, vi sono esattamente due termini per cui il numero di confronti vale 2, vi
sono esattamente quattro termini per cui il numero di confronti vale 3, vi sono
esattamente otto termini per cui il numero di confronti vale 4, etc.
Quindi la sommatoria può essere riscritta come
Tavg (n) =
lg2 n
X
1
i=1
n
∗ i ∗ n.ro posizioni che richiedono i confronti
lg n
2
1X
1
=
i · 2i−1 = lg2 n − 1 + .
n i=1
n
22
Si osservi come il tempo medio non si discosti dal tempo peggiore, questo è
spiegabile con il fatto che metà degli elementi si comporta come il caso peggiore.
23
4
Statistiche d’Ordine
Il problema da risolvere è il seguente: dati n elementi ed un intero k ∈ 1, . . . , n,
trovare il k-esimo elemento quando la sequenza è ordinata (k-esimo più piccolo o
più grande a seconda dell’ordinamento). La ricerca del mediano avviene quando
k = b n2 c. Due algoritmi sono interessanti:
• uno randomizzato, basato su partition di quicksort;
Partiamo da un problema differente: selezione per piccoli valori di k. La ricerca del
minimo può essere fatta con n − 1 confronti, questo bound è ottimale in quanto se lo
facessimo con meno non confronteremmo qualche elemento che può essere il minimo.
Vogliamo generalizzare l’idea alla ricerca del secondo minimo ed in generale alla
ricerca del k-esimo minimo, con k = O( lgnn ).
Esaminiamo il semplice algoritmo per la ricerca del secondo minimo, Figura 5.2,
pagina 117.
Vale quanto segue
Lemma 4.1 L’algoritmo secondominimo esegue 2n − 3 confronti nel caso peggiore
e n + O(lg n) nel caso medio.
Dimostrazione: Il caso peggiore si verifica quando facciamo 2 confronti per ciascuna delle n − 2 iterazioni, e questo avviene quando il vettore è ordinato in maniera
decrescente.
Per l’analisi di caso medio supponiamo che ogni permutazione del vettore A sia
equiprobabile.
• Ogni iterazione esegue almeno il primo confronto, ma il secondo è eseguito solo
se A[i] è il minimo o il secondo minimo nei primi i valori di A.
24
• Ognuno dei primi i valori può essere uno dei 2 più piccoli con probabilità 2i .
• Quindi mediamente il secondo confronto è fatto
n
X
2
i=3
i
= 2 lg n + O(1)
Sommando gli n confronti fatti alla riga 5 otteniamo n + O(lg n).
Si osservi che 2i viene fuori dalla seguente semplice osservazione: il minimo può
trovarsi in una tra le i possibili posizioni, il secondo minimo può trovarsi in una
delle restanti i − 1 possibili posizioni quindi le possibili posizioni in cui trovare
primo e secondo minimo sono i(i − 1).
Ora, qual è la probabilità che il minimo o il secondo minimo si trovino in i ? Sono
tutti i casi della forma (i, j) e (j, i), con j ∈ {1, . . . , i−1}, cioè 2(i−1) casi favorevoli
sui i(i − 1) casi totali, da cui 2i .
A questo punto, possiamo fare di meglio nel caso peggiore e selezionare il secondo
minimo più efficientemente? In particolare, possiamo progettare un algoritmo il cui
caso peggiore sia uguale a quello di caso medio appena visto?
Si, l’idea è quella di suddividere gli n elementi in coppie e vedere chi è il minimo
in ciascuna coppia. Tali minimi vengono di nuovo confrontati a coppie tra di loro e
cosı̀ via. Colui che resta è il minimo.
Ecco un esempio su
44 55 12 42 94 18 06 67
Dov’è il secondo minimo? E’ in quelle coppie in cui compare il minimo. Rifacciamo una ricerca di minimo tra gli elementi che occorrono in tali coppie. Nota che
il numero di elementi è lg n. E’ chiaro quindi che abbiamo in mano un metodo per
ricercare il secondo minimo con n + O(lg n) confronti nel caso peggiore invece che
con 2n.
Concludiamo che abbiamo un metodo il cui caso peggiore è uguale a quello del caso
medio dell’algoritmo dato sopra. La struttura è nota come albero delle selezioni
25
L’albero delle selezioni altro non è che uno heap di cui ricordiamo la definizione:
la sequenza di elementi a1 , . . . , an ha la proprietà di heap, o più brevemente è uno
heap, se soddisfa le seguenti proprietà:
1. ai ≤ a2i , se esiste l’elemento a2i , cioè 2i ≤ n;
2. ai ≤ a2i+1 , se esiste l’elemento a2i+1 , cioè 2i + 1 ≤ n;
L’idea della ricerca del secondo minimo può essere generalizzata al caso in cui si
voglia il k-esimo minimo, con k = O( lgnn ). L’idea applica l’algoritmo heapsort che
merita una drata che forniamo qui di seguito.
4.1
HeapSort
Probabilmente HeapSort non occuperebbe il posto che occupa tra gli algoritmi di
ordinamento se J. Williams non avesse trovato un modo di rappresentare l’albero
di selezione mediante una struttura dati di n elementi. Tale struttura dati è nota
come heap, la cui definizione è stata fornita poco sopra. Si osservi che in base alle
condizioni date, gli elementi oltre la metà della sequenza, cioè an/2+1 , . . . , an non
devono soddisfare alcun vincolo rispetto agli altri elementi, quindi già rispettano la
proprietà di heap. Inotre, si osservi anche la similitudine tra proprietà di heap e
albero delle selezioni.
Adesso dobbiamo risolvere il seguente problema:
data una sequenza qualsiasi di elementi a1 , . . . , an come la trasformiamo in uno
heap?
La prima osservazione è che gli elementi della seconda metà sono già uno heap. Se
sappiamo trasformare in uno heap una sequenza data ai , . . . , an in cui ai+1 , . . . , an
è uno heap, allora siamo a posto, perchè basterà applicare tale algoritmo n2 volte a
partire dall’elemento di posto a n2 .
Questo algoritmo è facilmente descritto per ricorsione come segue:
26
si confronta ai con a2i e a2i+1 . Se ai è il minimo dei 3 allora ai , . . . , an è uno
heap e non c’è nulla che deve essere fatto. In caso contrario si scambia ai con
il più piccolo tra a2i e a2i+1 , cosı̀ che adesso ai rispetta la proprietà di heap.
Però a seconda del posto dove è avvenuto lo scambio, l’elemento di posto a2i
o quello di posto a2i+1 potrebbe non rispettare la propriètà di heap, e quindi
occorre procedere ricorsivamente a partire dalla posizione in cui è stato fatto
lo scambio. In pratica l’elemento inizialmente in posizione ai deve essere messo
al posto giusto in modo che non violi la proprietà di heap, quindi va fatto
progressivamente sprofondare all’interno della sequenza fino a che non occupi
un posto corretto.
L’algoritmo che abbiamo descritto va sotto il nome di setacciamento. Qui di seguito ne diamo una versione ricorsiva in cui a1 , . . . , an è la sequenza, s è l’indice
dell’elemento da aggiungere allo heap il quale parte dall’elemento di posto s + 1.
setaccio(a1 , . . . , an ,s)
SE 2s ≤ n e a2s è più piccolo di as
ALLORA min = 2s altrimenti min = s;
SE 2s + 1 ≤ n e a2s+1 è più piccolo di amin
ALLORA min = 2s + 1
SE min 6= s
ALLORA scambia amin con as ;
setaccio(a1 , . . . , an ,min)
• Al termine della chiamata a setaccio la sequenza as , . . . , an è uno heap se la
sequenza as+1 , . . . , an prima della chiamata era uno heap. In Figura 5 ne diamo
una possibile implementazione in linguaggio C.
• A questo punto possiamo usare la procedura setaccio per ottenere uno heap.
Come? Basta iterare la chiamata a setaccio in modo da allungare progressivamente la sequenza di elementi che soddisfano la proprietà di heap.
• Ovviamente partiamo dall’elemento di mezzo, dato che la seconda parte già
27
/* n indice dell’ultimo elemento del vettore */
/* ricorda che i vettori partono all’indice 0 */
void setaccio(int a[],int s,int n){
int min,temp;
if (2*s+1<=n && a[2*s+1]<=a[s]) min=2*s+1;
else min=s;
if (2*s+2<=n && a[2*s+2]<=a[min]) min=2*s+2;
if (s != min){
temp=a[s];
a[s]=a[min];
a[min]=temp;
setaccio(a,min,n);
}
}
Figura 5: Implementazione della funzione setaccio
soddisfa la proprietà di essere uno heap. Qui di seguito descriviamo in linguaggio
naturale la procedura.
faiUnoHeap(a1 , . . . , an )
Per i che varia da n2 a 1:
setaccio(a1 , . . . , an ,i)
Una implementazione in linguaggio C è fornita in Figura 6.
/* n indice dell’ultimo elemento del vettore */
/* ricorda che i vettori partono all’indice 0 */
void faiUnoHeap(int a[],int n){
int i;
for (i=(n+1)/2-1;i>=0;i--)
setaccio(a,i,n);
}
Figura 6: Implementazione della funzione faiUnoHeap
28
• Siamo adesso giunti al punto cruciale: come usiamo lo heap per ottenere un
vettore ordinato?
• Osserviamo che in cima allo heap abbiamo il minimo. Scambiamolo con l’ultimo elemento e consideriamo la sequenza a1 , . . . , an−1 . Essa non è uno heap a
causa dello scambio, ma possiamo setacciare a1 e farlo scendere al posto giusto.
L’elemento a1 che emerge è il minimo di a1 , . . . , an−1 (cioè il secondo minimo
della sequenza originaria).
• Procediamo facendo uno scambio tra a1 e an−1 ed un setacciamento, e cosı̀ via
fino a arrivare ad una sequenza di un elemento. Gli elementi a1 , . . . , an sono
ordinati in maniera descrescente.
• Quest’ultima parte costituisce il nucleo di HeapSort che può essere descritto
come segue:
HeapSort(a1 , . . . , an )
faiUnoHeap(a1 , . . . , an )
Per i che varia da n a 2:
scambia a1 con ai
setaccio(a1 , . . . , ai−1 , 1)
Si veda Figura 7 per una implementazione C.
La struttura dell’algoritmo è la seguente:
• rendiamo heap il vettore (procedura heapify), e questo è fatto in tempo O(n);
• a questo punto cancelliamo il minimo k volte e dopo ogni cancellazione ripristiniamo la proprietà di heap (procedura fixheap(A,N-1)) e questo è fatto in
tempo O(lg n).
Ricordiamo che uno heap può essere equivalentemente rappresentato come vettore e
come albero binario in cui tutti i livelli sono completi al più ad eccezione dell’ultimo,
che viene riempito da sinistra a destra.
29
/*n è l’indice dell’ultimo elemento del vettore */
void heapSort(int a[],int n){
int i,temp;
faiUnoHeap(a,n);
for(i=n;i>=1;i--){
temp=a[0];
a[0]=a[i];
a[i]=temp;
setaccio(a,0,i-1);
}
}
Figura 7: Implementazione di heapSort
Cosı̀ complessivamente il tempo è O(n + k lg n). L’algoritmo (in pratica HeapSort),
è il seguente:
Algoritmo Heapselect(array A, intero K)-> elem
1. heapify(A)
2. for i = 1 to k-1 do
3.
scambia(a[1],A[N])
4.
fixheap(A,N-1)
5. return A[1]
Si osservi che se il k-esimo minimo che si desidera reperire è in posizione pari a
k = O( lgnn ), allora il tempo è O(n). Ma se applichiamo tale algoritmo alla ricerca
del mediano, ovvero k = d n2 e il tempo è O(n lg n), come un ordinamento.
30
4.2
Calcolo Randomizzato del Mediano
L’idea fondamentale è la seguente:
• partizioniamo l’array in 3 regioni:
A1 , contenente gli elementi più piccoli del perno,
A2 con gli elementi uguali al perno,
A3 con gli elementi maggiori del perno.
• Se gli estremi di A2 includono la posizione che stiamo cercando abbiamo finito,
altrimenti procediamo ricorsivamente su A1 oppure A3 a seconda di quella che
occupa la posizione che stiamo cercando.
• Si tratta di modificare partition di quicksort . La modifica è dettata da ordini di efficienza e semplicità dell’analisi probabilistica (Figura 5.7, pagina 121).
• Prima di tutto notiamo che l’algoritmo termina, dato che A1 ed A3 contengono
meno elementi di A.
• Il caso peggiore si verifica (come con quicksort) quando le partizioni sono
sbilanciate. In tal caso l’equazione di ricorrenza è
T (n) = O(n) + T (n − 1), T (1) = K ∈ N
che ha come soluzione T (n) = O(n2 ).
• Facciamo l’analisi probabilistica:
preso il perno x e considerata la partizione degli elementi in due insiemi S1 e S2
tali che S1 contiene tutti gli elementi ≤ x e S2 tutti gli elementi ≥ x, il mediano
sta sicuramente nell’insieme più grande, la cui dimensione è ≥ n2 .
• Quindi, a meno di non restituire il mediano, nel passo ricorsivo dell’algoritmo
eliminiamo |A2 | + min(|A3 |, |A1 |) elementi.
• Si dimostra che il caso della selezione del mediano, cioè k = d n2 e, è il peggiore,
infatti per valori diversi di k, la probabilità di eseguire il passo ricorsivo sulla
31
partizione più piccola aumenta. Quando k 6= d n2 e può accadere di fare il passo
ricorsivo su una partizione (quella che contiene la posizione k) il cui numero di
elementi è minore di d n2 e, cioè minore della metà degli elementi in A.
Questo mai accade se k = d n2 e, cioè se k è la posizione del mediano. Quindi
quando si deve stabilire il mediano la ricorsione è sempre fatta su partizioni che
contengono più di metà degli elementi.
• Ad ogni passo eliminiamo un numero di elementi compreso tra 1 e n2 , tale numero
è equiprobabile se il vettore ha tutti elementi diversi e equiprobabile è la scelta
tra tutti i possibili x.
• Se ogni partizione è equiprobabile allora la chiamata ricorsiva avviene in maniera equiprobabile su array che hanno dimensione compresa tra n2 e n − 1 e la
probabilità di incorrere su un array di dimensione i ∈ { n2 , . . . , n − 1} è
1
n−1−
n
2
+1
=
2
n
• Se l’array ha dimensione n viene partizionato in 3 con n − 1 confronti usando
un array di supporto oppure con 2(n − 1) confronti operando in loco. Quindi il
numero di confronti atteso è
P
C(n) = O(n) + n2 n−1
i= n2 C(i), n ≥ 1
C(0) = 0
da cui si dimostra per sostituzione che vale
C(n) ≤ tṅ, t ∈ N
Base: C(0) = 0 ≤ t · 0;
32
Induzione:
Pn−1
C(n) = (n − 1) + n2 i=
n C(i), per hp induttiva segue:
2
P
n−1
C(n) ≤ (n − 1) + n2 i=
n t · i
2
P
C(n) ≤ (n − 1) + n2 · t n−1
i= n i
2
C(n) ≤ (n − 1) +
2
n
P
P n2 −1
· t( n−1
i
−
i=1 i)
i=1
C(n) ≤ (n − 1) +
2
n
· t( (n−1)n
−
2
(n/2−1)n/2
)
2
C(n) ≤ (n − 1) + t(3/4n − 1/2)
C(n) ≤ n(3/4t + 1) − 1/2t − 1
C(n) ≤ n(3/4t + 1) ≤ tn ⇒ t ≥ 4
• Concludiamo quindi che il numero di confronti atteso è lineare in nella quantità
di dati.
In Figura 8 forniamo un’implementazione in linguaggio C della procedura Seleziona,
che implementa quickselect.
int seleziona(int a[],int sx,int dx,int k){
int q;
if (sx == dx ) return a[k];
q=partiziona(a,sx,dx);
if (k <= q ) return seleziona(a,sx,q,k);
else return seleziona(a,q+1,dx,k);
}
Figura 8: Implementazione della funzione seleziona
33
4.3
Calcolo Deterministico del Mediano
• L’algoritmo che presentiamo è deterministico nel senso che l’elemento x su cui
fare la partizione è scelto in modo deterministico mediante chiamata ricorsiva.
• In questo modo si garantisce che il partizionamento basato su x divida l’array
in tre parti tali che le loro dimensioni dipendano da un fattore costante. In
particolare,
• l’obiettivo è di selezionare x in modo che non disti troppo dal mediano cosı̀
da garantire che ogni chiamata sia, ad esempio, su un array grande al più 34 di
quello di input. L’obiettivo è raggiunto applicando l’idea di calcolare il mediano
dei mediani, come segue:
1. Se il numero di elementi su cui calcolare il mediano è piccolo si usa un algoritmo
di ordinamento, altrimenti si procede come segue:
2. l’insieme degli elementi è frazionato in tanti gruppi da 5. Si ottengono d n5 e gruppi in cui l’ultimo può contenere eventualmente meno di 5 elementi. Abbiamo
cosı̀ S1 , . . . , Sd n5 e gruppi;
3. di ogni gruppo, calcoliamo il mediano con un qualsiasi algoritmo. Chiamiamo
tali mediani m1 , . . . , md n5 e ;
4. per chiamata ricorsiva troviamo il mediano M dei mediani m1 , . . . , md n5 e ;
5. usiamo l’algoritmo (modificato) di partizionamento di quicksort dove l’elemento pivot x vale M ;
6. procediamo ricorsivamente sulla regione più estesa (o su quella contenente la
posizione k di interesse).
L’algoritmo descritto sopra può essere schematizzato come segue, dove l e r sono
rispettivamente indice dell’elemento più a sinistra e più a destra della sequenza, k è
la posizione di interesse all’interno della sequenza di input.
34
medianoDet(a1 , . . . , an , l, r, k)->intero
0. SE r-l inferiore a 10
ALLORA ordina la sequenza al , . . . , ar e restituisci l’elemento di posto
k;
1. sia gruppi= (r−l+1)
e avanzo= (r − l + 1)%5, cioè gruppi denota
5
il numero di gruppi da 5 elementi in cui possono essere suddivisi
gli elementi da al , . . . , ar e avanzo il numero di elementi nell’ultimo
gruppo.
SE avanzo 6= 0 poniamo totGr=gruppi+1,
ALTRIMENTI totGr=gruppi.
2. Per i che varia da 1 a gruppi esegui:
3.
sia mi il mediano di a5∗(i−1)+1 , . . . , a5∗(i−1)+5
4. SE avanzo 6= 0
5. ALLORA sia mtotGr il mediano di
a5∗gruppi+1 , . . . , a5∗gruppi+avanzo ;
6. M=medianoDet(m1 , . . . , mtotGr , 1, totGr, totGr/2)
7. j=partizionaV2(a1 , . . . , an , l, r, M)
8.SE k <= j ALLORA return medianoDet (a1 , . . . , an , l, j, k)
9.ALTRIMENTI return medianoDet (a1 , . . . , an , j + 1, r, k)
Di seguito forniamo una possibile implementazione in linguaggio C. Il passo base è
eseguito tramite selectionSort opportunamente adattato.
In Figura 11 presentiamo la funzione di partizionamento partizionaV2, variante
della usuale funzione di partizionamento di quicksort. La funzione partizionaV2
ha tra i suoi parametri formali il pivot x. Si lascia come esercizio la modifica di questa
funzione di partizionamento in modo che il partizionamento divida gli elelementi
nelle tre regioni A1 , A2 , e A3 .
35
int medianoDet(int a[],int l,int r,int k){
/* l: indice primo elemento,
r: indice ultimo elemento,
k: posizione da estrarre, tra gli estremi l e r */
int gruppi, avanzo,totGr;
int m[r]; /* vettore dei mediani dei gruppi:
m[0] contiene il mediano del primo gruppo etc...*/
int M;
int i,j;
if ((r-l+1)< 10 ){ /* passo base: con pochi elementi uso un metodo quasiasi */
selectionSort(a,l,r);
return a[k];
}
gruppi=(r-l+1)/5;
avanzo=(r-l+1)%5;
if (avanzo != 0) totGr=gruppi+1;
else totGr=gruppi;
for(i=0;i<gruppi;i++){ /* calcolo del mediano di 5 elementi,
uso un metodo qualsiasi */
selectionSort(a,5*i+l , 5*i+4+l); /* osserva +l*/
m[i]=a[5*i+2+l];
}
if (avanzo !=0 ){
/* osserva che la variabile i vale gruppi */
/* calcolo del mediano per il gruppo, se esiste,
con meno di 5 elementi */
selectionSort(a,5*i+l , 5*i+(avanzo-1)+l);
m[i]=a[5*i+(avanzo-1)/2+l];
}
M=medianoDet(m,0,i,i/2); /* calcolo del mediano dei mediani */
j=partizionaV2(a,l,r,M);
if (k<=j) return medianoDet(a,l,j,k);
else return medianoDet(a,j+1,r,k);
}
Figura 9: Implementazione del Mediano deterministico
36
int indexMin(int a[],int start,int stop){
/* start: indice della prima cella,
stop: indice dell’ultima */
int i,idxmin;
idxmin=start;
for(i=start+1;i<=stop;i++){
if (a[idxmin]>a[i]) idxmin=i;
}
return idxmin;
}
void selectionSort(int a[],int start,int stop){
/* start e stop sono gli estremi del vettore a*/
int i,idxMin,help;
for(i=start;i<=stop;i++){
idxMin=indexMin(a,i,stop);
help=a[i];
a[i]=a[idxMin];
a[idxMin]=help;
}
}
Figura 10: Versione di selectionSort per il calcolo del mediano
37
int partizionaV2(int a[],int sx,int dx, int x){
int i,j,temp;
i=sx-1;j=dx+1;
while (1){
do i++; while (a[i]<x);
do j--; while (a[j]>x);
if (i< j){
temp=a[i];
a[i]=a[j];
a[j]=temp;}
else return j;
}
}
Figura 11: La funzione partizionaV2, variante di partiziona
38
4.3.1
Analisi di complessità
Esiste un metodo che permette di trovare il mediano tra 5 elementi facendo solo 6
confronti (esercizio!), quindi tutti i mediani mi possono essere trovati in 6d n5 e passi.
Il calcolo del mediano dei mediani è fatto per chiamata ricorsiva e quindi richiede
tempo T (d n5 e) <= T ( n5 + 1).
Il vero problema è sapere su quale dimensione dell’array è fatta la chiamata ricorsiva,
cioè la dimensione che il partizionamento produce.
Il seguente lemma stabilisce che la chiamata ricorsiva viene fatta su una frazione
degli elementi di input:
Lemma 4.2 La chiamata ricorsiva è effettuata su al più
7
10 n
+ 3 elementi.
Dimostrazione:
Ad ogni passo dopo aver partizionato scartiamo gli elementi uguali a M più quelli o
più grandi o più piccoli. Supponiamo di scartare quelli più grandi, quanti elementi
scartiamo in tutto?
Dato che M è mediano degli mi almeno metà di questi è scartata, e quindi almeno
n
d 21 d n5 ee = d 10
e dato che gli mi sono d n5 e.
Ora, ciascun mi è mediano del proprio gruppo Si di 5 elementi quindi è garantito
dalla definizione di mediano che almeno altri due elementi di Si siano maggiori di
mi e quindi siano scartati.
Cosı̀ quei gruppi Si tali che mi ≥ M hanno almeno 3 elementi ≥ M che quindi
vengono scartati. I gruppi Si con 5 elementi tali che mi ≥ M sono, per quanto
n
osservato sopra, d 10
− 1e (-1 è dovuto al fatto che l’ultimo gruppo potrebbe non
avere 5 elementi).
Cosı̀ è garantito che vengano scartati almeno
3n
10
− 3 elementi.
Con un ragionamento assolutamente analogo possiamo dimostrare che il medesimo
numero di elementi è scartato se la chiamata ricorsiva scarta quelli più piccoli.
Se ne deduce che la chiamata ricorsiva è fatta su
39
7n
10
+ 3 elementi.
Theorem 4.3 Nel caso peggiore select esegue O(n) confronti.
Dimostrazone:
ci vuole tempo 6d n5 e < 6( n5 + 1) per il calcolo degli mi ;
il mediano dei mediani M è calcolato per chiamata ricorsiva su d n5 e elementi, quindi
il tempo è T (d n5 e).
Per partizionare l’array di n elementi ci vuole tempo (n − 1).
La chiamata ricorsiva per ottenere la soluzione è fatta su
7
impiega tempo T ( 10
n + 3).
7n
10
+ 3 elementi, quindi
Complessivamente i confronti, ovvero il tempo, sono
T (n) ≤
11
n
7
n + T (d e) + T ( n + 3) + 5
5
5
10
A questo punto per sostituzione si può dimostrare che T (n) ha un andamento lineare,
cioè T (n) = Kn, con K ∈ N . In particolare dimostriamo che esiste un valore per
K tale che
11
n
7
T (n) ≤ n + T (d e) + T ( n + 3) + 5 ≤ Kn
5
5
10
Se T (n) ha un andamento lineare allora
T (n) ≤
11
n
7
n + K( + 1) + K( n + 3) + 5 ≤ Kn
5
5
10
Trascurando il termine 4K che non dipende da n, possiamo dividere le ultime due
disequazioni per n ottenendo
11 K
7
+ )+K ≤K
5
5
10
11
9
+ K≤K
5
10
Che è soddisfatta per K ≥ 22. Abbiamo quindi dimostrato che l’andamento di T (n)
è lineare ed il fattore moltiplcativo di n è 22.
40
5
Tecniche Algoritmiche
• La tecnica greedy costruisce la soluzione applicando di volta in volta la scelta localmente più promettente. Molti problemi di ottimizzazione ammettono
algoritmi greedy ottimali.
41
6
Tecnica Divide et Impera
• La tecnica divide et impera è una tecnica che consiste nel dividere l’istanza
del problema da risolvere in sottoistanze le quali vengono risolte ricorsivamente
e le cui soluzioni sono ricombinate per ottenere la soluzione dell’istanza.
• E’ una tecnica top-down nel senso che si parte dall’istanza generale e si divide
in istanze più piccole.
• Lo sforzo del progettista consiste nell’individuare sia come dividere il problema
sia (ma soprattutto) come ricombinare le soluzioni parziali.
6.1
MergeSort
É un algoritmo di ordinamento che ha una chiara struttura ricorsiva: richiama
se stesso più volte per trattare istanze più semplici di quella data in input. In
questo caso istanza più semplice è sinonimo di sequenza più corta rispetto a
quella fornita in input.
Data una sequenza a1 , . . . , an , MergeSort opera come segue:
1. divide la sequenza da ordinare in due sottosequenze di
n
2
elementi ciascuna;
2. ordina le due sottosequenze ricorsivamente usando MergeSort;
3. fonde le due sottosequenze ordinate per produrre la risposta ordinata.
– Si osservi che nel passo 2 la base della ricorsione si ha quando la sequenza
da ordinare è lunga 1.
– L’operazione chiave di MergeSort è il passo 3, dove si fondono due sequenze
ordinate.
Nelle Figure 12 e 13 forniamo un’implementazione C degli algoritmi.
42
/* p è l’indice del primo elemento della sequenza,
r è l’indice dell’ultimo elemento della sequenza
*/
void mergesort(int a[],int p,int r){
int q;
if (p<r){
q=(p+r)/2;
mergesort(a,p,q);
mergesort(a,q+1,r);
merge(a,p,q,r);
}
}
Figura 12: Implementazione in linguaggio C di mergesort
6.2
Quicksort
Anche quicksort ha una chiara struttura dividi et impera. Data una sequenza
S di elementi:
1. dividi: scegli un elemento x e usalo per partizionare S in due:
A, quella contenente solo gli elementi minori o uguali a x;
B, quella contenente solo gli elementi maggiori o uguali a x;
2. impera:
procedi ricorsivamente su A come se fosse S, ottenendo A0 ;
procedi ricorsivamente su B come se fosse S, ottenendo B 0 ;
3. A0 seguito da B 0 costituisce l’ordinamento di S.
Nota che in tal caso la terza fase, quella di ricombinazione delle soluzioni parziali
è molto facile. Al contrario la fase di dividi è sofisticata. Si osservi come nel
aso di mergeSort la fase di dividi sia banale mentre la fase di ricombinazione è
sofisticata.
43
void merge(int a[],int p,int q,int r){
int b[r+1]; /* conterrà il risultato della fusione*/
int i,j,t;
/*
i punta alla testa della sottosequenza di sx
j punta alla testa della sottosequenza di dx
t punta alla prima cella libera di b
*/
i=p;j=q+1;t=1;
/* itera il corpo finchè entrambe le sottosequenza hanno elementi*/
while (i<=q && j<=r){
if (a[i]<a[j]) { b[t]=a[i];i++;t++; }
else { b[t]=a[j];t++;j++; }
}
/* copia della coda della sottosequenza non terminata*/
/* nota: solo uno dei due seguenti while parte */
while (i<=q){
b[t]=a[i]; i++; t++;
}
while (j<=r){
b[t]=a[j]; j++; t++;
}
/* copia b in a, adesso le due sottosequenze sono fuse in un’unica
sequenza ordinata che sta in b */
for(i=1,j=p; i<t; i++,j++){
a[j]=b[i];
}
}
Figura 13: Implementazione in Linguaggio C della funzione merge
44
6.3
Esercizi
– Scrivere una procedura che dato un vettore v ed un elemento x stabilisca se
v è partizionato rispetto a x. In caso affermativo deve restituire la posizione
di confine tra le due regioni altrimenti -1.
– Modificate MergeSort, cosı̀ che divida la sequenza di dati in 3 parti e non
due.
45
6.4
Moltiplicazione di interi di grandezza arbitraria
• La moltiplicazione di numeri che non stanno in una cella di memoria non può
essere considerata a tempo costante.
• Presi 2 numeri di n cifre X = xn−1 . . . x0 e Y = yn−1 . . . y0 entrambi di n cifre,
la moltiplicazione prende tempo O(n2 ), dove la moltiplicazione di 2 cifre prende
tempo costante.
• Supponiamo per semplicità che n sia potenza di 2 e suddividiamo ciascun
numero in 2 gruppi di n2 cifre:
n
X = X1 10 2 + X0
n
Y = Y1 10 2 + Y0
ovvero, X0 = x n2 −1 . . . x0 , X1 = xn . . . x n2 , Y0 = y n2 −1 . . . y0 , Y1 = yn . . . y n2 .
• Cosı̀
n
n
XY = (X1 10 2 + X0 )(Y1 10 2 + Y0 )
n
n
= (X1 Y1 10n ) + X1 Y0 10 2 + X0 Y1 10 2 + X0 Y0
n
= 10n (X1 Y1 ) + 10 2 (X1 Y0 + X0 Y1 ) + (X0 Y0 )
• Si osservi come le moltiplicazioni tra parentesi siano quelle del doppio prodotto
(X1 + X0 )(Y0 + Y1 ) = X1 Y1 + X0 Y1 + X1 Y0 + X0 Y0 , da cui
(X1 + X0 )(Y0 + Y1 ) − X1 Y1 − X0 Y0 = X0 Y1 + X1 Y0
Tutto questo ci dice che con i 3 prodotti
(X1 + X0 )(Y0 + Y1 ), X1 Y1 e X0 Y0
possiamo calcolare XY come
n
10n (X1 Y1 ) + 10 2 ((X1 + X0 )(Y0 + Y1 ) − X1 Y1 − X0 Y0 ) + (X0 Y0 )
dove questi 3 prodotti sono tra numeri aventi al più
46
n
2
+ 1 cifre.
• Per semplicità nell’analisi seguente consideriamo i numeri su cui si fanno le
moltiplicazioni di n2 cifre in quanto l’analisi è asintotica.
• L’espressione data sopra dice che il tempo per moltiplicare 2 numeri di n cifre
equivale al tempo per fare 3 moltiplicazioni tra numeri di n2 cifre più il tempo
di fare somme e shift (le moltiplicazioni per 10) di numeri a n cifre, quindi il
tempo è:
T (n) = 3T ( n2 ) + K2 · n, n > 1
T (1) = K1 ∈ N
• Srotolando la ricorrenza otteniamo
T (n) = 3k T (1) + K2 n + K2
n
n
+ · · · + K2 k
2
2
con k = lg2 n, da cui
T (n) = 3
lg2 n
lg2 n
X
1
= O(nlg2 3 )
K1 + K2 n
i
2
i=0
Esercizio Scrivere un programma C per fare operazioni aritmetiche con numeri di lunghezza arbitraria. Per cominciare si consideri il seguente frammento di
programma:
#include<stdio.h>
#define LUNGHEZZA 10
/* primo addendo, posizione del’unita’ del primo addendo, secondo addendo,
posizione unita’ del secondo addendo, vettore risultato
*/
int somma(int add1[], int startAdd1, int add2[], int startAdd2, int ris[]);
/*
vettore e sua dimensione
*/
void printAdd(int add[],int dim){
int i;
for(i=0;i<dim;i++) printf("%d",add[i]);
}
/* ritorna la posizione delle unita’ */
47
int leggiAddendo(int add[]){
int i;
char cifra;
scanf("%c",&cifra);
i=0;
while (cifra != ’\n’){
add[i]=cifra-’0’;
i++;
scanf("%c",&cifra);
}
return --i;
}
int main(){
int ris[LUNGHEZZA+1],add1[LUNGHEZZA],add2[LUNGHEZZA], i,j, add1Dim,add2Dim;
char cifra;
printf("Primo addendo:");
add1Dim=leggiAddendo(add1);
printf("secondo addendo:");
add2Dim=leggiAddendo(add2);
somma(add1,add1Dim,add2,add2Dim,ris);
printAdd(ris,LUNGHEZZA);
printf("\n");
return 0;
}
Esercizio 1. Per poter elaborare numeri di grandezza arbitraria è necessario poter
fare confronti tra due numeri di lunghezza arbitraria, quindi bisgona ridefinire gli
operatori relazionali >, <, =, implementandoli come funzioni; 2. implementare la
sottrazione tra interi di grandezza arbitraria; 3. implementare la divisione intera
(quoziente e resto) tra numeri di grandezza arbitraria. Dato che la divisione
Tra l’altro
48
6.5
Polinomi e Trasformata Veloce di Fourier (FFT)
...To Be Prepared...
49
6.6
La Programmazione Dinamica
• Tipicamente, la tecnica della programmazione dinamica si basa sulla filosofia bottom-up. Procediamo dai sottoproblemi più piccoli verso quelli più
grandi.
• E’ una tecnica utile quando le sottoistanze non sono una indipendente dall’altra
ed una stessa sottoistanza dell’istanza originaria può comparire più volte. Questo in pratica significa che analizzando l’albero delle chiamate ricorsive si nota
che ci sono due o più chiamate ricorsive aventi con gli stessi parametri attuali;
• Le soluzioni delle sottoistanze sono memorizzate in una tabella, cosı̀ qualora
la medesima sottoistanza dovesse essere reincontrata sarà sufficiente esaminare
l’elemento della tabella che ne contiene la soluzione.
• La tecnica algoritmica Programmazione dinamica, prende nome dal fatto
che si basa su una tabella (mono o pluridimensionale) la quale memorizza le
soluzioni dei sottoproblemi del problema originario e tale tabella viene compilata
o meglio programmata dinamicamente, a run-time.
• E’ una tecnica bottom up in quanto la tabella è compilata partendo dai sottoproblemi più semplici, cioè dai casi base e passando poi a sottoproblemi più
difficili combinando in maniera opportuna le soluzioni dei problemi più semplici
per ottenere quelle dei problemi più difficili.
• In opposizione, la tecnica top-down affronta l’istanza del problema generale e
la divide man mano in sottoistanze più piccole. La tecnica di programmazione
dinamica procede come segue:
1. si identificano i sottoproblemi del problema originario e si utilizza una tabella
per memorizzare le soluzioni dei sottoproblemi;
2. si definiscono i valori iniziali della tabella relativi ai problemi più semplici;
3. si avanza nella tabella calcolando il valore della soluzione di un sottoproblema
50
(cioè un entry della tabella) in funzione dei valori delle soluzioni dei sottoproblemi già risolti;
4. si restituisce la soluzione del problema originario.
51
6.7
Associatività del prodotto tra matrici
• Di seguito ci occupiamo della moltiplicazione di matrici. E’ noto che M1 M2 = M
può essere fatto se il numero di colonne di M1 è uguale al numero di righe di
M2 e la matrice M ha il numero di righe di M1 ed il numero di colonne di M2 .
• Il prodotto di matrici è associativo ma non commutativo. Di seguito considereremo unitario il tempo per fare una moltiplicazione ed una somma.
• Date le n matrici M1 M2 . . . Mn dove la matrice i-esima ha dimensione li × li+1
qual è il modo di associarle cosı̀ da minimizzare il numero di operazioni di
moltiplicazione?
• Il modo di associare le matrici influenza il modo in cui operiamo. Ad esempio,
siano M1 (10, 20), M2 (20, 30), M3 (30, 2).
• E’ facile verificare che il numero di moltiplicazioni varia di molto a seconda che
si faccia M1 (M2 M3 ) o (M1 M2 )M3 . Infatti:
M1 ∗ M2 è una matrice di dimensione 10x30 e per calcolare ciascun elemento
occorrono 20 moltiplicazioni;
(M1 ∗ M2 ) ∗ M3 è una matrice 10x2 e per calcolare ciscun elemento occorrono
30 moltiplicazioni.
Segue quindi che il prodotto (M1 ∗ M2 ) ∗ M3 richiede 6600 moltiplicazioni.
D’altronde, la stessa matrice può essere calcolata associando M1 ∗ (M2 ∗ M3 ),
in tal caso:
M2 ∗ M3 ha 20x2 elementi, ciascuno calcolato con 30 moltiplicazioni;
M1 ∗ (M2 ∗ M3 ) ha 10x2 elementi, ciascuno calcolato con 20 moltiplicazioni.
Segue quindi che il prodotto M1 ∗ (M2 ∗ M3 ) richiede 1600 moltiplicazioni.
• questo semplice esempio contiene il succo della strategia: conoscendo il costo
delle associazioni (M1 ∗ M2 ) e (M2 ∗ M3 ) possiamo scegliere quella definitiva
per la moltiplicazione delle 3 matrici. Nota la nostra scelta è ottima perchè
(banalmente) ottime sono le associatività scelte per la moltiplicazione di (M1 ∗
M2 ) e (M2 ∗ M3 ).
52
• Prima di vedere il problema generale, illustriamo l’idea di base, supponendo di
dover moltiplicare M1 ∗ M2 ∗ M3 ∗ M4 .
• Osserviamo che ci sono diverse associazioni possibili per ottenere la matrice
risultato:
M1 ∗ (M2 ∗ M3 ∗ M4 ), (M1 ∗ M2 ) ∗ (M3 ∗ M4 ), (M1 ∗ M2 ∗ M3 ) ∗ M4 .
• Osserviamo quindi che se conoscessimo il costo della moltiplicazione di M2 ∗M3 ∗
M4 , M1 ∗ M2 , M3 ∗ M4 , M1 ∗ M2 ∗ M3 , potremmo decidere quale associazione ci
conviene fare conteggiando per ciascuno dei 3 casi il numero di moltiplicazioni.
Ovviamente, decideremmo per l’associatività che produce il numero inferiore di
moltiplicazioni;
• inoltre se sapessimo anche che i costi delle moltiplicazioni M2 ∗M3 ∗M4 , M1 ∗M2 ,
M3 ∗ M4 , M1 ∗ M2 ∗ M3 sono ottimi, cioè minimi, potremmo anche affermare che
il numero di moltiplicazioni per l’associtività scelta è il minimo possibile.
• Ma per fare questa affermazione qualcuno dovrebbe calcolare i costi minimi per
la moltiplicazione di M2 ∗ M3 ∗ M4 , M1 ∗ M2 , M3 ∗ M4 , M1 ∗ M2 ∗ M3 . Questo
significa, in particolare trovare l’associatività migliore per tali moltiplicazioni di
matrici, in particolare per M2 ∗ M3 ∗ M4 e M1 ∗ M2 ∗ M3 , dato che per M1 ∗ M2 ,
M3 ∗ M4 la scelta è obbligata.
• Consideriamo di avere le matrici M1 (10, 2), M2 (2, 5), M3 (5, 20), M4 (20, 50). Le
dimensioni sono quindi
l1 = 10, l2 = 2, l3 = 5, l4 = 20, l5 = 50
E’ immediato calcolare il costo della moltiplicazione tra 2 matrici: basta moltiplicare tra loro le rispettive dimensioni. Per quanto rigarda la moltiplicazione
M1 M2 M3 abbiamo due possibili casi:
(M1 M2 )M3 che costa 100+1000;
M1 (M2 M3 ) che costa 200+400;
Quindi (M1 M2 )M3 è la parentesizzazione migliore, e 600 è il valore inserito in
riga 1 colonna 3 ad esprimere il fatto che 600 è il numero minimo di moltiplicazioni da fare per ottere il risultato di M1 M2 M3 .
53
Procediamo analogamente per la moltiplicazione di M2 M3 M4 ottenendo che
(M2 M3 )M4 è l’associatività più economica dato che richiede solo 2200 moltiplicazioni contro le 5500 richiesta dall’associatività M2 (M3 M4 ).
Per moltiplicare M1 M2 M3 M4 abbiamo 4 possibili modi di associare le matrici,
per ciascuno dobbiamo calcolarne il costo. Scopriremo che 3200=2200+1000 è
il costo minimo ottenuto associando M1 (M2 M3 M4 ). Possiamo quindi compilare
la tabella
a 1 2
3
4
da
1
0 100 600 3200
2
0
200 2200
3
0
5000
4
0
Oltre alla matrice dei costi data qui sopra, possiamo compilare la matrice della soluzione, che permetta di ricostruire come fare l’associazione. La matrice
soluzione è analoga a quella dei costi: le celle significative sono solo quelle sopra la diagonale principale e se j è il valore che compare alle coordianate (r,c)
significa che la moltiplicazione di Mr . . . Mc deve essere fatta con associazione
(Mr . . . Mj )(Mj+1 . . . Mc ). Svolgendo l’esempio dato sopra si ottiene la seguente
matrice soluzione:
a 1 2 3 4
da
1
1 1 1
2
2 3
3
3
4
Quindi da questa matrice deduciamo che M1 M2 M3 M4 devono essere associate
come M1 (M2 M3 M4 ) e che M2 M3 M4 devono essere associate come (M2 M3 )M4
• Torniamo al problema con n matrici. Indichiamo con P (i, j) il sottoproblema
che consiste nel moltiplicare le matrici Mi . . . Mj .
54
• Quindi il problema originario è P (1, n). Abbiamo che
1. I sottoproblemi che dobbiamo risolvere sono i P (i, j) con i ≤ j; il costo
ottimo lo memorizziamo in una matrice C bidimensionale, che risulta una
triangolare superiore, nell’entry (i, j);
2. i sottoproblemi del tipo P (i, i), i = 1, . . . , n, sono banali perchè ci sono 0
moltiplicazioni, quindi C[i, i] = 0;
3. la soluzione al problema M1 M2 . . . Mn sta in C[1, n];
• Dobbiamo specificare come calcoliamo il valore di C[i, j] associato al problema
Mi . . . Mj in funzione dei sottoproblemi più piccoli già risolti cioè già sapendo
come associare prodotti di un numero di matrici inferiori a j−i+1, in particolare
sapendo come associare in modo ottimo prodotti di sequenze di matrici del tipo
Mi . . . Mr e Mr . . . Mj . Per fare questo dobbiamo tentare tutti i possibili valori
di r e vedere quale di questi produce il minimo di
C[i, r] + C[r + 1, j] + li lr+1 lj
cioè porremo
C[i, j] = min C[i, r] + C[r + 1, j] + li lr+1 lj
i≤r≤j−1
• Si tratta quindi di compilare in maniera sistematica gli entry della matrice,
partendo dai problemi con una matrice (casi base), passando con quei problemi
con due matrici, poi a quelli con 3 etc. Questo significa compilare la matrice
muovendosi per diagonali a partire da quella principale e poi salendo via via su
quelle parallele.
• Quello che si ottiene è l’algoritmo in Figura 14 (vedi anche pagina 252), in cui
viene anche compilata S, la matrice soluzione.
Theorem 6.1 L’algoritmo ordinematrici richiede tempo O(n3 ), dove n è il
numero di matrici.
55
Algoritmo OrdineMatrici(l1 ,l2 ,...,ln+1 )
1. matrice C di n x n interi
2. for i= 1 to n do C[i,i]=0;
3. for d= 1 to (n-1) do
4.
for i= 1 to (n-d) do
5.
j=d+i;
6.
C[i,j]=C[i,i]+C[i+1,j]+li li+1 lj ; S[i,j]=i;
7.
for r=i+1 to (j-1) do
8.
if C[i,j]>(C[i,r]+C[r+1,j]+li lr+1 lj )
9.
then
10.
C[i,j]=C[i,r]+C[r+1,j]+li lr+1 lj
11.
S[i,j]=r
12. return C[1,n]
Figura 14: Associatività di matrici
• Dimostrazione: nella diagonale d, 1 ≤ d ≤ n − 1 ci sono n − d elementi e per
ciascuno di essi, che rappresenta un problema di moltiplicazione di d+1 matrici,
ci sono d − 1 possibilità (i possibili valori di r sono d − 1).
56
• Otteniamo quindi la seguente serie di sommatorie:
T (n) =
n−d d+i−1
n−1 X
X
X
d=1 i=1
=
n−d
n−1 X
X
1
r=i
d
d=1 i=1
=
n−1
X
d(n − d + 1)
d=1
=
n−1
X
dn −
d=1
= n
n−1
X
d=1
d+
n−1
X
1
d=1
(n − 1)n (n − 1)n
−
+n
2
2
= O(n3 )
• Si osservi come sia notevolmente più lento un algoritmo di tipo divide et impera
in cui presa l’istanza Mi . . . Mj per ogni r = i, . . . , j − 1 faccia una chiamata
ricorsiva su Mi . . . Mr e Mr+1 . . . Mj per trovare la partizione ottima e poi decide
quale r è il migliore per partizionare Mi . . . Mj . Molti sottoproblemi di Mi . . . Mr
e Mr+1 . . . Mj verrebebro risolti più volte!
• Si osservi che se dato Mi . . . Mj la partizione ottima è
(Mi . . . Mr )(Mr+1 . . . Mj )
allora anche le due partizioni devono essere parentesizzate ottimamente, altrimenti non potrebbe esserlo
(Mi . . . Mr )(Mr+1 . . . Mj ).
57
• Questo significa che il problema dell’associatività del prodotto di matrici verifica
il principio della sottostruttura ottima: se la soluzione ad un problema è ottima
allora anche le soluzioni ai sottoproblemi sono ottime.
• Molti problemi riguardanti i grafi ammettono algoritmi progettati secondo la
tecnica di programmazione dinamica.
Eserczio Progettare e codificare in C un algoritmo che date le dimensioni l1 , . . . , ln+1
di n matrici stampi a video l’associatività ottima ed il costo. Sul nostro esempio
introduttivo il programma dovrebbe stampare a video M1((M2M3)M4) come soluzione
e 3200 come costo.
58
Stampa di una tabella di associatività di dimensione 6x6.
#include<stdio.h>
void stampaAssociativita(int s[][6],int i,int j){
if (i==j) printf("A%d",i);
else
{
printf("(");
stampaAssociativita(s,i,s[i][j]);
stampaAssociativita(s,s[i][j]+1,j);
printf(")");
}
}
/* Main di prova */
int main(void){
/*Inizializza la matrice di programmazione dinamica */
int s[6][6]=
{
{0,0,0,2,2,2},
{0,1,1,2,2,2},
{0,0,2,2,2,2},
{0,0,0,3,3,4},
{0,0,0,0,4,4},
{0,0,0,0,0,5}
};
int i,j;
stampaAssociativita(s,0,5);
return 0;
}
59
60
Knapsack
• Il problema dello zaino è il seguente:
• si ha a disposizione uno zaino di capacità C nel quale si vogliono mettere degli
oggetti, ciascun tipo di oggetto ha un peso ed un valore.
• Il problema è di massimizzare il valore posto nello zaino senza superare il peso
C che lo zaino in tutto può trasportare. Di ogni tipo di oggetto sono disponibili
un numero illimitato di copie.
• Per scrivere un algoritmo che risolve il problema osserviamo che se per ciascun
tipo di oggetto i conosco il valore della soluzione ottima del problema dello
zaino quando la capacità è C − peso(i) (C − peso(i) ≥ 0) allora posso calcolare
il valore della soluzione ottima del problema dello zaino con capacità C vedendo
per ogni oggetto i qual è quello che aggiunto allo zaino ne massimizza il valore.
61
Knapsack (2)
• Più formalmente potremmo dire che date le soluzioni ottime ott(1), . . . , ott(n)
ai problemi con zaini di capacità C − peso(1), . . . , C − peso(n) aggiungiamo allo
zaino quell’oggetto j tale che ott(j) + valore(j) = maxi=1,...,n (ott(i) + valore(i)).
• Una prima soluzione a questa idea potrebbe essere la seguente:
zaino(intero capacita, array peso, array valore)-> intero
{
1. if capacita <= 0 return 0
2. else
{
3.
max <- 0;
4.
for(i <- 0; i<n; i++)
5.
{
6.
if ( capacita-peso[i] >= 0 )
{
ris=zaino(capacita-peso(i),peso, valore)+valore[i];
7.
if (ris > max ) max=ris
}
8.
}
9.
return max
10.}
}
62
Knapsack (3)
• Questo algoritmo ricalcola più volte la soluzione al medesimo problema. Cosı̀ il
tempo è esponenziale rispetto al valore di capacità e al numero di oggetti.
• Una analisi delle chiamate ricorsive dell’algoritmo evidenzia come il medesimo
sottoproblema possa essere risolto più volte.
• Per ridurre il numero di chiamate dobbiamo memorizzare il valore delle soluzioni
ottime mano a mano che le troviamo cosı̀ da evitare delle chiamate inutili.
• Siccome i problemi differiscono tra loro per il diverso valore di capacità introduciamo un vettore di C + 1 celle, una cella per ogni possibile valore che la
capacità dello zaino può assumere nei sottoproblemi del problema originario.
63
Knapsack (3)
zaino2(intero capacita, array peso, array valore,
array soluzioneottima)->intero
{
if (soluzioneottima[capacita] <> -1 )
return soluzioneottima[capacita]
else
{
max=0
for(i <- 1; i <= nroOgg ; i++)
{
if (capacita-peso[i]>=0)
{
ris=zaino2(capacita-peso[i],peso,valore,
soluzioneottima)+valore[i]
if (ris > max) max=ris
}
}
soluzioneottima[capacita]=max
return max
}
}
64
Knapsack (4)
• Possiamo apportare un’altra modifica all’algoritmo procedendo bottom-up, cioè
cercare di compilare il vettore delle capacità dalle capacità più piccole alle
capacità più grandi.
• Se abbiamo il problema dello zaino con capacità C e già abbiamo le soluzioni
per tutte le capacità più piccole di C non c’è bisogno di fare chiamate ricorsive
per risolvere i sottoproblemi per zaini di capacità C − peso(i), per i = 1, . . . , n,
basta fare un ciclo su i che va da 1 a n perchè il valore ottimo per il problema
C − peso(i) già l’abbiamo. Il passo base del problema è ovviamente per lo zaino
di capacità uguale a zero, il cui ottimo è zero.
zaino3(capacita, array peso, array valore, array soluzioneottima) {
for c <- 1 to capacita do
{
max <- 0
for i <- 1 to n do
if ( c-peso(i)>=0)
if (max < soluzioneottima[c-peso[i]]+valore[i])
max <- soluzioneottima[c-peso[i]]+valore[i]
soluzioneottima[c]=max;
}
}
65
Knapsack (5)
• Differente è l’idea se abbiamo uno zaino di capacità C ed n tipi di oggetti
1, . . . , n, ciascuno con un peso p(1), . . . , p(n) ed un valore v(1), . . . , v(n), ma
di ogni oggetto abbiamo solo una copia, quindi nella soluzione ottima ciascun
oggetto o compare, o non compare.
• Se l’oggetto 1 compare nella soluzione ottima, allora la soluzione senza l’oggetto
1 è ottima per il problema con uno zaino di capacità C − p(1) e n − 1 tipi di
oggetti, 2, . . . , n, ciascuno con un peso p(2), . . . , p(n) ed un valore v(2), . . . , v(n);
se l’oggetto 1 non compare nella soluzione ottima allora la soluzione è uguale
alla soluzione ottima per il problema con uno zaino di capacità C e n − 1 tipi di
oggetti 2, . . . , n, ciascuno con un peso p(2), . . . , p(n) ed un valore v(2), . . . , v(n).
• Se il primo oggetto appartiene alla soluzione ottima, allora la soluzione senza
tale oggetto è soluzione ottima del problema senza tale oggetto con uno zaino
che ha capacità diminuita del peso dell’oggetto; se l’oggetto non appartiene
alla soluzione ottima, allora basta risolvere il problema in cui l’oggetto non è
presente.
• Una prima soluzione che non usa la programmazione dinamica è la seguente:
66
Knapsack (6)
zaino4(intero capacita,array peso,array valore,intero lastobj)-> intero
{
if (capacita <= 0 || lastobj<0) return 0
else {
ris1=zaino4(capacita, peso, valore, lastobj-1)
ris2=zaino4(capacita-peso[lastobj],peso,valore,lastobj-1)
+valore[lastobj]
if ris1 > ris2 return ris1
else return ris2
}
}
• Questa soluzione soffre comunque del fatto che il medesimo problema può essere
sottoproblema di più problemi e quindi risolto più volte, come indica una facile
analisi delle chiamate ricorsive.
• Ciò che distingue un problema dai suoi sottoproblemi sono gli oggetti presenti
e la capacità dello zaino. Possiamo quindi utilizzare una matrice che ha tante
colonne quanti sono gli oggetti nell’istanza del problema e tante righe quanto
è il valore della capacità dello zaino e nella posizione di indice (j, i) l’ottimo
del sottoproblema la cui istanza ha i primi i oggetti dell’istanza del problema
originario ed uno zaino di capacità j.
67
Knapsack (7)
zaino5(intero capacita, array peso, array valore,
intero lastobj, matrice soluzioneottima)-> intero
{
if (capacita <= 0 || lastobj<0) return 0
else {
if (soluzioneottima[capacita,lastobj-1]==-1)
ris1=zaino5(capacita, peso, valore,
lastobj-1, soluzioneottima)
else ris1=soluzioneottima[capacita,lastobj-1]
if (soluzioneottima[capacita-peso[lastobj],lastobj-1]==-1)
ris2=zaino4(capacita-peso[lastobj], peso, valore,
lastobj-1, soluzioneottima)+valore[lastobj]
else ris2=soluzioneottima[capacita-peso[lastobj],
lastobj-1]+valore[lastobj]
if (ris1 > ris2) soluzioneottima[capacita,lastobj]=ris1
else soluzioneottima[capacita,lastobj]=ris2
return soluzioneottima[capacita,lastobj];
}
}
68
6.8
Longest Common Subsequence (LCS)
Data una sequenza di elementi una sua sottosequenza è ottenuta privando la sequenza di un numero qualsiasi di elementi. Ad esempio le parole sono sequenze di
lettere e una sottosequenza di “MAGGIO” è “MGG”.
Formalmente, date due sequenze X = hx1 , . . . , xn i e Z = hz1 , . . . , zk i diremo che Z
è sottosequenza di X se esiste una sequenza di indici di X hi1 , . . . ik i strettamente
crescente e tale che per ogni j = 1, . . . , k, Xij = Zj . Date due sequenze X e Y , Z è
sottosequenza comune di X e Y se Z è sottosequenza sia di X che si Y .
Nel problema di trovare la sottosequenza più lunga ci vengono date due sequenze
X = hx1 , . . . , xm i e Y = hy1 , . . . , yn i e dobbiamo trovare la sottosequenza comune
più lunga.
Vediamo come possiamo risolvere il problema usando la programmazione dinamica.
Innanzitutto è immediato vedere che un algoritmo a forza bruta che enumeri tutte
le sottosequenze di X e vede se sono anche sottosequenze di Y è impraticabile in
quanto dato X = hx1 , . . . , xm i le sue sottosequenze sono tutti i sottoinsiemi di X
cioè 2m .
Comunque la programmazione dinamica è applicabile se vale il principio della sottostruttura ottima. Dimostriamo tale proprietà nel seguente teorema:
69
Theorem 6.2 Siano X = hx1 , . . . , xm i e Y = hy1 , . . . , yn i due sequenze e Z =
hz1 , . . . , zk i una LCS di X e Y . Allora vale quanto segue:
1. se xm = yn allora deve essere zk = xm = yn e hz1 , . . . , zk−1 i è LCS di hx1 , . . . , xm−1 i
e hy1 , . . . , yn−1 i;
2. se xm 6= yn allora se zk 6= xm allora Z è LCS di hx1 , . . . , xm−1 i e Y ;
3. se xm 6= yn allora se zk 6= yn allora Z è LCS di X e hy1 , . . . , yn−1 i.
Dimostrazione:
1. per hp è xm = yn , allora se per assurdo fosse zk 6= xm ( e quindi zk 6= yn ),
allora Z potrebbe essere allungata aggiungendo xm e troveremmo una nuova
sottosequenza comune tra X e Y più lunga di Z, contro l’hp del teorema che dice
che Z è LCS. Quindi deve essere zk = xm (= yn ). A questo punto hz1 , . . . , zk−1 i è
LCS di hx1 , . . . , xm−1 i e hy1 , . . . , yn−1 i perchè se esistesse un LCS W di lunghezza
maggiore di k − 1, allora attaccando a W l’elemento xm (= yn ) otterremmo un
LCS di lunghezza maggiore di k per X e Y , contro l’hp del th che dice che Z,
di lunghezza k, è LCS di X e Y ;
2. per hp è xm 6= yn e zk 6= xm , allora i k elementi comuni a X e Y si trovano in
hx1 , . . . , xm−1 i e Y = hy1 , . . . , yn i. D’altronde non può esistere in hx1 , . . . , xm−1 i
e Y = hy1 , . . . , yn i una sottosequenza W con più di k elementi, altrimenti essa
sarebbe anche sottosequenza di X e Y , quindi Z di k elementi non sarebbe LCS
di X e Y . Cosı̀ Z è una LCS di hx1 , . . . , xm−1 i e Y .
3. simmetrico al punto precedente.
70
Il teorema precedente ci dice come analizzare i sottoproblemi per ottenere la soluzione al problema dato:
1. se xm = yn allora cerchiamo un LCS di hx1 , . . . , xm−1 i e hy1 , . . . , yn−1 i e una
volta trovata l’allunghiamo con xm ;
2. se xm 6= yn cerchiamo un LCS di hx1 , . . . , xm−1 i e Y , e un LCS di X e hy1 , . . . , yn−1 i,
e prendiamo a soluzione migliore la quale è anche quella per X e Y .
Esempio Calcoliamo una LCS tra X = hA, B, C, B, D, A, Bi e Y = hB, D, C, A, B, Ai.
E’ facile rendersi conto che molti sottoproblemi si sovrappongono, ovvero può capitare di dover ricalcolare più volte una LCS tra medesime sequenze.
Ovviamente se una delle due sequenze ha lunghezza 0 allora la LCS ha lunghezza
0. Questo è il caso base. Possiamo introdurre una matrice C[m × n] tale che C[i, j]
è il valore della LCS nel caso hx1 , . . . , xi i e hy1 , . . . , yj i. Per quanto detto vale che
1. C[0, t] = 0, per t = 0, . . . , n;
2. C[t, 0] = 0, per t = 0, . . . , m;
C[i − 1, j − 1] + 1, se xi = yj
3. se i, j > 0, C[i, j] =
max{C[i, j − 1], C[i − 1, j], } altrimenti
Quindi un primo algoritmo risolutivo computa la matrice procedendo ricorsivamente
top-down. Possiamo risparmiare le chiamate ricorsive e procedere nella compilazione
della matrice secondo uno schema bottom up tenendo conto che la prima riga e la
prima colonna valgono zero.
Vediamo come con il nostro esempio dato sopra.
Esercizio Determinare una LCS tra h1, 0, 0, 1, 0, 1, 0, 1i e h0, 1, 0, 1, 1, 0, 1, 1, 0i
La Figura 15 mostra dell’algoritmo in versione bottom-up: X ha m + 1 componenti,
Y ha n + 1 componenti, C é la matrice calcolata e in C[m, n] si trova LCS(X,Y).
Nota: nello pseudolinguaggio consideriamo che le componenti degli array siano indicizzate a partire da 0 e che le lettere che realmente fanno parte della sequenza siano
memorizzate a partire dall’indice 1.
71
Algoritmo LCS(array X, array Y, intero m, intero n, matrice C)
1. for i= 1 to m do c[i,0]=0;
2. for i= 0 to n do c[0,j]=0;
3. for i= 1 to m do
4.
for j= 1 to n do
5.
if (x[i]=y[j]) then c[i,j]=c[i-1,j-1]+1
6.
else if (c[i-1,j]>=c[i,j-1])
7.
then c[i,j]=c[i-1,j]
8.
else c[i,j]=c[i,j-1]
Figura 15: Algoritmo per LCS calcolata in versione bottom-up
Un’ulteriore analisi dei valori da porre nelle celle C[i, j], se i, j > 0 mostra che
possiamo ottenere la risposta facendo uso di una matrice di sole due righe.
Nel seguente algoritmo la matrice C ha due righe, quella di indice 0 e quella di indice
1. Le colonne sono n.
Algoritmo LCS2(array X, array Y, intero m, intero n, matrice C)
1. for j= 0 to n do c[0,j]=0;
3. for i= 1 to m do
c[i%2,0]=0
4.
for j= 1 to n do
5.
if (x[i]=y[j]) then c[i%2,j]=c[(i-1)%2,j-1]+1
6.
else if (c[(i-1) % 2,j]>=c[i%2,j-])
7.
then c[i%2,j]=c[(i-1)%2,j]
8.
else c[i%2,j]=c[i%2,j-1]
Figura 16: Algoritmo per LCS ottimizzato
Infine si osservi che a patto di complicare ulteriormente l’algoritmo è possibile dare
un’implemntazione che utilizzi solo un vettore e 2 variabili.
Nei problemi di ottimizzazione determinare il valore ottimo è solo uno degli aspetti.
L’altro è determinare una soluzione ottima.
Vediamo come determinare una sottosequenza ottima. A tale scopo è necessario tenere in memoria l’intera matrice C. Se applichiamo l’algoritmo illustrato in Figura 15
al problema di trovare la massima lunghezza di una LCS tra hA, B, C, B, D, A, Bi e
72
hB, D, C, A, B, Ai compileremo la seguente matrice:
∅
A
B
C
B
D
A
B
∅
0
0
0
0
0
0
0
0
B
0
0
1
1
1
1
1
1
D
0
0
1
1
1
2
2
2
C
0
0
1
2
2
2
2
2
A
0
1
1
2
2
2
3
3
B
0
1
2
2
3
3
3
4
A
0
1
2
2
3
3
4
4
Da questa matrice è possibile procedendo a ritroso stabilire una soluzione ottima. Il
modo di procedere a ritroso è dettato dal modo che abbiamo impiegato per costruire
la matrice.
Esercizio Scrivere un programma che date 2 sequenze di caratteri ASCII (tipo char
del C) computi la lunghezza di una LCS e stampi una LCS.
Esercizio Variante del precedente esercizio: il programma deve stampare l’elenco
di tutte le LCS di due sequenze date.
73
7
Grafi
Definizione di grafo:
un grafo G è una coppia (V, E) dove V è un insieme non vuoto di oggetti e E ⊆ V 2 .
Se per ogni (x, y) ∈ E vale (y, x) ∈ E si parla di grafo non orientato, altrimenti il grafo si considera
orientato.
Dato (x, y) ∈ E diremo che (x, y) incide sui vertici x e y. Per grafi non orientati si dice che x e y
sono adiacenti.
Un cammino in un grafo G da un vertice x ad un vertice y è una sequenza di vertici (v0 , v1 , . . . , vu )
dove v0 = x, vu = y e (vi−1 , vi ) ∈ E. In tal caso il cammino passa per i vertici v0 , . . . , vu e gli archi
(vi−1 , vi ). Il cammino è semplice se i vertici sono distinti.
Un cammino che inizia e finisce nello stesso nodo è un ciclo. Il ciclo è semplice se i nodi intermedi
del cammino sono tutti distinti tra loro.
Un grafo non orientato è connesso se esiste un cammino tra ogni coppia di nodi del grafo. Un grafo
orientato è fortemente connesso se esiste un cammino orientato tra ogni coppia di vertici del grafo.
7.1
Come rappresentare i grafi
Lista di archi: gli archi del grafo vengono elencati in un vettore o in un lista. Ogni componente
del vettore o della lista rappresenta un arco, contiene quindi i (puntatori ai) nodi estremi dell’arco.
Talune manipolazioni o algoritmi richiedono la scansione dell’intera lista, compromettendo talvolta
l’efficienza. In Figura 17 un esempio di codice che permette di memorizzare gli archi di un grafo
come lista.
Lista di adiacenza: una delle strutture dati più utilizzata per i grafi. Per ogni nodo vi è una lista che
elenca i nodi a lui adiacenti. Si osservi che in tale rappresentazione gli archi sono implicitamente
codificati. Efficiente per visite di grafi. In figura 18 un esempio di codice in Linguaggio C che
manipola un grafo mediante liste di adiacenza.
Liste di incidenza: si elencano gli archi in una struttura, come nel primo caso, ed inoltre per
ogni nodo v si mantiene una lista che elenca gli archi incidenti su v. In Figura 19 un esempio di
programma C che manipola un grafo mediante liste di incidenza.
matrice di adiacenza: matrice M di dimensione |V | × |V |, indicizzata dai nodi del grafo, tale
che M [x, y] = 1 se (x, y) ∈ E, M [x, y] = 0 altrimenti. Permette la verifica dell’esistenza di un arco
tra coppie di nodi in tempo costante, ma stabilire chi sono i vicini di un nodo comporta un tempo
proporzionale a |V |. Rappresentazione utile per calcoli algebrici. Infatti dato che codifica cammini
di lunghezza 1 tra i nodi, per trovare nodi a distanza 2 basta moltiplicare la matrice per se stessa,
74
#include<stdio.h>
#include<stdlib.h>
typedef struct{int Info; } Nodo;
typedef struct{int from, to;} Arco;
int main(void){
int nroNodi, nroArchi,i;
Arco *listaArchi;
Nodo *listaNodi;
scanf("%d%d",&nroNodi, &nroArchi);
listaNodi=malloc(nroNodi*sizeof(Nodo));
listaArchi=malloc(nroArchi*sizeof(Arco));
for(i=0;i<nroArchi;i++)
{
printf("Nodo di uscita: ");
scanf("%d", &listaArchi[i].from);
printf("Nodo di entrata: ");
scanf("%d", &listaArchi[i].to);
}
for(i=0;i<nroArchi;i++)
{
printf("(%d,%d)\n",listaArchi[i].from,listaArchi[i].to);
}
return 0;
}
Figura 17: Esempio di Grafo manipolato con lista di archi
75
#include<stdio.h>
#include<stdlib.h>
typedef struct{int Info, *listaNodiAdiacenti, nroAdiacenti; } Nodo;
int main(void){
int nroNodi, i,j;
Nodo *listaNodi;
scanf("%d",&nroNodi);
listaNodi=malloc(nroNodi*sizeof(Nodo));
for(i=0;i<nroNodi;i++)
{
printf("Nro nodi adiacenti al nodo %d:",i);
scanf("%d",&listaNodi[i].nroAdiacenti);
listaNodi[i].listaNodiAdiacenti=malloc(listaNodi[i].nroAdiacenti*sizeof(int));
for(j=0; j<listaNodi[i].nroAdiacenti; j++){
printf("Nodo adiacente al nodo %d: ",i);
scanf("%d", &listaNodi[i].listaNodiAdiacenti[j]);
}
}
for(i=0;i<nroNodi;i++)
{
printf("Lista dei nodi adiacenti al nodo %d:\t",i);
for(j=0; j<listaNodi[i].nroAdiacenti; j++){
printf("%d; ",listaNodi[i].listaNodiAdiacenti[j]);
}
printf("\n");
}
return 0;
}
Figura 18: Esempio di Grafo manipolato con liste di adiacenza
76
#include<stdio.h>
#include<stdlib.h>
typedef struct {int info, *listaDiIncidenza, dimListaDiIncidenza; } Nodo;
typedef struct{int from, to;} Arco;
int main(void){
int nroNodi, nroArchi,i,j,pos;
Arco *listaArchi;
Nodo *listaNodi;
scanf("%d%d",&nroNodi, &nroArchi);
/* Alloca spazio per la lista dei nodi */
listaNodi=malloc(nroNodi*sizeof(Nodo));
/*Inizializza il campo dimListaDiIncidenza a zero */
for(i=0;i<nroNodi;i++) listaNodi[i].dimListaDiIncidenza=0;
listaArchi=malloc(nroArchi*sizeof(Arco));
for(i=0;i<nroArchi;i++)
{
printf("Nodo di uscita: ");
scanf("%d", &listaArchi[i].from);
/* NOTA BENE: consideriamo grafi orientati */
listaNodi[listaArchi[i].from].dimListaDiIncidenza++;
printf("Nodo di entrata: ");
scanf("%d", &listaArchi[i].to);
}
for(i=0;i<nroNodi;i++){
if (listaNodi[i].dimListaDiIncidenza>0) {
/* dichiara il vettore che rappresenta la lista di Incidenza */
listaNodi[i].listaDiIncidenza=malloc(listaNodi[i].dimListaDiIncidenza*sizeof(int));
}
else listaNodi[i].listaDiIncidenza=NULL;
}
/* per ogni nodo costruisce la lista di incidenza */
for(i=0;i<nroNodi;i++){
pos=0;
for(j=0;j<nroArchi;j++){
if (listaArchi[j].from == i ){
/* se il j-esimo arco parte dal nodo i,
allora si memorizza che l’arco j-esimo esce dal nodo i-esimo */
listaNodi[i].listaDiIncidenza[pos]=j;
pos++;
}
}
}
printf("Lista Archi: \n");
for(i=0;i<nroArchi;i++) printf("(%d,%d)\n",listaArchi[i].from,listaArchi[i].to);
printf("Informazioni sui nodi:\n");
for(i=0; i<nroNodi; i++){
printf("Nodo %d ha %d archi uscenti: ", i,listaNodi[i].dimListaDiIncidenza);
for(j=0;j<listaNodi[i].dimListaDiIncidenza;j++) printf("%d ", listaNodi[i].listaDiIncidenza[j]);
printf("\n");
}
return 0;
}
Figura 19: Grafo manipolato con liste di incidenza
77
e cosi’ via per trovare nodi connessi tra loro con cammini di lunghezza 3, 4 etc. In Figura 20 un
esempio di programma C per la gestione di un grafo mediante matrice di adiacenza.
matrice di incidenza: matrice M di dimensione |V |x|E|, dove le righe sono indicizzate dai nodi,
le colonne dagli archi. Se il grafo orientato contiene un arco (i, j) allora lungo una certa colonna
c della matrice che rappresenta l’arco (i, j) varrà che M [i, c] = 1, M [j, c] = −1 e altrove lungo la
colonna i valori saranno 0. M [i, c] = 1, denota che l’arco è uscente da i, M [j, c] = −1 denota che
l’arco è entrante in j. Per grafi non orientati si usa semplicemente 1 per denotare i nodi su cui l’arco
associato alla colonna c incide. In Figura 21 un esempio in linguaggio C.
78
#include<stdio.h>
#include<stdlib.h>
int main(void){
int nroNodi, nroArchi,*matrice, from, to;
scanf("%d%d",&nroNodi,&nroArchi);
matrice=malloc(nroNodi*nroNodi*sizeof(char));
/*Inizializzazione della matrice */
for (from=0;from< nroNodi;from++)
for(to=0;to<nroNodi;to++)
matrice[from*nroNodi+to]=’0’;
do
{
printf("Nodo Uscente:");
scanf("%d",&from);
printf("Nodo Entrante:");
scanf("%d",&to);
matrice[from*nroNodi+to]=’1’;
nroArchi--;
} while (nroArchi>0);
printf("Matrice di Adiacenza:\n");
for (from=0;from< nroNodi;from++){
for(to=0;to<nroNodi;to++)
printf("%c ", matrice[from*nroNodi+to]);
printf("\n");
}
return 0;
}
Figura 20: Grafo manipolato mediante matrice di adiacenza
79
#include<stdio.h>
#include<stdlib.h>
int main(void){
int nroNodi, nroArchi,*matrice, from, to,i;
scanf("%d%d",&nroNodi,&nroArchi);
matrice=malloc(nroNodi*nroArchi*sizeof(int));
/*Inizializzazione della matrice */
for (from=0;from< nroNodi;from++)
for(to=0;to<nroArchi;to++)
matrice[from*nroArchi+to]=0;
for(i=0;i<nroArchi;i++)
{
printf("Nodo Uscente:");
scanf("%d",&from);
printf("Nodo Entrante:");
scanf("%d",&to);
matrice[from*nroArchi+i]=1;
matrice[to*nroArchi+i]=-1;
}
printf("Matrice di Incidenza:\n");
for (from=0;from< nroNodi;from++){
for(to=0;to<nroArchi;to++)
printf("%d ", matrice[from*nroArchi+to]);
printf("\n");
}
return 0;
}
Figura 21: Grafo manipolato mediante matrice di incidenza
80
7.2
Visite di Grafi
Visitare un grafo significa accedere ai nodi di un grafo G = (V, E) dato a partire da un nodo sorgente
s∈V.
Ci sono due startegie per visitare i nodi di un grafo: visita in ampiezza (breadth-first) e visita in
profondità (depth-first).
7.3
Visita in ampiezza
Consideriamo un grafo G = (V, E), orientato o meno, ed un suo nodo s ∈ V .
Visitare in ampiezza G a partire da s significa
accedere a tutti i nodi r1 , . . . , rn raggiungibili direttamente da s. A questo punto per ogni nodo
ri appartenenente alla lista di nodi {r1 , . . . , rn } si accede ai nodi direttamente raggiungibili da
ri
purchè non siano già stati precedentemente raggiunti.
Si viene cosı̀ a determinare una seconda lista di nodi {t1 , . . . , tm }: sono quelli raggiungibili da
s attraversando due archi. Si procede iterativamente in questa maniera costruendo una nuova
lista di nodi a partire dai nodi in {t1 , . . . , tm } sino ad aver raggiunto tutti i nodi del grafo:
questo corrisponderà ad ottenere una lista vuota.
Vediamo un esempio.
Per tenere conto dei nodi già visitati è necessario marcarli, cioè sapere se un certo nodo è già stato
visitato precedentemente. Per rendere più chiara la dinamica dell’algoritmo è utile marcare i nodi
con i colori nero, grigio e bianco.
Inizialmente tutti i vertici sono bianchi, cioè non marcati. Il vertice designato come sorgente è il
primo ad essere scoperto e viene marcato di grigio. Tutti i suoi vicini, cioè i vertici raggiungibili da
s mediante un arco uscente ad s, che siano bianchi, vengono scoperti, marcati di grigio e accodati
ad una lista contenente nodi grigi. A questo punto dato che tutti i vicini di s sono stati scoperti
marchiamo s di nero. L’algoritmo procede come su s prendendo il primo nodo grigio dalla lista dei
nodi grigi. Quanto abbiamo appena descritto è un modo di esplorare il grafo che fa uso di un’unica
lista. I nodi che progressivamente vengono scoperti piuttosto che andare a formare una nuova lista
vengono accodati all’unica lista di nodi grigi. La lista di nodi grigi ha quindi un capo ed una coda
detti tecnicamente testa della lista e coda della lista. Quando una struttura dati quale la lista
dei nodi grigi viene manipolata come illustrato sopra la lista viene detta coda (queue, in inglese),
per enfatizzare il fatto che nella struttura i dati vengono inseriti da un estremo (la coda) e vengono
prelevati dall’altro (il capo).
Quindi i nodi grigi possono essere considerati nodi di frontiera, cioè nodi scoperti ma i cui vicini
potrebbero non ancora essere stati esplorati. Si noti che nell’esecuzione dell’ algoritmo un nodo da
81
bianco diventa grigio e non può più tornare bianco. Analogamente un nodo grigio che diventa nero
non può più tornare grigio o bianco.
Un nodo che diventa nero è un nodo che viene considerato esplorato ed è sinonomo del fatto che che
tutti i nodi appartenenti alla sua lista di adiacenza sono stati scoperti, cioè sono diventati grigi.
Dato che di un nodo da esplorare interessano i suoi vicini, ne segue che l’algoritmo lavora efficientemente quando il grafo è rappresentato mediante lista di adiacenza. A questo punto, dato che (i)
esplorare un nodo significa scandirne la lista di adiacenza, (ii) tale scansione è fatta al più una volta
(iii) la somma delle lunghezze delle liste di adiacenza è il numero di archi (iv) inizialmente dobbiamo
marcare di bianco tutti i nodi, segue che il tempo è proporzionale alla somma tra numero di nodi e
archi di G e quindi lineare nell’ampiezza della rappresentazione a liste di adiacenza di G.
Durante l’esplorazione è possibile tenere conto degli archi del grafo che vengono attraversati. Tali
archi vanno a costituire un albero detto di breadth-first. Si osservi che in base al modo di procedere la
ricerca definisce in modo naturale l’albero dei nodi raggiungibili da s. Per ogni nodo v raggiungibile
da s il percorso nell’albero dei nodi corrisponde al percorso più breve da s a v, cioè il percorso
contenente il minor numero di archi.
Di seguito diamo di visita in profondità in dettaglio.
Algoritmo VisitaBFS(grafo G, vertice s)-> albero
1. Rendi tutti i nodi marcati di bianco;
2. Sia T l’albero formato dal solo nodo s;
3. Sia F l’insieme vuoto;
4. marca di grigio il vertice s
5. aggiungi s in coda a F
6. while (F non e’ vuoto ) do
7.
estrai da F il primo vertice u;
8.
visita il vertice u;
9.
for each ( arco (u,v) di G ) do
10.
if (v e’ bianco) then
11.
marca v di grigio
12
aggiungilo in coda a F;
12.
rendi u padre di v in T
(cioè metti in T l’arco (u,v));
13.
marca di nero u
14. return T
Esercizio Codificare in C l’algoritmo VisitaBFS. In particolare bisogna decidere come tenere conto
della marcatura dei nodi. Occorre anche memorizzare gli archi che formano l’albero di breadth-first.
Esercizio Estendere l’algoritmo dell’esercizio precedente affichè stampi il percorso di ogni nodo del
grafo raggiungibile dalla sorgente;
82
Esercizio Modificare il programma svolto nell’esercizio precedente affinchè di ogni nodo del grafo
stampi il percorso. Si osservi che non è detto che tutti i nodi del grafo siano nell’albero breadth-first.
Infatti mancano quelli che dalla sorgente non sono raggiungibili. In pratica si tratta di determinare
quali siano i nodi che dalla sorgente sono irraggiungibili.
83
7.4
Visita in profondità
Consideriamo un grafo G = (V, E) orientato o meno ed un suo nodo s ∈ V . Visitare in profondità
G a partire da s significa
accedere a tutti i nodi r1 , . . . , rn direttamente raggiungibili da s. A questo punto si marca s
di nero (nodo esplorato). Si considera l’ultimo nodo nella lista hr1 , . . . , rn i. Se da rn sono
raggiungibili nodi non marcati di nero allora si aggiungono tutti alla lista, si marca rn di
nero e si prosegue come illustrato considerando l’ultimo nodo. Se da rn non sono raggiungibili
nuovi nodi allora si marca di nero rn , lo si cancella dalla lista. Si cerca a ritroso nella lista il
primo nodo che non sia marcato di nero, cancellando i nodi marcati di nero. Se tutti i nodi
nella lista sono marcati di nero allora ovviamente la lista diventa vuota e la visita termina,
altrimenti trovato un nodo non nero si procede come mostrato per rn .
Si osservi come in questo caso la procedura esplori l’ultimo nodo inserito nella lista, cercando di
scoprire sempre nuovi nodi. Quando non è più possibile la procedura torna indietro (backtrack) sui
propri passi cercando nuovi nodi da scoprire a partire da un nodo precedentemente considerato.
Si osservi come la lista dei nodi venga trattata secondo uno schema diverso rispetto allo schema a
coda del caso precedente. Qui un unico estremo della lista funge a punto di entrata e uscita.
Quando una struttura dati viene manipolata inserendo i dati dallo stesso punto da cui vengono fatti
uscire e viceversa si dice che la struttura dati è una pila (stack, in inglese). Dato che lo stack
possiede uno stesso estremo sia per l’inserimento che la cancellazione dei dati, segue che il dato che
viene cancellato è l’ultimo ad essere entrato, ecco perchè si dice che in uno stack vale il principio del
last-in first-out.
L’algoritmo descritto sopra può più compattamente essere descritto come procedura ricorsiva (Figura 22).
Una procedura iterativa basata su una lista di nodi è data in Figura 23.
Si osservi come tale algoritmo formalizzi direttamente la descrizione data a inizio paragrafo.
In tale algoritmo possiamo notare che:
1. si fa uso dei colori bianco e nero;
2. il ciclo for in linea 11 si occupa di aggiungere nodi non marcati di nero alla lista;
3. in riga 9 si stabilisce se la lista dei nodi grigi sia o meno estendibile con nuovi nodi non neri.
Concludiamo osservando che esiste un algoritmo alternativo a quello di Figura 23. Nella descrizione
data a inizio paragrafo si dice:
Se da rn sono raggiungibili nodi non marcati di nero allora si aggiungono tutti alla
lista, si marca rn di nero...
Questo corrisponde alle righe 11-14 dell’algoritmo. La variante che proponiamo consiste nell’aggiungere alla lista solo un successore alla volta. I successori aggiunti sono quelli NON ANCORA
84
Algoritmo visitaDFS(grafo G, vertice s)
1. Sia T l’albero vuoto;
2. Colora di bianco tutti i vertici;
3. VisitaDFSRicorsiva(grafo G, vertice s, albero T);
13. return T
Algoritmo VisitaDFSRicorsiva(grafo G, vertice v, albero T)
1. Marca di grigio e visita il vertice v;
2. Per ogni arco (v,w) in G esegui:
3.
if (w è marcato bianco) then
4.
aggiungi l’arco (v,w) a T
5.
VisitaDFSRicorsiva(grafo G, vertice w, albero T)
6. colora di nero v
Figura 22: Visita in profondità: algoritmo in versione ricorsiva
Algoritmo VisitaDFS(grafo G, vertice s)-> albero
1. Rendi tutti i nodi marcati di bianco;
2. Sia T l’albero formato dal solo nodo s;
3. Sia F l’insieme vuoto;
4. aggiungi s in coda a F
5. while (F non e’ vuoto ) do
6.
sia u l’ultimo vertice in F;
7.
visita il vertice u;
8.
marca il vertice u di nero
9.
if (esiste arco (u,v) di G tale che v non è marcato di nero)
10.
then
11.
for each ( arco (u,v) di G ) do
12.
if (v non e’ marcato nero)
13.
then
14.
aggiungi v in testa a F;
15.
sia t l’ultimo nodo in F;
16.
aggiungi l’arco (u,t) in T
17.
else
18.
while (ultimo vertice di F è nero)
19.
do estrai ultimo vertice di F
20. return T
Figura 23: Visita in profondità: algoritmo in versione iterativa
85
SCOPERTI. Questo significa che a differenza del precedente algoritmo un nodo compare nella lista
al massimo una volta. Un nodo cambia la sua marcatura in nero per il fatto di essere nella lista
dei nodi. Viene cancellato dalla lista dei nodi quando tutti i suoi vicini sono già stati esplorati, cioè
nessuno dei vicini è bianco.
Anche questo algoritmo può essere agevolmente descritto con una marcatura a due colori.
Algoritmo VisitaDFS(grafo G, vertice s)-> albero
1. Rendi tutti i nodi marcati di bianco;
2. Sia T l’albero vuoto;
3. Sia F l’insieme vuoto;
4. marca come nero il vertice s
5. aggiungi s in coda a F
6. visita s
6. while (F non e’ vuoto ) do
7.
sia u l’ultimo vertice in F;
8.
marca il vertice u di nero
9.
if (esiste arco (u,v) di G tale che v non è marcato di nero)
10.
then
11.
aggiungi v in testa a F;
12.
visita v
13.
aggiungi l’arco (u,v) in T
14.
else
15.
estrai ultimo vertice di F
16. return T
Concludiamo con la seguente osservazione. Per quanto riguarda la visita in profondità la nozione di
nodo sorgente non è rilevante come nel caso della visita in ampiezza. Quello a cui si è interessati è
cominciare l’esplorazione da un nodo bianco, cioè non visitato. Se al termine dell’esplorazione del
grafo ci sono nodi bianchi, cioè inespolari, cioè irraggiungibili dal nodo da cui si era partiti, allora
l’esplorazione riparte da uno dei nodi bianchi. L’esplorazione quindi si considera terminata quando
nel grafo non ci sono più nodi bianchi.
Esercizio. Riscrivere gli algoritmi dati per fare una visita in profondità completa.
L’effetto è quindi quello di una iterazione della visita in profondità da più nodi sorgenti che porta alla
costruzione di un singolo albero per ogni nodo sorgente. Il risultato è che i dati in T rappresentano
nel caso generale una serie di alberi, cioè una foresta.
86
7.5
Cammini minimi in grafi pesati: l’algoritmo di Dijkstra
Ci sono problemi in cui è necesario associare ad ogni arco un peso o costo. In tal caso si parla di grafi
pesati e per cammino di costo minimo non si intende un cammino che minimizza il numero di archi
attraversati ma un cammino che minimizza il totale della somma dei costi degli archi attraversati.
L’algoritmo di Dijkstra permette di trovare il percorso minimo da un nodo sorgente verso tutti i
nodi di un grafo pesato in cui i pesi degli archi siano non negativi. L’algoritmo è una variante
della procedura di visita in ampiezza. L’input dell’algoritmo è un grafo pesato ed un nodo sorgente.
L’output associa ad ogni nodo v del grafo il costo minimo del cammino dalla sorgente a v e un insieme
di archi che determinano un albero che contiene, a partire dalla sorgente, il percorso migliore verso
ogni nodo del grafo raggiungibile dalla sorgente.
Anche in questo algoritmo marchiamo i nodi come bianchi, grigi o neri. I nodi neri sono i nodi
per cui si è già determinata la distanza minima dalla sorgente; i nodi grigi sono quei nodi che sono
raggiungibili da un nodo marcato di nero. Questo implica che un nodo grigio è un nodo che appartiene
ad una delle liste di adiacenza dei nodi neri e che per un nodo grigio è stato determinato un percorso
dalla sorgente ma non è detto che sia ottimo. I nodi bianchi non sono ancora stati scoperti, quindi
non fanno parte di alcuna delle liste di adiacenza dei nodi neri.
Ciò che distingue l’algoritmo di Dijkstra dall’algoritmo di ricerca in ampiezza è il criterio usato per
selezionare il nodo grigio la cui lista di adiacenza deve essere scandita.
L’algoritmo associa ad ogni nodo v del grafo una distanza, misurata rispetto al costo degli archi
che costituiscono il percorso dalla sorgente s al nodo v. Di seguito con d(v) indicheremo la distanza
da s associata a v. Tale distanza inizialmente viene posta a zero per il nodo s e a infinito per tutti
gli altri nodi, quindi:
d(s) = 0, d(v) = ∞ per ogni nodo v ∈ V \ {s}
Questo significa che inizialmente l’algoritmo suppone che tutti i nodi abbiano distanza infinita da s.
La sorgente è il primo nodo a diventare grigio, gli altri sono bianchi.
Si scandisce la lista di adiacenza di s e si aggiornano le distanze dei nodi che in essa vi compaiono.
Tali nodi diventano grigi, s diventa nero.
Si osservi come i nodi non raggiunti restino bianchi e con distanza infinita, mentre i nodi grigi e neri,
i nodi raggiunti, abbiano una distanza finita. I nodi neri sono i nodi la cui lista di adiacenza è stata
scandita e per essi il percorso ottimo da s è stato calcolato. Dei nodi grigi la lista di adiacenza non
è stata scandita; si ha un valore per un pecorso a partire da s ma non si è ancora stabilito se sia
l’ottimo.
La scelta del nodo grigio di cui scandire la lista di adiacenza è fatta secondo il seguente criterio:
si sceglie il nodo grigio con distanza minore da s.
87
La lista di adiacenza del nodo grigio viene scandita ed il nodo diventa nero.
Osservazione: dato che il nodo selezionato ha distanza minima da s la sua distanza da s non necessita
di essere aggiornata.
Se ne deduce che i nodi neri sono i nodi di cui non è più necessario verificare la distanza da s perchè
già sappiamo essere minima.
Per ogni nodo r nella lista di adiacenza del nodo grigio t appena selezionato si eseguono le seguenti
operazioni:
se k è la distanza tra t e s (cioè d(t) = k) e l’arco (t, r) pesa z allora affermiamo che esiste
un percorso da s a r che passa per t di peso d(t) + z. A questo punto la distanza d(r) viene
aggiornata col valore d(t) + z se tale valore è inferiore a quello attualmemte presente
in d(r).
Dopo aver scandito la lista di adiacenza di t si annerisce t e si ripete il procedimento fino a quando
ci sono nodi grigi da selezionare.
Si noti quindi come durante l’esecuzione, per ogni nodo v del grafo, il valore in d(v) contenga sempre
la minor distanza sa s relativamente a quei percorsi finora analizzati, cioè relativamente a quegli
archi per ora attraversati.
Per quanto riguarda i nodi grigi, si noti anche che il percorso che l’algoritmo trova a partire da s
verso di loro è costituito da archi che collegano tra loro nodi neri. Se ne deduce che se un percorso
che conduce ad un nodo grigio non è ottimo allora non lo è a causa dell’ultimo arco, cioè quello
entrante nel nodo grigio.
Riassumendo, per ogni nodo l’algoritmo produce il miglior percorso a partire da s perchè durante la
sua esecuzione vengono soddisfatte le due seguenti condizioni:
1. per ogni nodo nero la distanza stabilita è ottima;
2. per ogni nodo non-nero la distanza stabilita è ottima relativamente agli archi attraversati.
Di seguito formalizziamo l’algoritmo appena descritto:
Algoritmo Dijkstra(Grafo G, vertice s)->albero
1. per ogni vertice v in G poni d(v) a infinito e padre[v]=nil;
2. marca tutti i nodi di bianco;
3. poni d[s]=0 e padre[s]=s;
4. marca s di grigio;
5. finchè ci sono nodi grigi esegui:
6.
sia v il nodo grigio con d[v] più piccolo;
7.
marca v di nero;
88
8.
per ogni vertice x nella lista di adiacenza di v esegui:
9.
marca x di grigio
10.
se d[x]> d[v]+costo(v,x)
11.
allora
12.
d[x]=d[v]+costo(v,x)
13.
padre[x]=v;
14. return padre
Per quanto riguarda l’analisi di complessità in tempo, ad ogni iterazione viene eseguita una ricerca di
minimo. Tale ricerca viene effettuata tante volte quanti sono i nodi del grafo. Viene poi scandita la
lista di adiacenza del nodo. La lunghezza totale delle liste di adiacenza è il numero di archi del grafo.
Tutte le altre operazioni di inizializzazione dipendono anch’esse dal numero di nodi e richiedono
complessivamente tempo proporzionale al numero di nodi del grafo. Quindi il tempo della procedura
è quadratico nel numero di nodi del grafo.
Si osservi che se le distanze minime vengono memorizzate in una struttura dati opportuna (più
sofisticata di un vettore lineare come abbiamo supposto noi) il tempo diventa O(|E| + |V | log |V |).
7.6
Raggiungibilità tra ogni coppia di nodi
Questa parte contiene le idee di base per sviluppare algoritmi che calcolino il costo mnimo del
cammino tra ogni coppia di nodi di un grafo.
Il problema di determinare l’esistenza di un qualche percorso tra ogni coppia di nodi di un grafo
è chiaramente un problema più semplice rispetto a quello di calcolare un percorso ottimo tra ogni
coppia di nodi. Dati due nodi x e y di un grafo G = (V, E), determinare se esiste un cammino tra x
e y significa stabilire se esiste una sequenza di nodi x = v0 , v1 , . . . , vn = y tale che (vi , vi+1 ) ∈ E per
i = 0, . . . , n − 1.
Per risolvere questo tipo di problemi si considera la rappresentazione di grafi mediante matrice di
adiacenza. Come vedremo la determinazione dell’esistenza di cammini tra ogni coppia di nodi di un
grafo dato corrisponderà ad una elaborazione algebrica della matrice di adiacenza ed in particolare
al suo elevamento a potenza. Per renderci conto del ruolo giocato dalla moltiplicazione di matrici
analizziamo un problma ancora più semplice:
per ogni coppia di nodi x e y di un grafo dato vogliamo determinare l’esistenza di cammini
di lunghezza al massimo 2, cioè cammini tali che per raggiungere y a partire da x si debbano
attraversare al massimo due archi, transitando quindi, al massimo, per un nodo intermedio.
Quanto detto sopra può essere parafrasato dicendo che
per ogni coppia di nodi x e y di un grafo dato vogliamo determinare l’esistenza di un cammino
da x a y del tipo x, z, y, dove z è nodo intermedio, cioè tale che (x, z) e (z, y) sono archi del
grafo dato.
89
Quindi, affinchè da x sia raggiungibile y con un cammino di lunghezza al più 2 deve accadere che
nel grafo dato esiste l’arco (x, y)
oppure
esiste un nodo z tale che (x, z), (z, y) ∈ E.
Quindi se v1 , . . . , vn sono gli archi del grafo deve valere che
(x, v1 ), (v1 , y) ∈ E, oppure
(x, v2 ), (v2 , y) ∈ E, oppure
...
(x, vn ), (vn , y) ∈ E
Vediamo cosa questo significa in termini di matrice di adiacenza su un esempio conceto. Per comodità
in Figura 24 riportiamo 2 copie della matrice di adiacenza
a
da
1
2
3
4
5
6
1
2
3
4
5
6
0
1
0
1
0
0
0
1
1
0
1
1
0
0
0
0
0
1
0
0
0
0
0
0
0
0
0
1
0
0
0
0
1
0
0
0
a
da
1
2
3
4
5
6
Figura 24: matrice di adiacenza di un grafo orientato
Dal nodo 3 c’è un cammino di lunghezza 2 al nodo 1 sse
• c’è un arco dal nodo 3 al nodo 1 oppure
• c’è un arco dal nodo 3 al nodo 2 e dal nodo 2 al nodo 1, oppure
• c’è un arco dal nodo 3 al nodo 3 e dal nodo 3 al nodo 1, oppure
• c’è un arco dal nodo 3 al nodo 4 e dal nodo 4 al nodo 1, oppure
• c’è un arco dal nodo 3 al nodo 5 e dal nodo 5 al nodo 1, oppure
90
1
2
3
4
5
6
0
1
0
1
0
0
0
1
1
0
1
1
0
0
0
0
0
1
0
0
0
0
0
0
0
0
0
1
0
0
0
0
1
0
0
0
Questo corrisponde a moltiplicare la terza riga con la prima colonna della matrice di adiacenza
secondo la nozione di prodotto riga per colonna. Quindi è sufficiente che uno degli addendi del
prodotto riga per colonna valga 1 affinchè ci sia un cammino di lunghezza 2.
La generalizzazione è immediata:
elevare al quadrato la matrice di adiacenza produce come risultato una matrice tale che la cella
di coordinate (i, j) vale 1 sse esiste un cammino di lunghezza minore o uguale a 2 tra il nodo i
ed il nodo j.
Ne segue che elevando alla k-esima potenza la matrice di adiacenza M di un grafo dato si ottiene la
matrice M k tale che
M k [i][j] = 1 sse essste un cammino da i a j di lunghezza minore o uguale a k.
Se ci sono n nodi, allora essite un cammino da un nodo x ad un nodo y di un grafo dato G purchè
M n [i][j] = 1. Quindi per determinare la raggiungibilità tra qualsiasi coppia di nodi di un grafo G è
sufficiente calcolare la potenza n-esima della sua matrice di adiacenza M .
Ovviamente la matrice M n può a sua volta essere interpretata come la matrice di adiacenza di un
nuovo grafo, usualmente denotato con G∗ , detto la chiusura transitiva di G: il grafo G∗ contiene
l’arco (x, y) sse in G esiste un cammino da x a y.
91
8
Array e Allocazione della Memoria
Il modo più frequente di dichiarare un array è attraverso una allocazione statica della memoria,
esempio:
int x[20][30];
In questo caso le dimensioni di x sono costanti, decise a priori dal programmatore e non dipendono
dai dati di input.
D’altronde per taluni problemi può essere opportuno poter dichiarare gli array cosı̀ che la loro dimensione dipenda dai dati di input. In tal caso si parla di allocazione dinamica della memoria
dato che il numero di celle di cui è composto l’array non è noto a priori al programmatore e per
differenti esecuzioni del programma l’array può assumere dimensioni differenti. Versioni recenti di
compilatori C mettono a disposizione questa caratteristica. E’ quindi corretto il seguente frammento
di programma:
int x;
scanf("%d",&x);
int v[x][x*x];
in cui le dimensioni di v dipendono dalla variabile x. Si osservi come non sia possibile determinare a
priori le dimensioni dell’array, le quali potranno variare ad ogni esecuzione del frammento di codice.
Si osservi anche come diventi quindi possibile mischiare dichiarazioni di variabili e istruzioni. Dato
che gli array possono essere usati come parametri attuali di funzioni ed essendoci in tal caso piccole
differenze tra array monodimensionali e pluridimensionali trattiamo i due casi separatamente.
Gli array monodimensionali (vettori) sono un caso particolare di quelli pluridimensionali. Per quanto
riguarda i vettori allocati dinamicamente non ci sono differenze rispetto al loro utilizzo come prametri
formali nelle funzioni. Consideriamo il frammento
int x;
scanf("%d",&x);
int v[x];
f(v,x);
La funzione f definita come:
void f(int v[],int x){
.....
}
92
è perfettamente adatta a trattare ogni vettore di interi indipendentemente dalla lunghezza del
parametro attuale che gli viene passato. Si osservi che anche
void f(int v[50],int x){
.....
}
va bene, dato che la costante 50 non viene considerata in fase di compilazione e neppure di esecuzione.
Deduciamo che non vi è alcun cambiamento nel trattamento dei vettori allocati dinamicamente.
Per quanto riguarda le matrici bi o pluridimensionali dei cambiamenti ci sono. Di un parametro
formale di tipo matrice occorre dichiarare la dimensione di tutte le componenti ad eccezione della
prima. Quindi se una funzione ha come parametro formale una matrice bidimensionale occorre
dichiarare da quante colonne è composta:
void f(int v[][50],int x){
.....
}
Questa funzione può essere chiamata passando una matrice di qualsivoglia righe ma con 50 colonne.
La necessità di conoscere il numero di colonne del parametro formale è legato a come una matrice
è realmente memorizzata: essa è un vettore di elementi memorizzati per righe. Quindi per sapere
dove, ad esempio, inizia il primo elemento della riga di indice 2 occorre sapere quante sono le colonne
della matrice. Quindi per sapere dove è memorizzato l’elemento alle coordinate (i, j) della matrice
basta applicare la formula
i * numero di colonne della matrice + j
Quindi, tornando all’esempio, l’assegnamento
void f(int v[][50],int x){
....
z= v[3][8]
....
}
prevede di applicare la formula 3 ∗ 50 + 8 per ritrovare l’elemento posto logicamente alle coordinate
(3, 8). Attenzione che se il parametro attuale corrispondente a v non è una matrice con 50 colonne, il
programma verrà comunque compilato ma in fase di esecuzione non si comporterà come ci si aspetta.
Esercizio. Come prova di quanto detto sopra, scrivete una funzione che debba stampare a video
l’elemento di posto (5,9) di una matrice con 50 colonne. Fate 2 chiamate a f: la prima passando
93
come parametro attuale una matrice con 50 colonne, la seconda passando una matrice di 20 colonne.
Vedrete che in questo secondo caso non viene stampato l’elemento che ci si aspetta.
Per quanto detto dovendo, ad esempio, stampare a video la diagonale principale di una matrice quadrata, non sapremmo come scrivere un’unica funzione. Abbiamo il problema di rendere parametrico
il numero di colonne. Abbiamo alcuni modi diversi per raggiungere questo obiettivo.
Il primo modo è quello di scrivere una intestazione come segue:
void f(int x,int v[][x])
in cui il numero di colonne non è più una costante ma una variabile. In tal caso il numero di colonne
del parametro formale v è funzione del primo parametro formale x. E’ un tipo di scrittura che
non tutti i compilatori accettano. L’esempio di Figura 25 mostra la soluzione per il problema della
stampa della diagonale di 2 matrici aventi numero di colonne l’uno diverso dall’altra.
Il secondo modo è quello di usare le variabili globali. Una variabile globale è una variabile che a
differenza di quelle locali dichiarate nel corpo delle funzioni, sono visibili a tutte le funzioni. Esse
vengono poste prima delle funzioni. Nell’esempio di Figura 26 la variabile nrocol è globale.
Questo metodo comunque non rappresenta un buono stile di programmazione. Il modo migliore
è fare riferimento al fatto che una matrice altro non è che un vettore le cui righe sono disposte
una dietro l’altra. Come programmatori trattiamo quindi il parametro formale matrice come un
vettore, associando le coordiante di riga e colonna alla posizione del vettore mediante la formula data
precedentemente. La relativa implementazione è in Figura 27. Questa versione può dare dei warning
dal momento che il compilatore rileva che il parametro formale è unidimensionale mentre quelli attuali
sono bidimensionali, ma il programma viene eseguito correttamente. Infine l’intestazione
void f(int v[], int dimcol)
può essere sostituita equivalentemente con
void f(int *v, int dimcol){
Per quanto riguarda l’allocazione dinamica di vettori e matrici, il modo più consueto di farlo è
attraverso la funzione malloc. Il frammento di codice
.....
int *z,*k;
scanf("%d%d", &x,&y);
z=malloc(sizeof(int)*x*x);
k=malloc(sizeof(int)*y*y);
ha lo stesso effetto della dichiarazione di z e k come matrici, cioè come sequenza di un certo numero
di elementi.
94
Scopo della funzione malloc, il cui prototipo è in stdlib.h (header che deve quindi essere incluso
nel programma), è riservare spazio in memoria. Essa riserva un numero di byte pari al valore del
parametro attuale con cui è invocata. Avendo bisogno di x*x interi il numero di byte complessivo
che deve essere allocato è x*x moltiplicato per il numero di byte occupato da un intero. Il comando
sizeof restituisce la spazio occupato dal suo argomento. Di fatto al termine della chiamata z e k
hanno la stessa natura di due vettori dichiarati dinamicamente come
int z[x*x],k[y*y];
La funzione malloc restituisce un indirizzo di memoria. L’indirizzo restituito è quello dove comincia
l’area di memoria riservata, cioè dove comincia il vettore di interi. L’informazione restituita consente
di accedere all’area di memoria riservata e quindi l’indirizzo viene memorizzato in una variabile
puntatore. A questo punto per accedere alle diverse celle del’area di memoria si utilizza la stessa
notazione usata per le variabili di tipo vettore: si usano le parentesi quadrate.
Il seguente è un esempio di allocazione dinamica di un vettore di strutture.
#include<stdio.h>
#include<stdlib.h>
typedef struct{int x;double y; float z;} Tripletta;
void f(Tripletta *v, int dim){
int i;
for(i=0;i<dim;i++)
printf("%d, %f, %f\n",v[i].x, v[i].y, v[i].z);
}
int main(void){
int nroElementi,i;
Tripletta *T;
scanf("%d",&nroElementi);
/*T = malloc(sizeof(Tripletta)*nroElementi);*/
T = calloc(nroElementi, sizeof(Tripletta));
for(i=0;i<nroElementi;i++){
T[i].x=i;
T[i].y=0.5;
T[i].z=1.5;
}
f(T,nroElementi);
95
return 0;
}
96
#include<stdio.h>
void f(int x,int v[][x]){
int i;
for(i=0;i<x;i++)
printf("%d;\n ",v[i][i]);
}
int main(void){
int e,x,y,i,j;
scanf("%d%d",&x,&y);
int z[x][x];
int k[y][y];
e=0;
for(i=0;i<x;i++)
for(j=0;j<x;j++)
{
z[i][j]=e;
printf("z[%d][%d]=%d;\n",i,j,z[i][j]);
e++;
}
f(x,z);
for(i=0;i<y;i++)
for(j=0;j<y;j++)
{
k[i][j]=e;
printf("k[%d][%d]=%d;\n",i,j,k[i][j]);
e++;
}
f(y,k);
return 0;
}
Figura 25: Stampa della diagonale Versione 1
97
#include<stdio.h>
int nrocol;
void f(int v[][nrocol]){
int i;
for(i=0;i<nrocol;i++)
printf("%d;\n ",v[i][i]);
}
int main(void){
int e,x,y,i,j;
scanf("%d%d",&x,&y);
int z[x][x];
int k[y][y];
e=0;
for(i=0;i<x;i++)
for(j=0;j<x;j++)
{
z[i][j]=e;
printf("z[%d][%d]=%d;\n",i,j,z[i][j]);
e++;
}
nrocol=x;
f(z);
for(i=0;i<y;i++)
for(j=0;j<y;j++)
{
k[i][j]=e;
printf("k[%d][%d]=%d;\n",i,j,k[i][j]);
e++;
}
nrocol=y;
f(k);
return 0;
}
Figura 26: Stampa delle diagonali: uso delle variabili globali
98
#include<stdio.h>
void f(int v[], int dimcol){
int i;
for(i=0;i<dimcol;i++)
printf("%d;\n ",v[i*dimcol+i]);
}
int main(void){
int e,x,y,i,j;
scanf("%d%d",&x,&y);
int z[x][x];
int k[y][y];
e=0;
for(i=0;i<x;i++)
for(j=0;j<x;j++)
{
z[i][j]=e;
printf("z[%d][%d]=%d;\n",i,j,z[i][j]);
e++;
}
f(z,x);
for(i=0;i<y;i++)
for(j=0;j<y;j++)
{
k[i][j]=e;
printf("k[%d][%d]=%d;\n",i,j,k[i][j]);
e++;
}
f(k,y);
return 0;
}
Figura 27: Stampa della diagonale: versione con array
99
9
Nota su sprintf
La funzione sprintf è simile a printf. Mentre printf stampa a video, sprintf stampa “dentro
una stringa” cioè ciò che dovrebbe essere emesso a video (printf) viene emesso in una variabile di
tipo vettore di caratteri. Esso un esempio banale:
#include<stdio.h>
int main(void){
char v[20];
int i;
/*inizializzazione di v*/
for(i=0;i<10;i++) v[i]=’\0’;
printf("Salve Mondo\n");
sprintf(v,"Salve Mondo\n");
printf("%s",v);
printf(v);
return 0;
}
L’esempio mette in evidenza un’altra caratteristica di printf e cioè che il parametro attuale a primo
argomento può essere una variabile di tipo stringa. Infatti, vale la pena ricordare che il prototipo di
printf è:
int printf(char *format, ...);
Quello di sprintf è
int sprintf(char *str, char *format, ...);
A questo punto diventa agevole rendere parametrico l’output di printf, come nel seguente esempio:
#include<stdio.h>
int main(void){
char v[20];
int i,dim,x;
/*inizializzazione di v*/
for(i=0;i<10;i++) v[i]=’\0’;
scanf("%d%d",&x,&dim);
100
sprintf(v,"%c%dd",’%’,dim);
printf("stringa di formato:%s\n",v);
printf(v,x);
return 0;
}
10
Le Funzioni C per la Generazione di Numeri Casuali
Per evitare di dover inserire a mano dati di input, può essere comodo generare casualmente dei
numeri.
• la funzione C
long int random(void)
restituisce un numero casuale compreso tra 0 e RAND MAX;
• la funzione C
void srandom(unsigned int seed)
inizializza la sequenza casuale. Il modo più comune di farlo è per mezzo della chiamata alla
funzione
time t time(time t *t)
che restituisce il tempo trascorso dal 1 gennaio 1970, misurato in secondi.
• Tipicamente la chiamata
srandom(time(NULL));
inizializza il generatore di numeri casuali.
• Le funzioni random() e srandom() sono nella libreria stdlib.h, mentre time() è compresa
nella libreria time.h, occore quindi ricordarsi di includerle.
In Figura 28 vi è un programma che genera e stampa a video 1000 numeri casuali.
101
#include <stdlib.h>
#include<time.h>
#include<stdio.h>
int main(void){
int a;
srandom(time(NULL));
for(a=0;a<1000;a++)
printf("%d\n", random());
return 0;
}
Figura 28: Esempio di Generazione di Numeri Casuali
102