Sudoku anch’io
Summary: last meeting of the Milano XP UG was about TTDing a program to solve Sudoku games. Pretty good fun.
Non ho mai avuto la pazienza per risolvere i sudoku. Essendo pigro, scriverò un programma per risolverli…
L’ultimo meeting del Milano XP User Group ha avuto come tema “risolviamo il Sudoku con TDD”. Il problema è: dato un sudoku, scrivi un programma che per ogni cella mi dice quali sono i numeri che posso legalmente scriverci dentro.
Numeri proibiti
In coppia con l’ottimo Roberto Valenti abbiamo affrontato il problema in Ruby. Abbiamo cominciato decidendo arbitrariamente di rappresentare il tabellone con un singolo array di interi. E’ la rappresentazione più semplice, e fino a quando non risulterà inadeguata non c’è bisogno di usarne una più complicata. YAGNI!
TEST_BOARD = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, ]
(Questa tabella di test è stata generata con un semplice programma.)
Da qualche parte bisogna iniziare. Abbiamo deciso di iniziare sviluppando metodi per estrarre righe, colonne e quadranti dalla tabella.
def test_row_col assert_equal [18, 19, 20, 21, 22, 23, 24, 25, 26], row(TEST_BOARD, 2) assert_equal [1, 10, 19, 28, 37, 46, 55, 64, 73], col(TEST_BOARD, 1) assert_equal [27, 28, 29, 36, 37, 38, 45, 46, 47], quad(TEST_BOARD, 4, 1) end
Da notare solo che quad(board, r, c) restituisce il quadrante dentro cui cade r, c. Quindi quad(board, 0, 0) sarà uguale a quad(board, 2, 2) perché entrambi restituiscono il primo quadrante.
A questo punto il problema è già quasi risolto. È più facile ottenere prima i numeri proibiti. Prima di tutto, se il tabellone è tutto vuoto, non ci sono vincoli.
# rappresentiamo le caselle vuote con 0 EMPTY_BOARD = Array.new(81, 0) def test_forbidden_numbers_on_empty_board assert_equal [], forbidden_numbers(EMPTY_BOARD, 4, 4) end
e la soluzione è facile:
def forbidden_numbers(board, r, c) [] end
Poi testiamo che vengano considerati proibiti i numeri che appaiono nel quadrante. Inputiamo un sudoku diagonale giusto per testare questo:
DIAG_BOARD = [ 1,0,0,0,0,0,0,0,0, 0,2,0,0,0,0,0,0,0, 0,0,3,0,0,0,0,0,0, 0,0,0,4,0,0,0,0,0, 0,0,0,0,5,0,0,0,0, 0,0,0,0,0,6,0,0,0, 0,0,0,0,0,0,7,0,0, 0,0,0,0,0,0,0,8,0, 0,0,0,0,0,0,0,0,9, ] def test_forbidden_numbers_on_quadrant assert_equal [1,2,3], forbidden_numbers(DIAG_BOARD, 0, 1) assert_equal [7,8,9], forbidden_numbers(DIAG_BOARD, 8, 7) end
e modifichiamo di conseguenza la funzione
def forbidden_numbers(board, r, c) quad(board, r, c).reject {|x| 0 == x} end
Ora vogliamo tenere conto anche dei numeri che stanno sulle colonne
def test_forbidden_numbers_on_columns assert_equal [1], forbidden_numbers(DIAG_BOARD, 8, 0) assert_equal [6], forbidden_numbers(DIAG_BOARD, 0, 5) end
E fin qui è facile
def forbidden_numbers(board, r, c) result = quad(board, r, c) + col(board, c) result.reject {|x| 0 == x} end
E così via per le righe
def forbidden_numbers(board, r, c) result = quad(board, r, c) + col(board, c) + row(board, r) result.reject {|x| 0 == x} end
Ora, è facile prevedere problemi nel caso in cui il tabellone non è una diagonale: rischiamo di avere numeri duplicati, e comunque non nell’ordine crescente che i nostri test si aspettano. Scriviamo un test che espone un problema, e lo risolviamo in stile Unix, con sort e uniq
def forbidden_numbers(board, r, c) result = quad(board, r, c) + col(board, c) + row(board, r) result = result.reject {|x| 0 == x} result.sort result.uniq end
I numeri permessi, a questo punto, sono semplicemente il complemento di quelli proibiti.
def possible_numbers(board, r, c) complement(forbidden_numbers(board, r, c)) end def complement(ar) universe = [1,2,3,4,5,6,7,8,9] universe.reject {|x| ar.member?(x) } end
Espiazione
Avrai notato che fino ad ora abbiamo lavorato in maniera molto poco object-oriented. Poco male! Il punto è risolvere il problema nella maniera più semplice possibile. Però a questo punto il nostro codice comincia ad avere parecchie “funzioni” che se ne stanno appese nel vuoto. E non mi piace il fatto che in parecchi punti dobbiamo convertire le coordinate “lineari” della nostra lista in coordinate quadrate (r,c).
Decidiamo di rifattorizzare il codice per nascondere l’implementazione della tabella. Creiamo una classe Board inizializzata da un array
class Board def initialize(list) @list = list.dup end end
e passiamo 10 minuti a convertire tutti i nostri test, spostando dentro alla classe i metodi che prima fluttuavano nel vuoto. Sempre però un test alla volta, spostando solo i metodi necessari, e sempre rieseguendo i test. Alla fine abbiamo questi test
class TestNameOfTestCases < Test::Unit::TestCase def test_board_at b = Board.new(SOLVED_BOARD) assert_equal b.at(0, 0), 8 assert_equal b.at(2, 3), 1 end def test_set b = Board.new(SOLVED_BOARD) b.set(2,3,255) assert_equal 255, b.at(2, 3) end def test_board_find_hole b = Board.new(SOLVED_BOARD) assert_equal nil, b.find_hole b.set(1, 2, 0) assert_equal [1, 2], b.find_hole end def test_row_col b = Board.new(TEST_BOARD) assert_equal [18, 19, 20, 21, 22, 23, 24, 25, 26], b.row(2) assert_equal [1, 10, 19, 28, 37, 46, 55, 64, 73], b.col(1) assert_equal [0, 1, 2, 9, 10, 11, 18, 19, 20], b.quad(0, 0) assert_equal [0, 1, 2, 9, 10, 11, 18, 19, 20], b.quad(1, 1) assert_equal [0, 1, 2, 9, 10, 11, 18, 19, 20], b.quad(2, 2) assert_equal [27, 28, 29, 36, 37, 38, 45, 46, 47], b.quad(4, 1) assert_equal [30, 31, 32, 39, 40, 41, 48, 49, 50], b.quad(5, 5) end def test_forbidden_numbers_on_quadrant assert_equal [], Board.new(EMPTY_BOARD).forbidden_numbers(4, 4) assert_equal [1,2,3], Board.new(DIAG_BOARD).forbidden_numbers(0, 1) end def test_forbidden_numbers_on_quad_and_row b = Board.new(DIAG_BOARD) b.set(0, 4, 4) assert_equal [1,2,3,4], b.forbidden_numbers(0, 1) end def test_forbidden_numbers_on_quad_and_col b = Board.new(DIAG_BOARD) b.set(4, 0, 5) assert_equal [1,2,3,5], b.forbidden_numbers(1, 0) end def test_possible_numbers_on_problem b = Board.new(PROBLEM) assert_equal [3], b.possible_numbers(0, 1) end def test_complement assert_equal [], Board.complement([1,2,3,4,5,6,7,8,9]) assert_equal [9], Board.complement([1,2,3,4,5,6,7,8]) assert_equal [1,4,9], Board.complement([2,3,5,6,7,8]) end
e questo codice
class Board def initialize(list=nil) @list = list.dup end def at(r, c) @list[r*9 + c] end def set(r, c, val) @list[r*9 + c] = val end def forbidden_numbers(r, c) result = quad(r, c) result += row(r) result += col(c) result = result.reject { |n| n == 0 } result.sort result.uniq end def possible_numbers(r, c) Board.complement(forbidden_numbers(r, c)) end def self.complement(ar) universe = [1,2,3,4,5,6,7,8,9] universe.reject {|x| ar.member?(x) } end def quad(r, c) result = [] for rr in ((r/3)*3...(r/3)*3+3) for cc in ((c/3)*3...(c/3)*3+3) result << at(rr, cc) end end result end def row(n) @list[n*9...(n+1)*9] end def col(c) (0...9).map {|r| at(r, c)} end def list @list end private def linear_to_r_c(index) [index / 9, index % 9] end end
Facciamo un test di accettazione: prendiamo un sudoku risolto, e a turno cancelliamo un elemento. La funzione possible_numbers deve restituire il numero appena tolto.
def test_solved_board b = Board.new(SOLVED_BOARD) for r in (0...9) for c in (0...9) element = b.at(r, c) b.set(r, c, 0) assert_equal [element], b.possible_numbers(r, c) b.set(r, c, element) end end end
Funziona! E' vero che potremmo fare dei test più accurati, ma sono abbastanza confidente che la nostra soluzione sia corretta. Comunque, se non fossimo al pub alle 8 di sera probabilmente mi metterei il berretto da tester e farei qualche test in più.
Una volta si chiamava A.I.
Avevamo ancora un filo di energia, così abbiamo deciso di continuare scrivendo un metodo solve(board) che restituisca un sudoku completato.
In generale, questo tipo di problemi si risolvono per mezzo di ricerca. Ovvero, non c'è un algoritmo che mi possa dire in un colpo solo qual'è il numero da mettere. Quasi tutti i puzzle ricadono in questo schema: il problema delle N regine, il problema del 15, il cubo di Rubik...
Abbiamo uno stato iniziale; per ogni stato abbiamo un certo numero di mosse a disposizione. Risulta un albero di stati. Le foglie di questo albero sono stati in cui il gioco è risolto (yuppi!), oppure stati in cui il gioco non è risolto e non ci sono altre mosse possibili (buuu).
Il problema allora è esplorare l'albero degli stati in maniera da trovare il prima possibile un nodo-soluzione. Ci sono due strategie di base, che si applicano a tutti i puzzle. La strategia breadth-first: partendo dalla radice, considero tutti i nodi raggiungibili. Ho trovato la soluzione? Se no, per ogni nodo trovato, considero tutti i suoi nodi raggiungibili. Cioè, esamino tutti i nodi di livello 2. Vado avanti così, esaminando tutti i nodi di livello n fino a che non trovo una soluzione.
Il vantaggio del breadth-first è che trova di sicuro la soluzione più breve. Lo svantaggio è che rischio di metterci moltissimo tempo a trovarla.
L'altra strategia agnostica è depth-first: dalla radice scelgo la prima mossa, poi la prima mossa, e sempre la prima mossa fino a quando non raggiungo una foglia. Se è un fallimento, torno indietro di *un* passo e esamino la seconda mossa. Se esaurisco tutte le mosse possibili a quel livello, torno indietro di un altro passo e considero la seconda mossa da lì.
Lo svantaggio del depth-first è che non è garantito che possa mai trovare una soluzione. Nel caso del sudoku, però, questo non è un problema perché la profondità dell'albero è sempre limitata dal numero di caselle vuote da riempire.
E' facile implementare una ricerca depth-first, usando una funzione ricorsiva.
def solve(prob) return prob if prob.solved? for move in prob.possible_moves try = solve(prob.apply(move)) # if solved, stop here return try if try end # no more moves return false end
Lo stack implicito nella valutazione della funzione ricorsiva mi tiene traccia di a che punto sono nella mia esplorazione dell'albero.
Risolviamo un gioco completo
Con questo schema in mente, scriviamo il nostro primo test. Se il problema è risolto, deve restituire la tabella risolta
def test_solve assert_equal SOLVED_BOARD, solve(Board.new(SOLVED_BOARD)) end
E fin qui è facile. Poi proviamo a togliere una posizione dal gioco completato, e ci aspettiamo che ritrovi di nuovo la soluzione
def test_solve b = Board.new(SOLVED_BOARD) assert_equal SOLVED_BOARD, solve(b) b.set(0,0,0) # metto 0 alla posizione 0,0 assert_equal SOLVED_BOARD, solve(b) end
Anche questo si risolve facilmente. Ci serve una funzione che trovi il primo "buco". Se il buco non c'è, assumiamo che il gioco sia risolto
class Board def find_hole @list.each_with_index { |e, index| return linear_to_r_c(index) if e == 0 } nil end end def solve(board) if !board.find_hole return board.list end r, c = board.find_hole possibles = board.possible_numbers(r, c) board.set(r, c, possibles.first) end
Questo però assume che ci sia per forza un numero possibile. Cosa succede se siamo a un nodo di fallimento? Lo simuliamo passandogli un sudoku irrisolvibile
def test_failure_node b = Board.new(SOLVED_BOARD) b.set(0, 1, 8 ) # mossa non valida! ma non importa b.set(0, 0, 0) # questo buco ora è irrisolvibile assert_equal false, solve(b) end def solve(board) if !board.find_hole return board.list end r, c = board.find_hole possibles = board.possible_numbers(r, c) return false if !possibles board.set(r, c, possibles.first) end
A questo punto facciamo emergere la nostra soluzione presentando problemi sempre più difficili. In realtà essendo un po' stufi abbiamo preso una scorciatoia: abbiamo preso un problema da risolvere completo, e abbiamo implementato direttamente la soluzione ricorsiva. Con qualche calcio l'abbiamo fatta funzionare:
PROBLEM = [ 1, 0, 2, 0, 0, 0, 7, 0, 0, 0, 4, 7, 0, 2, 1, 3, 0, 0, 0, 8, 0, 7, 0, 3, 0, 1, 0, 4, 6, 0, 1, 0, 0, 0, 8, 0, 0, 0, 0, 2, 6, 4, 0, 0, 0, 0, 5, 0, 0, 0, 9, 0, 6, 4, 0, 9, 0, 5, 0, 7, 0, 2, 0, 0, 0, 1, 3, 4, 0, 9, 7, 0, 0, 0, 6, 0, 0, 0, 8, 0, 3, ] def test_solution sol = sove(PROBLEM) assert sol, "non ha risolto il problema :-(" do_print sol end def solve(board) if !board.find_hole return board.list end r, c = board.find_hole possibles = board.possible_numbers(r, c) for p in possibles board.set(r, c, p) solved = solve(Board.new(board.list)) return solved if solved end return false end
Questo problema viene risolto in 0.2 secondi sul mio Mac G4. Un problema più difficile, a livello evil da www.websudoku.com, viene risolto in 10 secondi. Sono molto soddisfatto.
Penso che la performance si possa migliorare nettamente, per il caso difficile, modificando semplicemente il metodo find_hole. Se invece di restituire il primo buco restituisse il buco che è maggiormente vincolato, immagino che taglieremmo di molto il numero di tentativi falliti. E questo, caro lettore, resta come esercizio...
September 2nd, 2006 at 13:42
molto carino, peccato che che in questi giorni non abbia tempo di farlo, ma penso che sarebbe un interessante kata :)
Ad esempio.. se possible_numbers diventa un metodo di un’ipotetica classe Cell si potrebbe fare un bel po’ di propagazione di vincoli prima di partire con la ricerca, mi sembra.
September 3rd, 2006 at 18:22
Per computare possible_numbers hai bisogno di esaminare un sacco di altre celle. Finiresti a chiedere un sacco di informazioni a Board, per cui credo che possible_numbers sia bene che resti una responsabilità di Board. A meno che… perché non provi e non ci fai vedere?
September 5th, 2006 at 08:02
ehehe .. carino veramente :)
June 29th, 2010 at 13:26
[…] while ago we had a fun evening at the Milano XPUG writing a Sudoku solver. I blogged about my solution. I’m not particularly proud of it, in retrospect. The code and the tests are not obvious. I […]