Laurea Magistrale in “Cinema e Media” Corso di “Rappresentazione e Algoritmi” Modulo I - 6 CFU Laurea Magistrale in “Scienze della Mente” Corso di “Intelligenza artificiale” Modulo I - 4 CFU Vincenzo Lombardo Note per il corso Queste note per i corsi di “Rappresentazione e algoritmi” e di “Intelligenza Artificiale” sono parte del programma d’esame 2013/14. L’idea di scrivere le note scaturisce dalla considerazione che, essendo frequentato il corso anche da studenti provenienti da altre sedi in Italia o all’estero, spesso la preparazione di base dell’informatica non è sufficiente per affrontare lo studio dei testi adottati (come da guida degli studi). Dopo una ricerca sul web di testi e dispense possibili, mi sono convinto che sono tutti pensati per scopi diversi da un corso nell’ambito dei media o della psicologia. Gli esempi riportati, la presentazione degli argomenti, l’obiettivo a cui è destinato lo studio non sono familiari agli studenti di “Cinema e media” e di “Scienze della Mente”. BOZZA Commenti benvenuti! Aggiornamento: Febbraio 2014 1 Introduzione: rappresentazione e algoritmi I computer oggi, anche connessi in rete, eseguono compiti molto differenziati tra loro, dalle prenotazioni delle vacanze al controllo di linee di metropolitana, per non dimenticare i compiti noiosi di inserimento dati in un foglio di calcolo. Sembra che non ci siano limiti alle mansioni che possono essere affidate a un calcolatore. Nell’esecuzione dei compiti, che possono essere soluzioni di problemi (qual è il viaggio più rapido tra Milano e Tozeur?), calcoli di funzioni (la soluzione di un’equazione differenziale), in generale, per passare da un input a un output, si adottano numerosi paradigmi, costruiti su modelli di macchine. Assumendo la nota architettura di alto livello di un computer, composta da un’unità di elaborazione centrale (CPU), dalla memoria (suddivisa tra centrale e di massa), e dai dispositivi di input/output, si può pensare a un modello di macchina su più livelli di astrazione (la cosiddetta struttura a cipolla), con l’individuazione di un linguaggio di rappresentazione a ogni livello. Il linguaggio macchina rappresenta il livello di descrizione più vicino alla modalità di funzionamento fisico di un elaboratore (operazioni al livello dei bit). Anche la più sofisticata delle macchine attuali è un ammasso di bit, interruttori che possono cambiare di stato, o come si dice “flippati” da uno stato all’altro dei due possibili, 0/1, Acceso/Spento, On/Off, … , o “testati” per il loro stato. L’operazione base di flip del bit, la descriviamo con un esempio: DATO: 01101 ISTRUZIONE: FLIP DEL TERZO BIT DATO: 01001 ISTRUZIONE: FLIP DEL PRIMO BIT DATO: 11001 Lo complichiamo leggermente, aggiungendo un test che vincola il flip ad avvenire solo nel caso in cui un bit valga un valore specifico, 0 o 1: DATO: 01101 ISTRUZIONE: SE IL TERZO BIT = 0, ALLORA FLIP DEL QUINTO BIT DATO: 01101 ISTRUZIONE: SE IL TERZO BIT = 1, ALLORA FLIP DEL PRIMO BIT DATO: 11101 L’astrazione del linguaggio assemblato sul linguaggio macchina è quella di inserire notazioni simboliche sulle locazioni di memoria, che contengono istruzioni (rappresentate da codici operativi mnemonici, opcode) e dati (ad esempio, in codifica esadecimale), pur mantenendo una corrispondenza stretta con il linguaggio macchina. 2 Ad esempio, l’istruzione in linguaggio assemblato Intel (riportata alla voce di Wikipedia) MOV AL, 61h ; Load AL with 97 decimal (61 hex) permette di caricare il registro AL (un’area di memoria riservata per l’accumulo dei risultati) con il valore numerico 97 (MOV indica proprio il “movimento” del dato verso il registro e 61h è il codice esadecimale del decimale 97). Si noti anche il commento che segue il “;”, utile solo al programmatore (umano) per ricordarsi il significato dell’istruzione. Per eseguire un programma in linguaggio assemblato, al livello macchina si scrive un programma traduttore, l’assemblatore, che si occupa di tradurre le istruzioni in linguaggio assemblato nelle corrispondenti istruzioni in linguaggio macchina (interpretabili direttamente dalla macchina). Il linguaggio assemblato, con la sua astrazione simbolica, porta l’attenzione sulla nozione di rappresentazione dei dati. Infatti, “flippare” i bit o caricare un registro con un dato, sono operazioni di per sé prive di contenuto, se non interpretiamo il significato del bit flippato o del dato caricato, rispettivamente. Una sequenza di bit (anche detta parola binaria) o un simbolo (come AL) possono rappresentare qualsiasi cosa a seconda dell’interpretazione adottata. Nel caso del codice ASCII (7 bit per la rappresentazione dei caratteri) la sequenza di bit 110 0001 rappresenta il carattere ‘a’; la stessa sequenza può essere interpretata come il numero decimale “97” (quindi a livello macchina sarebbe questa la traduzione del dato sopra, 61h, nell’esempio in linguaggio assemblato). Ogni qualvolta si lavori su un certo dominio, si prendono in esame le sue categorie, gli elementi individuali, i tratti che li contraddistinguono, le relazioni tra gli elementi, e si progetta una rappresentazione secondo una codifica. La codifica si basa su una struttura esistente o direttamente sul sistema binario. Vediamo due esempi. 1) Nel mondo delle immagini digitali, il colore di un pixel viene espresso con un valore discreto rispetto a uno spazio colore. Nello spazio colore RGB, una tripla di valori numerici (espressi con una parola binaria di 24 bit, 8+8+8, nella modalità TrueColor) rappresenta rispettivamente le quantità di Rosso (Red), di Verde (Green), e di Blu (Blue) presenti in un colore. In questo caso, quindi, i codici binari sono inseriti in una struttura a tre componenti che si comporta in modo posizionale, assegnando la prima parola binaria al rosso, la seconda al verde, la terza al blu. Questa struttura si chiama Array di tre elementi. 2) Una sequenza di parole che forma una frase in linguaggio naturale può essere rappresentata mediante una struttura a Lista, che inserisce una parola in un elenco che produce un ordinamento da sinistra verso destra. 3 La differenza con la struttura precedente è che, mentre nel caso delle componenti RGB la distribuzione dei valori nell’Array dipende dalla posizione rispettivamente di R, G e B, ma non c’è una nozione inerente di precedenza tra sinistra e destra. Notiamo anche come le parole all’interno degli elementi si potrebbero rappresentare come liste o array di caratteri, a loro volta rappresentati in codice ASCII (vedi sopra). Queste rappresentazioni sono codificate nei linguaggi di alto livello (l’ultimo che consideriamo nella struttura a cipolla dell’informatica), e presentate come elementi predefiniti (succede per Array e Liste nel linguaggio Java, ad esempio) e come possibilità di costruzione per strutture personalizzate. I linguaggi di alto livello rappresentano una forma di astrazione superiore dalla macchina sottostante; le nozioni implicate, dalle strutture dei dati (organizzate in tipi base e tipi strutturati) alle istruzioni, organizzate in subroutine, sono più vicine alla comprensione che un essere umano ha del processo di risoluzione. Molti sono i paradigmi utilizzati per la progettazione dei linguaggi di programmazione, dal momento che sono molte le metafore usate per concepire una soluzione a un problema: linguaggi imperativi (una sequenza di comandi sottoposti a istruzioni di controllo, condizionali e iteratori, che realizzano le modifiche delle strutture in memoria), linguaggi funzionali (idealmente basati sulla nozione di funzione matematica, con la ricorsione come principale strumento di controllo), linguaggi a oggetti (che strutturano l’universo del problema come comportamenti di dati diversi, i cui risultati vengono combinati attraverso scambio di informazioni). Alcune di queste nozioni ci saranno utili per descrivere gli algoritmi. Prima però abbiamo bisogno di un inquadramento. Lo schema sottostante illustra il flusso di lavoro per la soluzione di un problema o più in generale la rappresentazione dinamica di una situazione in evoluzione. L’idea che non si tratti solo di risolvere un problema, ma di rappresentare una situazione dinamica, è una visione comune agli approcci di intelligenza artificiale e alla moderna visione dell’informatica in termini di agenti, o servizi, sempre in attesa di comandi. 4 Nel flusso si può osservare come, per trovare la soluzione di un problema o monitorare l’evoluzione di una situazione, si affronti un passo (creativo, con intervento umano) di modellazione, che traduce in un linguaggio formale una rappresentazione del problema o della situazione attuale. Si noti che si tratta di un passo di modellazione, poiché si richiede l’individuazione degli elementi fondamentali da inserire nella rappresentazione, trascurando gli elementi non significativi. Si noti anche che il linguaggio formale risponde a regole generali di corretta formazione delle frasi, espressioni, in quel linguaggio. Esistono centinaia di linguaggi possibili di rappresentazione, destinati a problemi specifici, dal mondo della fisica, della grafica, delle funzioni matematiche …, o di tipo general purpose, applicabili in senso lato. Ciascuno di essi arriva con i suoi simboli specifici, i costruttori di simboli personalizzati, le regole di composizione per le espressioni del linguaggio. Più avanti, useremo come linguaggio di rappresentazione quello della logica matematica. In base alla rappresentazione del problema/situazione, e in generale di input e output significativi per un certo caso, si elabora un algoritmo che permetta di raggiungere la soluzione o nei casi più complessi l’evoluzione corretta della situazione. L’algoritmo, cioè la sequenza di istruzioni che manipola la rappresentazione, produce una nuova rappresentazione. Occorre un passo inverso rispetto alla modellazione, che interpreti la rappresentazione prodotta in termini di soluzione al problema o di situazione aggiornata (interpretazione). Anche questo passo richiede l’intervento dell’uomo, che fornisce un contesto apposito e delle regole per interpretare correttamente i simboli. Oggi, in molte situazioni, l’interpretazione è fornita mediante un’interfaccia grafica, che presenta all’utente i risultati maturati in termini di strutture dati come quelle viste prime. Come si è visto, gli elementi cruciali per il raggiungimento della soluzione sono la rappresentazione, risultato del passo di modellazione e quindi sottoposta a un passo di interpretazione, nonché il terreno su cui lavora l’algoritmo che ricerca la soluzione, e l’algoritmo, il metodo escogitato per risolvere il problema o emulare il comportamento degli attori della situazione, che ne consentono un’evoluzione. L’algoritmica è la scienza e l’arte degli algoritmi. L’algoritmica affronta i problemi modellando un processo di manipolazione dei dati in input in modo da produrre l’output corretto. Con gli algoritmi ci confrontiamo tutti i giorni: dalle operazioni aritmetiche (pensiamo all’algoritmo dell’addizione appreso a scuola), cercare una parola in un dizionario (sfogliare le pagine guardando le voci riportate a bordo pagina fino alla coppia di pagine che contiene la parola cercata e quindi scorrere le voci all’interno delle due pagine), spedire una email (lanciare il programma client di posta, aprire il modulo di spedizione, inserire l’indirizzo, scrivere il testo, inviarla). Si tratta dello spirito della computazione, non della sua concretizzazione fisica nell’elaboratore digitale. Non è un nostro obbiettivo affrontare uno studio esaustivo degli algoritmi, con le possibili classificazioni (divide et impera, programmazione dinamica, …). Qui descriviamo solo alcuni algoritmi esempio, per comprendere come si possa simulare il loro comportamento nella risoluzione dei problemi. La simulazione ha l’obiettivo di far comprendere il funzionamento della macchina sottostante in termini di evoluzione delle strutture in memoria; gli elementi principali che incontreremo nella scrittura degli algoritmi ci saranno utili per comprendere gli aspetti fondamentali dell’intelligenza artificiale e la rappresentazione della conoscenza in termini logici. 5 La scrittura degli algoritmi Sia dato un problema o una situazione, di cui si intende trovare la soluzione o determinarne le possibili evoluzioni, rispettivamente. Facciamo due esempi: • un problema può essere l’ordinamento di una sequenza casuale di parole secondo l’ordine alfabetico; • una situazione è una possibile configurazione di un videogioco di cui si vuole trovare una possibile evoluzione, l’evoluzione migliore per uno dei personaggi o tutte le possibili evoluzioni. Un algoritmo consiste di istruzioni elementari, tratte da un insieme di istruzioni che sono possibili per il problema/situazione (ad esempio, spostare una parola della sequenza o muovere un pezzo degli scacchi in modo opportuno), che, dato in input un insieme di valori, produce in output la soluzione al problema o un’evoluzione possibile della situazione. Data la discussione precedente, dovrebbe essere chiaro che occorre fare un passo di modellazione per stabilire quali sono le istruzioni possibili, come si indica l’ordine in cui si eseguono, come sono fatti gli oggetti che sono manipolati dalle istruzioni. L’esecuzione di un algoritmo viene portata avanti dalla macchina, o processore, che legge le istruzioni e i dati (rappresentazioni) da manipolare secondo le istruzioni e produce nuovi dati che rappresentano la soluzione o la nuova situazione. Quale istruzione occorre eseguire a un certo punto è di cruciale importanza, da cui discende che il processore deve essere a conoscenza di come eseguire le istruzioni e dell’ordine con cui si eseguono; infine, il processore deve sapere quando fermarsi. Si possono quindi individuare due tipi di istruzione da comunicare al processore: • le istruzioni di contenuto, necessarie per manipolare i dati, dipendenti dalla loro rappresentazione e dal linguaggio usato per la rappresentazione; • le istruzioni di controllo, necessarie per stabilire l’ordine di esecuzione delle istruzioni. Nel tempo sono stati introdotti numerosi paradigmi o modelli di computazione, che hanno introdotto diverse modalità di comportamento degli algoritmi, immaginando processori sottostanti di tipo diverso. Mantenendo la dicotomia tra dati e istruzioni, dobbiamo ricorrere a linguaggi per i dati e per le istruzioni: le istruzioni di base, che manipolano i dati, devono essere coerenti con la rappresentazione dei dati; le istruzioni di controllo devono essere coerenti con il comportamento del processore che dovrà poi eseguire l’algoritmo. Cominciamo dalle istruzioni di controllo. Qui consideriamo un modello ispirato direttamente dall’architettura di macchina vista sopra. L’architettura che consideriamo è di fatto un’architettura virtuale, ma in questo caso coincide abbastanza con l’architettura della macchina fisica vera e propria. Altre architetture virtuali si distanziano di più dalla macchina fisica, rendendo necessaria una realizzazione più articolata delle transizioni tra i livelli della cipolla. a) La prima istruzione di controllo deriva dalla considerazione intuitiva della sequenza di istruzioni (come avviene per le ricette di cucina ad esempio), che fa riferimento diretto all’organizzazione della memoria, che contiene le istruzioni e che è costituita da celle con indirizzi consecutivi. Prelevando le istruzioni dalla memoria, è immediato prelevare un’istruzione a partire dalla precedente, sommando uno all’indirizzo in memoria della precedente. Questa istruzione di controllo si può chiamare sequenzializzazione diretta. Di solito, nella scrittura algoritmica che useremo qui, le istruzioni da eseguire in sequenza sono scritte su righe successive di 6 testo. Ad esempio, in un algoritmo che risolve il problema di ricercare i multipli di 7 tra 1356 e 1376 ci sono due istruzioni in sequenza del tipo Calcola il resto della divisione per 7 Incrementa il numero corrente di 1 Quando l’esecuzione sarà arrivata a considerare il numero 1357, l’incremento di 1 permetterà di prendere in considerazione il numero 1358 e l’istruzione successiva ne calcolerà il resto della divisione per 7. Nella scrittura algoritmica si darà per scontato questo controllo, posizionando le istruzioni sulle righe del testo. b) La seconda istruzione di controllo si chiama condizionale e ha la forma if C then A (else B) C è una condizione che viene valutata VERO o FALSO (secondo la logica booleana a due valori, un po’ come avviene per i bit): se essa viene valutata VERO, allora si eseguiranno le istruzioni codificate con A (potrebbe essere una istruzione singola o una sequenza di istruzioni A1, A2, … An, secondo l’istruzione di controllo vista prima); se essa viene valutata FALSO, allora si eseguiranno le istruzioni codificate con B (o la sequenza B1, B2, … Bn). La presenza della parentesi indica che la parte alternativa non è necessaria: l’istruzione condizionale potrebbe essere limitata a fare qualcosa solo se la condizione C risulta vera. In questo caso, il controllo una volta verificato che la condizione C è falsa, si limiterebbe a proseguire con l’istruzione successiva nella sequenza. Riprendendo l’esempio dell’algoritmo che ricerca i multipli di 7, occorre memorizzare un ritrovamento se il resto della divisione è zero: if Resto della divisione è 0 then Memorizza il numero corrente C è la condizione che il resto della divisione uguale a 0 sia VERO; A è l’istruzione di memorizzare il numero che ha prodotto tale resto; non si ha una componente alternativa (else). Si noti anche una pratica comune di indentare un’istruzione qualora sia relativa a un ambito (detto scope) specifico: in questo esempio, l’istruzione di memorizzazione avverrà solo nel caso in cui la condizione risulti vera (scope della parte Then) c) Come avrete compreso, per trovare tutti i multipli di 7 tra 1356 e 1376, occorre aumentare di uno una ventina di volte; per le tutte le 21 volte occorre poi calcolare il resto della divisione per 7 e memorizzare eventualmente il numero ottenuto. Questa ripetizione di istruzioni porta a una terza istruzione di controllo: l’iterazione o loop. L’iterazione è la responsabile della realizzazione di processi lunghi di calcolo, anche se il numero di istruzioni è molto ridotto. In particolare, si potrebbero avere processi lunghi un numero qualsiasi di istruzioni (supponete di ricercare i multipli di 7 in un intervallo di valori di lunghezza qualsiasi), ma le istruzioni sono di fatto le tre che abbiamo visto prima (una sequenza di tre istruzioni, di cui l’ultima è un condizionale). Si possono riconoscere due tipi di iterazione. L’iterazione limitata, della forma for N volte do A 7 In questo caso si intende che l’istruzione (o la sequenza di istruzioni) A viene eseguita N volte. Ad esempio, nel caso precedente si potrebbe scrivere for 21 volte do Calcola il resto della divisione per 7 Incrementa il numero corrente di 1 Per 20 volte si incrementa il numero corrente di 1 e si calcola il resto della divisione per 7. Non inserendo i 21 numeri in modo esplicito, rende l’algoritmo generalizzabile a una ventina di numeri consecutivi qualsiasi. L’istruzione di iterazione condizionale o illimitata è necessaria quando non si conosce in anticipo il numero di volte che occorre iterare. Le espressioni in questo caso sono del tipo while C do A che si interpreta come “mentre la condizione C è VERA fai A”. Questa istruzione determina un controllo del tipo “Verifica la condizione C; se è VERA, esegui A; al termine verifica ancora C; se è VERA, esegui A; … quando la condizione C diventa FALSA, si esce dal ciclo e si procede con l’istruzione successiva al WHILE”. L’esempio precedente, l’algoritmo che ricerca i multipli di 7 tra 1356 e 1376 potrebbe essere codificato nel modo seguente: Sia 1356 il numero corrente while numero corrente minore o uguale a 1376 do Calcola il resto della divisione per 7 if Resto della divisione è 0 then Memorizza il numero corrente Incrementa il numero corrente di 1 Come si vede, le istruzioni di controllo possono essere annidate in altre istruzioni di controllo (if contenuto nel while) per costruire sofisticate condizioni di esecuzione; anche nella scrittura, l’indentazione riflette questi scope annidati. Nell’esecuzione, il numero corrente all’inizio è fissato a 1356. Superata la prima verifica (1356 ≤ 1376), si calcola il resto della divisione per 7 e in caso sia 0 (non lo sarà prima del 1358), il numero viene memorizzato. Con l’incremento di 1, si rientra nel ciclo con il numero corrente a 1357. Si lascia il while per proseguire all’istruzione successiva quando si raggiunge quota 1377 (in quanto 1377 > 1376). Prima di procedere con un’ultima nozione di controllo che ci sarà molto utile in seguito, soffermiamoci un attimo sulle istruzioni di contenuto. Come abbiamo affermato in precedenza, le istruzioni di contenuto dipendono dalla rappresentazione dei dati che sono manipolati e di conseguenza dal linguaggio utilizzato per la rappresentazione. Una nozione elementare, basata sul modello di architettura di alto livello utilizzato come riferimento è la nozione di area di memoria, a cui si associa tipicamente un simbolo nei linguaggi di alto livello. Spesso al simbolo è associato anche un tipo di dato: quindi, se diciamo che nc è una variabile di tipo intero, la sua rappresentazione in memoria rappresenterà un numero intero qualsiasi. Operativamente, ci possiamo figurare questa situazione come un’area di memoria che viene indicata con il nome nc. 8 La scatola disegnata è un’area di memoria adeguata a contenere il dato nc; nella scrittura algoritmica, il dettaglio del tipo di dato è spesso trascurato; ci pensa il programmatore, una volta sottoposto ai vincoli imposti dal linguaggio di programmazione. I nomi delle aree di memoria possono essere costanti o molto più frequentemente variabili; cioè il loro contenuto viene modificato con istruzioni di contenuto chiamate assegnazioni. Noi le indicheremo con la forma nc ← 1356 che comporta una modifica dell’area di memoria: Si noti che il simbolo ← indica il movimento del dato dentro l’area di memoria associata con il simbolo nc. L’istruzione nc ← nc + 1 incrementa di 1 il contenuto dell’area di memoria nc: Con l’utilizzo delle variabili possiamo riscrivere con istruzioni di contenuto più vicine alla rappresentazione della macchina l’algoritmo della ricerca dei multipli di 7. nc ← 1356 while nc ≤ 1376 do r ← resto di nc diviso 7 if r = 0 then inserisci nc in un elenco di multipli di 7 nc ← nc + 1 In questa scrittura, le parti di rappresentazione formale dovrebbe essere quasi tutte comprensibili, da consentire un esercizio di simulazione delle variazioni della memoria con l’esecuzione dell’algoritmo. Questa scrittura algoritmica si dice in pseudo-codice e corrisponde a una nozione intuitiva di computazione che fa riferimento a un modello di macchina come l’architettura vista sopra. Man mano che istruzioni in pseudo-codice sono rimpiazzate da istruzioni più vicine alla rappresentazione in memoria (come è avvenuto per l’assegnazione), l’algoritmo muta per diventare un vero e proprio programma. Questo metodo di scrittura di un programma di dice per raffinamenti successivi (stepwise refinement). Noi ci fermeremo quasi sempre a un livello informale, assumendo istruzioni intuitivamente plausibili ma allo stesso tempo immediatamente traducibili in un linguaggio di programmazione imperativo; tuttavia, a volte è utile raffinare per motivi di concisione 9 e non ambiguità della scrittura, in quanto le istruzioni in linguaggio naturale potrebbero risultare tediose e poco comprensibili. In questi termini, rimangono da formalizzare due righe del nostro algoritmo. Cominciamo dalla penultima, “inserisci nc in un elenco di multipli di 7”. Per costruire l’elenco e la corrispondente operazione di inserimento, ci possiamo riferire alle nozioni di Array e Lista introdotte in precedenza. Sono entrambi delle forme di elenco: l’Array è una struttura di più componenti, ciascuno dei quali si può riferire con un indice (o cursore) relativo alla sua posizione assoluta nell’elenco e il posizionamento relativo delle componenti (chi precede o segue chi) si riferiscono alla corrispondente relazione tra i cursori (la componente R precede la componente G nella struttura RGB dei colori perché l’indice assoluto di R è 1, e precede l’indice assoluto di G che è 2); la Lista è di nuovo una struttura di più componenti in un elenco, ma l’ordine tra le componenti si riferisce direttamente alle posizioni relative tra le componenti (“Le” precede “aquile”, come “aquile” precede “volano”, come …, nella frase sopra, e l’indice assoluto di “Le”, che è 1, è una conseguenza del fatto che nessuna parola precede). Noi useremo entrambe le strutture nei nostri esempi, a seconda delle caratteristiche del problema. Nell’esempio attuale useremo la Lista: l’operazione di inserimento al fondo di una lista lo chiameremo “append”, intendendo che il valore inserito si posiziona all’attuale ultima posizione dell’elenco. Il codice viene completato da una creazione della struttura lista (che si assume vuota all’inizio). nc ← 1356; lista_multipli_di_7 ← Lista_vuota while nc ≤ 1376 do r ← resto di nc diviso 7 if r = 0 then append (nc, lista_multipli_di_7) nc ← nc + 1 La situazione iniziale e finale in memoria della variabile lista_multipli_di_7 è Ogni append aggiunge un elemento al fondo della lista, fino a un totale di tre elementi nel nostro caso (i multipli di 7 tra 1356 e 1376); l’append è scritta in una formulazione nella notazione funzionale y ← f(x) dove per y si intende il risultato della funzione f applicata sull’argomento x. Nel nostro caso, gli argomenti sono due: l’elemento da inserire (il numero corrente) e la lista da aggiornare (l’elenco dei multipli di 7 tra 1356 e 1376), quindi sarebbe una sorta di f(x1,x2). Nel caso particolare dell’append, non si ha un risultato vero e proprio, ma l’aggiornamento dell’elenco, cioè della memoria contenente il valore della variabile lista_multipli_di_7. Infine, si noti la formulazione del nome della variabile, una serie di parole inframezzate dal simbolo “_” (underscore): si tratta di una convenzione comune negli ambienti di programmazione. Una convenzione simile è quella che utilizza come nomi di variabili delle composizioni in cui la prima parola inizia con una lettera minuscola e le parole successiva iniziano con una lettera maiuscola: nel nostro esempio sarebbe listaMultipliDi7. 10 L’ultima riga da formalizzare nell’algoritmo è r ← resto di nc diviso 7 Il resto di una divisione è un’operazione predefinita nei linguaggi di programmazione, denominata modulo. Quindi, eliminando anche la variabile r, la versione definitiva dell’algoritmo è nc ← 1356; lista_multipli_di_7 ← Lista_vuota while nc ≤ 1376 do if (nc mod 7) = 0 then append (nc, lista_multipli_di_7) nc ← nc + 1 Chiudiamo questa parte sulle istruzioni di contenuto con il concetto di subroutine (o procedura o funzione). Anche con la formalizzazione di append avremmo potuto introdurre la nozione di subroutine, ma la sua realizzazione ci avrebbe portato un po’ distante con i dettagli di aggiornamento della struttura in memoria. Una subroutine si può definire come un’istruzione di contenuto complessa, un’istruzione che fa qualcosa di più articolato di una istruzione di base e un po’ come è successo per l’append si applica a dati con una struttura ben precisa. Cominciamo da un esempio. Vogliamo provare a generalizzare l’algoritmo di ricerca dei multipli di 7 tra 1356 e 1376 alla ricerca di multipli di un numero qualsiasi in un intervallo qualsiasi. Questa generalizzazione si ottiene già introducendo dei simboli variabili per il 7 (ad esempio, la variabile n), il 1356 (inizio_intervallo) e 1376 (fine_intervallo). L’algoritmo diverrebbe già il seguente (notare anche il cambio, non necessario, del nome della variabile elenco): inizio_intervallo ← 1356; fine_intervallo ← 1376 n ← 7 nc ← inizio_intervallo; lista_multipli_di_n ← Lista_vuota while nc ≤ fine_intervallo do if (nc mod n) = 0 then append (nc, lista_multipli_di_n) nc ← nc + 1 Tuttavia, qui vogliamo provare a variare un po’ la logica dell’algoritmo, della soluzione del problema. Si può pensare di introdurre un’istruzione articolata che, dato un inizio di intervallo e un numero, restituisca il multiplo del numero successivo all’inizio dell’intervallo. Cioè, se inizio_intervallo è 1356 e n è 7, allora la funzione prossimo_multiplo restituisce 1358. Se noi possedessimo tale funzione (o subroutine), potremmo scrivere l’algoritmo precedente in questo modo: inizio_intervallo ← 1356; fine_intervallo ← 1376 n ← 7; lista_multipli_di_n ← Lista_vuota multiplo ← prossimo_multiplo (inizio_intervallo, n) while multiplo ≤ fine_intervallo do append (nc, lista_multipli_di_n) multiplo ← prossimo_multiplo (inizio_intervallo, n) Nella nuova versione, l’algoritmo calcola subito il primo multiplo successivo all’inizio dell’intervallo e aggiorna l’elenco dei multipli di n all’interno dell’intervallo 11 finché i multipli, che costituiscono a ogni iterazione il nuovo inizio di intervallo, non superino la fine dell’intervallo. Vediamo ora come scrivere la subroutine prossimo_multiplo. integer prossimo_multiplo (inizio, num) m ← inizio while (m mod num) ≠ 0 do m ← m + 1 return m La funzione prossimo_multiplo avanza di 1 finché trova un multiplo di num; a quel punto lo restituisce come valore di funzione, che nell’algoritmo precedente finisce nella variabile multiplo. I benefici delle subroutine sono evidenti: non solo abbiamo generalizzato il calcolo del multiplo di un numero successivo a un certo valore, ma la subroutine si può chiamare anche in altre occasioni che richiedono la sua competenza. La subroutine è organizzata in modo da pubblicare all’esterno ciò che è necessario per l’esecuzione: all’interno della parentesi, come avveniva anche per append, sono presenti i parametri di input (variabili usate all’interno della subroutine che prendono i valori dall’esterno); il tipo di dato indicato davanti al nome della subroutine (integer, nel nostro esempio) indica come interpretare il risultato (sarà il valore della variabile multiplo). La subroutine si comporta come uno specialista, competente in un determinato ambito, che viene chiamato a eseguire un lavoro; all’esterno (cioè nell’algoritmo chiamante), la subroutine risulta essere una blackbox, una scatola nera che lavora in modo autonomo e restituisce il risultato a fronte di un input ben definito. La possibilità di definire e chiamare le subroutine, presenti in tutti i linguaggi di programmazione, permette di suddividere il lavoro in componenti più semplici e di ridurre al minimo le possibilità di introdurre errori nella programmazione, dal momento che un brano di codice che produce un certo comportamento è inserito solo in un punto del programma. Inoltre, accorcia gli algoritmi, rendendo più leggibile il codice, privo dei dettagli che sono affrontati all’interno della subroutine; chiaramente, nelle subroutine si possono annidare altre subroutine. La nozione di subroutine è immediatamente utile in un caso particolare di istruzione di controllo: la ricorsione. La ricorsione è uno degli aspetti del controllo che porta maggiore confusione, in quanto mette a disposizione degli algoritmi il potere di chiamare se stessi come specialisti competenti di un certo lavoro. La ricorsione è diversa dalla ciclicità ripetitiva se si introducono meccanismi che permettono nelle chiamate ricorsive di ridurre la dimensione del problema affrontato: in poche parole, un algoritmo richiama se stesso su una versione ridotta del problema, combinando in qualche modo il risultato prodotto dall’applicazione sul problema ridotto. E’ un costrutto simile, in linea di principio, all’iterazione (e infatti si può dimostrare l’equivalenza del potere espressivo), ma che sfrutta la nozione di ambiente di una funzione o subroutine per ridurre il carico di descrizione del controllo. Per illustrare la ricorsione, cominciamo da un esempio celeberrimo, la torre di Hanoi. Ispirato, secondo la tradizione, a un gioco praticato in un tempio indiano, la torre di Hanoi consiste di tre pioli, che chiamiamo A, B e C, e un certo numero di dischi impilati in uno dei pioli, supponiamo A, con il vincolo che un disco più grande non può star sopra un disco più piccolo. 12 Il gioco consiste nello spostare i dischi uno per volta, rispettando sempre il vincolo di cui sopra, per arrivare alla fine ad avere tutti i dischi impilati sul piolo B. Provando a giocare con i dischi, dopo un po’ di tentativi inutili, si può osservare che per spostare un certo numero di dischi occorre prima rimuovere i dischi sopra il più grande, spostare quindi il disco grande nel piolo di destinazione, e infine spostare sopra quel disco i dischi superiori prima accantonati in un piolo di supporto (per questo ce ne sono tre). Vediamo cosa succede nelle prime tre mosse. 13 Come si nota, per spostare i due dischi più piccoli (3 e 4) da A a B, abbiamo prima spostato il 4 sul piolo di supporto C, quindi il 3 sul piolo di destinazione B e infine abbiamo spostato il 4 sul piolo di destinazione B. Proseguendo, con il medesimo approccio, possiamo arrivare ad avere i tre dischi 2, 3 e 4, sul piolo C, per poter quindi spostare il disco più grande 1 sul piolo di destinazione B. Vediamo come ciò avviene. 14 A questo punto abbiamo spostato tre dischi dalla posizione originale A sul piolo C, che è un piolo di supporto, e abbiamo liberato il piolo B per accogliere il disco 1. Dopo avere sposta il disco 1, sarà sufficiente ripetere le operazioni effettuate per spostare i tre dischi (2, 3 e 4) da A a C, procedendo in questo caso da C a B, dove possono essere appoggiati, avendo alla base il disco 1, il più grande di tutti. Lasciamo al lettore la simulazione di questi ultimi passi. La considerazione generale da fare è che per spostare 4 dischi da A a B, si è proceduto spostando 3 dischi da A a C, spostandone quindi 1, il più grande, da A a B, infine spostando 3 dischi da C a B. Allo stesso modo, per spostare 3 dischi da A a C, si è proceduto spostando 2 dischi da A a B, quindi spostandone 1, il più grande dei 3, da A a C, infine spostando 2 dischi da B a C. In generale, insomma, per spostare N dischi dal piolo di origine (sia esso A, B o C) al piolo di destinazione (A, B o C), usando il terzo piolo (A, B o C) come supporto, occorre spostarne N-1 dal piolo di origine al piolo di supporto, quindi 1 dal piolo di origini al piolo di destinazione, infine N-1 dal piolo di supporto al piolo di destinazione. Come si vede, per risolvere un problema di dimensione N, si risolvono due problemi di dimensione N-1 e un problema di dimensione 1 (banale); la combinazione dei risultati dei sotto-problemi è intrinseca nella selezione dei ruoli dei pioli di volta in volta (origine, destinazione, supporto). Chiamando la subroutine con il nome hanoi, si ottiene il seguente algoritmo: hanoi (N, Origine, Destinazione, Supporto) if N = 1 then sposta il disco in Origine in Destinazione else hanoi (N-1, Origine, Supporto, Destinazione) hanoi (1, Origine, Destinazione, Supporto) hanoi (N-1, Supporto, Destinazione, Origine) La chiamata della subroutine hanoi all’interno della subroutine hanoi, ci fa comprendere che si tratta di una procedura ricorsiva. Si noti anche come i nomi dei parametri indichino, nell’intestazione della subroutine, i ruoli dei pioli stessi: il 15 secondo parametro rappresenta sempre l’origine, il terzo parametro la destinazione, il quarto parametro il piolo di supporto; infine, il primo parametro indica il numero di dischi. Sarà questo numero a essere diminuito a ogni chiamata ricorsiva (N-1), fino ad arrivare a 1, quando avverrà il vero e proprio spostamento di un disco. Come sarà chiaro tra un attimo, infatti, le chiamate ricorsivo introducono solo sovrastrutture di controllo che si assicurano solo l’ordine corretto delle mosse, ma non succede nulla finché N non diventa uguale a 1 (parte then del condizionale). Il caso N=1 si chiama base della ricorsione, il caso cioè che si può risolvere in modo immediato (lo spostamento di un disco da origine a destinazione – ci ha pensato la struttura della subroutine ricorsiva ad assicurare che il piolo di destinazione sia libero da impedimenti allo spostamento); la parte else del condizionale si chiama passo di ricorsione, cioè le istruzioni necessarie per condurci al caso base e la combinazione dei risultati. Le strutture di controllo introdotte (sequenza, condizionale, iterazione limitata e illimitata, ricorsione) e la rappresentazione dei dati semplici e aggregati in vettori (Array) e elenchi (Lista) ci permette di simulare l’evoluzione della memoria di un sistema mentre procede la computazione governata da un algoritmo. Nel prossimo paragrafo, simuliamo l’esecuzione dei due algoritmi qui presentati. La simulazione degli algoritmi In questa sezione, simuliamo l’esecuzione dell’algoritmo di ricerca dei multipli di un numero in un certo intervallo e la soluzione del gioco della torre di Hanoi. Alterniamo istruzioni degli algoritmi e contenuti della memoria durante l’esecuzione. 1. Algoritmo di ricerca dei multipli in un intervallo L’algoritmo è riscritto in modo completo, dopo le variazioni introdotte prima, nel modo seguente: lista ricerca_multipli_in_intervallo (inizio, fine, n) lista_multipli_di_n ← Lista_vuota multiplo ← prossimo_multiplo (inizio, n) while multiplo ≤ fine do append (multiplo, lista_multipli_di_n) multiplo ← prossimo_multiplo (inizio, n) return lista_multipli_di_n Simuliamo questo algoritmo nel caso della chiamata ricerca_multipli_in_intervallo(1356, 1376, 7) Al momento della chiamata, la macchina crea le aree di memoria necessarie, inclusi i parametri e le variabili locali: 16 multiplo ← prossimo_multiplo (inizio, n) while multiplo ≤ fine do append (multiplo, lista_multipli_di_n) multiplo ← prossimo_multiplo (inizio, n) while multiplo ≤ fine do append (multiplo, lista_multipli_di_n) multiplo ← prossimo_multiplo (inizio, n) while multiplo ≤ fine do append (multiplo, lista_multipli_di_n) multiplo ← prossimo_multiplo (inizio, n) while multiplo ≤ fine do … return lista_multipli_di_n 2. Torre di Hanoi Riscriviamo l’algoritmo ricorsivo della torre di Hanoi: 17 hanoi (N, Origine, Destinazione, Supporto) if N = 1 then sposta il disco in Origine in Destinazione else hanoi (N-1, Origine, Supporto, Destinazione) hanoi (1, Origine, Destinazione, Supporto) hanoi (N-1, Supporto, Destinazione, Origine) Simuliamo l’algoritmo con la chiamata hanoi(4, A, B, C) In questo caso, per dare conto delle chiamate ricorsive, è comodo costruire l’albero delle chiamate. L’albero è una struttura dati adatta alla rappresentazione di strutture gerarchiche: a partire da un nodo radice, si diramano i nodi figli, che possono avere a loro volta dei figli, fino a nodi senza figli, detti foglie. Nel caso dell’albero delle chiamate ricorsive di una funzione, i nodi foglia rappresentano il caso della ricorsione base e la radice la chiamata iniziale; per ogni nodo, si riporta sull’albero la situazione in memoria; la simulazione grafica sui dischi e i pioli la si ritrova nelle pagine precedenti. 18 Esempi di algoritmi e simulazioni La fase di modellazione dei dati e degli algoritmi è una fase creativa della messa in opera di un sistema software, che possa risolvere un problema o rappresentare una situazione in evoluzione. Molte conoscenze di base possono essere a dispositivo del “creativo” per disegnare il modello: una formulazione analitica del problema, una pratica quotidiana, la struttura in memoria che già ospita i dati. In questo paragrafo, vediamo due esempi di algoritmi e simulazioni, rispettivamente. I lettori sono invitati a ricavare le simulazioni dagli algoritmi. Ordinamento per inserimento E’ riportato nei testi classici di algoritmi come il metodo più semplice di ordinamento. Richiama il comportamento dei giocatori di carte, che ordinano una mano tipicamente suddividendo l’insieme di carte in due sequenze, una ordinata, che posizioniamo a sinistra, e una non ordinata, che posizioniamo a destra. Insertion_sort (Array A di n elementi) For i da 2 a n For j da 1 a i-1 If A[j] < A[i] Then Scambia A[j] e A[i] L’algoritmo usa la struttura Array per memorizzare la sequenza e rappresenta gli elementi con numeri naturali, assumendo la conoscenza del confronto di grandezza tra due numeri (<, >, =). I numeri non sono una limitazione dell’algoritmo; potrebbero essere elementi qualsiasi su cui si può stabilire un confronto di grandezza (ad esempio, l’ordine lessicografico per le parole di un dizionario). L’Array si può scorrere mediante due cursori, i e j, e gli elementi contenuti nell’Array sono indicati A[i] e A[j]. Quindi, l’elemento A[3] indica il contenuto del terzo elemento dell’array A: nell’esempio sottostante, A[3] = 8. Compresa la struttura in memorizzare, consideriamo i passi dell’algoritmo. Si tratta di due cicli annidati: il ciclo esterno muove il cursore i da 2 a n-1 (il penultimo elemento – 6 nell’esempio); il ciclo interno muove il cursore j dalla posizione i+1 (una in più della posizione di i) fino a n (7 nell’esempio). L’idea alla base dell’algoritmo è di dividere la sequenza in due parti: a sinistra una parte ordinata (che all’inizio consiste di un solo elemento, ordinata ovviamente, essendo lunga uno), che cresce di 1 elemento a ogni iterazione, con il cursore i del for più esterno; a destra, una parte non ordinata (che all’inizio vale n-1), che decresce di 1 elemento a ogni iterazione del for esterno. Il cursore i scorre nella parte disordinata (sulla destra); il cursore j scorre la parte ordinata (che finisce all’elemento che precede l’i-esimo); il ciclo esterno individua a ogni iterazione un elemento nella parte ordinata da piazzare nella parte disordinata, mediante scambio con un elemento nella parte ordinata (scorsa da j). 19 Nelle illustrazioni, la linea tratteggiata rossa indica la separazione tra la parte ordinata e parte non ordinata: all’inizio si trova tra il primo e il secondo elemento; alla fine si trova dopo la posizione n. Prima iterazione esterna: i = 2; j da 1 a 1; scambio tra A[1] e A[2] (5 < 7). Seconda iterazione esterna: i = 3; j da 1 a 2; nessuno scambio (8 è più grande di tutti i numeri della parte ordinata). Terza iterazione esterna: i = 4; j da 1 a 3; tre scambi (5 con 3 in posizione 1, 7 con 5 in posizione 2, 8 con 7 in posizione 3); 8 rimane il numero più grande della parte ordinata. 20 Quarta iterazione esterna: i = 5; j da 1 a 4; nessuno scambio (9 è più grande di tutti i numeri della parte ordinata). Quinta iterazione: i = 6; j da 1 a 5; cinque scambi (4 con 5 in posizione 2, 5 con 7 in posizione 3, 8 con 7 in posizione 4, 9 con 8 in posizione 5). Sesta iterazione esterna: i = 7; j da 1 a 6; cinque scambi (4 con 5 in posizione 2, 5 con 7 in posizione 3, 8 con 7 in posizione 4, 9 con 8 in posizione 5). 21 Variante: introduzione della subroutine di ricerca del minimo in una sequenza (search_min) e modifica dell’algoritmo. cursor search_min (Array A di n elementi) min ← A[1]; i_min ← 1; For i da 2 a n If A[i] < min Then min ← A[i]; i_min ← i; return i_min Insertion_sort (Array A di n elementi) For i da 1 a n i_min ← Search_min (A da i a n) Scambia A[i] e A[i_min] Il lettore simuli il funzionamento con questa variante dell’algoritmo. 2. I numeri di Fibonacci I numeri di Fibonacci sono molto diffusi nella scienza e nell’arte (consultare la pagina di Wikipedia per le curiosità: ad esempio, sono la base per l’installazione luminosa di Mario Merz sulla Mole Antonelliana di Torino – Il volo dei numeri). Il calcolo algoritmico dei numeri di Fibonacci si basa su una formulazione matematica, che spiana il terreno a un approccio ricorsivo alla scrittura dell’algoritmo. Formulazione matematica Fibonacci(1) = 1 Fibonacci(2) = 1 Fibonacci(n) = Fibonacci (n-1) + Fibonacci (n-2) integer if n=1 if n=2 return } Fibonacci (n) { then return 1 else then return 1 else Fibonacci(n-1) + Fibonacci (n-2) La formulazione è espressa in termini ricorsivi, con una ricorsione base per il primo e il secondo numero di Fibonacci e un passo di ricorsione che calcola l’ennesimo numero di Fibonacci sommando i due numeri di Fibonacci precedenti (n-1 e n-2). L’albero della ricorsione nel caso di Fibonacci(5) è nella figura successiva. 22 Il lettore calcoli il settimo numero di Fibonacci simulando l’algoritmo. Bibliografia utile per questa sezione David Harel e Yishai Feldman, Algoritmi. Lo spirito dell'informatica, Springer Verlag; 2 edizione (2008), ISBN-10: 8847005795, ISBN-13: 978-8847005792. I primi tre capitoli. Voci di Wikipedia utili: Computer Architecture, Assembly language, Modulo operation, Tower of Hanoi, Recursion, Successione di Fibonacci. Esercizio valido per l’esame: come trovare gli anagrammi di una parola Nella figura sottostante è illustrato lo schema di un algoritmo per il calcolo di tutti gli anagrammi di una parola: da una parola in input si calcola una permutazione (cioè una variante di ordine tra le lettere della parola) e si verifica che quest’ultima sia una parola presente nel dizionario della lingua italiana. In figura, il caso della parola LIBANO. 23 Si hanno quindi due subroutine: la prima calcola la prossima permutazione, la seconda ricerca la permutazione proposta nel dizionario. La seconda subroutine è molto semplice: Sia DIZ un Array di N parole boolean cerca_in_dizionario (parola) for i da 1 a N do if parola = DIZ[i] then return true return false Il calcolo combinatorio delle permutazioni deve generare tutte le permutazioni possibili. L’idea è di portare avanti una enumerazione ordinata: si introduce un ordine tra le lettere per tener traccia di cosa scambio; si lavora sempre con elementi di ordine maggiore finché non ho fatto tutti gli scambi possibili. L’ordine è arbitrario, ma rimane fissato, una volta che viene stabilito; l’obiettivo è la generazione di permutazioni in ordine crescente (rispetto all’ordine fissato), in modo da non perdersi neanche una permutazione procedendo in modo automatico. I passi dell’algoritmo ad alto livello sono i seguenti: Data una permutazione, nell’array A, di lunghezza N: 1. Trova il più grande cursore k (cioè il valore di k più a destra) tale che A[k] < A[k + 1]. Se tale indice k non esiste, allora questa era l’ultima permutazione 2. Trova il più grande j (cioè il j più a destra) tale che A[k] < A[j] (l’indice j esiste di sicuro, dal momento che almeno A[k + 1] è più grande di A[k]) 3. Scambia i valori A[k] e A[j]. 4. Inverti la sequenza dalla posizione [k + 1] alla posizione [N] L’idea è di generare le permutazioni nell’ordine lessicografico crescente che è stato stabilito. Si assume la struttura seguente in memoria. 24 Proviamo l’algoritmo sul contenuto dell’array. Nell’ordine introdotto: R-1 < O-2 < S3 < A-4. Si noti che il cursore lo caratterizziamo con i numeri romani per causare confusione tra l’ordine lessicografico e i cursori. Si parte da ROSA. Corrisponde al numero 1234. • • • • k = III, il massimo indice tale che A[k] (= S-3) < A[k + 1] (= A-4) j = IV, A[j] (= A-4) è l’unico valore della sequenza tale che > A[k] (= S-3) Si scambiano i valori di A[III] e A[IV]: la sequenza diventa [R-1, O-2, A-4, S3] La sotto-sequenza dopo k (da k+1) viene invertita: trattandosi di un unico valore, A[IV], la sotto-sequenza rimane identica. Risultato: [R-1, O-2, A-4, S-3]; corrisponde al numero 1243. ROAS non è presente nel dizionario. • • • • k = II, il massimo indice tale che A[k] (= O-2) < A[k + 1] (= A-4) j = IV, A[j] (= S-3) è il massimo valore della sequenza tale che > A[k] (= O2) Si scambiano i valori di A[II] e A[IV]: la sequenza diventa [R-1, S-3, A-4, O2] La sotto-sequenza dopo k (da k+1) viene invertita (indici III e IV): [R-1, S-3, O-2, A-4] Risultato: [R-1, S-3, O-2, A-4]; corrisponde al numero 1324. RSOA non è presente nel dizionario. • • • • k = III, il massimo indice tale che A[k] (= O-2) < A[k + 1] (= A-4) j = IV, A[j] (= A-4) è il massimo valore della sequenza tale che > A[k] (= S3) Si scambiano i valori di A[III] e A[IV]: la sequenza diventa [R-1, S-3, A-4, O2] La sotto-sequenza dopo k (da k+1) viene invertita: trattandosi di un unico valore A[IV], la sotto-sequenza rimane identica. Risultato: [R-1, S-3, A-4, O-2]; corrisponde al numero 1342. RSAO non è presente nel dizionario. • • • • k = II, il massimo indice tale che A[k] (= S-3) < A[k + 1] (= A-4) j = III, A[j] (= A-4) è il massimo valore della sequenza tale che > A[k] (= S-3) Si scambiano i valori di A[II] e A[III]: la sequenza diventa [R-1, A-4, S-3, O2] La sotto-sequenza dopo k (da k+1) viene invertita (indici III e IV): [R-1, A-4, O-2, S-3]. Risultato: [R-1, A-4, O-2, S-3]; corrisponde al numero 1423. RAOS non è presente nel dizionario. 25 • • • • k = III, il massimo indice tale che A[k] (= O-2) < A[k + 1] (= S-3) j = IV, A[j] (= S-3) è il massimo valore della sequenza tale che > A[k] (= O2) Si scambiano i valori di A[III] e A[IV]: la sequenza diventa [R-1, A-4, S-3, O2] La sotto-sequenza dopo k (da k+1) viene invertita: solo indice III, nessuna inversione. Risultato: [R-1, A-4, S-3, O-2]; corrisponde al numero 1432. RASO è presente nel dizionario. … Si procede fino alla 24-esima permutazione ([A-4, S-3, O-2, R-1]), per la quale non esiste alcun k per cui A[k] < A[k + 1], cioè è l’ultima permutazione. Di seguito, un raffinamento dell’algoritmo nei termini delle istruzioni di controllo che abbiamo descritto in precedenza. Array Combinatorio (array A di N caratteri) cursori k,j da 1 a N; repeat boolean trovato <- false; for i da 1 a N-1 do if A[i] < A[i+1] then k ← i; trovato ← true; if trovato = false then exit; for i da k a N do if A[k] < A[i] then j ← i; buffer ← A[k]; A[k] ← A[j]; A[j] ← buffer; B array di N-k caratteri for i da 1 a N-k do B[i] ← A[j-i+1] for i da 1 a N-k do A[k+i] ← B[i] until trovato=false Il ciclo repeat-until cicla per tutte le permutazioni; dopo il primo for, nell’array A è stata inserita l’ultima permutazione; buffer è una variabile di supporto per realizzare lo scambio; B è un array di supporto per invertire l’ultima parte di A. Esercizio: comprendere il funzionamento dell’algoritmo di alto livello e simularlo per calcolare gli anagrammi di una parola di 4 lettere; presentare la simulazione in forma scritta, nei termini riportati qui sopra, all’esame. 26