Risoluzione di Problemi con gli algoritmi Ricorsivi Laboratorio di Algoritmi e Strutture Dati Risoluzione di Problemi con gli algoritmi Ricorsivi Proff. Francesco Cutugno e Luigi Lamberti 2009-2010
Problem Solving Sia assegnato un Problema (useremo anche il termine Sistema) rappresentato da : un insieme (finito) di variabili discrete V = {X1, X2, … Xn} ciascuna associata ad un dominio D1, D2, … Dn un insieme di vincoli (relazioni tra variabili): R = {C1, C2,… Cm} Lo Stato è una combinazione dei valori delle variabili che definiscono il problema {Xi = vi i=1,n} ; Lo Spazio degli Stati, insieme di tutte le possibili combinazioni dei suddetti valori, non coincide con il semplice prodotto cartesiano dei domini, per la presenza dei vincoli. Lo Stato Iniziale è la condizione di partenza del nostro problema (in base alla formulazione fissata).
Un’Azione (o mossa legale) è l’assegnazione di un valore ad una variabile nel rispetto dei vincoli; la modifica di una variabile, porta il Sistema descritto da uno stato ad un altro (transizione). Il numero delle azioni possibili (input del Sistema) è limitato superiormente dalla cardinalità del prodotto dei domini. La Soluzione (o Stato Obiettivo) è una particolare assegnazione completa delle variabili, soddisfacente tutti i vincoli; la soluzione può non essere unica. Un Processo è la sequenza di azioni che conduce il Sistema da uno stato ad un altro; il Processo risolutivo, sarà la sequenza di azioni per giungere dallo Stato iniziale allo Stato Finale atteso.
Diagramma degli Stati Stato1 A1 A2 A4 Stato2 Stato3 Stato4 A3 A5 A6 A7 L’evoluzione di un Sistema è descritta da un Grafo con un Nodo per ciascuno stato e un Arco uscente per ogni azione possibile in quello stato: la destinazione indica la transizione effettuata. Il diagramma degli stati diventa improponibile al crescere della complessità del problema. Il grafo può contenere cicli e può essere complicato dimostrare che l'algoritmo di ricerca termina.
Albero di Ricerca Stato1 Stato2 Stato3 Stato4 Stato5 Stato6 Stato7 A1 A2 A3 A4 A5 A6 A7 La gestione del problema è rappresentabile anche tramite un albero (che chiameremo Albero di Ricerca) ove la radice è lo Stato Iniziale, mentre le eventuali soluzioni sono da ricercarsi nelle foglie. Si elimina il problema dei cicli, ma lo stesso nodo potrebbe essere generato più volte.
Salvare Pecora e Cavoli Problema: un Agricoltore va al mercato con un Lupo, una Pecora e un grosso Cavolo; deve attraversare un fiume su una barca tanto piccola da poter portare un solo oggetto per volta. Variabili: detto 0 il lato di partenza del Fiume e 1 il lato di arrivo, avremo 4 variabili, ciascuna con due possibili valori: A {0,1}, L {0,1}, P {0,1}, C {0,1} Vincoli: Lupo, Pecora e Cavolo non sanno attraversare il fiume da soli; il Lupo mangia la Pecora, se non c’è l’Agricoltore la Pecora mangia il Cavolo, se non c’è l’Agricoltore. Azioni: l’Agricoltore può attraversare il fiume da solo (A = 1-A), o con un passeggero A=1-A L=1-L, A=1-A P=1-P, A=1-A C=1-C, che sia dal suo stesso lato (non tutte le azioni sono legali). Spazio Stati: Abbiamo 16 stati possibili con le combinazioni ALPC. 0000 è lo stato iniziale; 1111 è lo stato obiettivo (finale); 0110, 0011, 0111, 1001, 1100, 1000 sono stati da evitare; i restanti sono possibili stati intermedi
0 000 A 1 100 1 010 1 001 AL AC 1 000 0 010 1 110 0 100 0 011 1 011 0 001 1 101 0 110 0 111 0 101 1 111 AP
Backtracking Partendo dallo stato raggiunto, ad ogni passo si assegna un valore ad una variabile, rispettando i vincoli, eseguendo così una Transizione : Se è stato raggiunto lo stato finale, segnaliamo la soluzione. Se è possibile avanzare ulteriormente, ripetiamo l’analisi partendo dallo stato attuale. In caso di fallimento (vicolo cieco, violazione dei vincoli, ritorno ciclico ad un nodo già visitato) si torna indietro. Se si ritorna allo stato iniziale senza aver trovato la soluzione, vuol dire che il problema è irrisolubile Supponiamo che il problema non preveda possibili percorsi ciclici, possiamo ipotizzare il seguente algoritmo:
funzione Ricerca ( Stato_Attuale ) { Risultato = ‘Fallimento’ E = Elenco Azioni possibili secondo i vincoli // mosse legali per ogni Azione A E, mentre Risultato = ‘Fallimento’ { Esegui A se Nuovo_Stato = Stato_Obiettivo Risultato = ‘Soluzione’ altrimenti Risultato = Ricerca ( Nuovo_Stato ) se Risultato = ‘Fallimento’ Annulla esecuzione di A // backtracking } restituisci Risultato }
Sudoku Usiamo una griglia quadrata di 81 celle: 9 righe orizzontali per 9 colonne verticali; inoltre, la griglia è divisa in 9 riquadri 3x3 (gruppi) di 9 celle ciascuno. Ciascuna riga, colonna e riquadro 3x3 contiene tutti i numeri da 1 a 9. Pertanto in nessuna riga, colonna o riquadro può esserci un numero ripetuto. Nella matrice, ogni cella vale 0 se è libera, da 1 a 9 se occupata. Chiameremo Gruppi i 9 riquadri 3x3 interni. Detta (r,c) una cella della Tavola, le coordinate della cella in alto a sinistra del suo gruppo di appartenenza saranno: rg = (r / 3) * 3 = r - r % 3 cg = (c / 3) * 3 = c - c % 3 Il gruppo sarà composto dalle nove celle (i,j) con rg <= i <= rg+2 e cg <= j <= cg+2
/*======================================================= Esempio base per la risoluzione di un Sudoku. Manca un’interfaccia utente e il caricamento dello schema iniziale --------------------------------------------------------*/ main (void) { int Tav [9][9], // Tavola di gioco Nvuote; // Numero di caselle vuote nella tavola IniziaTavola (Tav, &Nvuote); StampaTavola (Tav); if (Nvuote<1 || !VerificaVincoli(Tav)) printf("\n Il problema e` mal posto ! \n"); else if (! CercaSudoku(Tav,Nvuote) ) printf("\n NON esistono soluzioni ! \n"); else { printf("\n Soluzione trovata. \n"); StampaTavola (Tav); } getch();
/* Esamina la tavola, con ALMENO con una cella vuota, provando a riempire una qualsiasi cella vuota, ove il valore sia possibile. OUTPUT: 1 soluzione trovata e copiata sulla Tavola da gioco 0 nessuna soluzione possibile -----------------------------------------------------------------*/ int CercaSudoku ( int Tav[][9], // Tavola da esaminare int Nvuote // Numero di caselle vuote nella tavola > 0 ) { int r, c, // riga e colonna della cella da occupare N, // valore da inserire i, j, Trovato = 0; r = -1; for (i=0 ; i<9 && r == -1 ; i++) // cerca una cella vuota for (j=0 ; j<9 && r == -1 ; j++) if (Tav[i][j] == 0) {r=i; c=j;} // termina se trova una vuota for (N=9; N && !Trovato; N--) if ( VerificaLegalita(Tav, r, c, N) ) // se è possibile inserire N { Tav[r][c] = N; // se era l'ultima casella vuota, la soluzione è stata trovata Trovato = (Nvuote == 1) ? 1 : CercaSudoku (Tav, Nvuote-1); // se non è praticabile, annulla la mossa (backtracking) if (! Trovato) Tav[r][c] = 0; } return Trovato;
/* Valuta la legalità di un inserimento di un nuovo valore in una casella vuota, esaminando la riga la colonna e il gruppo. OUTPUT: 0 non è possibile inserire il valore nella cella 1 il gioco può proseguire alla ricerca di soluzioni ---------------------------------------------------------------*/ int VerificaLegalita ( int Tav[][9], // Tavola di gioco int r, int c, // riga e colonna della cella da occupare int N // valore da inserire ) { int i, j, rg, cg, // angolo del gruppo di appartenenza Congrua; // 0 = tavola inconguente Congrua = 1; for (j=0; j<9; j++) // esamina la riga if (Tav[r][j] == N) Congrua = 0; for (i=0; i<9; i++) // esamina la colonna if (Tav[i][c] == N) Congrua = 0; rg = r - r % 3; // esamina il riquadro cg = c - c % 3; for (i=0; i<3; i++) for (j=0; j<3; j++) if (Tav[rg+i][cg+j] == N) Congrua = 0; return Congrua; }
/*------------------------------------------------------------ Valuta la congruenza dell`intero schema di gioco OUTPUT: 0 La tabella contiene incongruenze 1 il gioco può proseguire alla ricerca di soluzioni --------------------------------------------------------------*/ int VerificaVincoli ( int Tav[][9] // Tavola di gioco ) { int r, c, N, Congrua = 1; // 0 = tavola inconguente for (r=0 ; r<9 ; r++) for (c=0 ; c<9 ; c++) if ( Tav[r][c] ) // esaminiamo ogni elemento gia` inserito { N = Tav[r][c]; Tav[r][c] = 0; Congrua &= VerificaLegalita (Tav, r, c, N); Tav[r][c] = N; } return Congrua;
Problemi di ottimizzazione Occorre cercare la migliore tra più soluzioni possibili, secondo una certa metrica. Il fallimento varrà -∞ funzione Ricerca ( Stato_Attuale ) { E = Elenco Azioni possibili secondo i vincoli V = elenco dei valori corrispondenti a ciascuna azione per ogni Azione Ai E { Esegui Ai se Nuovo_Stato = Soluzione Vi = Valore della Soluzione altrimenti Vi = Ricerca ( Nuovo_Stato ) } se E ≠ Risultato = max (Valori) altrimenti Risultato = -∞ (Fallimento) restituisci Risultato }
Giochi con Avversario I Giochi a 2 giocatori “a conoscenza completa ” devono avere le seguenti caratteristiche: Il gioco è definito da una serie di regole che definiscono le mosse lecite. Le mosse sono fatte alternativamente da 2 giocatori A e B. Ogni giocatore ha informazioni complete sullo stato del gioco in ogni istante. Non c’è intervento del caso (sistema deterministico). Uno Stato Terminale rappresenta una situazione di Vittoria, Sconfitta o Pareggio per un giocatore. Diremo che un gioco è Equo, quando nessuno dei due giocatori può vincere se l’avversario gioca “al meglio ”.
Procedura Neg-Max Usiamo il backtracking ricorsivo per trovare la mossa migliore; ad ogni passo verrà scelta la mossa che è più conveniente per A o per B. La funzione di valutazione capovolge il parere dell’avversario, negando il risultato restituito dalla successiva istanza ricorsiva. Stabilito un valore positivo per la vittoria, uno negativo per la sconfitta e Zero per il pareggio: A esamina lo stato attuale del gioco e cataloga le possibili mosse. A esegue ad una ad una le mosse possibili. Se la mossa è terminale, assegna il valore (es. +1, -1, 0). Se la mossa non è terminale, consegna lo stato del gioco a B chiedendo il suo parere (ricorsione). B risponderà secondo il suo punto di vista: ad esempio restituirà +1 se è certo di vincere. A trasforma il parere di B, assumendo -1 per quella mossa. A conclude l’analisi indicando come miglior mossa quella che ha il valore più alto dal suo punto di vista.
Tic-Tac-Toe Ciascuno dei due giocatori inserisce a turno una pedina del proprio colore sul tavolo di gioco, fino a formare un Tris, ovvero una fila di tre pedine dello stesso colore, in orizzontale, in verticale o in diagonale: vince chi realizza per primo un tris; riempito il campo senza tris, la partita è Pari. Struttura Dati: Tavola[10] scacchiera del gioco; sono usate le caselle da [1] a [9] ogni casella vale 0 se vuota, 1 o 2 se occupata. Nvuote numero delle caselle ancora vuote a disposizione Who giocatore che deve muovere: 1 o 2 DoveGioco casella della prossima mossa da eseguire Finito 0 = gioco in corso 1 = gioco finito (vinto 1) 2 = gioco finito (vinto 2) 3 = gioco finito in pareggio 27 = gioco finito per abbandono
/*==================================================================== Esempio base per il gioco del Tris tra due giocatori scelti tra: -) Umano ( tramite keyboard ) -) PC ( tramite sub di valutazione mossa ). Manca un’interfaccia utente e la possibilità di scegliere chi inizia. ---------------------------------------------------------------------*/ main (void) { int Tavola[10], // scacchiera di gioco Nvuote, // numero delle caselle vuote (con valore 0) Who, // giocatore che deve muovere: 1 o 2 DoveGioco, // mossa da fare da parte di Who i, // indice generico Finito; // 0 = gioco in corso // 1,2 = gioco finito (vinto 1 o 2) // 3 = gioco finito in pareggio // 27= gioco finito per abbandono for (Nvuote = i= 9; i; Tavola[i--]=0 ); // pulizia scacchiera StampaTavola (Tavola); // visualizza la tavola Who = 1; // muove per primo il PC assegnato a 1 Finito = 0; while (! Finito) { if (Who == 1) // mossa decisa dalla funzione di valutazione { ValutaMossa (Tavola, Nvuote, Who, &DoveGioco); } else // Who == 2: mossa del giocatore umano tramite keyboard
else // Who == 2: mossa del giocatore umano tramite keyboard { DoveGioco = 0; do { i = getch(); if (i == 27) // se si vuole abbandonare il gioco Finito = 27; else if (i>=49 && i<=57 && Tavola[i-48]==0) //solo case vuote DoveGioco = i-48; } while (!DoveGioco && !Finito); if (!Finito) // se non c'è stato un abbandono { Tavola[DoveGioco] = Who; // occupa la casella if ( VerificaTris(Tavola) ) Finito = Who; // mossa vincente else if (Nvuote == 1) // se unica vuota, pareggio Finito = 3; else // passa all'avversario { Who = 3 - Who; Nvuote --; StampaTavola (Tavola); // Visualizza il campo di gioco while (!Finito); printf ("Partita terminata"); getch();
/*========================================================== Verifica se esiste un tris qualsiasi sulla tavola di gioco Considerando i valori 1 e 2 in binario, 10 e 01, l’AND di tre caselle è diverso da zero solo se sono uguali. Output : 1 l'ultima mossa effettuata ha prodotto un Tris 0 non esistono tris sulla tavola ---------------------------------------------------------*/ int VerificaTris ( int *Tav // scacchiera di gioco ) { int Fatto; Fatto = ( Tav[1] & Tav[2] & Tav[3] || // 1ø rigo Tav[4] & Tav[5] & Tav[6] || // 2ø rigo Tav[7] & Tav[8] & Tav[9] || // 3ø rigo Tav[1] & Tav[4] & Tav[7] || // 1ø colonna Tav[2] & Tav[5] & Tav[8] || // 2ø colonna Tav[3] & Tav[6] & Tav[9] || // 3ø colonna Tav[1] & Tav[5] & Tav[9] || // diagonale / Tav[7] & Tav[5] & Tav[3] ); // diagonale \ return Fatto; }
/. Valutazione della mossa migliore in un dato stato del gioco /* Valutazione della mossa migliore in un dato stato del gioco. si ferma nell'analisi quando trova una mossa vincente. Output: valore della scacchiera vista da Who che deve muovere -1 sconfitta, 0 pareggio, +1 vittoria. */ int ValutaMossa ( int *Tav, // scacchiera di gioco int Nvuote, // numero delle caselle vuote (valore 0) int Who, // giocatore che deve muovere: 1 o 2 int *DoveMeglio // casella della mossa migliore ) { int i, // indice sulle caselle della scacchiera Dove, // miglior contromossa dell'avversario Vale, // valore della contromossa MaxVale; // valore della miglior mossa possibile for (MaxVale = -INFINITO, i=9; i && MaxVale<1; i--) if (Tav[i]==0) // se la casella è vuota { Tav[i] = Who; // occupa la casella if ( VerificaTris(Tav) ) // se si è creato un tris Vale = +1; // mossa vincente else if (Nvuote == 1) // se unica vuota, pareggio Vale = 0; else // chiede il parere dell'avversario Vale = -ValutaMossa (Tav, Nvuote-1, 3-Who, &Dove); if (Vale > MaxVale) { MaxVale = Vale; *DoveMeglio = i;} Tav[i] = 0; // libera la casella } return MaxVale;
Hexagon 19 Sia assegnata una scacchiera con 19 caselle esagonali (inizialmente vuote). Due giocatori (Nero e Bianco) collocano alternativamente una pedina in una casella vuota; il primo che è costretto ad occupare due caselle adiacenti, ha perso la partita. 1. Costruire una procedura che valuti la mossa migliore da compiere in qualsiasi situazione di gioco (considerando che anche l’avversario giocherà sempre “al meglio”), utilizzando il Backtracking ricorsivo. 2. Utilizzare la suddetta procedura per stabilire se si può sicuramente vincere giocando per primi o per secondi (un pareggio è impossibile). 3. Consentire ad un umano di giocare contro il computer, sia pure con un’interfaccia spartana.
Bibliografia Web http://it.wikipedia.org/wiki/Ricorsione http://www.cad.polito.it/~bernardi/ corsi/APA_IVREA/APA2/W4/minmax.pdf http://www.di.unipi.it/~simi/ AI/SI2007/Lucidi/games.pdf http://www.cs.unibo.it/~cianca/ wwwpages/chesssite/tozzi.pdf