Fondamenti di Programmazione

Pile

In questa lezione vedremo come risolvere labirinti. L'algoritmo che useremo è noto come breadth first search (in italiano visita in ampiezza). È un algoritmo semplice con numerose applicazioni e Python ne permette una implementazione molto facile. Nel fare ciò incontreremo il secondo costrutto di iterazione di Python, il while. Il materiale necessario per questa lezione si puo' scaricare in questo pacchetto.

Labirinti

Possiamo descrivere un labirinto tramite i caratteri in modo molto semplice. Il carattere spazio indica un blocco o un passaggio tra blocchi, i caratteri - e | indicano muri e il carattere + è all'incrocio dei muri e dei passaggi. Ecco un semplice esempio

    +-+-+-+-+-+-+-+-+
          |     |   |
    + +-+ +-+-+ + + +
    |   |     | | | |
    + + +-+-+ + + + +
    | |   | | |   | |
    + +-+ + + +-+-+ +
    |   | | |   |   |
    +-+-+ + +-+ + + +
    |   | |     | | |
    +-+ + +-+ +-+ + +
    | | |   |     | |
    + + +-+ +-+-+-+ +
    | | |   | |   | |
    + + + +-+ + + + +
    |     |     |    
    +-+-+-+-+-+-+-+-+

È un labirinto 8x8 nel senso che i blocchi sono 8 su ogni riga e 8 su ogni colonna. Ha un entrata in alto a sinistra e un'uscita in basso a destra. Questo labirinto è nel file maze1.txt che possiamo leggere e rappresentarlo tramite una lista in cui ogni elemento è la lista dei caratteri della corrispondente riga:

def read_maze(fname):
    mz = []
    with open(fname, 'U') as f:
        for r in f:
            mz.append(list(r.replace('\n', '')))
    return mz

mz = read_maze('maze1.txt')
for r in mz:
    print ''.join(r)

Certo sarebbe meglio visualizzare il labirinto con una immagine. Per fare ciò si può usare la funzione create_img() nel modulo maze (contenuto nel pacchetto di questa lezione) che crea una immagine png del labirinto mz:

import maze

img = maze.create_img(mz)
image.save('maze1.png', img)

Ed ecco il risultato

Risolvere labirinti

Per risolvere un labirinto dobbiamo trovare un cammino dall'entrata all'uscita che passa solamente per blocchi e passaggi (non passa cioè attraverso i muri). Come possiamo trovarlo in modo automatico, cioè tramite un algoritmo? Consideriamo il labirinto maze1.txt, immediatamente ci troviamo di fronte ad un bivio, dal blocco iniziale 0 ci possiamo muovere o nel blocco a o nel blocco b.

    +-+-+-+-+-+-+-+-+
     0 a  |     |   |
    + +-+ +-+-+ + + +
    |b  |     | | | |
    + + +-+-+ + + + +
    | |   | | |   | |
    + +-+ + + +-+-+ +
    |   | | |   |   |
    +-+-+ + +-+ + + +
    |   | |     | | |
    +-+ + +-+ +-+ + +
    | | |   |     | |
    + + +-+ +-+-+-+ +
    | | |   | |   | |
    + + + +-+ + + + +
    |     |     |    
    +-+-+-+-+-+-+-+-+

Se potessimo sdoppiarci e potessomo seguire entrambe le strade? Arriveremo simultaneamente nei blocchi che adesso indichiamo con 1 e da questi potremmo passare nei blocchi a, b e c

    +-+-+-+-+-+-+-+-+
     0 1 a|     |   |
    + +-+ +-+-+ + + +
    |1 b|     | | | |
    + + +-+-+ + + + +
    |c|   | | |   | |
    + +-+ + + +-+-+ +
    |   | | |   |   |
    +-+-+ + +-+ + + +
    |   | |     | | |
    +-+ + +-+ +-+ + +
    | | |   |     | |
    + + +-+ +-+-+-+ +
    | | |   | |   | |
    + + + +-+ + + + +
    |     |     |    
    +-+-+-+-+-+-+-+-+

Ma ci possiamo sdoppiare di nuovo e seguire tutte e tre le strade (i cui blocchi indichiamo con 2) e poi continuiamo così

    +-+-+-+-+-+-+-+-+          +-+-+-+-+-+-+-+-+
     0 1 2|     |   |           0 1 2|     |   |
    + +-+ +-+-+ + + +          + +-+ +-+-+ + + +
    |1 2|a    | | | |          |1 2|3 a  | | | |
    + + +-+-+ + + + +          + + +-+-+ + + + +
    |2|b  | | |   | |          |2|3 b| | |   | |
    + +-+ + + +-+-+ +          + +-+ + + +-+-+ +
    |c  | | |   |   |          |3 c| | |   |   |
    +-+-+ + +-+ + + +  ---->   +-+-+ + +-+ + + +
    |   | |     | | |          |   | |     | | |
    +-+ + +-+ +-+ + +          +-+ + +-+ +-+ + +
    | | |   |     | |          | | |   |     | |
    + + +-+ +-+-+-+ +          + + +-+ +-+-+-+ +
    | | |   | |   | |          | | |   | |   | |
    + + + +-+ + + + +          + + + +-+ + + + +
    |     |     |              |     |     |    
    +-+-+-+-+-+-+-+-+          +-+-+-+-+-+-+-+-+

Se un cammino che arriva all'uscita esiste, in questo modo non possiamo mancarlo. Prima o poi arriveremo al blocco antistante l'uscita. Questo è l'algoritmo che stavamo cercando. Ricapitolando l'algoritmo inizia con il blocco antistante l'entrata questo è il blocco corrente. Ad ogni passo si considerano tutti i blocchi non ancora raggiunti e che si possono raggiungere tramite i passaggi dai blocchi correnti. I nuovi blocchi diventano i blocchi correnti. Nell'esempio, dopo il blocco 0 al primo passo i blocchi correnti sono quelli indicati con 1 e poi al secondo passo diventano quelli indicati con 2 e poi quelli indicati con 3.

C'è però ancora un problema da risolvere. La visita dei blocchi che l'algoritmo compie contiene il cammino dall'entrata all'uscita ma mescolato con molti altri cammini sbagliati. Per ritrovarlo possiamo procedere a ritroso dall'uscita verso l'entrata. Infatti, ci sarà un solo cammino che arriva all'uscita. Bisogna solo avere l'accortezza di segnare in quale verso sono stati attraversati i passaggi. Il cammino a ritroso li attraverserà in senso inverso fino all'entrata.

La funzione solve(mz) che implementa questo algoritmo di visita (il cammino sarà poi ricostruito da un'altra funzione) prende in input la rappresentazione tramite liste di un labirinto. Inoltre, marca con * i blocchi visitati e segna i passaggi che attraversa tramite opportuni caratteri che sono memorizzati nel dizionario MOVES

MOVES = {(-1, 0):'^', (1, 0):'.', (0, -1):'<', (0, 1):'>'}

(-1, 0) rappresenta il passaggio verso il blocco in alto, (1, 0) il passaggio verso il blocco il blocco in basso e (0, -1), (0, 1) verso i blocchi a sinistra e a destra, rispettivamente. Quindi il carattere che segna il passaggio verso il blocco in alto è ^ e così via.

Ma ci manca ancora qualcosa. Il procedimento di visita va ripetuto finché non si arriva all'uscita e non si sa quando questo accadrà. In casi come questo in cui bisogna ripetere un procedimento (un blocco di istruzioni) un numero di volte che dipende da una certa condizione, si usa il costrutto while. La sintassi è la seguente

while condizione:
    istruzioni

Finchè la condizione è vera le istruzioni sono eseguite, non appena diventa falsa il ciclo termina.

Ed ecco il nostro algoritmo in Python

def solve(mz):
    nr, nc = len(mz), len(mz[0])
    ex = (nr - 2, nc - 2)       # Blocco d'uscita
    blk = [(1, 1)]              # Blocco d'entrata
    mz[1][1] = '*'
    while len(blk) > 0 and ex not in pos:
        newblk = []
        for r, c in pos:
            for (dr, dc), ch in MOVES.items():
                rr, cc = r + dr, c + dc
                if mz[rr][cc] == ' ':
                    if mz[rr + dr][cc + dc] == ' ':
                        mz[rr][cc] = ch
                        mz[rr + dr][cc + dc] = '*'
                        newblk.append((rr + dr, cc + dc))
        blk = newblk

I blocchi sono rappresentati dalle loro coordinate nella lista di liste (la matrice) mz. blk contiene la lista dei blocchi correnti. La condizione del while fa sì che continui la visita finché la lista dei blocchi non è vuota (questo accade se non c'è un cammino che arriva all'uscita) e la lista non contiene il blocco d'uscita. Ad ogni iterazione, si cercano i blocchi non ancora visitati che si possono raggiungere con tramite passaggi. Per ogni blocco corrente i possibili passaggi sono scanditi tramite gli elementi di MOVES.

La figura seguente mostra i passi della visita effettuata da solve() applicata al labirinto d'esempio

I blocchi che sono stati correnti allo stesso tempo sono colorati con la stessa gradazione di grigio. E ecco l'output della funzione

+-+-+-+-+-+-+-+-+
 *>*>*|     |*<*|
+.+-+.+-+-+ + +^+
|*>*|*>*>*| | |*|
+.+.+-+-+.+ + +^+
|*|*>*|*|*|   |*|
+.+-+.+^+.+-+-+^+
|*>*|*|*|*>*|*>*|
+-+-+.+^+-+.+^+.+
|*<*|*|*<*<*|*|*|
+-+^+.+-+.+-+^+.+
|*|*|*>*|*>*>*|*|
+^+^+-+.+-+-+-+.+
|*|*|*<*| |   |*|
+^+^+.+-+ + + +.+
|*<*<*|     |  * 
+-+-+-+-+-+-+-+-+

Abbastanza incomprensibile. Ma tanto deve essere letto dalla nostra prossima funzione. La funzione find_path(mz) prende in input il labirinto visitato da solve() e ricostruisce a ritroso dall'uscita verso l'entrata il cammino risolutore. All'inzio la lista che conterrà il cammino contiene solamente il blocco d'uscita e ad ogni iterazione sarà aggiunto un blocco all'inizio della lista fino a quando si raggiunge il blocco d'entrata.

def find_path(mz):
    nr, nc = len(mz), len(mz[0])
    start = (1, 1)               # i blocco d'entrata
    path = [(nr - 2, nc - 2)]    # Il blocco d'uscita
    while path[0] != start:
        r, c = path[0]
        for dr, dc in MOVES:
            rr, cc = r + dr, c + dc
            if mz[rr][cc] == MOVES[(-dr, -dc)]:
                pos = (rr + dr, cc + dc)
                path.insert(0, pos)
                break
    return path

Per ogni possibile passaggio adiacente al blocco in cima alla lista si prende quello che è stato seguito dalla precedente visita per visitare proprio quel blocco. Si osservi che (-dr, -dc) rapresenta il passaggio inverso di (dr, dc), nel senso che è lo stesso passaggio attraversato in senso inverso.

Usando la funzione draw_path() del modulo maze possiamo visualizzare il cammino

path = find_path(mz)
maze.draw_path(img, mz, path)
image.save('maze1_sol.png', img)

Possiamo applicare queste funzioni per risolvere altri labirinti. Ad esempio i labirinti in maze2.txt e maze3.txt: