Università degli studi di Messina Facoltà di Ingegneria Corso di Laurea in Ingegneria Informatica e delle Telecomunicazioni Fondamenti di Informatica II Algoritmi ricorsivi e Backtracking
Introduzione Dalla prefazione al libro Algoritmi + Strutture Dati = Programmi di Niklaus Wirth La programmazione è un'arte costruttiva. Come si può insegnare un'attività costruttiva e creativa? Un metodo consiste nel cristallizzare principi elementari di composizione, estratti da molti casi, e mostrarli in modo sistematico. Tuttavia la programmazione è un campo di grande varietà, e spesso richiede complesse attività intellettuali. La credenza che essa possa essere condensata in una specie di puro insegnamento di ricette è sbagliata. Ciò che rimane nel nostro arsenale di metodi didattici è l'attenta selezione e presentazione di esempi importanti. [...] Una caratteristica di questo approccio è che molto viene lasciato allo studente, alla sua diligenza ed alla sua intuizione. Il nostro tenente generale, L. Eulero, confessa apertamente: [...] che in fututo non riempirà mai più di sessanta pagine (di calcoli) per ottenere un risultato che potrebbe essere dedotto in dieci linee, dopo qualche attenta considerazione [...] Tratto da Diatribe du docteur Akakia di Voltaire (Novembre 1752) 2
Algoritmi ricorsivi Un oggetto viene detto ricorsivo se esso comprende parzialmente se stesso, o se è definito in termini di se stesso. La ricorsione è uno strumento particolarmente potente per le definizioni matematiche. Alcuni esempi sono costituiti fai numeri naturali, dalle strutture ad albero e da certe funzioni (ad es. Il calcolo del fattoriale). La potenza della ricorsione nasce dalla possibilità di definire un insieme infinito di oggetti con una regola finita. Un insieme infinito di computazioni può, quindi, essere descritto con un programma ricorsivo finito, persino se il programma non contiene iterazioni esplicite. 3
Algoritmi ricorsivi (cont.) Gli algoritmi ricorsivi sono appropriati principalmente quando i problemi da risolvere, o le funzioni da calcolare o le strutture dati da elaborare sono già definiti in termini ricorsivi. Le funzioni (in linguaggio C) sono lo strumento necessario e sufficiente per esprimere ricorsivamente i programmi, poichè permettono di dare un nome a delle istruzioni che potranno poi essere invocate con quel nome. Vi sono due tipi possibili di funzioni ricorsive: dirette e indirette. L'uso della ricorsione potrebbe anche non essere immediatamente evidente nel testo di un programma. 4
Parametri delle funzioni ricorsive È pratica comune associare ad una funzione un insieme di oggetti locali, cioè variabili, costanti, ecc., che sono definiti localmente alla funzione e non esistono, nè hanno significato, al di fuori di essa. Ogni volta che la funzione viene attivata ricorsivamente, viene creato un nuovo insieme di variabili locali. Sebbene esse abbiano lo stesso nome dei corrispondenti elementi dell'insieme che era locale nella precedente istanza della funzione, i valori sono distinti. I conflitti vengono evitati mediante le regole di visibilità degli identificatori. La stessa regola vale per i parametri della funzione. 5
Il problema della terminazione Le funzioni ricorsive, come le istruzioni iterative, introducono la possibilità di computazioni che non terminano. La chiamata ricorsiva di una funzione deve essere subordinata ad una condizione che, ad un certo istante, divenga non soddisfatta. La tecnica fondamentale utilizzata per dimostrare la terminazione di una chiamata ricorsiva consiste nel definire una funzione f(x) (x è l'insieme delle variabili del programma), tale che f(x) 0 implichi la condizione di terminazione e nel dimostrare che f(x) decresce ad ogni chiamata. Nelle applicazioni pratiche è doveroso dimostrare che la massima profondità di ricorsione non solo è finita, ma è anche piccola (uso intensivo della memoria). 6
Quando non usare la ricorsione Gli algoritmi ricorsivi sono particolarmente appropriati quando i problemi, o i dati da trattare, sono definiti in termini ricorsivi. Ciò non significa che la presenza di una definizione ricorsiva basti a garantire che il modo migliore per risolvere un problema sia tramite un algoritmo ricorsivo. La spiegazione del concetto di algoritmo ricorsivo mediante esempi inappropriati, è stata una delle cause dell'identificazione del concetto di ricorsione con quello di inefficienza. 7
Quando non usare la ricorsione (cont.) La ricorsione deve essere evitata per quella classe di problemi in cui si devono calcolare dei valori che sono definiti in termini di più o meno semplici relazioni di ricorrenza. Fattoriale: f(n)=n*f(n-1) f(0)=1 int fattoriale_r (int n){ if (n==0) return 1; else return n*fattoriale_r(n 1); int fattoriale (int n){ int fatt=1; while(n>0) fatt*=n ; return fatt; Fibonacci: f(n)=f(n-1)+f(n-2) f(1)=1, f(0)=0 int fib_r (int n){ if (n==0 n==1) return n; else return fib_r(n 1)+fib_r(n 2); int fib (int n){ int i=1,fib=1,y=0; if (n==0) return 0; while(i<n){ fib+=y; y=fib y; i++; return fib; 8
Quando non usare la ricorsione (cont.) Quindi la lezione da trarre è che la ricorsione deve essere evitata quando esiste una soluzione iterativa ovvia. Ciò però non deve indurre ad evitare la ricorsione a qualunque costo. Il fatto che esistano implementazioni di funzioni ricorsive per macchine essenzialmente non ricorsive, dimostra che, per scopi pratici, ogni programma ricorsivo può essere trasformato in uno puramente iterativo. Questo procedimento, però, necessita della manipolazione esplicita di una pila di chiamata ricorsive che rende l'essenza del programma così oscura che esso diventa estremamente difficile da capire. Concludendo, algoritmi che per loro natura sono ricorsivi, piuttosto che iterativi, dovrebbero essere formulati con delle funzioni ricorsive. 9
Algoritmi di Backtracking Un'attività programmativa particolarmente affascinante è quella della risoluzione generale di problemi. Essa consiste nel determinare algoritmi che trovino delle soluzioni a specifici problemi senza seguire una regola fissa di computazione ma che procedano per tentativi. Decomposizione del procedimento in obiettivi parziali che possono essere espressi in termini ricorsivi e consistono nell'esplorazione di un numero finito di sottobiettivi. L'intero procedimento potrebbe essere visto come un processo di ricerca che gradulamente costruisce e percorre (pota) un albero di obiettivi parziali. Per molti problemi tale albero (e quindi il costo della ricerca) cresce molto rapidamente e deve essere potato mediante metodi euristici. 10
Algoritmi di Backtracking (cont.) Gli algoritmi di backtracking possono essere schematizzati nel seguente modo: vengono testati dei passi verso la soluzione finale. i passi vengono memorizzati e possono essere successivamente percorsi all'indietro e cancellati, se si scopre che non possono condure alla soluzione globale, cioè che portano ad un vicolo cieco. function tenta (candidato){ inizializza la scelta dei candidati successivi; while(la soluzione non è raggiunta && ci sono ancora candidati){ seleziona il candidato successivo; if (è accettabile){ registralo; if(la soluzione non è raggiunta){ tenta(candidato successivo); if (ha avuto esito negativo) cancella la registrazione; 11
Problemi risolvibili con il Backtracking Raggiungibilità in un grafo Il problema delle otto regine Il labirinto Il sudoku 12