Corso di Informatica Generale II u.d. Traccia delle lezioni sulla calcolabilità Mauro Brunato 17 maggio 2006 Indice 1 Introduzione 1.1 Esempi . . . . . . . . . . 1.1.1 Calcolo numerico . 1.1.2 Calcolo simbolico 1.1.3 Teorie matematiche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 2 2 2 3 2 Risultati di base 2.1 Alfabeti, stringhe e programmi . . . 2.1.1 L’insieme dei programmi C 2.2 Funzioni . . . . . . . . . . . . . . . 2.3 Funzioni calcolabili . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 4 4 5 6 3 Funzioni non calcolabili 3.1 La terminazione di un programma C . . . . . . . . . . . . . . . . . . 3.2 Irrisolvibilità dell’halting problem . . . . . . . . . . . . . . . . . . . 3.3 Una semplificazione . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 7 8 9 4 Ricorsività 4.1 Esempi . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2 Il problema della fermata è ricorsivamente enumerabile . . . . . . . . 4.2.1 Un dubbio... . . . . . . . . . . . . . . . . . . . . . . . . . . 10 10 10 11 . . . . . . . . . . . . . . . . 2 Sommario Questa dispensa prsenta una traccia delle lezioni relative alla calcolabilità. Non è necessariamente comprensibile da chi non ha seguito le lezioni. Il documento è soggetto a variazioni e aggiustamenti durante il corso del semestre di insegnamento; l’ultima versione piò sempre essere recuperata dalla sezione “risorse” della pagina del corso di Informatica Generale II u.d. per Matematica sul sito http://dit.unitn.it/˜brunato/info2/ Per distinguere le versioni è sufficiente confrontare la data riportata sotto il titolo con quella scritta nell’elenco delle risorse (non è dunque necessario scaricare il file tutte le volte). Capitolo 1 Introduzione Nell’attività di un matematico esistono alcuni metodi di lavoro che portano a risultati comunicabili: la dimostrazione di un teorema, l’esecuzione di un calcolo, l’introduzione di nuove definizioni. . . Per contro, un articolo scientifico che utilizza procedimenti “arbitrari” come la lettura dei fondi di caffè o ispirazioni varie non può essere considerato accettabile. Nel seguito, considereremo “rigoroso” un procedimento che può essere espresso in un linguaggio di programmazione, come la Macchina di Turing o un linguaggio di programmazione moderno, che hanno esattamente la stessa potenza: ogni cosa esprimibile in uno dei due formalismi è esprimibile nell’altro. Il punto fondamentale della teoria della calcolabilità è proprio l’Ipotesi di ChurchTuring: ogni procedimento matematico che sia in qualche modo “accettabile” può essere espresso in termini di un programma per Macchina di Turing (o di programma C). 1.1 Esempi 1.1.1 Calcolo numerico C’è poco da dire: è un campo nel quale i calcolatori da sempre battono gli uomini, e un’operazione sui numeri è un caso evidente di procedimento esprimibile in linguaggio C. 1.1.2 Calcolo simbolico La risoluzione simbolica di equazioni, integrali e altro è un campo in cui regna la creatività umana: i risultati migliori si ottengono tuttora applicando con cognizione di causa le regole classiche (integrazione per parti, procedimenti di riduzione a forme note. . . ). Ciononostante, esistono programmi estremamente versatili: Mathematica, Maple ed altri. Resta aperto il problema estetico di portare un particolare integrale in una forma “elegante”: l’eleganza è un criterio non esprimibile (almeno per ora) in termini di un linguaggio di programmazione, ma è percepita in modo diverso dai vari esseri umani, quindi non è detto che possa essere formalizzata. In altri termini: condizione necessaria all’esprimibilità di un procedimento in termini di linguaggi di programmazione è che questo procedimento sia inteso in senso univoco da tutti gli esseri umani. 2 1.1.3 Teorie matematiche Un teorema è una proposizione che viene dimostrata a partire da proposizioni precedentemente assunte come vere. Una teoria è data da un insieme di proposizioni vere a priori (assiomi) e da tutti i teoremi originati da queste. La dimostrazione di un teorema non è altro che una sequenza finita di proposizioni, l’ultima essendo l’enunciato del teorema che si vuole dimostrare, ciascuna delle quali • è un assioma, oppure • è un teorema dimostrato in precedenza, oppure • segue dall’applicazione del modus ponens a due proposizioni precedenti. Non è difficile convincersi che ogni dimostrazione può essere vista in questo modo. Se è cosı̀, è possibile scrivere un programma C, per quanto complesso, in grado di generare tutti i teoremi veri in una particolare teoria. Questo programma può ricevere come input gli assiomi della teoria (ad esempio l’aritmetica) e proverà a combinarli tra loro in tutti i modi possibili, ottenendo tutti i teoremi. È convinzione comune che nessun programma potrà mai sostituire il lavoro di un matematico, perché la discussione precedente non considera l’utilità o l’interesse di alcuni risultati rispetto ad altri, ma in sé la dimstrazione di teoremi è esprimibile in C. 3 Capitolo 2 Risultati di base 2.1 Alfabeti, stringhe e programmi Sia A un insieme finito che noi chiameremo alfabeto. Alcuni esempi: lettere dell’alfabeto latino, Kanji giapponesi, simboli compresi nel codice ASCII. Una stringa in A è una sequenza finita di elementi di A. La lunghezza di una stringa è il numero di elementi che la compongono. L’insieme delle stringhe di lunghezza n > 0 è quindi isomorfo all’insieme potenza An . Consideriamo anche la stringa nulla, unico elemento di A0 . L’insieme di tutte le stringhe si denota con A∗ = ∞ [ An . n=0 Osserviamo che, dato che A è un insieme finito, l’insieme delle stringhe di lunghezza n è anch’esso finito, ed ha cardinalità |A|n . L’insieme A∗ è invece infinito, ma è numerabile, in quanto unione numerabile di insiemi finiti. 2.1.1 L’insieme dei programmi C Un programma C è scritto come una sequenza di caratteri. In altre parole, i programmi C sono stringhe di caratteri ASCII. Ovviamente, non tutte le stringhe sono programmi C (è necessario che sia rispettata una data sintassi). Se chiamiamo A l’insieme dei caratteri ASCII e C l’inieme dei programmi C, otteniamo il seguente risultato fondamentale: C ⊂ A∗ . È evidente che l’insieme dei programmi C non può essere finito1 , quindi la seguente proposizione è vera: Proposizione 1 L’insieme dei programmi C è numerabile. 1 Si pensi ad esempio alla possibilità di scrivere programmi C di lunghezza arbitraria 4 2.2 Funzioni Sia X un qualunque insieme numerabile (ad esempio, i numeri naturali, oppure le stringhe su un alfabeto). Sia X X = {f : X → X} l’insieme di tutte le possibili funzioni aventi dominio e codominio in X. Con un procedimento diagonale analogo a quello di Cantor è possibile dimostrare per assurdo che X X non è numerabile. Supponiamo di possedere un’enumerazione (f1 , f2 , . . . ) di tutte le funzioni f : X → X. Sia (x1 , x2 , . . . ) un’enumerazione degli elementi di X (che è un insieme numerabile per ipotesi). Allora è possibile definire la funzione g : X → X: ( x1 se fi (xi ) 6= x1 g(xi ) = x2 altrimenti. È facile convincersi che g differisce da ogni funzione nella presunta enumerazione (f1 , f2 , . . . ) di X X , che quindi è incompleta. Abbiamo quindi il seguente risultato: Proposizione 2 Sia X un insieme numerabile, e sia X X l’insieme di tutte le funzioni da X in X. Allora X X è infinito e non numerabile. Abbiamo le due seguenti “specializzazioni” importanti. Corollario 1 L’insieme delle funzioni che mappano stringhe ASCII in stringhe ASCII è infinito e non numerabile. Corollario 2 L’insieme delle funzioni che mappano interi in interi è infinito e non numerabile. Notiamo inoltre che la prova per diagonalizzazione funziona anche se il codominio è finito, a patto che sia composto di più di un elemento. Consideriamo il caso delle funzioni di decisione, che mappano un dominio X su un insieme binario, ad esempio {0, 1}. Ad esempio, la funzione f : N → {0, 1} che mappa n in 1 se e soltanto se n è primo è una funzione di decisione. In generale, una funzione di decisione può essere vista come un procedimento che risponde “sı̀” o “no” per un elemento del dominio. In altre parole, una funzione di decisione classifica gli elementi di X in due categorie. Ogni funzione di decisione f : X → {0, 1} corrisponde a un sottoinsieme di X: χf = {x ∈ X : f (x) = 1}. Viceversa, a ogni sottoinsieme Y ⊆ X corrisponde una funzione di decisione, detta la funzione caratteristica di Y : ( 1 se x ∈ Y χY (x) = 0 altrimenti. Conseguenze immediate della Proposizione 2 sono le seguenti. Corollario 3 L’insieme delle funzioni di decisione sugli interi è infinito e non numerabile. Corollario 4 L’insieme delle funzioni di decisione sulle stringhe è infinito e non numerabile. 5 2.3 Funzioni calcolabili Abbiamo visto che, da un punto di vista sintattico, un programma C può essere visto come una stringa. Dal punto di vista operativo (“semantico”), un programma C è una cosa che riceve dati in ingresso e produce dati in uscita. I dati in ingresso e in uscita sono anch’essi stringhe ASCII, quindi un programma C è una funzione che mappa stringhe ASCII in sringhe ASCII. Nella notazione delle sezioni precedenti, abbiamo2 ∗ C ⊂ (A∗ )A . Si tratta di un’inclusione propria? Ricordiamo dalla Proposizione 1 che l’insieme dei programmi C è numerabile, ∗ mentre dal corollario 1 (A∗ )A non lo è. Se definiamo calcolabile una funzione per la quale esiste un programma C che la calcola, abbiamo il seguente risultato: Teorema 1 Esistono funzioni su stringhe di un alfabeto che non sono calcolabili. Possiamo anche limitarci a programmi C che operano su numeri interi (anch’essi ovviamente numerabili) e alle funzioni di decisione sui numeri interi (non numerabili): Teorema 2 Esistono funzioni di decisione sugli interi che non sono calcolabili. 2 In realtà, non stiamo considerando che più programmi (infiniti) calcolano la stessa mappa; il discorso, comunque, non cambia 6 Capitolo 3 Funzioni non calcolabili Il teorema 1 assicura che esistono funzioni non calcolabili. Nell’ambito delle funzioni di decisione sulle stringhe, la più famosa (nonché il primo esempio) è correlata all’halting problem. 3.1 La terminazione di un programma C Esistono programmi C la cui esecuzione termina sempre. Ad esempio, il seguente programma che decide se il numero in ingresso è primo oppure no termina per ogni valore in ingresso: scanf ("%d", &n); for ( i = 2; i < n && n % i; i++ ); printf ("%d", i == n); Viceversa, il programma scanf ("%d", &n); for ( i = n-1; i < n; i-- ); printf ("%d", i == n); non termina mai, dato che la condizione di continuazione del ciclo for è sempre soddisfatta. Altri programmi terminano, oppure no, a seconda del dato in ingresso. Il programma scanf ("%d", &n); for ( i = 0; i != n; i-- ); printf ("%d", i == n); termina se il valore in ingresso è negativo, altrimenti continua a decrementare i indefinitamente. Dato un programma p ∈ C ⊂ A∗ e una stringa s ∈ A∗ , definiamo p(s) la stringa fornita in uscita dal programma p quando riceve in input la stringa s. Dato che il programma non termina necessariamente, diremo che p(s) ∈ A∗ ∪ {∞}, dove la scrittura convenzionale p(s) = ∞ significa che il programma p non termina quando riceve in ingresso la stringa s. Il problema di determinare se un programma termina o prosegue indefinitamente può essere espresso nei seguenti termini. 7 Problema 1 (Halting problem) Dato un programma p ∈ C ⊂ A∗ e una stringa s ∈ A∗ , stabilire se p(s) ∈ A∗ . Si noti che, per via della tesi di Church-Turing, il problema può dirsi risolto soltanto se si individua una procedura formale e finita (in altre parole, un programma C) in grado di dare una risposta per ogni possibile programma e per ogni input. 3.2 Irrisolvibilità dell’halting problem Possiamo dimostrare per assurdo che una procedura per determinare la fermata di ogni programma e ogni input non esiste. Supponiamo infatti che esista. Potremo esprimerla come una funzione C: int HALT (char *p, char *s) { ... } La funzione HALT riceve in ingresso due stringhe. La prima viene interpretata come programma C, la seconda come l’input da fornire al programma. La funzione HALT termina sempre e restituisce 1 se p(s) ∈ A∗ , 0 se p(s) = ∞. Supponiamo anche che se p non contiene un programma C ben formato la funzione restituisca 1 (in altri termini, se p non è un programma C, allora assumiamo che p termina). Nell’ipotesi che la funzione HALT possa essere realizzata, chiamiamo H il seguente programma: gets (s); if ( HALT(s,s) ) while ( 1 ); printf ("1"); Il programma H riceve una stringa s in ingresso, verifica se s, intesa come programma, termina scrivendo quando riceve s stessa come ingresso, ovvero se s(s) ∈ A∗ . In tal caso, entra in un ciclo infinito. Altrimenti, se la procedura HALT (che supponiamo esistente) dice che s(s) = ∞, H termina scrivendo “1”. Data una stringa s in ingresso, H(s) può valere 1 oppure ∞. In particolare, • se HALT dice che s(s) ∈ A∗ , allora H(s) = ∞; • se HALT dice che s(s) = ∞, allora H(s) = 1 ∈ A∗ . L’assurdo si ottiene sostituendo nella descrizione sopra s con H, ovvero fornendo a H come input la stringa che definisce H stesso. Infatti, otteniamo che • se HALT dice che H(H) ∈ A∗ , allora H(H) = ∞; • se HALT dice che H(H) = ∞, allora H(H) = 1 ∈ A∗ . Insomma, la funzione HALT sbaglia sistematicamente quando viene applicata al programma H con input H. 8 3.3 Una semplificazione È possibile dimostrare molte “semplificazioni” del risultato precedente. Ad esempio: • non è possibile realizzare un programma che decide se un programma si ferma per ogni possibile stringa in ingresso; • non è possibile realizzare un programma che decide se un programma si ferma per qualche stringa in ingresso. • non è possibile realizzare un programma che decide se un programma si ferma per una stringa di ingresso prefissata. • non è possibile realizzare un programma che decide se un programma che non richiede dati in ingresso si ferma. 9 Capitolo 4 Ricorsività Sia X un insieme numerabile, ad esempio N, oppure A∗ . Un sottoinsieme Y ⊂ X si dice ricorsivo (in X) se la sua funzione caratteristica χY : X → {0, 1} è calcolabile. In altri termini, un insieme si dice ricorsivo se esiste un programma C che ermina per ogni ingresso decidendo se un elemento x ∈ X appartiene a Y . Nelle stesse ipotesi, il sottoinsieme Y si dice ricorsivamente enumerabile in X se esiste un programma C in grado di elencarne gli elementi. Un insieme ricorsivo è anche ricorsivamente enumerabile. Infatti, dato un programma che calcola la sua funzione caratteristica χY , la seguente procedura stampa tutti e soli gli elementi di Y : Per ogni elemento x ∈ X Se χY (x) = 1 Stampa x 4.1 Esempi L’insieme dei numeri primi è ricorsivo in N, in quanto esiste un programma C che, ricevendo in ingresso un numero naturale, scrive “1” se è primo, “0” altrimenti. L’insieme C dei programmi C ben formati è ricorsivo in A∗ ; infatti, esiste un programma C che permette di distinguere tra le stringhe che rappresentano un programma C e quelle che non li rappresentano. Questo programma è il compilatore, il cui compito è, fra gli altri, quello di verificare se una stringa (contenuta in un file) è un programma C. Ecco una procedura che elenca tutti i programmi C: Per ogni stringa s ∈ A∗ Se il compilatore compila s senza notificare errori Stampa s 4.2 Il problema della fermata è ricorsivamente enumerabile Consideriamo i programmi che non richiedono dati in ingresso. Per coerenza con la notazione già introdotta, se p ∈ C chiamiamo p() la stringa prodotta in uscita se non viene fornito input, con la convenzione che se p non termina allora p() = ∞. Definiamo 10 l’insieme dei programmi senza input che terminano: HLT = {p ∈ A∗ : p() ∈ A∗ } Una formulazione alternativa delle conclusioni del capitolo 3 è che HLT non è ricorsivo in A∗ . Pur non essendo ricorsivo, HLT è ricorsivamente enumerabile. Infatti, è sufficiente generare uno dopo l’altro tutti i programmi C validi ed avviarli in parallelo; non appena un programma si ferma, viene stampato. In questo modo, ogni programma che si ferma (cioè che appartiene a HLT ) viene prima o poi stampato; i programmi che non appartengono a HLT non si fermeranno mai, quindi non verranno mai stampati. Ecco una possibile realizzazione pratica della procedura descritta, che fa uso di un insieme S di stringhe e di un contatore n: S = ∅; n = 0; per ogni stringa s ∈ A∗ { Incrementa n; Aggiungi s all’insieme S; Per ogni stringa p ∈ S { Esegui p per n secondi; Se p è terminato prima di n secondi { Stampa p; Rimuovi p dall’insieme S; } } } Il nucleo del programma è l’istruzione “Esgui p per n secondi” che esegue il programma descritto dalla stringa p per un tempo limitato, interrompendolo nel caso in cui la sua esecuzioni duri più a lungo del tempo indicato. Supponiamo che un dato programma q ∈ A∗ termini. Allora, prima o poi arriverà il suo turno di essere inserito nell’insieme S, e prima o poi n diventerà abbastanza grande da permettere il suo completamento, e verrà stampato. Se q, viceversa, non termina, la condizione per la stampa non si verificherà mai, per quanto a lungo si esegua il programma, quindi q non verrà mai stampato. Si noti che la procedura è estremamente lenta: l’insieme S si “gonfia” ben presto di programmi, molti dei quali non terminanti, e per ciascuno di questi è prevista l’esecuzione ad ogni iterazione del ciclo esterno. 4.2.1 Un dubbio... Potremmo chiederci: dato che HLT è ricorsivamente enumerabile, per decidere se un programma p appartiene a HLT basta verificare se p viene stampato dalla procedura appena descritta. . . Dov’è il problema? 11