Variabili parametro
Variabili locali
• Una variabile parametro di scambio
nell’intestazione della funzione:
(char nome[], int n, double &a)
• è visibile all’interno della funzione e non deve
essere ridefinita
• viene inizializzata all’atto dell’invocazione
della funzione
• viene creata (“nasce”) quando la funzione
viene chiamata
• viene eliminata (“muore”) quando la funzione
termina la sua esecuzione.
• Una variabile locale:
…int a; … double area;
• è visibile all’interno del blocco in cui è
definita e non deve essere ridefinita nel blocco
• deve essere esplicitamente inizializzata
• viene creata (“nasce”) quando viene eseguito
l’enunciato che la definisce
• viene
eliminata
(“muore”)
quando
l’esecuzione del programma esce dal blocco in
cui è stata definita.
Prototipo di funzione
Prototipo di funzione
• Poiché il compilatore esamina le righe del
codice in sequenza, è necessario che prima
della chiamata della funzione ci sia una
definizione della funzione stessa.
• Ciò può essere realizzato in due modi:
1) scrivendo il prototipo della funzione, la sua
firma
2) scrivendo il codice della funzione prima
della scrittura del main.
• 1) prototipo
• 2)
#include . . .
double f(double);
int main(){
. . .
if(f(x)>0) ...
return 0;
}
double f(double x){
return x*x-3;
}
#include . . .
double f(double x){
return x*x-3;
}
int main(){
. . .
if(f(x)>0) ...
}
1
Moduli esterni
Moduli esterni
• La progettazione di software richiede anche una
generalità nella scrittura del codice. Vogliamo
produrre programmi per risolvere problemi
riguardanti argomenti specifici (come avviene
nelle “librerie” di programmi).
• In C++ c’è la possibilità di costruire moduli
esterni, che contengono algoritmi scritti anche
su file fisici diversi da quello del programma
che li utilizza: moduli con algoritmi per gestire
per vettori, matrici, polinomi, ordinamenti, liste,
…
• Abbiamo visto che i sottoprogrammi possono
essere scritti, all’interno del file fisico che
contiene il main, con due modalità: scrivendo
il prototipo di funzione, il main e la funzione,
oppure scrivendo la funzione prima del main.
• Estendiamo queste due modalità ai moduli
esterni:
1) il prototipo e un modulo con il codice della
funzione da compilare separatamente
2) una istruzione include per inserire il
modulo con il codice della funzione.
Moduli esterni: caso 1
Moduli esterni: caso 1
• //file primo.c
• //file funzioni1.c
#include . . .
double f1(double);
double f2(double);
int main(){
. . .
if(f1(x)==f2(x))
...
return 0;
}
double f1(double x){
return x*x;
}
double f2(double x){
return -x*x+5;
}
• I
due
moduli
verranno
compilati
separatamente e successivamente si costruirà
un unico eseguibile tramite i due moduli
oggetto (se la compilazione ha avuto esito
positivo).
• Per eseguire compilazioni separate si utilizza
l’opzione -c che costruisce i moduli oggetto
di estensione .o
g++ -c primo.c funzioni1.c
per costruire l’eseguibile
g++ -o primo primo.o funzioni1.o
2
Moduli esterni: caso 2
• //file secondo.c
• //file funzioni2.h
#include
“funzioni2.h”
int main(){
. . .
if(f1(x)==f2(x))
...
return 0;
}
Moduli esterni: caso 2
• L’istruzione
#include “funzioni2.h”
double f1(double x){
return x*x;
}
double f2(double x){
return -x*x+5;
}
inserisce il file funzioni2.h e la compilazione è
unica (analogamente a ciò che avviene con le
altre istruzioni #include)
• In generale:
#include “nomefile”;
• Per costruire l’eseguibile, si utilizza la usuale
istruzione:
g++ -o secondo secondo.o
Esercizio
• Si calcoli la complessità dell’algoritmo
seguente, considerando i casi a>b, a=b, a<b;
specificando il numero di assegnazioni e di
confronti nei tre casi.
• Si supponga n≥0.
• Quale sarebbe il valore di a e b se si
stampassero a e b alla fine della struttura
iterativa “mentre”?
Esercizio
Algoritmo
definizione variabili
a, b, n, i, k
intero
continua logico
acquisire valori per a, b, n
continua ← vero
i←0
mentre i ≠ n e continua eseguire
i ← i+1
per k da 1 a i eseguire
3
Esercizio
se a > b
allora continua ← falso
altrimenti a ← a - 1
//finese
//fineper
//finementre
Stampare i valori di a e b
Soluzione esercizio
• Caso n > 0 .
• Caso a > b .
Viene eseguita la prima iterazione del ciclo esterno,
viene eseguita una iterazione del ciclo interno
(k=1,1); continua diventa falso e il ciclo esterno
termina:
2ta + tp + ta + 2 tp + tc + ta + tp ⇒ costante
continua←
i←
k=1,1
a>b
i←
b non varia, a non varia
continua←
Soluzione esercizio
• Caso n = 0 .
i = 0, quindi il predicato “mentre i ≠ 0 …” è
falso: il ciclo non viene eseguito.
• Caso n < 0 .
se a ≤ b il ciclo non termina
se a > b il ciclo termina:
continua diventa falso.
Soluzione esercizio
• Caso a <= b .
I casi a=b e a<b sono uguali: se a=b allora si esegue
l’istruzione a←a-1 e a diventa minore di b.
• Il numero di confronti e di assegnazioni nel ciclo
interno sono uguali e coincidono con il numero di
iterazioni eseguite:
k=1
1
k=2
2
...
k=n
n
La somma è:
1 + 2 + 3 + . . . + n = (n+1)*n/2
4
Soluzione esercizio
• Pertanto abbiamo:
2ta + (n+1) tp + nta + (n(n+1)/2 +n) tp +
continua←
i←
V
Algoritmi
esponenziali
F
i←
n(n+1)/2 tc +
a>b
+ n(n+1)/2 ta
⇒ c·n2
a←
a diventa a – n*(n+1)/2 , b non varia
Algoritmi esponenziali
Algoritmi esponenziali
• Supponiamo che f(n) sia la funzione che
rappresenta il numero di operazioni eseguite
da un algoritmo e supponiamo che il tempo
necessario per compiere una operazione sia un
microsecondo: 1µs = 10-6sec.
• Vogliamo vedere che per valori di n non
elevati gli algoritmi impiegano un tempo
troppo elevato per poter essere utilizzati: gli
algoritmi esponenziali sono impraticabili.
• Vediamo questa tabella dove riportiamo, al
variare di n, il tempo impiegato da alcune
funzioni di n (cap. 5)
n
log2(n)
100*n
10*n2
n3
10
2.3 µs
1ms
1ms
1ms
20
2.99µs
2ms
4ms
8ms
60
4.09µs
6ms
36ms
0.21sec
2n
1024µs
~ 1ms
1.048sec
260µs
~ 366 secoli
5
Algoritmi esponenziali
260µs ~ 366 secoli, vediamo come mai
260µs ~ 10 60×0.3µs ~ 1018µs~ 1012 sec (1.1529 ×1012)
(1µs = 10-6sec )
• Quanti secondi in un anno?
1 minuto = 60 secondi
1 ora = 3600 secondi
1 giorno = 86400 secondi 1 secolo = 3.15×109 secondi
secondi
1.1529 ×1012
secoli =
=
= 366
secondi in un secolo
3.15×109
Algoritmi esponenziali
• Esempio.
• Consideriamo la formula logica
F(x1, x2, x3) = (x1 e x2 ) o (non x3)
e ci chiediamo se esiste una scelta di valori per
le variabili x1, x2, x3 che renda vera F.
• Un algoritmo deterministico prova tutte le
possibilità e poiché il valore di xi può essere
vero o falso, le possibilità sono 23.
Algoritmi esponenziali
• Esistono
degli
algoritmi
che
sono
intrinsecamente esponenziali:
• calcolare le permutazioni di n elementi:
n! ~ nn
• Ci sono problemi che sono risolti da un
algoritmo deterministico esponenziale, ma il
cui algoritmo non deterministico è polinomiale.
L’algoritmo non deterministico è un algoritmo
ideale: può essere simulato pensando di poter
eseguire
scelte
contemporanee
ed
è
rappresentabile su un albero: la profondità
dell’albero può essere proporzionale alla
dimensione dei dati. Tali problemi si chiamano
NP (Non deterministico Polinomiale).
Algoritmi esponenziali
• In generale considerando F(x1, x2, .., xn) il
problema può essere risolto in un tempo O(2n).
• L’algoritmo non deterministico è in grado di
scegliere il valore di xi che porta alla
soluzione. Per simulare tale comportamento si
può costruire un albero: ogni nodo rappresenta
una variabile della formula; da ogni nodo
partono due rami che rappresentano il vero e il
falso.
6
Algoritmi esponenziali
• Se potessimo esplorare l’albero in modo da poter
percorrere contemporaneamente i due rami V e F, in
n passi arriveremmo alle foglie. Quindi l’algoritmo
non deterministico è O(n).
V
x1
x2
x3
……
F
x1
x2
x3
x2
x3
x3
x3
..............
Complessità asintotica
• Le considerazioni fatte sulla complessità
valgono solo se
n →∞
e F(n) →∞
• Se invece n si mantiene limitato anche un
algoritmo esponenziale può essere utilizzato;
per lo stesso motivo anche la costante
moltiplicativa, che solitamente trascuriamo
nella notazione O-grande, può invece essere
fondamentale nella scelta.
Complessità asintotica
• Esempio.
f1(n) = 1000 * n
f1 è O(n)
f2(n) = 10 * n2
f2 è O(n2)
• Quindi:
• se n →∞
è preferibile f1
• se n ≤10
è preferibile f2 , infatti:
1000 * n ≤ 1000*10 = 104
10 * n2 ≤ 10*100 = 103
Ordinamento per
inserimento
7
Ordinamento per inserimento
• L’idea è quella di inserire una componente in
ordine rispetto ad una sequenza di componenti
già ordinate.
• Esempio. Vogliamo inserire 6 nella sequenza:
2 5 7 9 10
• La cosa più efficiente è partire dall’ultima posizione e
slittare verso destra le componenti che sono maggiori
si 6: in tale modo quelle più piccole restano “ferme”:
2 5
7
7
9 10
ora c’è posto per inserire 6.
Ordinamento per inserimento
• Per effettuare l’ordinamento si parte dalla seconda
componente che si inserisce in ordine rispetto alla
prima, poi si considera la terza, che si inserisce in
ordine rispetto alle prime due, in generale: si vuole
inserire la k-esima componente in ordine rispetto alle
k-1 già ordinate.
• Poiché il numero di componenti non aumenta, per
poter slittare in avanti le componenti che precedono
nella sequenza, è necessaria una variabile di appoggio
x per salvare il valore:
2 5 9 11 3 1 20
2 3
5
9
x
11 1
20
Ordinamento per inserimento
Ordinamento per inserimento
• Bisogna non uscire dall’array se si deve inserire un
valore prima della prima componente. Ci sono varie
strategie per realizzare ciò, una di queste è sfruttare la
“valutazione pigra dei predicati”:
• Complessità.
• Caso favorevole: array già ordinato
1 3 8 10 20
il predicato del ciclo interno è sempre falso, ( x <
v[i-1] è falso), quindi il numero di operazioni è
proporzionale a n : Ω (n).
• Caso peggiore: array in ordine inverso
20 10 9 7 4 1
il ciclo interno viene sempre eseguito per valori
crescenti di i=k:
1 + 2 + 3 + …. + (n-1) = n (n-1)/2 : O(n2/2)
for(k=1; k<=n-1; k++) {//v[0] è già
//in ordine
x=v[k];
i=k;
while((i!=0) && (x < v[i-1])){
v[i]=v[i-1];
i--; }
//fine while
v[i]=x;
}//fine for
8
Ricorsione
Ricorsione
• Una scomposizione di un problema P in
sottoproblemi P1, P2, …, Pn si dice ricorsiva se
almeno uno dei sottoproblemi è formalmente
simile a P e di dimensione inferiore.
• Esempio. Fattoriale di un numero naturale.
Per definizione 0! = 1
(caso base)
n! = n * (n-1)!
• Vediamo che n! viene definito tramite (n-1)!
che è un numero più “piccolo” di n.
Ricorsione
Ricorsione
• Per capire una definizione ricorsiva, bisogna
“espanderla”: si “ricopia” la formula
sostituendo al posto di n il numero n-1 e si
prosegue fino al “caso base”:
n! = n*(n-1)! = n* [(n-1) * (n-2)!] = . . . =
= n*(n-1)* . . . * (n-n)!
• Ci chiediamo se è una buona definizione.
• È una buona definizione perché esiste una
dimensione del problema, una condizione, che
non necessita di ulteriori scomposizioni; il
problema viene risolto direttamente:
0! = 1
• il problema per n=0 è risolto senza utilizzo
della ricorsione.
• Poiché (n-n)! = 0! = 1 per definizione, si
ottiene:
n! = n*(n-1)* . . . * 1
9
Ricorsione
Ricorsione
• Possiamo scrivere algoritmi ricorsivi, algoritmi
che “richiamano” se stessi. Il main non può
essere ricorsivo: il main è richiamato dal
Sistema Operativo.
• Un generico algoritmo ricorsivo avrà una
struttura del tipo:
se condizione
allora
risolvi direttamente
altrimenti
ricorsione
oppure
se condizione
allora
ricorsione
• In questo caso può esserci o meno una
alternativa: se la condizione è falsa non si
esegue nulla.
• Se la ricorsione non termina, si hanno infinite
chiamate per l’algoritmo e si può occupare
tutta la memoria: questo è un errore grave,
come quello di costruire un ciclo infinito.
Ricorsione
Ricorsione
• La scrittura di un algoritmo ricorsivo è
semplice se si sta realizzando una formula
matematica come quella del fattoriale: 0! =1,
n! = n*(n-1)!
intestazione della funzione fattoriale(n intero)
definizione variabili
f intero
se n==0
allora f ←1
altrimenti f ← n * fattoriale(n-1)
//finese
restituire f
• Cosa accade veramente? Facciamo
schema della scomposizione:
uno
n!
n*
(n-1)!
(n-1)*
(n-2)!
.. . . . . . .
1*
0!
=1
10
Ricorsione
• I vari prodotti n* , (n-1)* , … restano
“sospesi” perché il controllo passa al
sottoprogramma chiamato.
• Dopo l’ultima chiamata che restituisce 1, si
può ritornare indietro ed eseguire i prodotti
“sospesi”.
• Come può una funzione rimanere sospesa e
poi, quando si riattiva, eseguire i prodotti
giusti?
Ricorsione
• La funzione ricorsiva avrà una scrittura del
tipo:
int fattoriale(int n){
int f;
if(n == 0)
f = 1;
else
f = n * fattoriale(n-1);
return f;
}//fine fattoriale ricorsivo
• Nella funzione appare:
n nell’intestazione n-1 nella chiamata.
Ricorsione
Ricorsione
• Una parte della memoria, RunTimeStack, mantiene
le descrizioni delle attivazioni dei sottoprogrammi
(funzioni):
• Con la ricorsione la funzione è sempre la
stessa, ma la gestione nel RunTimeStack è
analoga: nelle varie “copie” della funzione
sono memorizzati i parametri e le istruzioni di
quella chiamata:
funzione M
main
…….
chiama M
…….
quando si esegue
l’istruzione di
chiamata di una funzione,
il “controllo” passa alla
funzione e quando la
funzione è terminata, il
controllo ritorna al
chiamante
f
f
f
ritorno
chiama f
….
chiama f
….
main …
chiama f
…..
il PC (contatore di
programma) contiene
l’indirizzo della prossima
istruzione da eseguire
11
Ricorsione
Ricorsione
• Che cosa deve essere memorizzato per poter
eseguire le operazioni?
• Si deve memorizzare:
• quali sono le operazioni da eseguire prima
e dopo la chiamata
• quale è il valore delle variabili a quel
livello di chiamata.
• Vediamo
un
esempio
di
questa
memorizzazione calcolando ricorsivamente 5!
• Quando un sottoprogramma termina, l’area
allocata ritorna libera.
ritorno
1
1
*
0!
2
*
1!
2*1 = 2
3
*
2!
3*2 = 6
4
*
3!
4*6 = 24
5
*
4!
5*24 =120
Complessità di un algoritmo
ricorsivo
Complessità di un algoritmo
ricorsivo
• Il tempo di un algoritmo ricorsivo si ottiene
sommando vari tempi:
• Il tempo per effettuare una chiamata è O(1):
infatti si effettua un passaggio di parametri, in
chiamata, e il ritorno di un valore, quando il
metodo è terminato; questo equivale ad un
numero finito di assegnazioni e pertanto è
costante.
• Il tempo dell’algoritmo di dimensione inferiore
è T(dimensioneinferiore)
• il tempo delle operazioni eseguite nell’algoritmo
(esclusa la ricorsione)
• il tempo della chiamata della funzione
• il tempo dell’algoritmo di dimensione inferiore
• Il tempo delle operazioni della parte non
ricorsiva si calcola contando confronti,
assegnazioni, cicli: T(n).
12
Complessità di un algoritmo
ricorsivo
Complessità di un algoritmo
ricorsivo
• Esempio. Complessità dell’algoritmo ricorsivo
per il calcolo di n!
• Sia T(n) il tempo per calcolare n!: possiamo
contare il numero di moltiplicazioni, dal
momento che questa è l’operazione
fondamentale:
costante
se n=0
T(n) =
costante + T(n-1) se n>0
Se contiamo solo le moltiplicazioni la prima costante
• Se vogliamo con precisione contare tutte le
operazioni avremo:
• per la prima costante
tc + ta + tritorno
• per la seconda costante:
tc + tprodotto + tchiamata + ta + tritorno
• Otteniamo così la formula:
T(n) = c + T(n-1)
è 0 e la seconda costante è 1.
Complessità di un algoritmo
ricorsivo
• Analogamente a quanto fatto con la definizione,
espandiamo la formula:
T(n) = c + T(n-1) = c + (c + T(n-2)) = 2c + T(n-2) =
= 2c + (c + T(n-3)) = 3c + T(n-3) =
= 3c + (c + T(n-4)) = 4c + T(n-4) = ….
= n · c + T(n-n) = n · c + T(0) =
= (n+1) · c ⇒ O(n)
• Se avessimo contato le moltiplicazioni, avremmo avuto:
T(n) = 1 + T(n-1) = …. = n + T(0) = n
Ricorsione e iterazione
• Avremmo anche potuto calcolare il fattoriale
in maniera iterativa; la scomposizione iterativa
del fattoriale è diversa:
n!
f←
← f*1
f←
← f*2
.......
f←
← f*n
13
Ricorsione e iterazione
Ricorsione e iterazione
• Anche la scrittura dell’algoritmo cambia;
possiamo scrivere delle istruzioni del tipo:
• La complessità non cambia: abbiamo infatti
una struttura iterativa che viene eseguita n
volte:
ta + (n+1) · tp + n · ta + tritorno
intestazione della funzione fattiterativo(n intero)
definizione variabili
f, i intero
f ←1
per i da 1 a n eseguire
f←f*i
//fineper
restituire f
quindi sempre c··n operazioni.
Numeri di Fibonacci
Numeri di Fibonacci
• Leonardo da Pisa (detto Fibonacci, 1175-1240)
fu un illustre matematico che si interessò di
vari problemi, alcuni dei quali oggi potremmo
chiamarli “dinamica delle popolazioni”, ossia
lo studio di come si evolvono le popolazioni.
• Indichiamo con Fn il numero di conigli dopo n
anni e proviamo a calcolarli a partire dal primo
anno:
• F1 = 1
coppia iniziale
• F2 = 1
la stessa coppia
• F3 = 1 + 1 = 2
F1 + la coppia nata da F1 (≥2 anni)
• F4 = 2 + 1 = 3
F3 + la coppia nata da F2 (≥2 anni)
• F5 = 3 + 1 + 1 = 5 F4 + le due coppie nate da F3
• Problema astratto. Consideriamo:
• un’isola deserta: sistema isolato
• una coppia di conigli genera un’altra coppia ogni
anno
• i conigli si riproducono solo dopo due anni dalla
loro nascita
• i conigli sono immortali (n → +∞ )
• Quante coppie ci sono dopo n anni?
(≥2 anni)
…..
14
Numeri di Fibonacci
Numeri di Fibonacci
• In generale si avrà
Fn = Fn-1 + Fn-2
dove Fn-1 rappresenta le coppie presenti l’anno
precedente ed Fn-2 rappresenta una nuova
coppia per ogni coppia di almeno 2 anni.
• I numeri si calcolano facilmente sommando i
valori dei due posti precedenti:
• L’algoritmo più immediato da scrivere è quello che
ricopia la definizione e pertanto è un algoritmo
ricorsivo, che avrà una scrittura del tipo:
n
1 2 3
F(n) 1 1 2
4 5 6 7
3 5 8 13
8 9 10
21 34 55
intestazione funzione fibonacci (n intero)
definizione variabili
fib intero
se n ==1 oppure n ==2
allora fib ← 1
altrimenti fib ← fibonacci (n-1) + fibonacci (n-2)
//finese
restituire fib
11 12
89 144
Numeri di Fibonacci
• Possiamo rappresentare le chiamate ricorsive
con una struttura di “albero”, come abbiamo
fatto con il fattoriale.
• Un albero è un insieme di punti, detti nodi, a
cui è associata una struttura d’ordine che
gode delle seguenti proprietà:
• esiste uno ed un solo nodo che precede tutti gli
altri, detto radice
• ogni nodo, esclusa la radice, ha un unico
predecessore immediato.
Numeri di Fibonacci
•
•
•
•
Ogni nodo con successore si chiama padre.
Ogni nodo con predecessore si chiama figlio.
I nodi senza successore si chiamano foglie.
L’arco che collega un nodo padre a un nodo
figlio si chiama ramo.
• Possiamo rappresentare l’albero per n = 5.
15
Numeri di Fibonacci
F2
F3
F4
F1
F5
F2
F2
F3
Numeri di Fibonacci
F1
F5 è la radice, F1 e F2 sono foglie; le foglie non
hanno ulteriori chiamate ricorsive e
restituiscono il valore 1 e nel ritorno si
eseguono le somme.
• Si può dimostrare che il numero delle foglie
dell’albero della ricorsione per la costruzione
di Fn coincide con il valore di Fn.
• Se vogliamo contare le chiamate della
funzione, dobbiamo anche aggiungere il
numero dei nodi interni, che corrisponde al
numero delle chiamate ricorsive. Si può
dimostrare che tale numero è uguale al numero
delle foglie meno uno.
• Possiamo concludere che: la complessità
dell’algoritmo ricorsivo “cresce” come F(n).
Numeri di Fibonacci
Numeri di Fibonacci
• Osservando l’albero della ricorsione si nota
che molti valori Fn sono calcolati più volte: nel
caso di F5, F2 viene calcolato 3 volte.
• Si possono pertanto memorizzare tali valori in
un array e calcolarli una volta sola.
• Si dovrà però dare una dimensione all’array,
stabilendo un numero massimo di elementi da
calcolare.
intestazione funzione fibonacci2 (n intero)
definizione variabili fib[nummax], i intero
fib[1] ← 1
fib[2] ← 1
per i da 3 a n eseguire
fib[i] ← fib[i-1] + fib[i-2]
//fineper
restituire fib[n]
16
Numeri di Fibonacci
Numeri di Fibonacci
• Quale complessità ha l’algoritmo che utilizza l’array?
• Osserviamo nuovamente il calcolo dei valori Fn , ed
osserviamo che ad ogni passo si utilizzano solo i
valori precedenti, che possono essere salvati in due
variabili scalari:
• F1 = 1
• F2 = 1
• F3 = F2 + F1
• F4 = F3 + F2
• F5 = F4 + F3
queste somme sono del tipo:
f ← f + valoreprecedente
tempo O(n):
ciclo che viene eseguito n volte
spazio O(n):
si utilizza un array di nummax
componenti per calcolare Fn con n<=nummax
• Si può scrivere un algoritmo ancora più efficiente.
Numeri di Fibonacci
• Si ottiene così il seguente algoritmo:
intestazione funzione fibonacci3 (n intero)
definizione variabili fib, i, prec, prec1 intero
prec ← 1
//F1
fib ← 1
//F2
per i da 3 a n eseguire
prec1 ← fib
fib ← fib + prec
prec ← prec1
//fineper
restituire fib
//salviamo F2, prima di
// F3 = F2 + F1
// perché servirà nel
//calcolo di F4
Numeri di Fibonacci
• Quale complessità ha l’algoritmo che utilizza
le sole variabili scalari?
tempo O(n):
ciclo che viene eseguito n volte
spazio O(1):
si utilizza un numero costante di
locazioni di memoria
17
Numeri di Fibonacci
Numeri di Fibonacci
• Per calcolare la complessità dell’algoritmo ricorsivo
dobbiamo capire “come” il valore di F(n) cresce,
andando all’infinito.
• Possiamo stimare il valore utilizzando un algoritmo
numerico.
• Si cerca una funzione che soddisfi la relazione di
ricorrenza
Fn = Fn-1 + Fn-2
e si prova con an , a ≠ 0; l’equazione diventa:
an = an-1 + an-2
da cui raccogliendo an-2 si ottiene:
an-2 ( a2 – a – 1) = 0
• Poiché a ≠ 0 cerchiamo le soluzioni
dell’equazione ( a2 – a – 1) = 0 e troviamo le
due radici reali:
φ = (1 + √5 ) / 2
~ 1.618
φ = (1- √ 5 ) / 2
~ - 0.618
φ è la sezione aurea.
• Si può dimostrare che
Fn = (φn - φn ) / √ 5
Numeri di Fibonacci
Numeri di Fibonacci
• Esiste quindi un algoritmo numerico con il
quale calcolare il numero Fn.
• Però tale algoritmo non può essere preciso, dal
momento che Fn è un numero naturale e la
radice di 5 è un numero irrazionale: quindi una
qualunque applicazione di tale algoritmo
fornisce solo una approssimazione.
• La complessità di tempo e di spazio è O(1).
• Utilizziamo la formula
Fn = (φn - φn ) / √ 5
per stimare come F(n) → + ∞
• L’algoritmo ricorsivo ha complessità O(Fn); dal
momento che:
|φ| <1
si ha che | φn | → 0
1< φ < 2
si ha che φn < 2n
e pertanto l’algoritmo cresce in maniera esponenziale
con limitazione superiore 2n: tempo O(2n).
18
Numeri di Fibonacci
La torre di Hanoi
F5
F4
F3
F3
F2
F2
F2
F1
F1
• La complessità di spazio è O(n); infatti le
chiamate ricorsive si espandono in profondità,
non sono contemporanee: F5, F4, F3, F2,
ritorno, F1, ritorno F2, calcola F3, ritorno, …
La torre di Hanoi
• La configurazione finale dovrà essere:
• La leggenda narra che dei sacerdoti di un tempio di
Brahma lavorino per spostare una pila di 64 dischi
d’oro da un piolo ad un altro, utilizzandone uno di
appoggio e seguendo delle regole; alla fine del lavoro
ci sarà la fine del mondo (par. 8.2).
A
B
C
La torre di Hanoi
• La regola è la seguente:
• si può spostare un solo disco alla volta
• non si può mettere un disco grande su uno piccolo.
• La soluzione più intuitiva è quella ricorsiva:
• se spostiamo la pila di n-1 dischi da A a B,
possiamo muovere il primo disco da A a C e poi
spostare la pila di n-1 dischi da B a C.
A
B
C
• Indichiamo con H(n, A, B,C) il problema di
Hanoi di dimensione n.
19
La torre di Hanoi
• La scomposizione ricorsiva sarà perciò:
H(n-1, A, C, B)
H(1, A, B, C) //muove un disco
H(n-1, B, A, C)
• Possiamo scrivere le chiamate ricorsive nel
caso n=3.
• Applichiamo l’espansione della formula
ricorsiva e vediamo come si muovono i dischi.
La torre di Hanoi
• Complessità. Quanti sono gli spostamenti ei
dischi?
• Per spostare un disco da un piolo ad un altro,
ed ottenere la stessa configurazione, si deve
spostare 2 volte la pila di dischi che gli sta
sopra; quindi ogni disco si muove un numero
di volte che è doppio rispetto al disco che gli
sta immediatamente sotto.
• Contiamo gli spostamenti a partire dal primo:
La torre di Hanoi
H(2,A,C,B)
H(3,A,B,C)
H(1,A,B,C)
H(1,A,C,B)
H(1,C,A,B)
H(1,A,B,C)
H(2,B,A,C)
H(1,B,C,A)
H(1,B,A,C)
H(1,A,B,C)
La torre di Hanoi
disco 1 1
spostamento
2 2
3 2*2 = 4 = 22
4 2*4 = 8 = 23
5 2*8 = 16 = 24
………
n
2n-1 sommiamo gli spostamenti
2
1 + 2 + 2 + 23 + 24 + …+ 2n-1 = 2n -1
quindi O(2n) l’algoritmo è esponenziale
20
La torre di Hanoi
• Si può anche scrivere un algoritmo iterativo,
che rimane esponenziale, osservando il
movimento dei dischi:
disco1
disco2
disco3
A–C
A–B–C
A–C–B–A–C
• I dischi pari percorrono ciclicamente in ordine
alfabetico i pioli, i dischi dispari li percorrono
in ordine inverso.
Trasformare array paralleli in
array di record
• Un array è una struttura di dati omogenea: gli
elementi dell’array sono tutti dello stesso tipo
(che è il tipo dell’array).
• A volte è necessario gestire informazioni di
tipo diverso ma riferite allo stesso concetto.
• Supponiamo di voler memorizzare delle
informazioni riguardanti gli impiegati di una
ditta: nome, stipendio, età. Possiamo pensare
di costruire tre array distinti, uno per ogni tipo
di informazione.
Trasformare array
paralleli in array di
record
Trasformare array paralleli in
array di record
char nome[1000][31];
double stipendio[1000];
int eta[1000];
Per avere informazioni
nome stipendio eta
sull’impiegato i-esimo
accediamo alla componente
i-esima di ciascuno dei tre array.
21
Trasformare array paralleli in
array di record
Trasformare array paralleli in
array di record
• I tre array sono strettamente correlati tra loro: devono
avere la stessa lunghezza, un algoritmo che elabora le
informazioni su un impiegato deve avere i tre array
tra i suoi parametri, se si volesse aggiungere
un’ulteriore informazione, si dovrebbe tenere
presente l’organizzazione comune ai tre array (l’iesimo impiegato sta all’i-esimo posto).
• Linguaggi come C++ mettono a disposizione la
possibilità di considerare l’impiegato come un
concetto e di considerarlo un’unica informazione
suddivisa in tre campi.
• Questo tipo di informazione nei linguaggi di
programmazione si chiama record e
rappresenta una collezione di elementi di tipo
diverso. La parola record (registrazione) è una
parola
“antica”
dei
linguaggi
di
programmazione, così come la parola file
(archivio). Sono parole nate in riferimento alla
registrazione di dati su supporti fisici come i
nastri magnetici (o i dischi); l’archivio che
contiene l’insieme delle informazioni registrate
prendeva il nome di file di dati (par. 10.1).
Trasformare array paralleli in
array di record
Trasformare array paralleli in
array di record
• Noi possiamo realizzare un concetto
“impiegato” ed utilizzare una classe, che è un
concetto del C++, oppure una struttura, che è
un concetto del C; i campi nome, stipendio, eta
saranno le informazioni che caratterizzano
l’impiegato:
• La “struttura” impiegato è perciò un’unica
informazione suddivisa in tre campi:
struct impiegato{
char nome[31];
double stipendio;
int eta;
};
nome
char[31]
stipendio
double
eta
int
• I valori che rappresentano le informazioni
degli impiegati potranno essere memorizzati in
un array.
22
Trasformare array paralleli in
array di record
Trasformare array paralleli in
array di record
• Costruiamo un array ditta le cui componenti
sono di tipo impiegato:
• Il record rappresenta quindi una collezione di
elementi (campi) di tipo diverso.
• Per accedere ai singoli campi dell’i-esimo
impiegato si scrive il nome della i-esima
componente (record), seguito da un punto e dal
nome del campo:
ditta[i].nome
ditta[i].stipendio
ditta[i].eta
impiegato ditta[100];
• In tale modo invece di avere tre array paralleli
abbiamo un array di record, detto anche
tabella, e per accedere all’i-esimo impiegato
utilizzeremo la componente i-esima dell’array:
ditta[i]
Trasformare array paralleli in
array di record
• Sintassi.
nomerecord.nomecampo
• I valori del record saranno memorizzati in un
file fisico e memorizzati secondo l’ordine di
lettura: nome, stipendio, età:
N
S
E
1° impiegato
N
S
E
2° impiegato
…
N
S
Gestione dei file
E
ultimo impiegato
23
Gestione dei file
Gestione dei file
• Finora abbiamo visto programmi che
utilizzano solo i flussi di ingresso standard e
questi flussi erano collegati attraverso la
ridirezione ai corrispondenti file fisici.
• Vogliamo definire nel programma un file di
lettura o di scrittura, per poter leggere più di un
file e poter gestire file diversi da cin e cout.
• Il file definito nel programma prende il nome
di file formale; ad esso sarà associato un file
fisico contenente i valori da acquisire.
• I file formali diversi dai file standard devono
essere definiti tramite un tipo: si deve dichiarare
se il file è in lettura o in scrittura.
• Per poter accedere ad un file questo deve essere
esplicitamente aperto e collegato al file fisico.
• Quando il file non è più in uso, esso deve
essere esplicitamente chiuso.
• Il nome del file formale è noto a livello di
programma, il nome del file fisico è noto al file
system (par. 13.7).
Gestione dei file
Gestione dei file
#include <fstream.h>
int main(){
ifstream dati;
//lettura
ofstream risultati; //scrittura
dati.open("nomefiledat");
risultati.open("nomefileris");
dati>> . . . ;
risultati<< . . .;
dati.close();
risultati.close();
return 0;}
• Il file (fisico) per la lettura deve essere
costruito (come i file fisici collegati a cin).
• La fine di un file viene individuata da un
funzione booleana di nome eof (end-of-file):
la “testina di lettura” individua la fine del file e
la funzione eof restituisce true se il file è finito
e restituisce false altrimenti.
↑ eof: false
↑eof: true
24
Gestione dei file
• Il file fisico in scrittura, se non esiste viene
creato: viene costruito un file fisico con il
nome indicato nella stringa ed in maniera
sequenziale
vengono
inseriti
i
dati
corrispondenti alle istruzioni di scrittura.
• Se il file esiste già, esso viene cancellato e poi
costruito nuovamente, vale a dire sovrascritto:
la “testina di scrittura” si riposiziona all’inizio
del file per inserire i nuovi dati.
Gestione dei file
• È necessario chiudere il file:
out.close();
• non viene segnalato alcun errore ma può
succedere che il programma termini prima che
i dati siano stati inseriti e pertanto il contenuto
del file rimane incompleto.
• Se è presente l’istruzione “close”, il file viene
chiuso e solo dopo il programma termina;
pertanto il file viene chiuso solo se tutti i dati
sono stati scritti nel file.
Scelte multiple: switch
Scelte multiple:
switch
• La struttura switch si usa al posto di una
situazione in cui ci siano scelte del tipo “else if“
annidate (par. 13.7.2).
• Esempio.
int x, y;
cin>>x;
if (x ==
y
else if
y
else if
y
1)
= 1;
(x == 2)
= 4;
(x == 4)
= 16;
25
Scelte multiple: switch
Scelte multiple: switch
• Sintassi.
• Viene valutata una espressione (di tipo intero o
carattere:
• Semantica.
• Il valore dell’espressione viene cercata tra i
valori presenti nella case e se viene trovata si
esegue l’istruzione indicata e tutte le
successive.
• Se si vuole che le istruzioni siano in
alternativa, come avviene con l’annidamento
delle if, si deve utilizzare la parola chiave
break.
switch(espressione){
case valore1: istruzione1;
case valore2: istruzione2;
case valore3: istruzione3;
}
dove valore1, valore2, valore3 sono valori
costanti e diversi tra loro.
Scelte multiple: switch
Scelte multiple: switch
switch(espressione){
case costante1: istruzione; break;
case costante2: istruzione; break;
case costante3: istruzione;
}
int k;
cin>>k;
switch(k){
case val1: istruzione; break;
case val2: istruzione; break;
case val3: istruzione; break;
default: cout<<"il valore di k" <<
"non e’ presente \n";
}
• Se il valore dell’espressione non è presente
nelle case, non vi è alcuna segnalazione di
errore.
• Per gestire meglio il caso “valore non
presente”, si può introdurre un “default”.
26
Scelte multiple: switch
• Se non si usa break, l’ordine delle case fa
variare le operazioni da eseguire.
• Esempio.
cin>>x;
switch (x){
case 1: y = 1;
case 2: y = 4;
case 4: y = 16;
}
//se x = 1
y = 16
vengono eseguite tutte le assegnazioni su y.
Scelte multiple: switch
• Il valore 1 è dell’ultima case:
switch (x){
case 4: y = 16;
case 2: y = 4;
case 1: y = 1;
}
//se x = 1
y = 1
• Questa struttura è utile quando si devono
scegliere metodi diversi da eseguire in
alternativa, come un menu che propone delle
scelte: la lettura del menu è sicuramente più
chiara che non l’elenco delle varie “else if”.
Ciclo do
Ciclo do
• Capita a volte di dover eseguire il corpo di un
ciclo almeno una volta, per poi ripeterne
l’esecuzione se è verificata una particolare
condizione.
• Esempio: leggiamo un valore in ingresso e
vogliamo che esso sia positivo: se il valore non
è positivo vogliamo poter ripetere la lettura.
27
Ciclo do
• Sintassi.
Ciclo do
• Equivale a scrivere:
do {
//iterazione
}
while(condizione);
• Semantica.
L’iterazione viene eseguita la prima volta;
viene valutata la condizione: se è vera si
esegue nuovamente l’iterazione, se è falsa il
ciclo termina.
• oppure
boolean
iterazione;
continua =true;
while(condizione){
while(continua){
iterazione;
iterazione;
}
if(!condizione)
continua=false;
}
Ciclo do
• Esercizio.
Calcolare la somma
1 +1./2 + 1./3 + 1./4 + … + 1./n + …
fino a quando 1./n > t con t valore stabilito
(ad esempio t = 10-6).
• Provare a scrivere l’algoritmo usando sia il
ciclo while che il ciclo do.
28