Programmazione 1

Transcript

Programmazione 1
19
3. Analisi degli Algoritmi
Dato un problema, sia A un algoritmo che lo risolve. A può essere visto come una
scatola in cui entrano gli input e da cui escono gli output. In certi casi non si ha
interesse a sapere che cosa ci sia dentro la scatola, ma basta che l'uscita sia quella che
ci si aspetta, noti i dati in ingresso. In questo caso, l'algoritmo è una black box, di cui
interessa solo conoscere la coppia <Input, Output>. In altri casi, invece, occorre
sapere che cosa ci sia dentro la scatola, per poter capire come le uscite siano ottenute
dagli ingressi: l'algoritmo è dunque una glass box trasparente, di cui si può vedere
l'interno.
Gli aspetti di analisi degli algoritmi che ci interessano sono due:
• Correttezza dell'algoritmo
• Terminazione in un tempo finito
• Generazione del risultato atteso
• Complessità dell'algoritmo
• Tempo (Numero di passi di esecuzione)
• Spazio (Occupazione di memoria)
Per definire in modo preciso la complessità di un algoritmo, in modo indipendente dal
linguaggio di programmazione e dalla particolare macchina usata, si introduce un
modello astratto di computazione. Noi useremo la Macchina di Turing, definita da
Alan Turing negli anni '30.
3.1. Correttezza dei Programmi
Dato un algoritmo o un programma π, come possiamo dimostrare che π è corretto ?
Un modo empirico è quello di testare il programma su alcuni valori di ingresso e
verificare che il valore di uscita è quello atteso. Siccome però non è possibile
verificare il programma su tutti gli input (che in generale sono infiniti), si può
simulare il programma su input significativi. Il testing si ferma se troviamo dei valori
di input per cui il risultato non è corretto, nel qual caso il programma non è
sicuramente corretto. Ma se non si trovano valori di input per cui il risultato non è
corretto, non possiamo dire che il programma è corretto. In effetti il testing può al
massimo rilevare la presenza di errori ma non la loro assenza.
D’altra parte, prima di poter verificare che un programma è corretto, dobbiamo
stabilire cosa il programma deve fare. Per fare questo, useremo (a volte in modo
informale) il calcolo dei predicati. In particolare, esprimeremo quali limitazioni
devono avere gli input (asserzione iniziale) e quale deve essere la relazione fra le
variabili di input e quelle che vengono restituite come output (asserzione finale). Le
asserzioni sono formule del calcolo dei predicati che esprimono le relazioni fra le
variabili usate nell’algoritmo. Per verificare poi che il programma sia corretto,
decoreremo il diagramma di flusso con asserzioni che ci permettano di dimostrare
che l’asserzione finale può essere derivata da quella iniziale attraverso l'esecuzione
delle istruzioni del programma. Per far questo introdurremo delle regole che ci
permettono di propagare le asserzioni nel diagramma di flusso. Prima e dopo ogni
istruzione si annotano le relazioni fra le variabili, e cioè le asserzioni, che valgono a
quel punto del diagramma. Chiamiamo la condizione che precede una istruzione
premessa e quella che segue conclusione.
20
premessa
conclusione
Le tre regole che seguono stabiliscono se il diagramma è decorato con asserzioni
consistenti.
Regola 1 : Se ci sono più flussi in ingresso in un blocco, deve esserci un'implicazione
logica tra le conclusioni dei blocchi precedenti e la premessa del blocco. In altre
parole, P, C1, C2, e C3 devono essere tali che la formula
α ≡ (C1 → P ) ∨ ( C2 → P ) ∨ ( C3→ P )
sia vera.
❐
C1
C2
C3
P
Regola 2 : Se vale l'asserzione P prima di un blocco di decisione controllato
dall'espressione test, allora all’uscita "SI" deve valere sia P che test, mentre all’uscita
"NO" deve valere sia P che ¬ test.
❐
P
NO
test
SI
P ∧ ¬test
P ∧ test
Regola 3 : Se in un blocco assegnamo l'espressione expr alla variabile v e vale
l'asserzione P(expr) prima del blocco, allora, dopo l'assegnamento, vale l’asserzione
P(v), e cioè l’asserzione P in cui alcune delle occorrenze della espressione expr sono
sostituite da v.
❐
P(expr)
v ← expr
P(v)
Vediamo alcuni semplici esempi delle tre regole.
21
Esempio 18 – Esempio di blocco con due ingressi:
C1 ≡ - 10 ≤ x < 0
C2 ≡ 0 ≤ x < 10
Le asserzioni P1 ≡ - 10 ≤ x < 10 e P2 ≡ x ≥ -10 sono corrette, mentre
l'asserzione P3 ≡ x ≥ 10 non lo è.
❐
Esempio 19 – Consideriamo il blocco di assegnamento che calcola una
somma:
x+y =0
z ← x + y
z = 0
La premessa del blocco è P(x + y) ≡ x + y = 0. Sostituendo a (x + y) la
variabile z otteniamo z = 0 (in questo caso ho sostituito tutte le occorrenze
dell’espressione (x + y) con z.
❐
Esempio 20 – Consideriamo la seguente assegnazione:
True
z ← 3
z =3
Non occorre alcuna premessa per poter derivare l’asserzione z = 3 dopo
l'assegnamento, poichè l’asserzione True = P(3) ≡ ( 3 = 3) è sempre vera.
Possiamo quindi sostituire la prima occorrenza di 3 con z, ottenendo
l’asserzione z = 3.
Quando nel diagramma di flusso si ha un ciclo, l’istruzione iniziale del ciclo ha più di
un ingresso. In particolare ne ha uno che arriva dalle istruzioni precedenti il ciclo e
uno che ritorna dalla fine del ciclo. La Regola 1 ci dice che l’asserzione che deve
valere all’inizio del ciclo deve essere implicata sia dall’asserzione alla fine delle
istruzioni che precedono, sia da quella alla fine del ciclo. Chiamiamo tale asserzione
l’invariante del ciclo, perchè l’asserzione deve rimanere vera a ogni iterazione.
All’uscita del ciclo vale sia l’invariante che la condizione di terminazione.
Esempio 21 – Consideriamo l'Esempio 15, relativo alla somma dei primi n
numeri. Il diagramma di flusso decorato con le asserzioni è quello riportato
sotto.
L'invariante del ciclo è il seguente:
(acc = 1 + 2 + ... + k) ∧ (1 ≤ k ≤ n) ∧ (n > 0)
L’asserzione iniziale su n è n > 0. Questa asserzione, se è vera dopo aver letto
n lo sarà sempre poiché n non viene mai modificato. Quando si entra nel
ciclo la prima volta, si ha k = 1 e acc = 0, per cui l’invariante è soddisfatto.
22
n
n>0
acc ← 0
k ← 1
(acc = 1+ 2 + ... + k)
(1 k n)
(n > 0)
km == nn
k <n
acc←acc+k
SI
acc
acc
NO
acc = 1+2+.. + n
k ← k+1
(acc = 1+2+... + (k -1))
(1 k n)
Dopo il test, se n non è uguale ad k, questo implica che n > k. Inoltre acc = 1
+ 2 + ... + k implica acc = 1 + 2 + ... + (k+1), quando k viene incrementato di
1 e aggiunto ad acc. Di conseguenza, dopo l’assegnamento ad acc
riotteniamo l’invariante. La condizione di terminazione k = n e l’invariante
fanno sì che prima della scrittura di acc valga l'asserzione finale del
programma acc = 1 + 2 + … + n.
La costanza dell'invariante può essere provata per induzione matematica
❐
3.2. Terminazione dei Programmi
E’ importante notare che, in aggiunta all’invariante, per essere sicuri che un algoritmo
o programma è conforme alle sue specifiche, esso deve terminare in un numero
finito di passi. Determinare se un programma si fermerà su certi dati di ingresso non
può essere fatto in modo uniforme. In altri termini, non è possibile costruire un
algoritmo generale che, preso in ingresso un generico programma A e i suoi input,
restituisce una risposta "SI" se A si fermerà e "NO" altrimenti. Di conseguenza,
occorre usare metodi potenzialmente diversi per provare la terminazione di
algoritmi/programmi diversi.
Se un algoritmo non si ferma, è certo scorretto, ma se termina, non è detto che lo sia.
Infatti può contenere errori sintattici, logici o semantici pur terminando in tempo
finito.
23
3.3. Complessità
La complessità di un algoritmo è una misura delle risorse da esso utilizzate. Le risorse
fondamentali sono di due tipi:
• lo spazio di memoria occupato
• il tempo impiegato per l'esecuzione.
Questi due tipi di risorse differiscono per la loro natura, in quanto lo spazio è una
risorsa riutilizzabile, mentre il tempo non lo è. Il fatto che lo spazio sia riutilizzabile
rende difficile calcolarne l’utilizzazione. In queste note ci occuperemo soprattutto (e
in maniera molto introduttiva) della complessità in termini di tempo.
La complessità temporale di un programma è una misura di quanto tempo il
programma impiega a calcolare il suo risultato. Le domande che ci dobbiamo fare a
questo proposito sono:
1. Come calcolare il tempo
2. Quali parametri utilizzare per il calcolo.
Calcolare il tempo in secondi/minuti non è un buon metodo, perchè in questo caso il
tempo dipenderebbe dalla macchina utilizzata e ovviamente dall’input che viene
fornito al programma. Occorre invece un'unità di misura astratta, indipendente dagli
aspetti di implementazione, e intrinseca all'algoritmo stesso. Il numero delle
operazioni eseguite ci fornisce tale unità di misura. Contare il numero delle operazioni
ci dà una valutazione a meno di un fattore costante, poichè l’assegnamento v← 3 è
certemente più semplice di quello v← (a+b)/c, ma, con questa misura, il calcolo
del tempo per entrambi gli assegnamenti avrebbero peso 1. Questo fatto, comunque,
appare ragionevole, dato che macchine diverse hanno un diverso set di istruzioni che
potrebbe rendere semplici calcoli apparentemente complessi. Inoltre, non siamo
interessati alla valutazione precisa della complessità di un algoritmo (che, quasi
sempre, risulta impossibile da calcolare), ma a un suo limite superiore. Quindi, per
ogni operazione elementare, si può considerare la massima complessità della sua
esecuzione.
Come operazioni elementari, valutate 1 passo, consideriamo:
• Le operazioni aritmetiche (+, –, *, /, Div, Mod)
• Le operazioni logiche (NOT, AND, OR)
• I confronti (=, ≠, >, ≥, <, ≤)
• La lettura di un valore
• La scrittura di un valore
• L'assegnamento di un valore a una variabile.
La complessità, inoltre, dipenderà dalla dimensione dei dati di ingresso. Indicando
genericamente con n questa dimensione, ci interessa sapere come aumenta la
complessità di un algoritmo al crescere di n. Per questo, occorre introdurre la nozione
di ordine di grandezza.
Date due funzioni f(n) e g(n) dal dominio degli interi N allo stesso dominio N:
f:N→N
g : N → N,
Consideriamo il loro rapporto al crescere di n. Può accadere che:
24
lim
n→∞
f(n)
= ∞0
g(n)
La funzione f(n) è di ordine di grandezza inferiore,
rispetto a g(n), al crescere di n
f(n)
a ≠ 0, ∞
lim
= ∞
n→∞ g(n)
Le funzioni f(n) e g(n) sono dello stesso ordine di
grandezza
f(n)
lim
= ∞
n→∞ g(n)
La funzione f(n) è di ordine di grandezza superiore,
rispetto a g(n), al crescere di n.
Introduciamo adesso la notazione O(.), e cioè "ordine di". Diremo che:
f(n)
lim
= 0, a
f(n) = O(g(n)) sse
n→∞ g(n)
Dire che f(n) è dell'ordine di g(n), non vuol dire che esse sono dello stesso ordine di
grandezza, ma che la funzione f(n) è di un ordine di grandezza non superiore a quello
di g(n). Formalmente:
f(n) = O(g(n))
sse ∃n0 ∃(c > 0) [∀(n ≥ n0 ) [f(n) ≤ c g(n)]]
(7)
Nella Figura 11 la relazione tra f(n) e g(n) descritta dalla (7) è rappresentata
graficamente.
c g(n)
f(n)
1
2
……….
n0
n
Figura 11 – Significato della relazione f(n) = O(g(n)). Dopo un certo valore
n0 la f(n) deve essere sempre inferiore alla g(n), eventualmente moltiplicata
per una costante positiva (c può essere 1). Prima di n0 non ha importanza se la
f(n) sia o no maggiore di c g(n).
Vediamo alcuni esempi di ordini di grandezza:
• f(n) = 50n2 +20n +3 = O(n2)
• f(n) = n3+ 30n2+100 = O(n3),
n
n
• f(n) = 3 + 100 n3 = O(3 )
Nel primo e secondo caso sarebbe corretto anche scrivere O(n4), anche se questo
fornirebbe una stima per eccesso. Nel calcolo degli ordini di grandezza, dunque, si
possono trascurare sia gli addendi di ordine inferiore che le costanti addittive e
moltiplicative.
Supponiamo di avere una macchina che svolge 1.000.000 di operazioni al secondo.
Ecco come varia la durata di un programma in funzione dell'input e della complessità
dell'algoritmo che esso implementa. Le righe corrispondono al tempo di esecuzione:
25
lineare il primo, quadratico il secondo ed esponenziale il terzo. Le colonne
corrispondono alla dimensione dell’input (da cui dipende la complessità).
f(n)
100n
10n2
2n
n = 10
1 ms
1 ms
1 ms
n = 30
3 ms
9 ms
18 min
n = 60
6 ms
36 ms
366 secoli
Come si vede, per n piccoli non c’è grande differenza. All’aumentare di n cresce la
differenza fra i tempi di esecuzione.
3.4. Macchina di Turing
Una Macchina di Turing è costituita da tre elementi:
• Un nastro di lettura e scrittura, su cui si scrivono i dati di ingresso e si leggono i
risultati. Il nastro è illimitato in lunghezza, con caselle numerate da – ∞ a +∞.
• Una unità di controllo a stati finiti, che contiene il programma.
• Una testina di lettura/scrittura, che legge e scrive sul nastro.
Lo schema della macchina è rappresentato nella Figura 12. Le caselle del nastro
contengono, all'inizio, un simbolo speciale , b , che indica "casella vuota". Definiamo:
• Un alfabeto Σ con cui si scrivono i dati di ingresso.
• Un alfabeto esteso Γ = Σ ∪ { b } che contiene tutti i simboli che si possono
leggere o scrivere sul nastro.
• Un insieme ∆ = {+, –, =}, che indica i possibili movimenti della testina (avanti di
una casella, indietro di una casella, sta ferma).
• Un insieme Q di stati. L'insieme Q è l'unione di tre insiemi: Q = {q0} ∪ {q1, …,
qr} ∪ QF, dove q 0 è lo stato iniziale, Q F è l'insieme degli stati finali e {q 1, …, q r }
è l'insieme degli stati intermedi.
• Una funzione di transizione δ : (Q - QF ) × Γ → (Q - {q0}) × Γ × ∆. La funzione
δ è il programma della Macchina di Turing. Possiamo anche scrivere δ nella
seguente forma: (q',s',d) = δ(q,s).
CSF
R/W
..... b b b b b b b b b b b b .....
–1
0
1
2
3
4
5
Figura 12 – Schema della Macchina di Turing.
La funzione δ specifica che se il controllo si trova nello stato q e nella casella su cui è
posizionata la testina è scritto il simbolo s, allora il controllo passa nello stato s', la
testina scrive nella stessa casella il simbolo s' ed esegue il movimento indicato da d.
26
Siamo adesso in grado di introdurre le nozioni che servono per definire la
complessità. Innanzi tutto, i dati di ingresso vengono scritti sul nastro, a partire dalla
casella 1 in avanti; sia σ la stringa scritta nell'alfabeto Σ che rappresenta i dati:
σ = s1, …, sn
La lunghezza n = |σ| di σ è la dimensione del problema considerato. Inoltre, un passo
elementare è il trattamento di una casella (lettura e scrittura) del nastro. Quindi, il
numero di passi di un algoritmo è precisamente il numero di caselle visitate durante
l'esecuzione. Osserviamo che questo numero può essere molto maggiore di n, perchè
la testina può visitare più volte la stessa casella, andando avanti e indietro.
Dato un insieme ∏ di problemi e un algoritmo A che lo risolve, sia ∏n il sottoinsieme
di ∏ che contiene problemi di dimensione n. Consideriamo un problema particolare π
∈ ∏n e facciamo eseguire il programma che risolve ∏ alla Macchina di Turing; sia
TA(π,n) il numero di passi eseguiti dalla macchina. Chiaramente TA(π,n) è la
complessità dell'algoritmo eseguito sul problema π di dimensione n. Questa
definizione di complessità è però troppo dettagliata, ed è impossibile da valutare per
ogni π. Usiamo quindi una definizione più generica: consideriamo come complessità
dell'algoritmo A sui problemi di dimensione n il massimo, calcolato su tutti i
problemi in ∏n, del numero di passi fatti da A. Quindi, la complessità non sarà
funzione di π, ma solo di n :
TA(n) = M a x TA(π,n)
(8)
π ∈∏n
Esempio 22 – Consideriamo una Macchina di Turing e siano Σ = {0, 1}
l'alfabeto di ingresso e Q = {q0, q1, q2, qY, qN} l'insieme degli stati. La
funzione di transizione δ è data in forma tabulare come segue:
0
1
q0
(q0, 0, +)
(q0, 1, +)
q1
b , –)
(qY, b , –)
(qN, 1, =)
q2
(q2,
b
(q1, b , –)
(qN, b , =)
(qN, b , =)
(qN, 1, =)
La stessa funzione può essere rappresentata graficamente, come in Figura 13.
La funzione di transizione δ rappresenta un programma che decide se un
numero m, scritto in binario, è divisibile per 4. Nella Figura 14 è riportata
l'esecuzione del programma per m = 24.
(0,0,+)
( b , b , =)
q1
q0
( b,b ,–)
(1, 1, = )
(1,1,+)
qN
qY
(0,b,–)
q2
(1, 1, =)
( b , b ,= )
Figura 13 – Rappresentazione grafica della funzione di transizione δ,
descritta dalla tabella precedente.
27
Il programma legge dapprima tutto il numero senza modificarlo, individuando
la sua fine quando incontra il primo blank. A questo punto, si riposiziona
sull'ultima cifra (binaria) del numero e controlla se essa è "0". Se non lo è, il
numero non può essere divisibile per 4, perché è dispari (i numeri pari
terminano per 0), e quindi il controllo passa nello stato finale qN, che
corrisponde alla risposta "NO". Se l'ultima cifra è 0, allora si deve controllare
la penultima: se questa è 0, la risposta è "SI" e il controllo passa nello stato
finale qY, altrimenti la risposta è di nuovo "NO".
b
1
b
0
1
0
0
0
b
b
b
q
0
b
b 1
0
1
0
0
0
b
1
0
1
0
0
0
b b
q
b
b
b
b
b
b
b
0
b
q
1
b
b
1
0
1
0
0
b
q
1
b
b
1
0
1
0
0
b
q
b
b
b
b
b
b
b
2
b
b
1
0
1
0
b
q
Y
Figura 14 – Esecuzione del programma che controlla se un numero m è
divisibile per 4, eseguito per m = 24. Nel nastro si legge il quoziente della
divisione.
Osserviamo che la funzione δ fornisce il risultato NO quando m = 0 oppure
quando sul nastro non è scritto alcun numero (tutti blank).
Il programma considerato ha una dimensione n = lg2m (arrotondato all'intero
superiore, se m non è una potenza di 2. Esso controlla al massimo (n + 1 + 2)
caselle. Quindi, la sua complessità f(n) è data da:
f(n) = O(n) = O(lg2m)
❐
La Macchina di Turing è un modello astratto che, pur nella sua semplicità, riesce a
computare qualunque algoritmo si possa pensare: è una macchina universale. Questa
asserzione costituisce la Tesi di Church e non è mai stata nè provata nè confutata.
La potenza della macchina deriva dalla illimitatezza in lunghezza del suo nastro.
3.5. Universal Register Machine (URM)
Ci sono altre macchine universali, che calcolano tutto quello che può calcolare la
Macchina di Turing. Una di queste, più vicina al concetto di calcolatore usuale, è la
URM (Universal Register Machine). La URM è una macchina che riconosce quattro
istruzioni e opera su un numero illimitato di registri, denominati Rj (j ≥ 1) e ordinati
28
per indice crescente. Le istruzioni, che hanno per argomenti i registri, sono le
seguenti:
Z(Rj)
S(Rj)
T(Ri,Rj)
J(Ri,Rj,h)
Azzera il contenuto del registro Rj
Incrementa di 1 il contenuto del registro Rj
Copia il contenuto del registro Ri nel registro R j, lasciando inalterato il
registro Ri
Se il contenuto del registro Ri è uguale al contenuto del registro Rj,
salta all'istruzione numero h del programma, altrimenti continua
sequenzialmente l'esecuzione.
Un programma sulla URM è una sequenza di istruzioni, numerate da 1. Esso viene
denotato con Nome(a,b, ..,), dove Nome è il nome del programma, e a, b, … sono
eventuali valori di inizializzazione, che vengono assegnati, per definizione, ai registri
R1, R2, … in ordine. Il programma si ferma quando non ci sono più istruzioni da
eseguire o quando salta a una istruzione che non è stata definita. Il risultato, di norma,
si legge nel registro R1.
Esempio 23 – Scrivere un programma Somma(x,y) che calcola la somma
aritmetica di x e y.
Dal nome del programma deduciamo che il numero x va scritto nel registro R1
e il numero y nel registro R2. Il metodo di calcolo è quello del successivo
incremento del contenuto di R1 tante volte quante sono le unità di y.
Somma(x,y)
1 Z(3)
{Azzera R3}
2 J(2,3,6)
{Se R2 = R3 salta all'istruzione 6 e termina}
3 S(1)
{Incrementa R1}
4 S(3)
{Incrementa R3}
5 J(1,1,2)
{Ritorna all'istruzione 2}
Il registro R 3 funge da contatore, che controlla quante unità vanno sommate a
x in R1. Quindi, questo algoritmo esegue un numero di passi O(Max[x,y]).
Osserviamo che l'istruzione J(1,1,2) è un salto incondizionato, perchè il test è
sempre vero.
❐
Esempio 24 – Scrivere un programma Conf(x,y) che determina se x ≥ y.
Dal nome del programma deduciamo che il numero x va scritto nel registro R 1
e il numero y nel registro R2. Il metodo per il confronto è quello di vedere se,
incrementando R 3, si raggiunge prima x o y. Se si raggiunge prima y, allora è
vero che x ≥ y e si pone 1 in R1, altrimenti R1 contiene 0 come risultato.
Conf(x,y)
1 Z(3)
2 J(1,3,6)
3 J(2,3,6)
4 S(3)
5 J(1,1,2)
6 Z(1)
7 J(2,3,9)
8 J(1,1,10)
9 S(1)
{Azzera R3}
{Se R1 = R3 salta all'istruzione 6 }
{Se R2 = R3 salta all'istruzione 6}
{Incrementa R3}
{Ritorna all'istruzione 2}
{Azzera R1}
{Se R2 = R3 salta all'istruzione 9}
{Halt}
{Incrementa R1}
29
Vediamo come viene eseguito il programma Conf(6,3).
Istruzione eseguita
–
1
2,3,4
5,2,3,4
5,2,3,4
5,2,3
6,7
9
R1
6
6
6
6
6
6
0
1
R2
3
3
3
3
3
3
3
3
R3
–
0
1
2
3
3
3
3
Il risultato (1 = True) si legge in R1. Anche questo algoritmo esegue un
numero di passi O(Max(x,y)).
❐
3.6. Invariante di Ciclo
Dato un programma con un ciclo del tipo rappresentato in Figura 15(a), possiamo
astrarlo come in Figura 15(b), in cui è evidenziata la variabile k, che conta il numero
di esecuzioni del ciclo, e le variabili in ingresso e in uscita.
n
y=a
yk-1
k=1
k
y = f(y, k,...)
k=k+1
NO
k=n
(a)
SI
yk
y
(b)
Figura 15 – (a) Ciclo con numero fisso di esecuzioni. (b) Descrizione astratta
del ciclo.
Nella descrizione astratta del ciclo (Vedi Figura 15( b)), viene esplicitata la relazione
tra le variabili di ingresso e di uscita. Il contatore k indica quante volte il ciclo è stato
eseguito. Alla descrizione astratta occorre associare:
• Lo stato iniziale, descritto dall'asserzione di inizio ciclo. Quando k = 1, y0 denota
il valore di inizializzazione della variabile y, assegnato prima che il ciclo venga
eseguito per la prima volta.
30
•
La condizione di terminazione. In questo caso il ciclo finisce quando k = n, perchè
si conosce il numero totale di esecuzioni.
• La relazione input-output, desunta dal contenuto delle operazioni all'interno del
ciclo stesso:
yk = f(yk-1, k, …)
La funzione f(yk-1, k, …) permette di esprimere l'invariante del ciclo e di provare la
correttezza dell'algoritmo.
Esempio 25 – Calcolare la potenza x n di un numero positivo x, essendo n un
intero ≥ 1.
Possiamo fare il calcolo mediante moltiplicazioni ripetute:
n
y=x =xxx…x
Il diagramma di flusso è il seguente:
n, x
(n ≥ 1) ∧ (x > 0)
y=1
(y = xk) ∧ (k < n)
k=1
y=y*x
NO
k=k+1
(k = n) ∧ (y = xn)
SI
k=n
y
Il ciclo può essere sintetizzato come segue:
yk-1
Asserzione iniziale: (n ≥ 1) ∧ (x > 0)
Condizione iniziale: y0 = 1
Condizione di terminazione: k = n
yk = yk-1 * x
Invariante: y = xk
k
yk
Terminazione: L'algoritmo termina perchè n è ≥ 1 e il ciclo viene eseguito
esattamente n volte.
Correttezza: Proviamo la correttezza mediante il principio di induzione
matematica. L'invariante è vero per k = 1; infatti, y1 = x1 e questo è il passo
base dell'induzione. Assumiamo ora che esso sia vero fino a (k-1); per ipotesi
induttiva, sarà quindi yk-1 = xk-1. Ma abbiamo che yk = yk-1 * x, quindi si otterrà
che y k = x k-1* x = xk. L'invariante allora è vero per ogni k ≥ 1. In particolare,
sarà vero per k = n e dunque il risultato fornito sarà y = xn.
31
Complessità: L'algoritmo ha una parte lineare, comprendente 4 operazioni
elementari prima del ciclo e 1 alla fine (scrittura di y). Il ciclo ha anch'esso 5
operazioni elementari ed è eseguito in modo completo (n - 1) volte. Quando k
= n vengono eseguite solo 3 operazioni, perchè il ciclo si interrompe al test.
Quindi, indicamdo con C(n) la complessità dell'algoritmo, avremo:
C(n ) = 5 + 5 (n-1) + 3 = 5n + 3 = O(n)
❐
Esempio 26 – Calcolare la somma dei primi n numeri interi accumulandoli
in modo discendente.
Consideriamo il diagramma di flusso:
n
n>0
acc←0
acc←n
m←0
m←n
acc = n + (n-1) +...+ m ∧ 0 ≤ m ≤ ∧ n > 0
m >0
acc←acc+m
m = 0
m←m+1
m←m-1
acc
acc = 1+2+.. + n
acc = 1+2+... +(m + 1) ∧ (0 < m ≤ n)
Le considerazioni per provare la terminazione, la correttezza e per calcolare la
complessità sono analoghe a quelle dell'Esempio 21.
❐