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