Dispensa del corso (PDF) - Dipartimento di Informatica

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