Gestione dei file. Gestione dei file. Gestione dei file. Gestione dei file Costruiamo un oggetto di tipo FileReader: Gestione dei file

Documenti analoghi
Altri modi per leggere dati in ingresso

Divide et impera. Divide et impera. Divide et impera. Divide et impera

Algoritmi esponenziali

Variabili locali. Variabili parametro. Prototipo di funzione. Prototipo di funzione. Moduli esterni. Moduli esterni

Tempo e spazio di calcolo (continua)

Tempo e spazio di calcolo (continua)

Algoritmi e Strutture Dati

Trasformare array paralleli in array di record

n n 1 n = > Il calcolo del fattoriale La funzione fattoriale, molto usata nel calcolo combinatorio, è così definita

Algoritmi e Strutture Dati

Un tipico esempio è la definizione del fattoriale n! di un numero n, la cui definizione è la seguente:

Algoritmi e Strutture Dati

Programmazione a Oggetti e JAVA. Prof. B.Buttarazzi A.A. 2012/2013

Note per la Lezione 4 Ugo Vaccaro

Calcolare x n = x x x (n volte)

Ordinamento per inserzione e per fusione

Capitolo 19. Ricorsione

Nell informatica esistono alcuni problemi particolarmente rilevanti, poiché essi:

Parte 2. Ricorsione. [M.C.Escher Drawing hands, 1948] - AA. 2012/13 2.1

Rappresentazione degli algoritmi

Un esempio di calcolo di complessità: insertion sort

Esempio : i numeri di Fibonacci

Lezione 4. Problemi trattabili e soluzioni sempre più efficienti. Gianluca Rossi

n n 1 n = > Il calcolo del fattoriale La funzione fattoriale, molto usata nel calcolo combinatorio, è così definita

Tecniche Algoritmiche: divide et impera

Lezione 8 programmazione in Java. Anteprima. La ricorsione. Nicola Drago Dipartimento di Informatica Università di Verona

RICORSIONE. Informatica B - A.A. 2013/2014

LE STRUTTURE DATI DINAMICHE: GLI ALBERI. Cosimo Laneve

Fondamenti di Informatica 6. Algoritmi e pseudocodifica

Sistemi lineari. Lorenzo Pareschi. Dipartimento di Matematica & Facoltá di Architettura Universitá di Ferrara

La ricorsione. Ver Claudio Fornaro - Corso di programmazione in C

Note per la Lezione 6 Ugo Vaccaro

Input. Il tipo char Alcune modalità di acquisizione di input. Laboratorio di Programmazione - Luca Tesei

Algoritmi di ordinamento

Corso di Fondamenti di Programmazione canale E-O. Un esempio. Funzioni ricorsive. La ricorsione

7 - Programmazione procedurale: Dichiarazione e chiamata di metodi ausiliari

Definizione di metodi in Java

Funzioni e Ricorsione

Dati e Algoritmi I (Pietracaprina) Esercizi sulle Nozioni di Base

Problemi, istanze, soluzioni

19 - Eccezioni. Programmazione e analisi di dati Modulo A: Programmazione in Java. Paolo Milazzo

Ricorsione. quindi si può definire n! mediante i seguenti due punti:

Laboratorio di Python

Linguaggio C. Problemi di Ricerca e Ordinamento: Algoritmi e Complessità.

La ricorsione. Sommario. Fulvio CORNO - Matteo SONZA REORDA Dip. Automatica e Informatica Politecnico di Torino

Heap e code di priorità

Appunti lezione Capitolo 14 Greedy

Corso di Programmazione Problem Solving. Tipi di Problemi. Problemi Complessi Soluzione. Dott. Pasquale Lops. Primitivi. Complessi

Algoritmi e strutture dati

Indice. Prefazione. 3 Oggetti e Java 53

Algoritmi di ricerca. Per ricerca si intende qui il procedimento di localizzare una particolare informazione in un elenco di dati.

Laboratorio di Algoritmi e Strutture Dati

Algoritmi e Strutture Dati. Capitolo 4 Ordinamento: Selection e Insertion Sort

I numeri rossi sulla Mole Antonelliana a Natale. Algoritmi e Laboratorio a.a Lezioni. Le regole della riproduzione dei conigli.

6 - Blocchi e cicli. Programmazione e analisi di dati Modulo A: Programmazione in Java. Paolo Milazzo

Creazione, eliminazione, lettura e scrittura di file di testo

Ricerca lineare: elementi ripetuti

Algoritmi e strutture di dati 2

Appunti sui Codici di Reed Muller. Giovanni Barbarino

Albero di Riscorsione

Laboratorio di Informatica

Conversione di base. Conversione decimale binario. Si calcolano i resti delle divisioni per due

Gestione della produzione e della supply chain Logistica distributiva

Corso di Fondamenti di Informatica. La ricorsione

Cammini minimi fra tutte le coppie

A.A CORSO DI ALGEBRA 1. PROFF. P. PIAZZA, E. SPINELLI. SOLUZIONE ESERCIZI FOGLIO 5.

Programmazione dinamica

Linguaggi di programmazione - Principi e paradigmi 2/ed Maurizio Gabbrielli, Simone Martini Copyright The McGraw-Hill Companies srl

Università degli studi di Roma Tor Vergata Ingegneria Medica Informatica I Programma del Corso

UNIVERSITÀ DEGLI STUDI DI PAVIA FACOLTÀ DI INGEGNERIA. Algoritmi

Costo di esecuzione. Quanto costa? Spazio. Spazio e tempo. Università Roma La Sapienza Corsi di Laurea Informatica/Tecnologie Informatiche

UNIVERSITÀ DEGLI STUDI DI PAVIA FACOLTÀ DI INGEGNERIA. Matlab: esempi ed esercizi

INPUT OUTPUT Programmazione in rete e laboratorio. Le operazioni di I/O avvengono attraverso stream (sequenze di byte)

Implementazione dell albero binario in linguaggio C++

DIPARTIMENTO DI ELETTRONICA, INFORMAZIONE E BIOINGEGNERIA. INFORMATICA B Ingegneria Elettrica. La ricorsione

Divide et impera su alberi

Alberi Binari di Ricerca

3.4 Metodo di Branch and Bound

Equazioni di primo grado

RISOLUZIONE DI SISTEMI LINEARI

Analisi algoritmi ricorsivi e relazioni di ricorrenza

Ricorsione. La ricorsione consiste nella possibilità di definire una funzione in termini di se stessa

Laboratorio 8. Programmazione - CdS Matematica. Marco Virgulin 13 Gennaio 2015

Studio delle successioni con il software

Alberi. Gli alberi sono una generalizzazione delle liste che consente di modellare delle strutture gerarchiche come questa: Largo. Fosco.

Problemi, algoritmi, calcolatore

Algoritmi e Strutture Dati - II modulo Soluzioni degli esercizi

Insegnamento di Laboratorio di algoritmi e strutture dati

Fondamenti di Informatica. Algoritmi di Ricerca e di Ordinamento

GLI ALBERI BINARI DI RICERCA. Cosimo Laneve

Laboratorio di Python

Algoritmi e Strutture Dati

Problemi e algoritmi. Il che cosa ed il come. Il che cosa ed il come. Il che cosa e il come

Laboratorio di Programmazione M-Z

Fondamenti di Informatica - 1. Prof. B.Buttarazzi A.A. 2011/2012

Introduzione alla programmazione

11.4 Chiusura transitiva

Def. 1. Si chiamano operazioni elementari sulle righe di A le tre seguenti operazioni:

Parte III: Algoritmo di Branch-and-Bound

Corso di Perfezionamento

Transcript:

Finora abbiamo visto programmi che utilizzano solo flussi di ingresso standard e questi flussi erano collegati attraverso la ridirezione ai corrispondenti file fisici. Vogliamo definire un file di lettura o di scrittura nel programma per poter leggere più di un file e poter gestire file diversi da System.in e System.out. Utilizzeremo le classi FileReader e FileWriter del pacchetto java.io. Per poter leggere caratteri da un file, esistente, occorre aprire il file in lettura; questa operazione si traduce in Java nella creazione di un oggetto di tipo FileReader. (par. 11.1) Il costruttore accetta il nome del file sotto forma di stringa. Se il file non esiste viene lanciata l eccezione FileNotFoundException. Supponiamo di leggere i dati da un file fisico di nome ingresso.txt. Costruiamo un oggetto di tipo FileReader: FileReader fl = new FileReader("ingresso.txt"); Con un oggetto di tipo FileReader si può invocare il metodo read, che legge un singolo carattere e lo restituisce sottoforma di intero compreso nell intervallo [0, 65535], iniziando dal primo carattere del file e procedendo fino alla fine del file stesso; se il file è finito il metodo read restituisce un intero che vale -1. Per facilitare l acquisizione dei dati si può usare un oggetto di tipo Scanner o di tipo BufferedReader: Scanner in = new Scanner(fl); e poi leggere con i metodi della classe Scanner, come in.nextint, oppure BufferedReader in = new BuffredReader(fl); e leggere con readline e poi utilizzare i metodi per la conversione ai vari tipi di dato. Al termine della lettura si deve chiudere il file: in.close(); Se il file non viene chiuso non viene segnalato alcun errore, ma è meglio eseguire la chiusura da programma. L eccezione FileNotFoundException è a controllo obbligatorio e quindi, se non è gestita, nell intestazione del metodo che apre il file si deve inserire: throws FileNotFoundException 1

Per poter scrivere dei caratteri in un file, occorre aprire il file in scrittura; questa operazione si traduce in Java nella creazione di un oggetto di tipo FileWriter: FileWriter fs = new FileWriter("uscita.txt"); Il costruttore accetta il nome del file sotto forma di stringa. Per poter usare i metodi print e println, si deve costruire PrintWriter: un oggetto di tipo PrintWriter out = new PrintWriter(fs); Il costruttore di PrintWriter accetta anche una stringa con il nome del file per cui si può più semplicemente scrivere: PrintWriter out = new PrintWriter("uscita.txt"); Se il file non esiste, esso viene creato: viene costruito un file fisico con il nome indicato nella stringa ed in maniera sequenziale vengono inseriti i dati corrispondenti alle istruzioni del metodo print. 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. È 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 deve essere chiuso prima della terminazione del programma, e il file viene chiuso solo se i dati sono stati tutti scritti nel file. La classe Scanner è stata progettata per rendere più comoda la lettura dei dati e pertanto è stato scelto di non legarla ad eccezioni di tipo controllato. Il metodo readline della classe BufferedReader invece solleva una eccezione controllata (IOException), che pertanto deve essere indicata con la clausola throws. Le classi che gestiscono i file possono sollevare eccezioni che riguardano lo scambio di informazioni con l esterno. Dal momento che FileNotFoundException è una sottoclasse di IOException, se un metodo dovesse dichiararle entrambe, può dichiarare solo quelle superiore, vale a dire basta che indichi: throws IOException 2

Poiché anche i metodi di lettura possono sollevare analoghe eccezioni, quando si usano i file non standard, è sempre conveniente scrivere la clausola throws IOException nell intestazione del main (che comporta anche l uso di eccezioni di eventuali sottoclassi). Si può gestire l acquisizione dei nomi dei file tramite l utilizzo degli argomenti sulla riga di comando; in tale modo è possibile inserire un controllo almeno sul fatto di aver scritto il numero esatto dei file da gestire; se, invece, i nomi sono errati, e quindi il file non esiste, si solleva l eccezione (che eventualmente può essere catturata). Il parametro String arg[] viene ad avere per componenti i nomi dei file che si vogliono utilizzare. /**Utilizzo di file lettore e scrittore */ import java.io.filereader; import java.io.filewriter; import java.io.bufferedreader; import java.io.printwriter; import java.io.ioexception; /* Si ricopia il contenuto di un file di testo una riga alla volta in un altro file; i nomi dei due file sono passati come parametri dalla riga di comando */ public class ProvaFile{ public static void main (String[] arg) throws IOException{ //inizio main if (arg.length < 2) System.out.println("dare i nomi" + "dei due file sulla riga " + " di comando\n"); else {//aprire i file BufferedReader in = new BufferedReader (new FileReader(arg[0])); PrintWriter out = new PrintWriter (new FileWriter(arg[1])); /* acquisire dal file "in" una riga e scrivere nel file "out" la stessa riga */ String line = in.readline(); while (line!= null){ out.println(line); line = in.readline(); }//fine while 3

//chiusura dei due file out.close(); //file di scrittura in.close(); // file di lettura }//fine else }//fine main }//fine classe Ordinamento per inserimento Dopo aver compilato e costruito il file da ricopiare dare il comando: java Provafile ingresso.txt uscita.txt 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 (la prima è ordinata rispetto alle precedenti ) e la si inserisce in ordine rispetto alla prima, poi si considera la terza, e la 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. Ordinamento per inserimento Poiché il numero di componenti non aumenta, per poter slittare verso destra le componenti che precedono nella sequenza, è necessaria una variabile di appoggio x per salvare il valore: 2 5 9 11 3 1 20 x 2 3 5 9 11 1 20 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 : for(k=1; k<=n-1; k++) {//si inizia // dalla seconda x=v[k]; //inserire v[k] i=k; while((i!=0) && (x < v[i-1])){ v[i]=v[i-1]; i--; } //fine while v[i]=x; }//fine for 4

Ordinamento per inserimento Complessità. Caso favorevole. l array è già ordinato 1 3 8 10 20 il predicato del ciclo interno (while) è sempre falso, ( x < v[i-1] è falso), quindi il numero di operazioni è proporzionale a n: si ha Ω (n). Ordinamento per inserimento Caso peggiore. L array è in ordine inverso 20 10 9 7 4 1 il ciclo interno (while) viene sempre eseguito per valori crescenti di i = k (k=1, 2,, n-1): 1 + 2 + 3 +. + (n-1) = n (n-1)/2 quindi O(n 2 /2) 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-6 sec. 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. Algoritmi esponenziali Vediamo questa tabella dove riportiamo, al variare di n, il tempo impiegato da alcune funzioni di n n log 2 (n) 100*n 10*n 2 n 3 2 n 10 2.3 µs 1ms 1ms 1ms 1024µs ~ 1ms 20 2.99µs 2ms 4ms 8ms 1.048sec 60 4.09µs 6ms 36ms 0.21sec 2 60 µs ~ 366 secoli Algoritmi esponenziali 2 60 µs ~ 366 secoli, vediamo come mai 2 60 µs ~ 10 60 0.3 µs ~ 10 18 µs~ 10 12 sec (1.1529 10 12 ) (1µs = 10-6 sec ) Quanti secondi in un anno? 1 minuto = 60 secondi 1 ora = 3600 secondi 1 giorno = 86400 secondi 1 secolo = 3.15 10 9 secondi secondi 1.1529 10 12 secoli = = = 366 secondi in un secolo 3.15 10 9 5

Algoritmi esponenziali Esistono degli algoritmi che sono intrinsecamente esponenziali: calcolare le permutazioni di n elementi: n! ~ n n Ci sono problemi che sono risolti da un algoritmo deterministico esponenziale, ma il cui algoritmo non deterministico è polinomiale. Algoritmi esponenziali L algoritmo non deterministico è un algoritmo ideale che può essere simulato pensando di poter eseguire scelte contemporanee. Esso è rappresentabile su un albero: la profondità dell albero può essere proporzionale alla dimensione del problema. Tali problemi si chiamano problemi NP (Non deterministico Polinomiale). Algoritmi esponenziali Esempio. Consideriamo la formula logica F(x 1, x 2, x 3 ) = (x 1 e x 2 e x 3 ) o (non x 1 e non x 2 ) e ci chiediamo se esiste una scelta di valori per le variabili x 1, x 2, x 3 che renda vera F. Un algoritmo deterministico prova tutte le possibilità e poiché il valore di x i può essere vero o falso, le possibilità sono 2 3. Algoritmi esponenziali In generale considerando F(x 1, x 2,.., x n ) il problema può essere risolto in un tempo O(2 n ). L algoritmo non deterministico è in grado di scegliere il valore di x i 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. 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). Complessità asintotica Le considerazioni fatte sulla complessità valgono solo se n e F(n) x 1 x 2 x 3 v x 1 x 2 x 2 x 3 x 3 x 3 x 3.............. f 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. 6

Complessità asintotica Esempio. f 1 (n) = 1000 * n f 2 (n) = 10 * n 2 f 1 è O(n) f 2 è O(n 2 ) Quindi: se n è preferibile f 1 se n 10 è preferibile f 2, infatti: 1000 * n 1000*10 = 10 4 10 * n 2 10*100 = 10 3 Due esempi di analisi asintotica Problema. Dati n numeri, costruire una sequenza di numeri che rappresentino le medie dei precedenti. Analisi. Data la sequenza x 0, x 1,, x n-1 costruire la sequenza a 0, a 1,, a n-1 in modo tale che a i = Σ x k / (i+1) k=0,1,, i Questo è un problema che si incontra spesso in economia: dati i profitti annuali, osservare i profitti medi degli ultimi 2, 3,, 5 anni. Due esempi di analisi asintotica Primo algoritmo. // definizione dati // acquisizione e stampa di n e x per i da 0 a n-1 eseguire s=0 per k da 0 a i eseguire s = s + x[k] //fine per a[i] = s/(i+1) //fine per Due esempi di analisi asintotica Ci sono due strutture iterative, delle quali quella più interna varia con i e le operazioni del ciclo interno sono eseguite: 1 + 2 + 3 +. + n = n(n+1)/2 Le operazioni del ciclo esterno sono c n. Quindi la complessità è O(n 2 ). Due esempi di analisi asintotica Secondo algoritmo. La media a i è simile alla successiva a i+1 : a i = Σ x k / (i+1) k = 0,1,, i a i+1 = Σ x k / ((i+1)+1) k = 0,1,, i, i+1 dove le somme al numeratore sono s i = x 0 + x 1 +. + x i s i+1 = x 0 + x 1 +. + x i + x i+1 = s i + x i+1 Due esempi di analisi asintotica Ottimizzando il calcolo della media: Secondo algoritmo. s=0 per i da 0 a n-1 eseguire s = s + x[i] a[i] = s/(i+1) //fine per La complessità di questo algoritmo è O(n). 7

Una scomposizione di un problema P in sottoproblemi P 1, P 2,, P n 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. (Capitolo 12) 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)! Poiché (n-n)! = 0! = 1 per definizione, si ottiene: n! = n*(n-1)*... * 1 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. 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. 8

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 del metodo fattoriale(n intero) definizione variabili f intero se n==0 allora f 1 altrimenti f n * fattoriale(n-1) //finese restituire f Il metodo ricorsivo avrà una scrittura del tipo: public static int fattoriale(int n){ int f; if(n == 0) f = 1; else f = n * fattoriale(n-1); return f; }//fine fattoriale ricorsivo Cosa accade veramente? Facciamo uno schema della scomposizione: n* n! (n-1)* (n-1)! (n-2)!........ I vari prodotti n*, (n-1)*,, 1* restano sospesi perché il controllo passa al metodo chiamato. Dopo l ultima chiamata che restituisce 1, tale valore torna indietro al chiamante e viene eseguito l ultimo prodotto sospeso (1* ); tale valore viene a sua volta restituito al chiamante, viene eseguito il prodotto sospeso 2*, 1* 0! =1 Come può un metodo rimanere sospeso e poi, quando si riattiva, eseguire i prodotti giusti? Nel codice del metodo appaiono due valori per la variabile n: appare n nell intestazione: public static int fattoriale(int n) appare n nel prodotto e n-1 nella chiamata: f = n * fattoriale(n-1); Abbiamo visto che quando un metodo viene invocato il controllo passa dal chiamante al metodo chiamato e l ambiente di quest ultimo è quello attivo durante l esecuzione del metodo (RuntimeStack). Se anche nel metodo chiamato c è una invocazione ad un altro metodo, il primo rimane sospeso e il controllo passa al secondo, e così via. Ogni metodo che termina restituisce il controllo al suo chiamante. 9

Nel caso della ricorsione il metodo è sempre lo stesso: nelle varie copie del metodo sono memorizzati i parametri e le istruzioni di quella chiamata: f f f ritorno chiama f. chiama f. main chiama f.. ogni ambiente ha la sua copia di parametri e di variabili locali 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. Quando un metodo termina, l area allocata ritorna libera. Vediamo un esempio di questa memorizzazione calcolando ricorsivamente 5! 1 * 0! 2 * 1! 3 * 2! 4 * 3! 5 * 4! ritorno 1 2*1 = 2 3*2 = 6 4*6 = 24 5*24 =120 Complessità di un algoritmo ricorsivo Il tempo di un algoritmo ricorsivo si ottiene sommando vari tempi: il tempo delle operazioni eseguite nell algoritmo (esclusa la ricorsione) il tempo per la chiamata di un metodo (ricorsivo o no) il tempo dell algoritmo (ricorsivo) di dimensione inferiore (tempo per tutte le chiamate successive) Il tempo delle operazioni della parte non ricorsiva si calcola contando confronti, assegnazioni, cicli: T(n). Complessità di un algoritmo ricorsivo Il tempo per effettuare una singola chiamata di metodo è 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) e rappresenta il costo puro della ricorsione: rappresenta il numero delle chiamate ricorsive. Complessità di un algoritmo ricorsivo Esempio. Complessità dell algoritmo ricorsivo per il calcolo di n! Sia T(n) il tempo per calcolare n!: possiamo anche contare solo 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 è 0 e la seconda costante è 1. 10

Complessità di un algoritmo ricorsivo Se invece vogliamo contare tutte le operazioni avremo: per la prima costante t c + t a + t ritorno ~ c per la seconda costante: t c + t prodotto + t chiamata + t a + t ritorno ~ c Otteniamo così la formula: T(n) = c + T(n-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 solo le moltiplicazioni, avremmo avuto: T(n) = 1 + T(n-1) =. = n + T(0) = n e iterazione Avremmo anche potuto calcolare il fattoriale in maniera iterativa; la scomposizione iterativa del fattoriale è diversa: f f*1 f f*2 n!....... f f*n e iterazione Anche la scrittura dell algoritmo cambia; possiamo scrivere delle istruzioni del tipo: intestazione del metodo fattiterativo(n intero) definizione variabili f, i intero f 1 per i da 1 a n eseguire f f * i //fineper restituire f e iterazione La complessità non cambia: abbiamo infatti una struttura iterativa che viene eseguita n volte: t a + (n+1) t p + n t a + t ritorno quindi sempre c n operazioni. Quando in un metodo ricorsivo c è una sola chiamata a se stesso la ricorsione si dice diretta; se invece il metodo chiama un altro metodo che ne chiama un altro ancora (e anche vari altri) e quest ultimo chiama il primo, la ricorsione si dice indiretta. Si parla poi di ricorsione lineare se la chiamata è unica nel metodo (diretta o indiretta); si parla di ricorsione multipla se c è più di una chiamata (binaria se le chiamate sono due). Nel fattoriale la ricorsione è diretta e lineare. 11

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. 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? (par. 12.4) Indichiamo con F n il numero di conigli dopo n anni e proviamo a calcolarli a partire dal primo anno: F 1 = 1 coppia iniziale F 2 = 1 la stessa coppia F 3 = 1 + 1 = 2 F 1 + la coppia nata da F 1 ( 2 anni) F 4 = 2 + 1 = 3 F 3 + la coppia nata da F 2 ( 2 anni) F 5 = 3 + 1 + 1 = 5 F 4 + le due coppie nate da F 3 ( 2 anni).. In generale si avrà F n = F n-1 + F n-2 dove F n-1 rappresenta le coppie presenti l anno precedente ed F n-2 rappresenta una nuova coppia per ogni coppia di almeno 2 anni. I numeri si calcolano facilmente sommando i valori dei due posti precedenti: n 1 2 3 4 5 6 7 8 9 10 11 12 F(n) 1 1 2 3 5 8 13 21 34 55 89 144 L algoritmo più immediato da scrivere è quello che ricopia la definizione e pertanto è un algoritmo ricorsivo. Il caso base è rappresentato da F 1 = 1 e F 2 = 1 L algoritmo avrà una scrittura del tipo: intestazione metodo fibonacci (n intero) definizione variabili fib intero se n ==1 oppure n ==2 allora fib 1 //caso base altrimenti fib fibonacci (n-1) + fibonacci (n-2) //finese restituire fib //fine metodo 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. 12

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. F 5 F 4 F 3 F 3 F 2 F 2 F 1 F 2 F 1 F 5 è la radice, F 1 e F 2 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 F n coincide con il valore di F n. Se vogliamo contare le chiamate del metodo, 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 F n -1. Possiamo concludere che: la complessità dell algoritmo ricorsivo cresce come F n. Osservando l albero della ricorsione si nota che molti valori F n sono calcolati più volte: nel caso di F 5, F 2 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 metodo fibonacci2 (n intero) definizione variabili fib[nummax], i intero fib[1] 1 fib[2] 1 per //fineper restituire fib[n] //fine metodo i da 3 a n eseguire fib[i] fib[i-1] + fib[i-2] Quale complessità ha l algoritmo che utilizza l array? tempo O(n): ciclo che viene eseguito n volte spazio O(n): si utilizza un array di nummax componenti per calcolare F n con n<=nummax Si può scrivere un algoritmo ancora più efficiente. 13

Osserviamo nuovamente il calcolo dei valori F n, ed osserviamo che ad ogni passo si utilizzano solo i valori precedenti, che possono essere salvati in due variabili scalari: F 1 = 1 F 2 = 1 F 3 = F 2 + F 1 F 4 = F 3 + F 2 F 5 = F 4 + F 3 queste somme sono del tipo: f f + valoreprecedente Si ottiene così il seguente algoritmo: intestazione metodo 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 //salviamo F 2, prima di fib fib + prec // F 3 = F 2 + F 1 prec prec1 // perché servirà nel //fineper //calcolo di F 4 restituire fib //fine metodo Quale complessità ha l algoritmo che utilizza le sole variabili scalari? tempo spazio O(n): ciclo che viene eseguito n volte O(1): si utilizza un numero costante di locazioni di memoria 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 F n = F n-1 + F n-2 e si prova con a n, a 0; l equazione diventa: a n = a n-1 + a n-2 da cui raccogliendo a n-2 si ottiene: a n-2 ( a 2 a 1) = 0 Poiché a 0 cerchiamo le soluzioni dell equazione ( a 2 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 F n = (φ n -φ n ) / 5 Esiste quindi un algoritmo numerico con il quale calcolare il numero F n. Però tale algoritmo non può essere preciso, dal momento che F n è un numero naturale e la radice di 5 è un numero irrazionale: quindi una applicazione di tale algoritmo fornisce, per molti valori di n, solo una approssimazione. La complessità di tempo e di spazio è O(1). 14

Utilizziamo la formula F n = (φ n -φ n ) / 5 per stimare come F(n) + L algoritmo ricorsivo ha complessità O(F n ); dal momento che: φ < 1 si ha che φ n 0 1< φ < 2 si ha che φ n < 2 n e pertanto l algoritmo cresce in maniera esponenziale con limitazione superiore 2 n : tempo O(2 n ). F 5 F 4 F 3 F 3 F 2 F 2 F 1 F 2 F 1 La complessità di spazio è O(n); infatti le chiamate ricorsive si espandono in profondità, non sono contemporanee: F 5, F 4, F 3, F 2, ritorno, F 1, ritorno F 2, calcola F 3, ritorno, La torre di Hanoi 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 La torre di Hanoi La configurazione finale dovrà essere: A B C 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. Indichiamo con H(n, A, B,C) il problema di Hanoi di dimensione n. (Esercizio P12.13) 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. 15

La torre di Hanoi La torre di Hanoi H(3,A,B,C) H(2,A,C,B) H(1,A,B,C) H(2,B,A,C) H(1,A,B,C) H(1,A,C,B) H(1,C,A,B) H(1,B,C,A) H(1,B,A,C) H(1,A,B,C) Complessità. Quanti sono gli spostamenti dei 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 disco 1 1 spostamento 2 2 3 2*2 = 4 = 2 2 4 2*4 = 8 = 2 3 5 2*8 = 16 = 2 4 n 2 n-1 sommiamo gli spostamenti 1 + 2 + 2 2 + 2 3 + 2 4 + + 2 n-1 = 2 n -1 quindi O(2 n ) l algoritmo è esponenziale La torre di Hanoi Si può anche scrivere un algoritmo iterativo, che rimane esponenziale, osservando il movimento dei dischi: disco1 A C disco2 A B C disco3 A C B A C I dischi pari percorrono ciclicamente in ordine alfabetico i pioli, i dischi dispari li percorrono in ordine inverso. Divide et impera Divide et impera La tecnica detta divide et impera è una strategia generale per impostare algoritmi. Consideriamo un problema P e sia n la dimensione dei dati, la strategia consiste nel: suddividere il problema in k sottoproblemi P i di dimensione inferiore (ciascuno di dimensione n i ) e successivamente riunire i risultati ottenuti dalle k soluzioni. Frase attribuita a Filippo il Macedone; fu un principio politico: mantenere divise le popolazioni dominate per poter governare con più facilità. (Argomenti avanzati 13.3) 16

Divide et impera Se i k sottoproblemi sono formalmente simili al problema di partenza, si ottiene una scomposizione ricorsiva. Ci deve pertanto essere una dimensione h del problema che porti ad una risoluzione diretta, vale a dire che non necessiti della ricorsione. Indichiamo con: S l insieme dei dati k il numero dei sottoproblemi h la dimensione limite Divide et impera: schema generale algoritmo DIVETIMP (S, n) se n < h allora risolvere direttamente il problema P altrimenti dividere S in k sottoinsiemi risolvere separatamente i k sottoproblemi P 1,, P k : DIVETIMP(S 1,n 1 ),, DIVETIMP(S k,n k ) riunire i risultati ottenuti //finese //fine algoritmo Divide et impera: complessità Indichiamo con T(n) la complessità del problema P sull insieme dei dati di dimensione n; poiché l algoritmo è ricorsivo si ottengono delle formule di ricorrenza. Indichiamo con: D(n): complessità dell algoritmo per dividere l insieme C(n): complessità dell algoritmo per riunire i risultati T(n i ): complessità dell algoritmo sull insieme di dimensione n i. Divide et impera: complessità La formula di ricorrenza è la seguente: T(n) = costante n<h T(n) = D(n) + C(n) + T(n 1 ) + T(n 2 ) + + T(n k ) Queste formule si espandono in modo analogo a quanto visto per il fattoriale ricorsivo. Analisi. L idea è la seguente: è più conveniente ordinare due array di s e t componenti piuttosto che un array di n componenti (s + t = n) si aumenta l efficienza dell ordinamento scambiando elementi lontani tra loro. (Argomenti avanzati 13.3) 17

Verifichiamo la prima idea. Supponiamo s=t=n/2 e prendiamo la formula n(n-1)/2 che rappresenta la complessità dell ordinamento nel caso peggiore e riscriviamola per n/2 invece che n: 2 [(n/2) (n/2 1) /2] = n 2 /4 n/2 n 2 /4 n/2 < n 2 /2 n/2 = (n 2 n)/2 = = n (n-1) /2 Verifichiamo la seconda idea. Supponiamo di avere un array ordinato in senso inverso; scambiando gli elementi opposti (il primo con l ultimo, il secondo con il penultimo, ecc.) in n/2 operazioni ordiniamo l array. Questi elementi sono lontani tra loro: quelli più grandi sono al posto di quelli più piccoli. Dividiamo l insieme in due parti (prima idea) e sfruttiamo la seconda idea: scambiare elementi lontani. Scegliamo un elemento dell array per eseguire i confronti e lo chiamiamo conf; dividiamo l insieme in modo tale che gli elementi più piccoli di conf possano essere messi a sinistra di conf e quelli più grandi a destra, rispettando così l ordine. A questo punto l insieme è diviso in due parti indipendenti e l elemento conf è al suo posto. conf Si potrà proseguire ordinando separatamente le due parti di array. Possiamo farlo in maniera ricorsiva. Si prosegue fino a considerare un array di dimensione 1 (dimensione limite), che non dovrà essere ulteriormente suddiviso. Se guardiamo l array dalla prima componente all ultima, vediamo che l array è ordinato: guardare tutto l array corrisponde a riunire i risultati. Scriviamo il progetto dell algoritmo secondo lo schema divide et impera. Poiché l array iniziale viene considerato in parti più piccole, sulle quali applicare lo stesso metodo, progettiamo l ordinamento di un array v dalla componente n1 alla componente n2. Alla prima invocazione del metodo n1 ed n2 saranno rispettivamente la prima e l ultima componente. algoritmo quicksort(n1, n2, v) se n1 < n2 allora //finese //fine quicksort chiamare l algoritmo partizione(n1, n2, v) che restituisce il valore k della posizione di conf chiamare quicksort(n1, k-1, v) chiamare quicksort(k+1, n2, v) Quando n1=n2 l array ha un solo elemento e pertanto la ricorsione ha termine. 18

Come scegliere l elemento conf? Dobbiamo stabilire un criterio che si possa facilmente ripetere in tutte le suddivisioni successive. Stabiliamo di scegliere la prima componente di quella porzione di array (da n1 a n2) che vogliamo ordinare: v[n1]. Ci sono varie scritture dell algoritmo quicksort, ma lasciano inalterata la complessità. Per realizzare la partizione avremo bisogno due indici: un indice i che scorre l array con valori crescenti e che parte dalla posizione successiva a quella di conf (i=n1+1), un altro indice k che descrive l array con valori decrescenti e parte dall ultima posizione (k=n2). Quando questi due indici saranno uguali avremo terminato la partizione e si potrà sistemare conf al suo posto. Vediamo un progetto per l algoritmo di partizione. algoritmo partizione(n1, n2, v) conf v[n1] i n1+1 k n2 mentre i k eseguire mentre v[i] conf e i k eseguire i i+1 //fine mentre mentre v[k] conf e i k eseguire k k-1 //fine mentre scambiare v[i] con v[k] //elementi lontani //fine mentre: ciclo esterno //sistemare conf nella posizione k=i //è al suo posto se v[k] > conf allora k k-1 //finese scambiare v[n1] con v[k] //fine partizione E necessario il confronto tra conf e v[k]? Esempio. 10 5 11 1 13 2 20 conf i=2 i=3 k=6 k=7 10 5 2 1 13 11 20 i=4 i=5 k=5 i = k = 5 termina anche il ciclo esterno v[k]>conf conf =10<13= v[k] quindi k-1 1 5 2 10 13 11 20 Esempio. 20 3 1 10 9 7 11 conf i=2 i=3 i=4 i=5 i=6 i=7= k i = k = 7 termina anche il ciclo esterno v[k]>conf falso : k non varia 11 3 1 10 9 7 20 19

Complessità. Contiamo i confronti tra conf e gli elementi v[i]: 0 se n = 0,1 (n1<n2) T(n) = D(n) + C(n) + T(k-1) + T(n-k) D(n) = complessità dell algoritmo partizione C(n) = 0 guardare le due parti dell array D(n) è O(n): n-1 confronti nei predicati dei cicli interni + 1 confronto per sistemare conf. Caso peggiore: vettore ordinato, la partizione è sbilanciata: T(n) = n + T(0) + T(n-1) = n + T(n-1) = = n + (n-1 + T(0) + T(n-2)) = n + n-1 + T(n-2) = = n + (n-1) + (n-2) + T(n-3) = =.. = n + (n-1) + + 1 + T(0) + T(n (n-1)) = = n (n-1)/2 O(n 2 /2) Caso favorevole: conf sempre al centro, la partizione è bilanciata L algoritmo del libro è scritto diversamente: from = n1 to = n2 T(n) = n + T(n/2) + T(n/2) = n + 2T(n/2) = = n + 2(n/2 + 2T(n/4)) = 2n + 2 2 T(n/2 2 ) = =.. = k n + 2 k T(n/2 k ) Se n = 2 k allora k = log 2 n O(n log 2 n) i=n1-1 k=n2+1 (valori esterni), conf=pivot il ciclo esterno ha predicato i<k ; all inizio del ciclo i++ ; il confronto v[i]<conf è falso (sono uguali) quindi si passa al secondo ciclo e conf viene messo come ultimo (non necessariamente al suo posto). Manca il confronto i k, perché in fondo c è conf. Nella versione con conf al centro è necessario il predicato i k per non uscire dall array (se conf è il più grande elemento, come nel secondo esempio). 20