sudoku/sudoku.py

240 lines
10 KiB
Python
Raw Normal View History

2013-08-28 23:09:55 -07:00
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
'''
A Sudoku solver.
'''
import copy
2013-08-28 23:09:55 -07:00
import logging
import math
LOG = logging.getLogger(__name__)
class Board(dict):
def __init__(self, size=9, **kwargs):
self.size = size
# Verify size is a perfect square. The sqrt(size) is also the dimension of each box on the
# Sudoku grid.
self.box_size = int(math.sqrt(size))
assert self.box_size == int(self.box_size), 'Invalid size; value must be a perfect square'
# The range of possible values for a square.
2013-08-29 09:42:06 -07:00
self.possible_values = range(1, self.size + 1)
2013-08-28 23:09:55 -07:00
# Make the grid.
super(Board, self).__init__([(Board.xy_key(x, y), list(self.possible_values))
2013-08-28 23:09:55 -07:00
for x in range(self.size)
for y in range(self.size)])
for square in self:
initial_value = kwargs.get(Board.xy_kwargs_key(*square))
if initial_value is None:
continue
if initial_value not in self.possible_values:
raise ValueError('Invalid initial value for square ({}, {}): {}'.format(x, y, initial_value))
LOG.debug('Setting initial value of {} to {}'.format(square, initial_value))
self.assign(square, [initial_value])
2013-08-28 23:09:55 -07:00
@classmethod
def xy_key(self, x, y):
2013-08-29 09:42:06 -07:00
'''Given {x} and {y}, generate a key to refer to the square at coordinate (x, y) in the grid.'''
return (int(x), int(y))
@classmethod
def xy_kwargs_key(self, x, y):
'''
Given {x} and {y}, generate a key to refer to the square in the __init__ kwargs at coordinate (x, y) in the
grid.
'''
return 'x{}y{}'.format(x, y)
2013-08-29 09:42:06 -07:00
@property
def solved(self):
2013-08-28 23:09:55 -07:00
'''
2013-08-29 09:42:06 -07:00
Determines if the board has been solved. First, determine if all squares have no more than one value (i.e. they
have had values assigned to them). If not, return False. If so, check each unit (rows, columns, and boxes) to
make sure that each has one and only one of each value in the range of possible values for a unit. If not,
return False; otherwise, return True.
2013-08-28 23:09:55 -07:00
'''
2013-08-29 09:42:06 -07:00
if not all(len(s) == 1 for s in self.values()):
return False
def validate_unit(unit):
'''
Validate {unit} by ensuring it has exactly 1 of each value in the range of possible values.
'''
necessary_values = list(self.possible_values)
for square, values in unit.items():
v = values[0]
if v in necessary_values:
necessary_values.remove(v)
else:
return False
if len(necessary_values) != 0:
return False
return True
return ( all(validate_unit(self.row(r)) for r in range(self.size))
and all(validate_unit(self.col(c)) for c in range(self.size))
and all(validate_unit(self.box(x, y))
for x in range(0, self.size, self.box_size)
for y in range(0, self.size, self.box_size)))
2013-08-28 23:09:55 -07:00
def row(self, idx):
'''
Return a dict of all squares in the {idx}'th row.
'''
assert idx >= 0 and idx < self.size, 'Invalid row index; value must be >= 0 and < {}'.format(self.size)
# key[1] is Y coordinate in the grid.
return {k: v for k, v in self.items() if k[1] == idx}
def col(self, idx):
'''
Return a dict of all squares in the {idx}'th column.
'''
assert idx >= 0 and idx < self.size, 'Invalid column index; value must be >= 0 and < {}'.format(self.size)
# key[0] is X coordinate in the grid.
return {k: v for k, v in self.items() if k[0] == idx}
def box(self, x, y):
'''
Given a square at coordinates ({x}, {y}), return a dictionary containing the squares that make up the box that
contains the point.
'''
bx = int(x / self.box_size) * self.box_size
by = int(y / self.box_size) * self.box_size
bx_range = range(bx, bx + self.box_size)
by_range = range(by, by + self.box_size)
return {k: v for k, v in self.items() if k[0] in bx_range and k[1] in by_range}
def peers(self, x, y):
2013-08-29 09:52:52 -07:00
'''
Generate and return a list of peers for the square at coordinates ({x}, {y}). Peers are defined as all the
squares in the same row, column, and box as the given square.
'''
2013-08-28 23:09:55 -07:00
# Generate a dictionary of all the squares in the row, column, and box containing the given square.
peers = dict(list(self.row(y).items()) + list(self.col(x).items()) + list(self.box(x, y).items()))
# Remove the given square.
del peers[Board.xy_key(x, y)]
2013-08-28 23:09:55 -07:00
return peers
def clear(self, squares=None):
2013-08-28 23:09:55 -07:00
'''
2013-08-29 09:52:52 -07:00
Clear the board. Resets each square's possible value list to the full range of possible values for this board.
2013-08-28 23:09:55 -07:00
'''
for square in (self if squares is None else squares):
self.assign(square, list(self.possible_values))
2013-08-28 23:09:55 -07:00
2013-08-29 15:05:30 -07:00
def solve(self):
return self.search()
2013-08-29 15:05:30 -07:00
2013-08-30 10:11:44 -07:00
def search(self):
'''
Search for a solution, depth-first. Return the solved Board as a new instance of this class.
'''
2013-08-30 10:11:44 -07:00
if self.solved:
return self
2013-08-30 10:11:44 -07:00
# Chose the square with the fewest possible values.
_, smallest = min((len(self[sq]), sq) for sq in self if len(self[sq]) > 1)
# Deepcopy the board.
for v in self[smallest]:
trial_board = copy.deepcopy(self)
try:
trial_board.assign(smallest, [v])
if trial_board.search():
return trial_board
except ValueError:
continue
raise ValueError('No possible solution found.')
2013-08-30 10:11:44 -07:00
2013-08-29 15:05:30 -07:00
def assign(self, square, value):
'''
Assign {value} to {square}. {value} is expected to be an iterable (a list). {key} should be a valid (x, y)
coordinate pair for referencing a square on the board.
'''
LOG.debug('Assigning values {} to square {}.'.format(value, square))
removed_values = set(self[square]) - set(value)
for v in removed_values:
try:
self.eliminate(square, v)
except ValueError:
raise
# TODO: To make this work right, I should probably also do the same as above for the values added *back* into
# the values for {square}.
2013-08-29 15:05:30 -07:00
return True
2013-08-28 23:09:55 -07:00
def eliminate(self, square, value):
'''
2013-08-29 09:52:52 -07:00
Eliminate {value} from {square}, propagating changes to the squares peers as necessary. This process involves
two logical rules of Sudoku. First, if a square is reduced to a single possible value, assign that value to the
square and remove the value from the possible value lists of its peers. Second, if a unit has only one square
that can hold a value, put the value in that square and propagate that change to its peers.
2013-08-28 23:09:55 -07:00
'''
if value not in self[square]:
LOG.debug('Value {} not in square {}; skipping'.format(value, square))
2013-08-29 15:05:30 -07:00
return True
2013-08-28 23:09:55 -07:00
# Update peer value list.
super(Board, self).__setitem__(square, [v for v in self[square] if v != value])
LOG.debug('Eliminating {} from square {}'.format(value, square))
# (1) If a square is reduced to one value, eliminate that value from its peers.
if len(self[square]) == 0:
# Whoops. Removed the last value... We have a contradiction now.
LOG.error('Removed last value from square {}'.format(square))
raise ValueError('Removed last value from square {}; board is now invalid'.format(square))
elif len(self[square]) == 1:
# One value left in this square. Propagate changes to its peers.
LOG.debug('One value left in square {}; eliminating {} from its peers'.format(square, self[square][0]))
try:
for peer in self.peers(*square):
self.eliminate(peer, self[square][0])
except ValueError:
raise
# (2) If a unit has only one square for a value, put it there.
for unit in (self.row(square[1]), self.col(square[0]), self.box(*square)):
places = [sq for sq in unit if value in unit[sq]]
if len(places) == 0:
LOG.error('No place for value {} to go in unit {}'.format(value, unit))
raise ValueError('No place for value {} to go in unit {}; board is now invalid'.format(value, unit))
elif len(places) == 1:
LOG.debug('One place for value {} to be in unit {}; setting'.format(value, unit))
self.assign(places[0], [value])
2013-08-28 23:09:55 -07:00
return True
def __delitem__(self, key):
# Don't allow deleting keys from self.
pass
def __str__(self):
lines = []
box_lines = []
square_size = len(str(self.size))
for y in range(self.size):
2013-08-28 23:09:55 -07:00
row_squares = []
box_squares = []
for x in range(self.size):
square = self.get(Board.xy_key(x, y))
2013-08-28 23:09:55 -07:00
if len(square) == 1:
box_squares.append(str(square[0]).center(square_size))
2013-08-28 23:09:55 -07:00
else:
box_squares.append('.'.center(square_size))
2013-08-28 23:09:55 -07:00
if len(box_squares) == self.box_size:
row_squares.append(' '.join(box_squares))
box_squares = []
# Print a divider between boxes.
box_lines.append(' | '.join(row_squares))
if len(box_lines) == self.box_size:
lines.append('\n'.join(box_lines))
box_lines = []
if y < self.size - 1:
2013-08-29 12:05:34 -07:00
# Don't print a divider on the last line.
box_dividers = ['-' * ((square_size + 1) * self.box_size - 1) for box in range(self.box_size)]
2013-08-28 23:09:55 -07:00
lines.append('\n{}\n'.format('-+-'.join(box_dividers)))
return ''.join(lines)