#!/usr/bin/env python ## ## Name: sudoku.py ## Purpose: Generate and solve Sudoku puzzles. ## Author: M. J. Fromberger ## Info: $Id: sudoku.py 320 2007-08-01 14:27:39Z sting $ ## import math, random, re # {{ class ConsistencyError class ConsistencyError (Exception): """Exception raised during puzzle solution when a consistency error is detected. """ pass # }} # {{ class Puzzle class Puzzle (object): """Represents a square Sudoku puzzle with rectangular subregions. Subclass and override ._getbox() to implement more complex shapes for subregions, such as jigsaw Sudoku. """ def __init__(self, dim = 9, hdiv = 3, init = None): """Initialize a new blank square Sudoku board with dim rows and columns, and hdiv-width rectangular subdivisions. It is required that hdiv be a divisor of dim. If init is not None, it is passed to the .load() method to initialize the contents of the board; otherwise the board begins blank. """ assert dim > 0 assert math.modf(dim / hdiv)[0] == 0.0 self._dim = dim self._hdiv = hdiv self._vdiv = dim / hdiv if init is None: self.reset() else: self.load(init) def reset(self): """Erase the board to blank.""" nr = nc = self._dim self._brd = [None] * nr for row in xrange(nr): self._brd[row] = [None] * nc for col in xrange(nc): self._brd[row][col] = set(xrange(1, self._dim + 1)) def nrows(self): """Returns the dimensions of the board.""" return self._dim ncols = nrows def rows(self): """Return all the rows of the board, top to bottom.""" return iter(self._getrow(r) for r in xrange(self.nrows())) def cols(self): """Return all the columns of the board, left to right.""" return iter(self._getcol(c) for c in xrange(self.ncols())) def load(self, p): """Load a puzzle into this board. The puzzle should be given as a string of numbers of the appropriate dimensions. White space delimits values, and "_" is used to mark a blank square. Raises ValueError if the string is of the wrong dimensions, or contains any unexpected characters. """ q = re.split(r'\s+', p.strip()) nr = self.nrows() nc = self.ncols() if len(q) <> (nr * nc): raise ValueError("Incorrect puzzle dimension") self.reset() pos = 0 for ch in q: if ch == '_': pass else: r = pos // nc c = pos % nc try: v = int(ch) except ValueError: raise ValueError("Invalid puzzle entry: %s" % ch) if v < 1 or v > self._dim: raise ValueError("Value out of range: %s" % v) s = self._brd[r][c] s.clear() s.add(v) pos += 1 def copy(self): """Return a copy of the puzzle as it is currently.""" out = type(self)(self._dim, self._hdiv) out._brd = self._copyboard() return out def unsolved(self): """Find an unsolved square of the board, returning its row and column index. Returns None, None if no squares are unsolved. """ cr = cc = None cv = self._dim * 2 for r in xrange(self.nrows()): for c in xrange(self.ncols()): sv = len(self._getcel(r, c)) if sv > 1 and sv < cv: cr, cc, cv = r, c, sv return cr, cc def solve(self): """Attempt to fill in a solution for the puzzle. If a solution is found, returns True; otherwise returns False. Note: If you call .solve() on a blank board, it generates a complete board, chosen at random. If there is more than one possible solution to the current board state, only one such state is actually found. """ self._count = 0 # Check for already-solved clues for r in xrange(self.nrows()): for c in xrange(self.ncols()): s = self._getcel(r, c) if len(s) == 1: self._setcel(r, c, list(s)[0]) # Initialize the work queue; this contains the backtrack # states for a depth-first scan of the unsolved spaces in the # board. r, c = self.unsolved() if r is None: return True more = [(r, c, self._brd, v) for v in self._getcel(r, c)] random.shuffle(more) # At the start of this loop, the board is unsolved and "more" # contains the possible next steps toward a solution; if it is # empty, there is no viable solution. while more: self._count += 1 r, c, s, v = more.pop() self._brd = s try: self._setcel(r, c, v) # may fail r, c = self.unsolved() if r is None: return True fwd = list((r, c, self._brd, v) for v in self._getcel(r, c)) random.shuffle(fwd) more.extend(fwd) except ConsistencyError: pass return False # no possible solution def _getcel(self, rx, cx): """[private] Get the contents of the specified square.""" return self._brd[rx][cx] def _clearcel(self, rx, cx): """[private] Clear the specified square.""" s = self._getcel(rx, cx) s.clear() s.update(xrange(1, self._dim + 1)) def _setcel(self, rx, cx, v): """[private] Set the contents of the specified square to v, and propagate its implications to the containing row, column, and box of that square. Raises ConsistencyError if there is a problem, and restores the original board state. """ save = self._brd try: self._brd = self._copyboard() s = self._getcel(rx, cx) s.clear() s.add(v) self._checkcel(rx, cx, v) except ConsistencyError: self._brd = save raise def _checkcel(self, rx, cx, v): """[private] Propagate implications of a value change to the row, column, and box neighbors of a square. Raises ConsistencyError if there is a problem. """ fix = set() for c, s in enumerate(self._getrow(rx)): os = len(s) if c <> cx: s.discard(v) if os > 1 and len(s) == 1: fix.add((rx, c, list(s)[0])) elif len(s) == 0: raise ConsistencyError(rx, c) for r, s in enumerate(self._getcol(cx)): os = len(s) if r <> rx: s.discard(v) if os > 1 and len(s) == 1: fix.add((r, cx, list(s)[0])) elif len(s) == 0: raise ConsistencyError(r, cx) for r, c, s in self._getbox(rx, cx): os = len(s) if r <> rx or c <> cx: s.discard(v) if os > 1 and len(s) == 1: fix.add((r, c, list(s)[0])) elif len(s) == 0: raise ConsistencyError(r, c) for r, c, v in fix: self._checkcel(r, c, v) def _getrow(self, rx): """[private] Get the row whose index is given.""" return self._brd[rx] def _getcol(self, cx): """[private] Get the column whose index is given.""" return list(self._getcel(rx, cx) for rx in xrange(self.nrows())) def _getbox(self, rx, cx): """[private] Get the box containing the square whose row and column indices are given. This is returned as a list of tuples (r, c, s) where r, c are the coordinates of a square and s is the content set for that square. """ br = (rx // self._hdiv) * self._hdiv bc = (cx // self._vdiv) * self._vdiv out = [None] * (self._hdiv * self._vdiv) pos = 0 for r in xrange(br, br + self._hdiv): for c in xrange(bc, bc + self._vdiv): out[pos] = r, c, self._getcel(r, c) pos += 1 return out def _copyboard(self): """[private] Make a detached copy of the board contents.""" return [[set(s) for s in row] for row in self._brd] def as_text(self): """Renders the board as human-readable text.""" out = '' ln = len(str(self._dim)) fmt = ' %%%ds ' % ln for rx, row in enumerate(self.rows()): out += '|'.join(fmt % setrep(s) for s in row) if rx < self._dim - 1: if rx % self._vdiv == (self._vdiv - 1): t = '=' * (ln + 2) else: t = '-' * (ln + 2) out += '\n' out += '+'.join(t for s in row) out += '\n' return out def __str__(self): ln = len(str(self._dim)) fmt = '%%%ds' % ln out = '\n'.join(' '.join(fmt % setrep(s, '_') for s in row) for row in self.rows()) return out def __eq__(self, other): """Compare two puzzle boards; they are equal iff they have the same dimensions and equal content. """ if not isinstance(other, type(self)): return False else: return str(self) == str(other) # }} # {{ class JigsawPuzzle class JigsawPuzzle (Puzzle): """A variant of the Sudoku puzzle that allows for arbitrary container regions instead of square boxes. Subclass and supply a value for the REGIONS member. To specify regions, supply a string of characters separated by whitespace. One nonspace character must be supplied for each cell of the puzzle, in row-major order. All those cells which share a region will be considered to be in the same "box". In order to be legal, each region must have the same number of elements, and that number must match the dimension of the puzzle. """ def __init__(self, dim = 9, init = None): super(JigsawPuzzle, self).__init__(dim, dim, init) self._checkreg() def _checkreg(self): """[private] Check the REGIONS field and build a quick lookup table to match positions to region contents. """ try: reg = self.REGIONS except AttributeError: raise TypeError("No value for REGIONS supplied") reg = re.split(r'\s+', reg.strip()) if len(reg) <> (self._dim ** 2): raise TypeError("Improper REGIONS value supplied") pos = 0 ; rmap = {} for r in xrange(self.nrows()): for c in xrange(self.ncols()): rmap[r, c] = reg[pos] pos += 1 cmap = dict((v, set()) for v in set(reg)) for k, v in rmap.iteritems(): cmap[v].add(k) for k, v in cmap.iteritems(): if len(v) <> self._dim: raise TypeError("Improper region size %s, %d" % (k, len(v))) self._rmap = rmap self._cmap = cmap def _getbox(self, r, c): """[private] Return the members of the region containing the given cell. Overrides the inherited version from Puzzle. """ tag = self._rmap[r, c] pts = self._cmap[tag] return list(v + (self._brd[r][c],) for v in pts) # }} # {{ setrep(s[, alt]) def setrep(s, alt = ' '): """[private] Generate a human-readable representation of a cell. """ if len(s) == 1: return str(list(s)[0]) else: return alt # }} # {{ test() def test(): """Unit test for Sudoku solver.""" global puzzle1, puzzle1s global puzzle2, puzzle2s global puzzle3, puzzle3s puzzle1 = ''' 1 2 6 _ 9 _ _ _ _ _ _ 5 7 _ _ _ 1 3 _ _ _ 6 _ _ _ _ _ 4 _ _ 5 8 _ 7 _ 9 3 5 _ 4 _ _ _ _ 1 _ _ _ 2 7 _ 4 _ _ _ 6 3 8 _ _ _ 9 _ _ _ 8 _ _ _ 5 7 _ _ _ _ _ 4 6 _ 2 _ ''' puzzle1s = ''' 1 2 6 3 9 5 8 4 7 8 9 5 7 2 4 6 1 3 7 3 4 6 1 8 9 5 2 4 1 2 5 8 3 7 6 9 3 5 7 4 6 9 2 8 1 6 8 9 2 7 1 4 3 5 2 6 3 8 5 7 1 9 4 9 4 8 1 3 2 5 7 6 5 7 1 9 4 6 3 2 8 ''' puzzle2 = ''' 2 _ 5 _ _ 6 _ _ _ 1 _ _ _ _ 3 _ _ 8 6 3 _ 5 4 7 _ _ 1 _ _ _ 1 _ _ 6 _ _ _ _ _ _ 3 4 _ 8 _ _ 8 _ _ 9 _ _ 7 _ _ 7 2 _ _ _ _ _ _ _ _ 6 _ 5 9 _ 2 3 8 _ _ _ _ 2 _ _ _ ''' puzzle2s = ''' 2 9 5 8 1 6 3 4 7 1 4 7 9 2 3 5 6 8 6 3 8 5 4 7 2 9 1 5 2 9 1 7 8 6 3 4 7 6 1 2 3 4 9 8 5 3 8 4 6 9 5 1 7 2 9 7 2 3 8 1 4 5 6 4 1 6 7 5 9 8 2 3 8 5 3 4 6 2 7 1 9 ''' # This is a very difficult puzzle to challenge brute-force # Sudoku solvers. It is linked from: # puzzle3 = ''' _ _ _ _ _ _ _ _ _ _ _ _ _ _ 3 _ 8 5 _ _ 1 _ 2 _ _ _ _ _ _ _ 5 _ 7 _ _ _ _ _ 4 _ _ _ 1 _ _ _ 9 _ _ _ _ _ _ _ 5 _ _ _ _ _ _ 7 3 _ _ 2 _ 1 _ _ _ _ _ _ _ _ 4 _ _ _ 9 ''' puzzle3s = ''' 9 8 7 6 5 4 3 2 1 2 4 6 1 7 3 9 8 5 3 5 1 9 2 8 7 4 6 1 2 8 5 3 7 6 9 4 6 3 4 8 9 2 1 5 7 7 9 5 4 6 1 8 3 2 5 1 9 2 8 6 4 7 3 4 7 2 3 1 9 5 6 8 8 6 3 7 4 5 2 1 9 ''' p1 = Puzzle(init = puzzle1) p1s = Puzzle(init = puzzle1s) p2 = Puzzle(init = puzzle2) p2s = Puzzle(init = puzzle2s) p3 = Puzzle(init = puzzle3) p3s = Puzzle(init = puzzle3s) assert p1.solve() == True if p1 == p1s: print "p1 solved, %d iterations" % p1._count else: print "p1 failed, got\n%s\nexpected\n%s" % (p1, p1s) assert p2.solve() == True if p2 == p2s: print "p2 solved, %d iterations" % p2._count else: print "p2 failed, got\n%s\nexpected\n%s" % (p2, p2s) assert p3.solve() == True if p3 == p3s: print "p3 solved, %d iterations" % p3._count else: print "p3 failed, got\n%s\nexpected\n%s" % (p3, p3s) # }} __all__ = ('Puzzle', 'JigsawPuzzle') # Here there be dragons