1 I dati del corso - Dipartimento di Matematica e Informatica

Transcript

1 I dati del corso - Dipartimento di Matematica e Informatica
Premessa
La presente raccolta di appunti si propone di fornire un aiuto per la preparazione
dell’esame di Informatica 1, corso di laurea triennale in Matematica, Università
di Udine.
Queste note non hanno pretesa di completezza, per cui è consigliata una integrazione con altri testi. In particolare, le presentazioni di argomenti di natura
matematica, talora necessarie per esporre/risolvere certi problemi, sono spesso
date senza troppa preoccupazione dei dettagli formali o delle dimostrazioni, e
un lettore interessato agli aspetti teorici troverà queste presentazioni non soddisfacenti. Per quanto riguarda la parte più informatica, una lettura di altri testi
sugli argomenti presentati è necessaria. Molte spiegazioni date a lezione non
saranno probabilmente presenti in questi appunti.
Si tenga presente che questi appunti sono “under construction” almeno fino al
termine del corso, quindi troverete errori, omissioni e lacune nell’esposizione.
Sarò grato a tutti coloro che contribuiranno a migliorare il file.
1
I dati del corso
• Nome del Corso: Informatica 1 - corso di Laurea in Matematica, primo
anno, Università di Udine
• docente: Fabio Alessi
• Programma (sintesi estrema): acquisire gli elementi matematici ed informatici che aiutino a rappresentare e risolvere (semplici) problemi. Il
linguaggio di programmazione in cui i programmi vengono sviluppati è il
C.
• Testi consigliati: nessuno in particolare, qualunque testo sul linguaggio di
programmazione C è adatto allo scopo. Menzioniamo il seguente testo:
“C corso completo di Programmazione”
autori: Deitel & Deitel,
Edizioni Apogeo
• Un ambiente di programmazione C (munito di editor) per sistema operativo Windows è scaricabile dal sito
http://cs-alb-pc3.massey.ac.nz/
Gli utenti Apple possono scaricare l’ambiente di sviluppo integrato “Xcode” all’indirizzo
http://developer.apple.com/tools/xcode/
1
2
Qualche preliminare matematico
Si abbia un compito che dobbiamo portare a termine, con l’ausilio di qualche
programma. Molto spesso il compito richiede una opportuna rappresentazione astratta, che traduca la situazione reale in termini matematici, tralasciando
dettagli considerati inessenziali. Ad esempio, se volessimo cercare un algoritmo
che risolva il cubo di Rubik, cercheremmo probabilmente di determinare, prima
di tutto, qualche struttura algebrica che rappresenti il cubo, e su tale struttura proveremmo a definire delle operazioni che modellino le rotazioni del cubo
reale. Dopo questo primo passo, di rappresentazione del problema, dovremmo
cercare un algoritmo che risolva il problema. Questo secondo passo (la ricerca
di un algoritmo) è influenzato dal precedente: la scelta della rappresentazione è
piuttosto rilevante nel vincolare l’algoritmo che su quella rappresentazione deve
lavorare. Infine, l’algoritmo viene tradotto in programma, in qualche linguaggio
di programmazione (per noi, il C).
Da una prospettiva “pratica” (senza pretendere di dare nessun carattere di universalità all’affermazione) si potrà constatare che in molti casi quanta più attenzione avremo prestato alle prime due fasi (rappresentazione e algoritmo), tanto
più sintetico ed efficace sarà il nostro programma finale.
Spesso la comprensione di un problema è in diretta corrispondenza con il bagaglio di conoscenze matematiche che il programmatore ha: se, ad esempio, si
conoscono i coefficienti binomiali, molti problemi possono essere risolti usando
le loro proprietà con poco sforzo ed in modo efficiente. Chiaramente non c’è
un limite superiore alle conoscenze matematiche utili per affrontare i problemi:
quante più se ne ha, tanto meglio è.
In questa sezione fissiamo, viceversa, un limite inferiore di conoscenze: principio
di induzione, cenni di matematica combinatoria, e analisi di un problema basata sulle ricerca di soluzioni ricorsive: pochi elementi, comunque sufficienti per
affrontare parecchi problemi che non necessitino di rappresentazioni particolarmente complesse. Si ricordi: si tratta di un bagaglio minimo, la cui integrazione
con le nuove conoscenze che via via si acquisiscono costituisce un processo che
non ha mai fine (il che è esaltante e deprimente al tempo stesso...).
Alcune caratteristiche dei tre elementi sopra menzionati:
- di fatto il principio di induzione non serve per elaborare soluzioni a problemi, ma è uno strumento molto utile per verificare formalmente che le soluzioni
escogitate sono corrette.
- La matematica combinatoria è invece uno strumento fondamentale per affrontare tutti i problemi di “conta” (che in matematica sono assai diffusi) e trovare
scorciatoie negli algoritmi risolutivi: il problema dei percorsi di Manhattan che
affronteremo fra non molto darà una idea di come possa essere applicata.
- Infine, la ricerca di una soluzione ricorsiva di un problema è una tecnica che
deve far parte del bagaglio di ogni bravo programmatore: è infatti una tecnica che consente uniformemente di progettare algoritmi risolutivi, coprendo una
gamma davvero vasta di problemi.
Chiudiamo il preambolo richiamando la notazione delle funzioni elementari
usate nel seguito (sui numeri interi):
2
• +, −, ∗, le usuali operazioni binarie di somma, differenza, prodotto;
• rm(x, y) = il resto della divisione intera dove x è il divisore e y il dividendo,
(esempio: rm(3, 17) = 2). Se rm(x, y) = 0 scriviamo anche x|y (x divide
y).
• qt(x, y) = il quoziente della divisione intera (stesse ipotesi del caso precedente) (esempio: qt(3, 17) = 5)
2.1
Il principio di induzione
Si consideri una proprietà P(n) sui numeri naturali.
Il principio di induzione (P.I.) afferma questo:
Se si vuole dimostrare che P(n) è vera per ogni n ∈ N è sufficiente:
(a) dimostrare che P(0) è vera;
(b) dato un qualunque n > 0, dimostrare che P(n) è vera, sfruttando eventualmente, come se fossero già dimostrate, P(0), P(1), . . . , P(n − 1).
In altre parole, una volta provato P(0) (il caso base), la dimostrazione di P(n)
viene semplificata dal principio di induzione per la possibilità di usare, in qualunque punto della dimostrazione stessa di P(n), tutti gli eventuali risultati che
P(0), . . . , P(n − 1) ci mettono a disposizione.1
Esercizio
Provare che per ogni n ∈ N, P(n) è vera, dove P(n) è la proprietà
1 + 3 + 5 + . . . + (2n + 1) = (n + 1)2
La somma a sinistra richiede di considerare tutti gli addendi dispari compresi
fra 1 e (2n + 1): ad esempio, per n = 6, dovremmo sommare tutti i dispari da 1
a 13: 1 + 3 + 5 + 7 + 9 + 11 + 13. La proprietà P può venire istanziata su qualsiasi
valore di n: ad esempio P(4) corrisponde all’enunciato 1+3+5+7+9 = (4+1)2 :
uguaglianza verificata, quindi si ha che P(4) è vera. Facendo i calcoli, si può
verificare che P(0), P(1), P(2) etc sono tutte vere. Ciò però non costituisce una
dimostrazione del fatto che P(n) è vera per tutti i numeri n ∈ N. Per provare
ciò possiamo usare il P.I., che richiede di dimostrare i punti (a) e (b) sopra.
Vediamo il dettaglio.
(a) P(0) è vera: infatti, se sostituiamo 0 al posto di n in P(n), otteniamo
l’enunciato:
1 = (0 + 1)2
1 Esistono
altre forme equivalenti di enunciato per il principio di induzione. Il lettore interessato può consultare qualunque testo sui fondamenti dell’informatica per eventuale
approfondimento.
3
banalmente vero.
(b) proviamo il caso generale P(n) per n > 0, ossia
1 + 3 + 5 + . . . + (2n + 1) = (n + 1)2
Come detto, il P.I. ci autorizza a usare una o più P(k), per 0 ≤ k < n.
Passiamo alla dimostrazione di P(n). Si ha
1 + 3 + . . . + (2n + 1) = [1 + 3 + . . . + (2n − 1)] + (2n + 1)
(l’uguaglianza sopra usa semplicemente la proprietà associativa dell’addizione).
Ora, P(n − 1) corrisponde all’enunciato (fare il cambio di variabile n → n − 1):
1 + 3 + . . . + (2(n − 1) + 1) = ((n − 1) + 1)2
ovvero
1 + 3 + . . . + (2n − 1) = n2
Il P.I. ci autorizza a sfruttare P(n − 1), ossia l’enunciato sopra. Allora abbiamo:
1 + 3 + . . . + (2n + 1)
= [1 + 3 + . . . + (2n − 1)] + (2n + 1)
= n2 + (2n + 1)
= (n + 1)2
P(n) è pertanto dimostrata nel caso generale.
Si vedranno in seguito altre applicazioni del principio di induzione, ma prima
di chiudere la sezione enunciamo una sua generalizzazione (abbastanza ovvia).
Sia, per k ∈ N, [k, +∞] = {n ∈ N | n ≥ k}. L’estensione del principio di
induzione asserisce quanto segue:
Per dimostrare che una proprietà P vale su tutto l’insieme [k, +∞], è sufficiente:
- Provare che P (k) è vera;
- Provare che, per ogni n > k, P (n) è vera, eventualmente usando nella dimostrazione di P (n) tutte le P (k) . . . P (n − 1) che si rivelassero utili per la
dimostrazione.
Per k = 0 otteniamo l’enunciato del principio di induzione per i naturali, essendo
[0, +∞] = N.
Esempio: provare con il principio di induzione questa semplice proprietà P (n):
per ogni n ≥ 3, n < n2 − 5.
Usiamo il principio di induzione esteso applicato a [3, +∞].
- P (3) è vera poiché 3 < 32 − 5 = 4.
- Proviamo P (n), per n > 3. L’asserzione da provare è n < n2 − 5. Sappiamo
che possiamo usare P (n − 1), quindi n − 1 < (n − 1)2 − 5. Da questo deduciamo
n < (n − 1)2 − 4
4
(†)
Ora, si osservi che (n − 1)2 − 4 < n2 − 5: infatti
(n − 1)2 − 4 < n2 − 5
2
⇔
2
n − 2n + 1 − 4 < n − 5
⇔
− 2n − 3 < −5
⇔
(♣)
2n > 2
L’ultima disuguaglianza è sempre verificata, per ogni n > 3. Quindi valgono sia
(♣) che (†), per cui abbiamo n < n2 − 5.
P
Cerchiamo
ora di migliorare la notazione degli esempi precedente. I simboli
Q
e
verranno usati per denotare somme indicizzate (sommatorie) e prodotti
indicizzati (produttorie). Una scrittura del tipo
n
X
ai
i=m
denota la somma am +am+1 +. . .+an−1 +an . Ad esempio,
6
X
(i+2) corrisponde
i=3
alla somma:
(3 + 2) + (4 + 2) + (5 + 2) + (6 + 2) = 26
n
Y
Similmente
ai corrisponde al prodotto
i=m
am ∗ am+1 ∗ . . . ∗ an−1 ∗ an
Ad esempio,
5
Y
(s + 1) corrisponde al prodotto:
s=2
(2 + 1) ∗ (3 + 1) ∗ (4 + 1) ∗ (5 + 1) = 360
È possibile avere somme [prodotti] di 0 addendi [fattori]. Ciò accade quando m > n, dove m è l’estremo inferiore ed n quello superiore della somma o
prodotto: ad esempio, la somma
2
X
(i + 1)
i=3
non
alcun addendo. Somme e prodotte vuoti si indicano sovente con
P contiene
Q
,
.
∅
∅
Si pone per convenzione
X
Y
=0
=1
∅
∅
5
Quindi, ad esempio:
2
X
(i + 1) = 0
i=3
Quando n = m si hanno somme [prodotti] di un solo addendo [fattore].
La seguente proprietà può essere utile per il calcolo di somme:
•
n
X
(x ∗ ai + y ∗ bi ) = x ∗
i=m
n
X
ai + y ∗
i=m
n
X
bi
i=m
Qualche somma che ricorre frequentemente:
•
n
X
i=0
•
n
X
i=
n ∗ (n + 1)
2
(2i − 1) = n2
i=1
•
n
X
i=0
i2 =
n(n + 1)(2n + 1)
6
I risultati sopra possono essere provati con il principio di induzione, o in altro
modo (fare le dimostrazioni per esercizio). Proviamo con il principio di induzione
la prima uguaglianza.
n
X
n(n + 1)
i=
]. P (0) vale poiché
Sia P (n) ≡ [
2
i=0
0
X
i=0=
i=0
0∗1
2
Sia n > 0 e proviamo P (n).
n
X
i
=
i=0
(
n−1
X
i) + n
i=0
=
=
=
=
Abbiamo pertanto provato
(n − 1)n
+n
2
n2 − n + 2n
2
n2 + n
2
n(n + 1)
2
n
X
i=0
i=
usando P (n − 1)
n(n + 1)
, ossia P (n).
2
6
Esercizio
Provare la seguente uguaglianza (0 ≤ k ≤ n):
n
X
i=k
i=
(n + k)(n − k + 1)
2
Esercizio
Definiamo la funzione fattoriale (_)! : N → N, come segue:
fatt(n) = Πni=1 i
(esempio: 0! = Π∅ = 1; 4! = 1 ∗ 2 ∗ 3 ∗ 4 = 24).
Definiamo quest’altra funzione fatt : N → N:
1
se n = 0
fatt(x) =
x ∗ f (x − 1) se x > 0
Provare, usando il principio di induzione, che ∀n ∈ N.fatt(n) = n!.
Esercizio
Trovare una dimostrazione per la seguente uguaglianza
rm(
n+k
Y
i, n!) = 0
i=k+1
(espresso altrimenti: per ogni n ∈ N, il prodotto 1 ∗ 2 ∗ . . . ∗ n divide qualsiasi
prodotto (k + 1) ∗ (k + 2) ∗ . . . ∗ (n + k − 1) ∗ (n + k)).
Notazione:
Nel seguito [1, n] = {1, 2, . . . , n − 1, n} (ad esempio [1, 4] = {1, 2, 3, 4}).
2.2
Cenni di matematica combinatoria
Disposizioni semplici/Permutazioni
Le disposizioni semplici (l’aggettivo “semplice” viene sovente omesso), indicate con D(n, k), esprimono il numero delle sequenze senza ripetizioni che
possiamo formare scegliendo k elementi da [1, n] (o da qualunque altro insieme di n elementi). Le restrizioni sui possibili valori di n e k sono: n, k ≥ 0 e
k ≤ n. Si ricorda che le sequenze non sono solo caratterizzate dagli elementi che
vi compaiono, ma anche dalla posizione degli elementi (ad esempio, [1, 2, 3, 5] è
una sequenza differente da [1, 2, 5, 3]). Si ha
D(n, k) = n ∗ (n − 1) ∗ . . . ∗ (n − k + 1) =
Esercizio
Giustificare la formula soprastante.
n!
(n − k)!
Quando k = n, si parla di permutazioni
7
di n elementi, indicate con P (n). Si ha, dalla definizione, la formula P (n) =
D(n, n). In particolare,
P (n) = n ∗ (n − 1) ∗ . . . ∗ (n − n + 1) = n!
Esempio: per n = 5 e k = 3 abbiamo queste possibili disposizioni semplici
[1, 2, 3]
[1, 4, 2]
[2, 1, 3]
[2, 4, 1]
[3, 1, 2]
[3, 4, 1]
[4, 1, 2]
[4, 3, 1]
[5, 1, 2]
[5, 3, 1]
[1, 2, 4]
[1, 4, 3]
[2, 1, 4]
[2, 4, 3]
[3, 1, 4]
[3, 4, 2]
[4, 1, 2]
[4, 3, 2]
[5, 1, 3]
[5, 3, 2]
[1, 2, 5]
[1, 4, 5]
[2, 1, 5]
[2, 4, 5]
[3, 1, 5]
[3, 4, 5]
[4, 1, 5]
[4, 3, 5]
[5, 1, 4]
[5, 3, 4]
[1, 3, 2]
[1, 5, 2]
[2, 3, 1]
[2, 5, 1]
[3, 2, 1]
[3, 5, 1]
[4, 2, 1]
[4, 5, 1]
[5, 2, 1]
[5, 4, 1]
[1, 3, 4]
[1, 5, 3]
[2, 3, 4]
[2, 5, 3]
[3, 2, 4]
[3, 5, 2]
[4, 2, 3]
[4, 5, 2]
[5, 2, 3]
[5, 4, 2]
[1, 3, 5]
[1, 5, 4]
[2, 3, 5]
[2, 5, 4]
[3, 2, 5]
[3, 5, 4]
[4, 2, 5]
[4, 5, 3]
[5, 2, 4]
[5, 4, 3]
Nel caso dell’esempio, abbiamo D(5, 3) = 5 ∗ 4 ∗ 3 = 60.
D(n, k) eguaglia il numero delle funzioni iniettive da [1, k] a [1, n].
Disposizioni con ripetizione
Le disposizioni con ripetizioni, indicate D(r) (n, k), esprimono il numero di
tutte le sequenze che si possono formare scegliendo k elementi da [1, n]. Nelle
sequenze possono verificarsi ripetizioni di elementi. La restrizioni sono: n, k ≥ 0.
Cade la restrizione k ≤ n. Si ha:
D(r) (n, k) = nk
Esempio: per n = 3 e k = 4 abbiamo queste possibili disposizioni con ripetizione:
[1, 1, 1, 1]
[1, 1, 3, 1]
[1, 2, 2, 1]
[1, 3, 1, 1]
[1, 3, 3, 1]
[2, 1, 2, 1]
[2, 2, 1, 1]
[2, 2, 3, 1]
[2, 3, 2, 1]
[3, 1, 1, 1]
[3, 1, 3, 1]
[3, 2, 2, 1]
[3, 3, 1, 1]
[3, 3, 3, 1]
[1, 1, 1, 2]
[1, 1, 3, 2]
[1, 2, 2, 2]
[1, 3, 1, 2]
[1, 3, 3, 2]
[2, 1, 2, 2]
[2, 2, 1, 2]
[2, 2, 3, 2]
[2, 3, 2, 2]
[3, 1, 1, 2]
[3, 1, 3, 2]
[3, 2, 2, 2]
[3, 3, 1, 2]
[3, 3, 3, 2]
[1, 1, 1, 3]
[1, 1, 3, 3]
[1, 2, 2, 3]
[1, 3, 1, 3]
[1, 3, 3, 3]
[2, 1, 2, 3]
[2, 2, 1, 3]
[2, 2, 3, 3]
[2, 3, 2, 3]
[3, 1, 1, 3]
[3, 1, 3, 3]
[3, 2, 2, 3]
[3, 3, 1, 3]
[3, 3, 3, 3]
[1, 1, 2, 1]
[1, 2, 1, 1]
[1, 2, 3, 1]
[1, 3, 2, 1]
[2, 1, 1, 1]
[2, 1, 3, 1]
[2, 2, 2, 1]
[2, 3, 1, 1]
[2, 3, 3, 1]
[3, 1, 2, 1]
[3, 2, 1, 1]
[3, 2, 3, 1]
[3, 3, 2, 1]
[1, 1, 2, 2]
[1, 2, 1, 2]
[1, 2, 3, 2]
[1, 3, 2, 2]
[2, 1, 1, 2]
[2, 1, 3, 2]
[2, 2, 2, 2]
[2, 3, 1, 2]
[2, 3, 3, 2]
[3, 1, 2, 2]
[3, 2, 1, 2]
[3, 2, 3, 2]
[3, 3, 2, 2]
[1, 1, 2, 3]
[1, 2, 1, 3]
[1, 2, 3, 3]
[1, 3, 2, 3]
[2, 1, 1, 3]
[2, 1, 3, 3]
[2, 2, 2, 3]
[2, 3, 1, 3]
[2, 3, 3, 3]
[3, 1, 2, 3]
[3, 2, 1, 3]
[3, 2, 3, 3]
[3, 3, 2, 3]
D(r) (n, k) corrisponde al numero delle funzioni da [1, k] a [1, n].
8
Combinazioni semplici/Coefficienti binomiali
Le
combinazioni semplici o coefficienti binomiali, indicate con C(n, k), oppure nk , esprimono il numero delle sequenze strettamente crescenti (quindi: senza
ripetizioni) che possiamo formare scegliendo k elementi da [1, n]. Le restrizioni
sui possibili valori di n e k sono le stesse delle disposizioni semplici: n, k ≥ 0 e
k ≤ n. Si ha:
D(n, k)
n
n!
=
=
k!(n − k)!
k!
k
Esercizio
Giustificare la formula soprastante.
Esempio: Per n = 5 e k = 3 abbiamo queste possibili sequenze:
[1, 2, 3] [1, 2, 4] [1, 2, 5] [1, 3, 4] [1, 3, 4]
[1, 4, 5] [2, 3, 4] [2, 3, 5] [2, 4, 5] [3, 4, 5]
Siccome, in [1, n], le sequenze strettamente crescenti di k elementi sono in
corrispondenza biunivoca con i sottinsiemi di k elementi (esempio: per k = 3,
la sequenza [2, 3, 5] identifica
l’insieme A rappresentabile come {2, 3, 5}, oppure
{5, 2, 3} etc), si ha che nk corrisponde anche al numero dei possibili sottinsiemi
di [1, n] di cardinalità k.
n
k soddisfa inoltre la seguente definizione ricorsiva:

 1
se k = 0 oppure k = n
n
n−1
n−1
=
+
se 0 < k < n

k
k
k−1
Esercizio
Provare che se una funzione f (n, k), definita sull’insieme {(n, k) | k, n ∈ N, 0 ≤
k ≤ n}, soddisfa la formula ricorsiva soprastante, allora deve necessariamente
essere, per ogni 0 ≤ k ≤ n,
n
f (n, k) =
k
Combinazioni con ripetizione
Le combinazioni semplici, indicate con C (r) (n, k), esprimono il numero delle
sequenze crescenti (con eventuali ripetizioni) che possiamo formare scegliendo
k elementi da [1, n]. Le restrizioni sui possibili valori di n e k sono n, k ≥ 0.
Si ha:
n+k−1
(r)
C (n, k) =
k
(perché?)
9
Esempio: Per n = 5 e k = 3 abbiamo queste possibili sequenze crescenti:
[1, 1, 1]
[1, 2, 2]
[1, 3, 4]
[2, 2, 2]
[2, 3, 4]
[3, 3, 3]
[3, 5, 5]
[1, 1, 2]
[1, 2, 3]
[1, 3, 5]
[2, 2, 3]
[2, 3, 5]
[3, 3, 4]
[4, 4, 4]
Le sequenze sono 35. Ed infatti
2.3
[1, 1, 3]
[1, 2, 4]
[1, 4, 4]
[2, 2, 4]
[2, 4, 4]
[3, 3, 5]
[4, 4, 5]
5+3−1
3
=
[1, 1, 4]
[1, 2, 5]
[1, 4, 5]
[2, 2, 5]
[2, 4, 5]
[3, 4, 4]
[4, 5, 5]
7
3
=
7!
3!4!
[1, 1, 5]
[1, 3, 3]
[1, 5, 5]
[2, 3, 3]
[2, 5, 5]
[3, 4, 5]
[5, 5, 5]
= 35.
Soluzioni ricorsive di problemi
Questa sezione pone l’accento su una situazione abbastanza frequente in matematica: per trovare la soluzione di un problema, i cui parametri sono numeri
naturali, è spesso utile:
- determinare le soluzioni del problema nei casi più semplici;
- scomporre il problema in sottoproblemi e poi trovare la relazione fra le soluzioni
dei sottoproblemi e la soluzione del problema di partenza.
Ciò porterà ad individuare una funzione ricorsiva che esprime la soluzione in
funzione dei parametri del problema, e la possibilità di calcolare la soluzione
sarà garantita dal principio di induzione.
Cosa significhi definizione ricorsiva non viene qua spiegato in termini precisi.
In termini informali, definire ricorsivamente una funzione f : X → Y (dove X
sarà per noi, nella quasi totalità dei casi, un prodotto cartesiano del tipo Nk ),
significa dare una definizione del tipo
f (x) = E
dove E è una espressione più o meno complicata che può contenere dei termini del
tipo f (z), per opportuni argomenti z ∈ X. Ossia, il termine che viene definito
(la funzione f ), può apparire come sottotermine della espressione definente.
È possibile muovere critiche a tale schema definitorio, considerandolo troppo
liberale. Ad esempio, sono possibili queste definizioni ricorsive di funzioni f :
N → N:
f (x) = f (x)
oppure
f (x) = f (x) + 1
oppure ancora
f (x) = f (x + 5) + 3
10
Tutte queste definizione paiono prive di senso.2 Accettiamo la possibilità di
avere definizioni “inutili”, come quelle sopra, e vediamo quali sono i vantaggi
delle definizioni ricorsive.
Un esempio di definizione ricorsiva “buona” è quella della funzione fattoriale,
già incontrata, (_)! : N → N
1
se n = 0
n! =
n ∗ (n − 1)! se n > 0
Per ogni n ∈ N il valore n! è definito. Ad esempio, applicando la definizione e
svolgendo tutti i passaggi:
4! = 4 ∗ 3!
= 4 ∗ 3 ∗ 2!
= 4 ∗ 3 ∗ 2 ∗ 1!
= 4 ∗ 3 ∗ 2 ∗ 1 ∗ 0!
=4∗3∗2∗1∗1
= 24
Ma il vero vantaggio dell’approccio ricorsivo emerge quando si cerca di risolvere
un problema. Vediamo un esempio specifico.
Consideriamo il seguente problema. Abbiamo a disposizione palline nere e rosse, in quantità arbitraria. Le palline rosse pesano il doppio delle palline nere.
Vogliamo formare una fila di palline che pesi n. Ad esempio, per n = 4 possiamo
formare queste file:
N
R
N
N
R
N
N
R
N
R
N N
N
N
R
Non ci sono altre possibili file.
Domanda: Fissato n, quante file differenti si possono formare? Abbiamo appena
visto che per n = 4 la risposta è 5. Come risolvere il problema in generale?
Vediamo prima qualche altro caso semplice: per n = 0, abbiamo una possibile
fila di peso 0: la fila vuota. Per n = 1 abbiamo di nuovo una unica fila, quella
costituita da una sola pallina nera. Per n = 2 abbiamo due file: la fila N N
e la fila R. Quindi, se chiamiamo f la funzione che, dato il peso n della fila,
restituisce come risultato il numero delle possibili file che possiamo scrivere,
avremo senz’altro:
f (0) = 1
f (1) = 1
f (2) = 2
2 Si tenga presente che in opportuni contesti, tali definizioni hanno, invece, un senso chiaro
e univoco, si veda ad esempio [2], Section 9: “Recursion Equations”.
11
Ovviamente non possiamo andare avanti in questo modo (avremmo infinite risposte da calcolare, se vogliamo risolvere il problema per tutti i numeri naturali).
Cerchiamo ora di risolvere in generale il problema, quando il peso n è maggiore
di 2. Facciamo una osservazione banale: se n > 0 una fila pesante n dovrà
contenere qualche pallina, e quindi inizierà con una prima pallina, che potrà
essere nera oppure rossa. Se una fila σ, di peso n, inizia con una pallina nera,
ha questa forma
N [...]
dove [...] rappresenta il resto della fila, che dovrà pesare n − 1. Quindi,
se ci chiediamo: in quanti modi può proseguire la fila σ che inizia con N? La
risposta è: in f (n − 1) modi differenti. Non dobbiamo preoccuparci del fatto
che la funzione f è proprio quella che dobbiamo calcolare: il fenomeno è simile
a quello visto con il principio di induzione: si vuole dimostrare P (n) e si usa
P (n − 1), o P (k), per k < n, come se fossero già state dimostrate. Passiamo ora
al secondo caso: la fila σ potrebbe iniziare con una pallina rossa, e quindi avere
la forma
R [...]
In questo caso il resto della fila dovrà pesare n − 2. Ora ci chiediamo: in quanti
modi può proseguire la fila σ che inizia con R? La risposta è in f (n − 2) modi
differenti.
Osserviamo ora che tutte le possibili file di peso n sono state prese in considerazione, visto che una fila non vuota deve per forza iniziare o con una pallina
nera oppure una pallina rossa. Pertanto siamo pervenuti a questa formula:
f (n) = f (n − 1) + f (n − 2)
Osserviamo ancora che la formula sopra può essere applicata fino al limite minimo di n = 2: f (2) = f (0) + f (1), sostituendo i valori calcolati in precedenza, dà
l’identità 2 = 1+1, come ci si aspetta. La formula f (n) = f (n−1)+f (n−2) non
è applicabile ai casi n = 0, 1. Riassumendo, siamo pervenuti a questa definizione
di f :
1
se n = 0 oppure n = 1
f (n) =
f (n − 1) + f (n − 2) se n > 1
Questa definizione, come potete osservare, ha carattere ricorsivo, in quanto il
termine a destra dell’uguaglianza contiene, in un caso, due richiami alla funzione che stiamo defininendo. Tale definizione è emersa in modo molto naturale
scomponendo il problema di partenza (il calcolo di f (n)) in sottoproblemi.
Si noti altresì che la circolarità della definizione è solo apparente. Ad esempio,
volendo calcolare f (4), arriviamo alla determinazione del risultato come segue:
f (4) = f (3) + f (2)
f (3) = f (2) + f (1) = f (2) + 1
f (2) = f (1) + f (0) = 1 + 1 = 2
f (3) = 2 + 1 = 3
f (4) = 3 + 2 = 5
12
Similmente f (5) = f (4) + f (3) = 5 + 3 = 8 e così via. Anche se la soluzione del
problema non è stata data con una cosiddetta formula “chiusa”, la funzione f
può essere calcolata per ogni valore di n e la sua definizione è quindi del tutto
soddisfacente. Per inciso, f è la funzione sui naturali che definisce la sequenza
di Fibonacci.
2.4
I percorsi di Manhattan
Un classico problema di combinatoria è quello dei cosiddetti “percorsi di Manhattan”. Una griglia rettangolare viene interpretata come la pianta delle vie
dell’isola di Manhattan, e ci chiediamo quanti sono i percorsi di lunghezza minima che collegano A con B, in funzione della base b ed altezza h del rettangolo
(nel caso dell’esempio sotto b = 4 e h = 2.
A
* -- * -- * -- * -- *
|
|
|
|
|
----*
*
*
*
*
|
|
|
|
|
* -- * -- * -- * -- * B
I percorsi di lunghezza minima sono quelli composti da spostamenti verso il basso oppure verso destra: il numero totale di spostamenti unitari (da un asterisco
ad un altro) sono ovviamenti fissati: un percorso minimo comporta b+h spostamenti unitari. Un esempio di percorso minimo nel caso della griglia soprastante
è questo:
A
* -- * -- *
|
* -- *
|
* -- * B
Il calcolo “manuale” dei percorsi di Manhattan, disegnandoli e contandoli,
è impraticabile (comporta diversi minuti già nel caso della griglia soprastante
4 × 2).
La risoluzione del problema si ricava dalle seguenti osservazioni:
- I casi b = 0 oppure h = 0 hanno soluzione immediata: in questo caso c’è
un solo percorso minimo, dato dall’unico segmento che unisce A con B. Ad
esempio, per b = 3 e h = 0 abbiamo questo unico percorso:
A
B
---*
*
*
*
13
Quindi, se chiamiamo manh la funzione che esprime il numero di percorsi in
funzione di b e h, abbiamo
manh(b, 0)
manh(0, h)
= 1
= 1
Consideriamo ora una griglia di dimensioni b, h generiche, maggiori di 0. Per
fissare le idee riconsideriamo la griglia 4 × 2:
D
A
C
* -- * -- * -|
|
|
* -- * -- * -|
|
|
* -- * -- * --
* -- *
|
|
* -- *
|
|
* -- * B
Ogni percorso minimo che collega A con B deve necessariamente passare per C
oppure per D. Tutti i percorsi che passano per C sono tanti quanti i percorsi di
Manhattan da C a B, e questo numero è dato da manh(3, 2). Similmente, tutti
i percorsi che passano per D sono tanti quanti i percorsi di Manhattan da D a
B: questo numero è dato da manh(4, 1). Come detto, non ci sono altri percorsi
da A a B. Quindi abbiamo
manh(4, 2) = manh(3, 2) + manh(4, 1)
Con un argomento simile si prova, in generale la seguente formula:
∀b, h > 0. manh(b, h) = manh(b − 1, h) + manh(b, h − 1)
Perveniamo così alla definizione ricorsiva della funzione manh : N × N → N:
1
se b = 0 oppure h = 0
manh(b, h) =
manh(b − 1, h) + manh(b, h − 1) se b > 0 e h > 0
Questa formula ricorsiva consente di calcolare manh(b, h), senza ricorrere alla
conta diretta. Nel caso della griglia 4 × 2, possiamo procedere al calcolo con un
approccio “top-down” come segue (useremo per accorciare le formule la proprietà
simmetrica ovvia della funzion manh: manh(b, h) = manh(h, b))
manh(4, 2)
=
=
=
=
=
=
=
=
=
=
manh(3, 2) + manh(4, 1)
manh(2, 2) + manh(3, 1) + manh(3, 1) + manh(4, 0)
manh(2, 2) + 2 ∗ manh(3, 1) + manh(4, 0)
manh(2, 1) + manh(1, 2) + 2 ∗ [manh(2, 1) + manh(3, 0)] + 1
4 ∗ manh(2, 1) + 2 ∗ manh(3, 0) + 1
4 ∗ [manh(1, 1) + manh(2, 0)] + 3
4 ∗ manh(1, 1) + 7
4 ∗ [manh(1, 0) + manh(0, 1)] + 7
8+7
15
14
Oppure possiamo utilizzare un approccio “bottom-up”, a partire dal calcolo dei
valori più semplici:
manh(4, 0) = manh(3, 0) = manh(2, 0) = manh(1, 0) = manh(0, 1) = 1
manh(1, 1) = manh(1, 0) + manh(0, 1) = 2
manh(2, 1) = manh(2, 0) + manh(1, 1) = 3
manh(1, 2) = 3 (per la proprietà simmetrica)
manh(2, 2) = manh(1, 2) + manh(2, 1) = 6
manh(3, 1) = manh(2, 1) + manh(3, 0) = 3 + 1 = 4
manh(4, 1) = manh(3, 1) + manh(4, 0) = 4 + 1 = 5
manh(3, 2) = manh(2, 2) + manh(3, 1) = 6 + 4 = 10
manh(4, 2) = manh(3, 2) + manh(4, 1) = 10 + 5 = 15
Osservazione È possibile dare una soluzione più brillante del problema dei
percorsi di Manhattan, che coinvolge i coefficienti binomiali. Si ha infatti:
b+h
manh(b, h) =
b
Provate a dimostrare o giustificare la formula soprastante. Questa soluzione
mostra in modo evidente l’importanza della matematica combinatoria, in quanto questa offre (“rileggendo” il problema) immediatamente la soluzione, senza
bisogno di impostare la soluzione ricorsiva.
3
Primi semplici programmi
Nella presente sezione si troverà il codice C di alcuni esercizî, che sono stati
inseriti nel presente file dopo le lezioni. Alcune veloci informazione e FAQ
relative all’utilizzo delle macchine del LAB 1.
• Per accedere alle macchine è necessario avere dal personale login e password.
• Gli strumenti principali per scrivere, salvare, compilare, ed eseguire i programmi sono l’editor di testo GEdit, ed una finestra del terminale da cui
lanciare il comando chiamato “cc”.
• Per creare ed eseguire un programma, le operazioni da fare, nell’ordine,
sono:
– Scrivere il programma con GEdit, e salvarlo con un nome (tale nome deve tassativamente terminare con “.c”) nella propria directory
principale;
– Dal terminale, dare il comando cc <nome_del_file>;
– Verrà generato un file eseguibile dal nome “a.out”. Scrivendo, sempre
sul terminale ./a.out, il programma entrerà in esecuzione;
15
– qualora per qualunque motivo il programma sia entrato in loop,
potete interrompere l’esecuzione con il comando <ctrl>C.
• Su alcune macchine l’editor di testo GEdit visualizza male i caratteri (un malfunzionamento tipico è l’invisibilità dei doppi apici): per ovviare all’inconveniente nelle “Preferenze” di GEdit eliminare il “Syntax
highlighting”.
• Ricordatevi di chiamare il compilatore dalla directory dove si trova il programma che volete compilare. Se non avete effettuato nessun cambio
di directory, di norma tutto dovrebbe funzionare (il terminale, alla sua
apertura, dovrebbe aprirsi sulla vostra directory principale: potete fare
eventualmente il test con il comando “pwd”).
• Ogni file, per essere compilato, deve avere l’estensione “.c” (ad esempio:
“primoProgramma.c”, ma non “primoProgramma”)
Prima di vedere gli esercizî, una beve presentazione su un tipo di dato fondamentale in C: gli interi. Rimandiamo ai vari libri di testo la discussione sugli
altri tipi di dato (char, float, double etc).
Quando dichiariamo una variabile x di tipo intero (ad esempio con l’istruzione
int x;) a x viene associata una locazione di memoria che comprende 4 bytes,
ossia 32 bit. Questo fatto implica che possiamo rappresentare al massimo 232
numeri interi (facilissimo esercizio di combinatoria, tenendo conto che ciascun
bit può assumere il valore 0 oppure 1, quindi abbiamo D(2, 32) possibili disposizioni con ripetizione di unità di informazione). L’intervallo che si intende
rappresentare è questo: [−231 , 231 − 1]. Tutti i valori compresi nell’intervallo
saranno correttamente rappresentati, e solo questi. Due parole ora sulla rappresentazione utilizzata. Essa è detta rappresentazione in complemento a due. I
numeri positivi fino a 231 − 1 vengono rappresentati secondo la rappresentazione
binaria standard: i bit di sinistra non utilizzati vengono posti uguali a 0, come
ovvio. Il numero positivo più grande che può essere rappresentato, ossia 231 − 1,
ha questa rappresentazione:
01111111 11111111 11111111 11111111 11111111
che corrisponde all’uguaglianza:
231 − 1 =
30
X
2i
i=0
I numeri negativi n = −m (per m > 0), vengono invece rappresentati mediante
la rappresentazione binaria standard del numero 232 − m, ed è questo il caso
che contraddistingue la rappresentazione in complemento a due.
Esiste un semplice algoritmo per determinare la rappresentazione in complemento a due di un numero negativo del tipo n = −m:
- scrivere la rappresentazione binaria (standard) del valore (positivo) m, ossia
l’opposto del numero in questione;
16
- scandire la rappresentazione binaria di m da destra verso sinistra, operando
come segue: gli (eventuali) zeri presenti a destra, non preceduti da alcun 1, restano invariati; il primo 1 che si incontra, resta anch’esso invariato; tutte le cifre
che si trovano alla sinistra del primo 1 incontrato, vengono “flippate” (ossia: gli 0
diventano 1, e gli 1 diventano 0). In questo modo si ottiene la rappresentazione
in complemento a due di −m, ossia di n (perché?).
Va osservato che con questo tipo di rappresentazione il bit più a sinistra ha la
funzione di segno (+ / −): ogni numero negativo avrà infatti una rappresentazione il cui bit più di sinistra è 1, mentre ogni numero maggiore o uguale a 0
avrà un rappresentazione il cui primo bit è 0.
Esercizio
Provate a fare qualche somma di interi usando la rappresentazione in complemento a due descritta sopra: per fare le somme, procedete nel modo standard
(posizione dopo posizione, da destra verso sinistra, con computo degli eventuali
riporti) e trascurate l’ultimo riporto che eventualmente si crea nel bit più a sinistra. Se non si verica overflow (ossia, se il risultato della somma resta all’interno
dei limiti di rappresentazione di int) le somme sono calcolate correttamente.
Provate ad esempio a calcolare 21 + 13, 21 - 13 (ovvero 21 + (-13)) e 13 - 21.
PRIMO PROGRAMMA IN C. Scrivere un programma che stampa il saluto
“ciao”.
#include <stdio.h>
int main() {
printf("ciao\n");
return 0;
}
Pochi commenti: ogni punto sottostante viene spiegato in ogni manuale sul C.
• #include <stdio.h> è una istruzione per il preprocessore usata per
includere il file di intestazione stdio.h, dove sono contenuti i prototipi delle
funzioni di input/output. Le parentesi angolari non fanno parte del nome
del file, ma indicano come effettuare la ricerca del file medesimo.
• Ogni programma deve contenere la funzione chiamata “main”. La sintassi di questa funzione ammette differenti presentazioni: altrove potrete
trovare una scrittura del tipo
int main() {
<corpo di istruzioni>
return 0;
}
• ‘\n‘ è una cosiddetta “escape sequence”: di fatto, nonostante sia stata
ottenuta battendo due caratteri da tastiera, viene considerata come un
carattere unico, che corrisponde all’andata a capo.
17
Esercizio
Scrivere un programma che legge un numero intero, lo memorizza in una variabile, e stampa il numero stesso.
#include <stdio.h>
int main () {
int x;
scanf("%d", &x);
printf("il numero da stampare e’: %d\n", x);
return 0;
}
In questo programma compaiono:
- Una variabile di tipo intero, chiamata x; il suo tipo è specificato nella sua
dichiarazione: int x; il tipo comunemente usato per i numeri interi è int,
che riserva uno spazio di 4 bytes per la memorizzazione degli interi (4 bytes
consentono di memorizzare numeri da 2−31 a 231 − 1): pertanto operazioni che
coinvolgano numeri molto grandi necessiteranno, per una corretta esecuzione,
di tipi per gli interi con più memoria. I nomi delle variabili sono sequenze di
caratteri (spesso singole lettere). Ricordarsi che il nome di una variabile non
può iniziare con un numero.
- La funzione scanf ha come primo argomento, racchiusa fra doppio apice, la
stringa di controllo del formato; %d è la specifica di conversione: essa indica che
il dato passato al programma andrà interpretato come numero intero in formato
decimale; il secondo argomento di scanf è la variabile dove va memorizzato il
dato, preceduta da &.
- printf ha anch’essa la stringa di controllo del formato come primo argomento:
in essa compare una sequenza di caratteri (“il numero da stampare e’:”) in
combinazione con la specifica di conversione che indicherà come stampare il
dato: %d, ossia come intero decimale). A seguire la escape sequence \n fa
andare a capo. Il secondo argomento di printf è la variabile x (senza &), che
fornirà il valore trattato dalla specifica di conversione %d.
Esercizio
Scrivere un programma che legge un carattere, lo memorizza in una variabile di
tipo carattere, e lo stampa.
Il tipo dei caratteri è char (spazio di memoria: 1 byte), lo specificatore di
conversione %c.
#include <stdio.h>
int main () {
char cr;
scanf("%c", &cr);
printf("il numero da stampare e’: %c\n", cr);
18
return 0;
}
→ !! ←
Un assegnamento di un valore ad un variabile (sia essa x) avviene attraverso la
sintassi
x = <espressione>;
ed ha come effetto porre nella locazione di memoria associata ad x il valore
dell’espressione. Se l’espressione contiene, a sua volta, delle variabili (fra cui
potrebbe comparire x stessa), alle variabili viene sostituito il valore che contengono. Un esempio: supponiamo che nella variabile intera x1 sia memorizzato il
valore 5, e nella variabile y1 il valore 7; l’istruzione
x1 = 2*x1 + y1;
produce questa catena di operazioni: nel membro di destra, viene sostituito alla
variabile x1 il suo valore attuale (5), ed alla variabile y1 7. Calcoliamo il valore
della espressione: 17. Questo è il nuovo valore che l’istruzione di assegnamento
dà a x1.
Esercizio
Scrivere un programma che legge un intero, lo memorizza in una variabile, e
stampa il successore del numero letto.
#include <stdio.h>
int main () {
int x1;
scanf("%d", &x1);
x1 = x1 + 1;
printf("il successore del numero e’: %d\n", x1);
return 0;
}
Riferimenti bibliografici
[1] Piergiorgio Odifreddi. Classical Recursion Theory (2 vol). North Holland,
1989.
[2] G. Winskel. The formal semantics of programming languages. MIT Press,
1993.
19