Esercizi su ricorsione
Descrivere il Principio di induzione matematica e fornire un esempio del suo uso.
Supponiamo di voler dimostrare una certa proposizione P che dipende da un numero
naturale: se P è vera per il numero 0, e se inoltre il fatto che sia vera per un generico
numero naturale n comporta necessariamente che sia vera anche per il successore di n,
n+1, allora è evidente che nessun numero naturale può sfuggire; la proprietà P è vera per
tutti i numeri naturali.
Se una proposizione P:
1. È vera per n=0
(base di induzione)
2. Se è vera per n, allora è vera anche per n+1
(ipotesi d’induzione)
Allora P è vera per ogni n appartenente ad N.
Non è necessario partire da 0 spesso una proposizione diventa significativa da un certo
numero naturale k in poi; per esempio la proposizione:
la somma degli angoli interni di un poligono di n lati è (n-2)*180, è significativa per n>=3. È
ovvio che il primo passo della dimostrazione per induzione consiste nel dimostrare che la
proprietà è vera per k=3 anziché per K=0; la conclusione è che la proposizione è vera per
tutti i numeri naturali n>=3.
Fornire almeno un esempio di insieme definito induttivamente.
Un insieme I ⊆ R viene detto induttivo se:
- 1∊I
- x ∊ I ⟹ x + 1∊ I.
L'insieme dei numeri naturali N è il più piccolo insieme induttivo di R, cioè:
N :={x ∊ R : ∀ I ⊆ R induttivo; x ∊ I} = induttivo I
Fornire almeno un esempio di funzione definita induttivamente.
1,
𝑠𝑒 𝑛 = 0 𝑐𝑎𝑠𝑜 𝑏𝑎𝑠𝑒
𝑓𝑎𝑡𝑡 (𝑛) = {
𝑛 𝑓𝑎𝑡𝑡 (𝑛 − 1),
𝑠𝑒 𝑛 > 0 𝑐𝑎𝑠𝑜 𝑖𝑛𝑑𝑢𝑡𝑡𝑖𝑣𝑜
𝑠𝑜𝑚𝑚𝑎(𝑥, 𝑦) = {
𝑥,
1 + 𝑠𝑜𝑚𝑚𝑎 (𝑥, 𝑦 − 1),
𝑠𝑒 𝑦 = 0 𝑐𝑎𝑠𝑜 𝑏𝑎𝑠𝑒
𝑠𝑒𝑦 > 0 𝑐𝑎𝑠𝑜 𝑖𝑛𝑑𝑢𝑡𝑡𝑖𝑣𝑜
Fornire almeno un esempio di funzione definita induttivamente con il relativo metodo
ricorsivo Java .
1,
𝑠𝑒 𝑛/10 = 0 𝑐𝑎𝑠𝑜 𝑏𝑎𝑠𝑒
n
(
)
𝑐𝑜𝑛𝑡𝑎𝑐𝑖𝑓𝑟𝑒 𝑛 = {
1 + contacifre ( ) ,
𝑠𝑒 𝑛 ≠ 0 𝑐𝑎𝑠𝑜 𝑖𝑛𝑑𝑢𝑡𝑡𝑖𝑣𝑜
10
public static int contacifre(int n) {
int cifre;
if (n/10 == 0) cifre =1;
else cifre = 1+contacifre(n/10);
return cifre;
}
𝑝𝑟𝑜𝑑𝑜𝑡𝑡𝑜(𝑥, 𝑦) = {
0,
𝑠𝑜𝑚𝑚𝑎(𝑥, 𝑝𝑟𝑜𝑑𝑜𝑡𝑡𝑜(𝑥, 𝑦 − 1)),
𝑠𝑒 𝑦 = 0 𝑐𝑎𝑠𝑜 𝑏𝑎𝑠𝑒
𝑠𝑒𝑦 > 0 𝑐𝑎𝑠𝑜 𝑖𝑛𝑑𝑢𝑡𝑡𝑖𝑣𝑜
public static int prodotto(int x, int y) {
// pre : x >= 0, y >= 0
int p;
if (y == 0) p = 0; // se y = 0
else p = somma(x, prodotto(x, y-1)); // se y > 0
return p;
}
Esercizi su gestione memoria
Descrivere le modalità di gestione della memoria della Java Virtual Machine
A tempo di esecuzione, la macchina virtuale Java (JVM) deve gestire diverse zone di
memoria :
la zona codice che contiene il Java bytecode (ovvero il codice eseguibile dalla JVM), l’heap
(o mucchio): zona di memoria che contiene gli oggetti e la pila dei record di attivazione (o
stack): zona di memoria per i dati locali ai metodi (variabili e parametri).
Un oggetto viene creato invocando un costruttore tramite l'operatore new e al momento
della creazione di un oggetto, la zona di memoria per l'oggetto stesso viene riservata nello
heap.
Quando un oggetto non è più utilizzato da un programma, la zona di memoria allocata
nello heap per l'oggetto può essere liberata e resa disponibile per nuovi oggetti ciò viene
effettuato dal garbage collector.
Il garbage collector è una componente della JVM che è in grado di rilevare quando un
oggetto non ha più riferimenti ad esso, e quindi di fatto non è più accessibile (utilizzabile) e
può essere deallocato. Tipicamente, il garbage collector viene invocato automaticamente
dalla JVM.
Descrivere le modalità di gestione della memoria nell'esecuzione di metodi Java
La JVM gestisce la pila dei Record di Attivazione (RDA): per ogni attivazione di metodo
viene creato un nuovo RDA in cima alla pila e al termine dell'attivazione del metodo il RDA
viene “rimosso” dalla pila. In generale: vengono valutati i parametri attuali, viene
individuato il metodo da eseguire in base al numero e tipo dei parametri attuali, viene
sospesa l’esecuzione del metodo chiamante, viene creato il RDA relativo all’attivazione
corrente del metodo chiamato, viene assegnato il valore dei parametri attuali ai parametri
formali l'indirizzo di ritorno nel RDA viene impostato all'indirizzo della successiva istruzione
che deve essere eseguita nel metodo chiamante al termine dell'invocazione, al PC viene
assegnato l'indirizzo della prima istruzione del metodo invocato, si passa ad eseguire la
prossima istruzione indicata dal PC.
Descrivere le modalità di gestione della memoria nell'esecuzione di metodi ricorsivi
Per le chiamate ricorsive occorre ricordare che un RDA non è associato ad un metodo, ma
a una singola attivazione dello stesso quindi: nella pila non ci sono valori di ritorno, il RDA
in fondo alla pila è relativo al main, e tutti gli altri sono relativi ad attivazioni successive del
ricorsivo, per le diverse attivazioni ricorsive vengono creati diversi RDA sulla pila, con valori
via via decrescenti e il bytecode associato alle diverse attivazioni ricorsive è sempre lo
stesso, ovvero quello del metodo
ricorsivo.
Fornire un esempio di esecuzione di un metodo ricorsivo a scelta
static void ricorsivo(int i) {
int j=i+1;
System.out.println(i);
ricorsivo(j);
}
Prima invocazione:
i=3;
j=indefinito;
Viene alterato il valore di j:
i=3;
j=4;
Viene stampato i (stampo 3)
Seconda invocazione
Al metodo viene passato il valore di j, ossia 4.
 si crea un altro record di attivazione
 questo record contiene altre variabili i e j
 nella i viene inizialmente messo il valore passato come parametro (4)
i=3;
j=4;
i=4;
j=indefinito;
Il metodo viene ora eseguito
Nel corpo del metodo, quando uso i e j queste sono le variabili del nuovo record di
attivazione (quello creato apposta per questa attivazione)
Si esegue il corpo del metodo, si mette 5 in j e si stampa i (vale 4).
i=3;
j=4;
i=4;
j=5;
Si fa riferimento alle variabili i e j del record creato per questa invocazione.
ETC…
Esercizi su costo dei programmi
Descrivere in cosa consiste l’analisi asintotica della complessità
Nello studio del costo dei programmi è importante valutare la funzione di costo al crescere
della dimensione dell'input. Lo studio asintotico del costo fornisce una idea
dell’andamento del costo all'aumentare della dimensione dell'input. Il modello di costo
asintotico suggerisce di identificare i blocchi di operazioni che vengono eseguiti in
sequenza e determinare, per ciascun blocco, il costo della sola operazione più costosa.
Descrivere il relativo modello di costo per l’analisi asintotica della complessità e il
significato della notazione O (O-grande)
Il costo asintotico del programma corrisponde al costo asintotico dell’operazione
dominante
Per individuare le operazioni dominanti, basta esaminare le condizioni e le istruzioni
eseguite nei cicli più interni (annidati) dei programmi, oppure eseguiti nell'ambito delle
attivazioni ricorsive. Esempio:Per valutare il costo di ricerca Sequenziale tramite
l'individuazione dell'operazione dominante, si consideri che ci sono diverse operazioni
dominanti:
i < a.length && !trovato
i++
a[i]==k
Ciascuna di esse viene eseguita nel caso peggiore nvolte. Quindi il costo è O(n).
La notazione matematica O-grande è utilizzata per descrivere il comportamento asintotico
delle funzioni. Il suo obiettivo è quello di caratterizzare il comportamento di una funzione
per argomenti elevati in modo semplice ma rigoroso, al fine di poter confrontare il
comportamento di più funzioni fra loro.
Descrivere il concetto di istruzione dominante
In pratica, una istruzione dominante viene eseguita un numero di volte proporzionale al
costo dell'algoritmo. Se esiste un'istruzione dominante, la complessità dell'algoritmo si
riconduce a quella legata all'istruzione dominante, ossia la complessità dell'algoritmo è O(
d(n) ).
Indicare la complessità del metodo di fusione di array ordinati fornendo le adeguate
motivazioni
public static int[] fusione(int[] a, int[] b) {
int[] s; // risultato della fusione di a e b
int ia, ib, is; // indici per a, b e s
s = new int[a.length+b.length];
ia = 0; ib = 0; is = 0;
/* prima fase della fusione: inserimento da a e b */
while (ia<a.length && ib<b.length) {
if (a[ia]<b[ib]) {
s[is] = a[ia]; ia++;
} else {
s[is] = b[ib]; ib++;
}
is++;
}
/* seconda fase della fusione: inserimento da a o b */
if (ib==b.length)
/* inserimento da a: */
/*l'indice ib non serve piu': lo riutilizzo nel ciclo */
for (ib=ia; ib<a.length; ib++){
s[is] = a[ib];
is++;
} else
/* inserimento da b */
/*l'indice ia non serve piu': lo riutilizzo nel ciclo */
for (ia=ib; ia<b.length; ia++) {
s[is] = b[ia];
is++;
}
/* fusione completata */
return s;
}
Tutte le istruzioni di inizializzazione e di return hanno costo unitario, quindi sono
trascurabili perché non contribuiscono al costo asintotico.
Osserviamo la prima parte della fusione:
- la condizione del ciclo while ha costo unitario, e nel caso peggiore viene valutata un
numero di volte pari alla lunghezza dell’array a più la lunghezza dell’array b (chiamiamolo
la+lb);
- la condizione dell’if ha costo unitario, e qualsiasi sia l’esito il costo del blocco eseguito è 2;
- quindi in generale la parte if ha costo 3, e il costo dell’intero ciclo while è 3*(la+lb).
Osserviamo ora la seconda parte della fusione:
- la condizione del primo if ha costo unitario, e qualsiasi sia l’esito i due possibili blocchi da
eseguire hanno lo stesso costo;
- il blocco del ciclo for ha costo 2, e nel caso peggiore il ciclo viene eseguito un numero di
volte pari al maggiore tra la lunghezza dell’array a e la lunghezza dell’array b (chiamiamolo
max(la,lb);
- quindi in generale il costo di questo ciclo if è max(la,lb).
Ora possiamo considerare la dimensione dell’input n come la+lb, e possiamo approssimare
max(la,lb) con n: in definitiva il costo totale risulta essere 3*n+n, e quindi il costo asintotico
dell’algoritmo è O(n).
Esercizi su ordinamento
Descrivere l’applicazione degli algoritmi di ordinamento conosciuti ad un array di interi
(al fine di ordinare l’array in modo crescente), mostrando lo stato dell’array dopo
l’esecuzione di ciascuna passata dell’algoritmo
Ordinamento per selezione: si basa sulla seguente strategia; finchè l’array non è ordinato,
seleziona l’elemento di valore minimo dell’array tra quelli che non sono stati ancora
ordinati e disponilo nella sua posizione definitiva.
12
24
5
89
28
44
7
11
viene selezionato l’elemento di valore 5 e indice 2 e posto nella posizione di indice 0.
Viene eseguito uno scambio 5 24 12 89 28 44 7 11 tramite una variabile di appoggio.
L’algoritmo procede cercando l’elemento di valore minimo tra gli elementi non ordinati
dell’array che in questo caso vale 7 e occupa la posizione 6 il quale viene collocato nella
posizione 1: e così per tutti gli elementi non ordinati.
5
24
12
89
28
44
7
11
5
7
12
89
28
44
24
11
5
7
11
89
28
44
24
12
5
7
11
12
28
44
24
89
5
7
11
12
24
44
28
89
5
7
11
12
24
28
44
89
Ordinamento a bolle adotta una strategia iterativa basata su passate, confronti e scambi.
durante ciascuna passata, l’ordinamento a bolle confronta tutte le coppie di elementi
adiacenti tra gli elementi non ordinati dell’array scandendole da sinistra verso destra; ogni
volta che una coppia di elementi adiacenti non è ordinata correttamente, gli elementi
vengono scambiati.
12
24
5
89
28
44
7
11
5
12
24
28
89
7
11
44
5
12
24
28
7
11
44
89
5
7
11
12
24
28
44
89
Nell’ ordinamento a bolle gli elementi dell’array da ordinare sono partizionati in due
insiemi contigui, uno di elementi ordinati cha hanno sicuramente raggiunto la loro
posizione definitiva e uno di elementi non ordinati che devono ancora raggiungere la loro
posizione definitiva. Una passata dell’ordinamento a bolle ha lo scopo di ordinare
l’elemento di valore massimo tra quelli non ancora ordinati. N-1 passate consentono di
ordinare un array composto da N elementi.
Nell’ordinamento per inserzione gli elementi sono partizionati in due insiemi, uno di
elementi relativamente ordinati, nelle posizioni più a sinistra, ma che non sono stati
necessariamente collocati nelle loro posizioni definitive e uno di elementi non
relativamente ordinati nelle posizioni più a destra. Inizialmente il primo elemento viene
considerato rel. ordinato e tutti gli altri non rel ordinati. In ciascuna passata il primo tra gli
elementi non rel ordinati viene collocato tra gli elementi rel ordinati inserendolo nella sua
posizione corretta.
12
24
5
89
28
44
7
11
5
12
24
89
28
44
7
11
5
7
12
24
89
28
44
11
5
7
11
12
24
89
28
44
5
7
11
12
24
28
89
44
5
7
11
12
24
28
44
89
L’algoritmo di ordinamento per fusione: la sequenza da ordinare contiene due elementi o
meno, allora viene ordinata direttamente, eventualmente mediante un confronto o
scambio, se invece la sequenza da ordinare contiene più di due elementi, allora viene
ordinata come segue:
- gli elementi della sequenza vengono partizionati in due sotto-sequenze;
- le due sotto-sequenze vengono ordinate separatamente;
- le due sotto-sequenze ordinate vengono fuse in un'unica sequenza ordinata.
L’idea alla base dell’ordinamento per fusione è che l’ordinamento di una sequenza “lunga”
può essere ricondotto all’ordinamento di due sequenze più corte.
Per implementare l’ordinamento per fusione bisogna:
- stabilire una strategia per la decomposizione della sequenza da ordinare in sottosequenze;
- implementare un algoritmo di ordinamento;
- implementare un algoritmo di fusione.
12
24
5
89
28
44
7
11
Decomposizione
12
24
5
89
28
44
7
11
Ordinamento
5
12
24
89
7
11
28
44
Fusione
5
7
11
12
24
28
44
89
L’algoritmo di ordinamento di array più usato in pratica è l’ordinamento veloce(quick sort)
- se la sequenza contiene un elemento o è vuota, è già ordinata (non bisogna fare
nulla)
- se invece S contiene due o più elementi
- Si determina un elemento della sequenza che fungerà da discriminante degli
elementi della sequenza: il pivot.
-
L’array verrà partizionato in due sottosequenze (non necessariamente della stessa
lunghezza)
• Quella più a sinistra conterrà tutti gli elementi minori o uguali del pivot
• Quella più a destra conterrà tutti gli elementi maggiori o uguali del pivot
• Alle due sottosequenze così ottenute verrà nuovamente applicato il quick sort
12
24
5
7
5
7 pivot
7
11
89
28
12 pivot
12
28
11
11
5
7
5
7
11
12
44
7
44
24
89
28 pivot
28
89
28
89
44
44
89
24
24
11
28
44