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...

4 Responses to “Sudoku anch’io”

  1. riffraff Says:

    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.

  2. matteo Says:

    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?

  3. Andrea G. Says:

    ehehe .. carino veramente :)

  4. Extreme Enthusiasm » Blog Archive » TDD is no substitute for knowing what you are doing Says:

    […] 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 […]

Leave a Reply