Add Sudoku._slice_completion_solver() to sudoku.py.
authorW. Trevor King <wking@drexel.edu>
Wed, 13 Oct 2010 11:39:03 +0000 (07:39 -0400)
committerW. Trevor King <wking@drexel.edu>
Wed, 13 Oct 2010 11:39:03 +0000 (07:39 -0400)
assignments/archive/sudoku/soln/sudoku.py

index 2c086979a862c8af17c2c537b942ebcd4e5806ea..9011067811391ec4491701eaf46ad35331dae0cb 100755 (executable)
@@ -1,25 +1,48 @@
 #!/usr/bin/env python
 
+import itertools
+
 import numpy
 
 
+TEST_PUZZLE_STRING = '\n'.join([
+        '5 3 -   - 7 -   - - -',
+        '6 - -   1 9 5   - - -',
+        '- 9 8   - - -   - 6 -',
+        '',
+        '8 - -   - 6 -   - - 3',
+        '4 - -   8 - 3   - - 1',
+        '7 - -   - 2 -   - - 6',
+        '',
+        '- 6 -   - - -   2 8 -',
+        '- - -   4 1 9   - - 5',
+        '- - -   - 8 -   - 7 9',
+        ])
+
+
+def power_set(iterable):
+    """Return the power set of `iterable`.
+
+    >>> for i in power_set([1,2,3]):
+    ...     print i
+    ()
+    (1,)
+    (2,)
+    (3,)
+    (1, 2)
+    (1, 3)
+    (2, 3)
+    (1, 2, 3)
+    """
+    s = list(iterable)
+    return itertools.chain.from_iterable(
+        itertools.combinations(s, r) for r in range(len(s)+1))
+
+
 class Sudoku (object):
-    r"""
-    >>> puzzle = '\n'.join([
-    ...     '5 3 -   - 7 -   - - -',
-    ...     '6 - -   1 9 5   - - -',
-    ...     '- 9 8   - - -   - 6 -',
-    ...     '',
-    ...     '8 - -   - 6 -   - - 3',
-    ...     '4 - -   8 - 3   - - 1',
-    ...     '7 - -   - 2 -   - - 6',
-    ...     '',
-    ...     '- 6 -   - - -   2 8 -',
-    ...     '- - -   4 1 9   - - 5',
-    ...     '- - -   - 8 -   - 7 9',
-    ...     ])
+    """
     >>> s = Sudoku()
-    >>> s.load(puzzle)
+    >>> s.load(TEST_PUZZLE_STRING)
     >>> s._num_solved()
     30
     >>> s.solve()
@@ -54,6 +77,8 @@ class Sudoku (object):
         self._empty = 0
         self._external_empty = '-'
         self.status = None
+        self.solvers = [self._direct_elimination_solver,
+                        self._slice_completion_solver]
 
     def load(self, text):
         row = 0
@@ -129,41 +154,108 @@ class Sudoku (object):
     def _col(self, col):
         return self._puzzle[:,col]
 
-    def _cell(self, cell_row, cell_col):
+    def _cell_bounds(self, cell_row, cell_col):
         ri = cell_row * 3
         rf = ri + 3
         ci = cell_col * 3
         cf = ci + 3
+        return (ri, rf, ci, cf)
+
+    def _cell(self, cell_row, cell_col):
+        ri,rf,ci,cf = self._cell_bounds(cell_row, cell_col)
         return self._puzzle[ri:rf,ci:cf]
 
+    def _slices(self):
+        """
+        >>> s = Sudoku()
+        >>> s.load(TEST_PUZZLE_STRING)
+        >>> for type,slice,index in s._slices():
+        ...     print type,slice,index  # doctest: +ELLIPSIS
+        row [5 3 0 0 7 0 0 0 0] 0
+        ...
+        row [0 0 0 0 8 0 0 7 9] 8
+        col [5 6 0 8 4 7 0 0 0] 0
+        ...
+        col [0 0 0 3 1 6 0 5 9] 8
+        cell [[5 3 0]
+         [6 0 0]
+         [0 9 8]] (0, 0)
+        ...
+        cell [[2 8 0]
+         [0 0 5]
+         [0 7 9]] (2, 2)
+        """
+        for row in range(9):
+            yield ('row', self._row(row), row)
+        for col in range(9):
+            yield ('col', self._col(col), col)
+        for cell_row in range(3):
+            for cell_col in range(3):
+                yield ('cell', self._cell(cell_row, cell_col),
+                       (cell_row, cell_col))
+
+    def _point_to_cell_coords(self, row, col):
+        """
+        >>> s = Sudoku()
+        >>> s._point_to_cell_coords(4, 6)
+        (1, 2, 3)
+
+        The point in question:
+
+        - - -   - - -   - - -
+        - 0 -   - 1 -   - 2 -
+        - - -   - - -   - - -
+        
+        - - -   - - -   0 1 2
+        - 1 -   - - -   * - -
+        - - -   - - -   - - -
+        
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        """
+        cell_row_residual = row % 3
+        cell_col_residual = col % 3
+        return (row/3, col/3,
+                cell_row_residual*3 + cell_col_residual)
+
+    def _cell_to_point_coords(self, cell_row, cell_col, i):
+        """
+        >>> s = Sudoku()
+        >>> s._cell_to_point_coords(1, 2, 3)
+        (4, 6)
+
+        The point in question:
+
+        0 1 2   3 4 5   6 - -
+        1 - -   - - -   - - -
+        2 - -   - - -   - - -
+        
+        3 - -   - - -   - - -
+        4 - -   - - -   * - -
+        - - -   - - -   - - -
+        
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        """
+        row = cell_row * 3 + (i / 3)
+        col = cell_col * 3 + (i % 3)
+        return (row, col)
+
     def _nonempty(self, values):
         return [x for x in values if x != self._empty]
 
     def _is_valid(self):
-        r"""
-        >>> puzzle = '\n'.join([
-        ...     '5 3 -   - 7 -   - - -',
-        ...     '6 - -   1 9 5   - - -',
-        ...     '- 9 8   - - -   - 6 -',
-        ...     '',
-        ...     '8 - -   - 6 -   - - 3',
-        ...     '4 - -   8 - 3   - - 1',
-        ...     '7 - -   - 2 -   - - 6',
-        ...     '',
-        ...     '- 6 -   - - -   2 8 -',
-        ...     '- - -   4 1 9   - - 5',
-        ...     '- - -   - 8 -   - 7 9',
-        ...     ])
+        """
         >>> s = Sudoku()
-        >>> s.load(puzzle)
+        >>> s.load(TEST_PUZZLE_STRING)
         >>> s._is_valid()
         True
 
         Test an invalid row.
 
         >>> s._puzzle[0,3] = 5
-        >>> s._is_valid_row(0)
-        False
         >>> s._is_valid()
         False
         >>> s._puzzle[0,3] = s._empty
@@ -171,8 +263,6 @@ class Sudoku (object):
         Test an invalid column.
 
         >>> s._puzzle[8,0] = 5
-        >>> s._is_valid_col(0)
-        False
         >>> s._is_valid()
         False
         >>> s._puzzle[8,0] = s._empty
@@ -180,40 +270,38 @@ class Sudoku (object):
         Test and invalid cell.
 
         >>> s._puzzle[2,0] = 3
-        >>> s._is_valid_cell(0, 0)
-        False
         >>> s._is_valid()
         False
         >>> s._puzzle[2,0] = s._empty
         """
-        for row in range(9):
-            if not self._is_valid_row(row):
-                return False
-        for col in range(9):
-            if not self._is_valid_col(col):
+        for type,slice,index in self._slices():
+            values = self._nonempty(slice.flat)
+            if len(values) != len(set(values)):
                 return False
-        for cell_row in range(3):
-            for cell_col in range(3):
-                if not self._is_valid_cell(cell_row, cell_col):
-                    return False
         return True
 
-    def _is_valid_row(self, row):
-        values = self._nonempty(self._row(row))
-        return len(values) == len(set(values))
-
-    def _is_valid_col(self, col):
-        values = self._nonempty(self._col(col))
-        return len(values) == len(set(values))
-
-    def _is_valid_cell(self, cell_row, cell_col):
-        values = self._nonempty(self._cell(cell_row, cell_col).flatten())
-        return len(values) == len(set(values))
-
     def _num_solved(self):
         return len(self._nonempty(self._puzzle.flatten()))
 
     def solve(self):
+        actions = 0
+        trials = self._setup_trials()
+        while True:
+            start_actions = actions
+            for solver in self.solvers:
+                acts,trials = solver(trials)
+                self._apply_trials(trials)
+                actions += acts
+                if acts > 0:
+                    break  # don't use slow solvers unless they're required
+            if self._num_solved() == 81:
+                self.status = 'solved in %d steps' % actions
+                return  # puzzle solved
+            elif actions == start_actions:
+                self.status = 'aborted after %d steps' % actions
+                return  # puzzle too hard to solve
+
+    def _setup_trials(self):
         trials = numpy.zeros((9,9,9), dtype=numpy.int)
         for row in range(9):
             for col in range(9):
@@ -222,42 +310,329 @@ class Sudoku (object):
                 else:
                     x = self._puzzle[row,col]
                     trials[row,col,x-1] = x
+        return trials
+
+    def _apply_trials(self, trials):
+        for row in range(9):
+            for col in range(9):
+                if len(self._nonempty(trials[row,col,:])) == 1:
+                    self._puzzle[row][col] = (
+                        self._nonempty(trials[row,col,:])[0])
+                    assert self._is_valid(), (
+                        'error setting [%d,%d] to %d'
+                        % (row, col, self._puzzle[row][col]))
+
+    def _trial_slice(self, trials, type, index):
+        """Return a slice from the trials array.
+
+        >>> s = Sudoku()
+        >>> s.load(TEST_PUZZLE_STRING)
+        >>> trials = s._setup_trials()
+        >>> t = s._trial_slice(trials, 'row', 0)
+        >>> t  # doctest: +REPORT_UDIFF
+        array([[0, 0, 0, 0, 5, 0, 0, 0, 0],
+               [0, 0, 3, 0, 0, 0, 0, 0, 0],
+               [1, 2, 3, 4, 5, 6, 7, 8, 9],
+               [1, 2, 3, 4, 5, 6, 7, 8, 9],
+               [0, 0, 0, 0, 0, 0, 7, 0, 0],
+               [1, 2, 3, 4, 5, 6, 7, 8, 9],
+               [1, 2, 3, 4, 5, 6, 7, 8, 9],
+               [1, 2, 3, 4, 5, 6, 7, 8, 9],
+               [1, 2, 3, 4, 5, 6, 7, 8, 9]])
 
+        For `row` and `column` slices, the original `trials` array
+        responds to changes in `t`.
+
+        >>> trials[0,2,:]
+        array([1, 2, 3, 4, 5, 6, 7, 8, 9])
+        >>> t[2,:] = 0
+        >>> t[2,3] = 4
+        >>> trials[0,2,:]
+        array([0, 0, 0, 4, 0, 0, 0, 0, 0])
+
+        `cell` slices don't work with "flat" indexing, because the
+        stride would not be constant.  You'll have to push changes
+        back to `trials` by hand.
+
+        >>> t = s._trial_slice(trials, 'cell', (0, 0))
+        >>> t  # doctest: +REPORT_UDIFF
+        array([[0, 0, 0, 0, 5, 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, 0, 6, 0, 0, 0],
+               [1, 2, 3, 4, 5, 6, 7, 8, 9],
+               [1, 2, 3, 4, 5, 6, 7, 8, 9],
+               [1, 2, 3, 4, 5, 6, 7, 8, 9],
+               [0, 0, 0, 0, 0, 0, 0, 0, 9],
+               [0, 0, 0, 0, 0, 0, 0, 8, 0]])
+        >>> trials[1,1,:]
+        array([1, 2, 3, 4, 5, 6, 7, 8, 9])
+        >>> t[4,:] = 0
+        >>> t[4,6] = 7
+        >>> trials[1,1,:] = t[4,:]
+        >>> trials[1,1,:]
+        array([0, 0, 0, 0, 0, 0, 7, 0, 0])
+        """
+        if type == 'row':
+            t = trials[index,:,:]
+        elif type == 'col':
+            t = trials[:,index,:]
+        else:
+            assert type == 'cell', type
+            cell_row,cell_col = index
+            ri,rf,ci,cf = self._cell_bounds(cell_row, cell_col)
+            t = trials[ri:rf,ci:cf,:]
+            t = t.reshape((t.shape[0]*t.shape[1], t.shape[2])).copy()
+        if type in ['row', 'col']:
+            assert t.flags.owndata == False, t.flags.owndata
+            assert t.base is trials, t.base
+        else:
+            assert t.flags.owndata == True, t.flags.owndata
+        return t
+
+    def _direct_elimination_solver(self, trials):
+        r"""Eliminate trials if a point already has the trial digit in
+        its row/col/cell.
+
+        >>> puzzle = '\n'.join([
+        ...         '1 2 3   - - -   - - -',
+        ...         '4 5 6   - - -   - - -',
+        ...         '7 8 -   - - -   - - -',
+        ...         '',
+        ...         '- - -   - - -   - - -',
+        ...         '- - -   - - -   - - -',
+        ...         '- - -   - - -   - - -',
+        ...         '',
+        ...         '- - -   - - -   - - -',
+        ...         '- - -   - - -   - - -',
+        ...         '- - -   - - -   - - -',
+        ...         ])
+        >>> s = Sudoku()
+        >>> s.load(puzzle)
+        >>> trials = s._setup_trials()
+        >>> actions,trials = s._direct_elimination_solver(trials)
+
+        The solver eliminated three numbers for two columns and rows
+        and two numbers for three columns and rows, which makes for
+            104 =   8    # point 2,2 (the solved point)
+                  + 6*3  # row 0
+                  + 6*3  # row 1
+                  + 6*2  # row 2
+                  + 6*3  # col 0
+                  + 6*3  # col 1
+                  + 6*2  # col 2
+        eliminations.
+
+        >>> actions
+        104
+        >>> s._apply_trials(trials)
+        >>> print s.dump()
+        1 2 3   - - -   - - -
+        4 5 6   - - -   - - -
+        7 8 9   - - -   - - -
+        <BLANKLINE>
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        <BLANKLINE>
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        """
         actions = 0
-        while True:
-            start_actions = actions
-            for row in range(9):
-                for col in range(9):
-                    if self._puzzle[row][col] == self._empty:
-                        for x in self._nonempty(trials[row,col,:]):
-                            self._puzzle[row,col] = x
-                            if not self._is_valid():
+        for row in range(9):
+            for col in range(9):
+                if self._puzzle[row][col] == self._empty:
+                    for x in self._nonempty(trials[row,col,:]):
+                        self._puzzle[row,col] = x
+                        if not self._is_valid():
+                            actions += 1
+                            trials[row,col,x-1] = self._empty
+                        self._puzzle[row,col] = self._empty
+        return (actions, trials)
+
+    def _slice_completion_solver(self, trials):
+        r"""Eliminate trials if a set of N points have trials drawn
+        only from a list of N options.
+
+        For example, a slice like
+          [1, 2, 3, {4,5}, {4,6}, {4,5,6}, {4,5,6,7,8,9},
+           {4,5,6,7,8,9}, {4,5,6,7,8,9}]
+        has three points {4,5}, {4,6}, and {4,5,6}, with three possible
+        numbers: 4, 5, and 6.  That means, 4, 5, and 6 would definitely
+        occupy the three points and other points should not have those
+        numbers.
+
+        >>> puzzle = '\n'.join([
+        ...         '1 2 3   - - -   - - -',
+        ...         '- - -   7 - -   - - -',
+        ...         '- - -   - 8 9   - - -',
+        ...         '',
+        ...         '- - -   6 5 -   - - -',
+        ...         '- - -   - - -   - - -',
+        ...         '- - -   - - -   - - -',
+        ...         '',
+        ...         '- - -   - - -   - - -',
+        ...         '- - -   - - -   - - -',
+        ...         '- - -   - - -   - - -',
+        ...         ])
+        >>> s = Sudoku()
+        >>> s.load(puzzle)
+        >>> trials = s._setup_trials()
+
+        Take a first pass through `_direct_elimination_solver()` to
+        eliminate trial values in the rows/columns/cells blocked by
+        the initialized points.
+
+        >>> actions,trials = s._direct_elimination_solver(trials)
+
+        The solver eliminated the following possibilities (by number)
+            142 =   6 + 6 + 6  # number 1, cell+row+col
+                  + 6 + 6 + 6  # number 2, cell+row+col
+                  + 6 + 6 + 6  # number 3, cell+row+col
+                  + 7 + 6 + 5  # number 5, cell+row+col
+                  + 7 + 6 + 5  # number 6, cell+row+col
+                  + 6 + 6 + 5  # number 7, cell+row+col
+                  + 6 + 6 + 5  # number 8, cell+row+col
+                  + 6 + 6 + 6  # number 9, cell+row+col
+
+        >>> actions
+        142
+
+        However the direct solver was unable to actually solve any new
+        points.
+
+        >>> s._apply_trials(trials)
+        >>> print s.dump()
+        1 2 3   - - -   - - -
+        - - -   7 - -   - - -
+        - - -   - 8 9   - - -
+        <BLANKLINE>
+        - - -   6 5 -   - - -
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        <BLANKLINE>
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        - - -   - - -   - - -
+        >>> trials[0,:,:]  # doctest: +REPORT_UDIFF
+        array([[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, 5, 0, 0, 0, 0],
+               [0, 0, 0, 4, 0, 6, 0, 0, 0],
+               [0, 0, 0, 4, 5, 6, 0, 0, 0],
+               [0, 0, 0, 4, 5, 6, 7, 8, 9],
+               [0, 0, 0, 4, 5, 6, 7, 8, 9],
+               [0, 0, 0, 4, 5, 6, 7, 8, 9]])
+
+        Now we proceed with the slice comparison solver.
+
+        >>> actions,trials = s._slice_completion_solver(trials)
+
+        The solver reduced trials in the following points
+            12 =   1  # point 0,6, removed 4,5,6  must be in center of row 0
+                 + 1  # point 0,7, removed 4,5,6  must be in center of row 0
+                 + 1  # point 0,8, removed 4,5,6  must be in center of row 0
+                 + 1  # point 1,4, removed 4,6    must be in top of cell 0,1
+                 + 1  # point 1,5, removed 4,5,6  must be in top of cell 0,1
+                 + 1  # point 2,3, removed 4,5    must be in top of cell 0,1
+                 + 1  # point 1,6, removed 8,9    must be in top of cell 0,2
+                 + 1  # point 1,7, removed 8,9    must be in top of cell 0,2
+                 + 1  # point 1,8, removed 8,9    must be in top of cell 0,2
+                 + 1  # point 2,6, removed 7      must be in top of cell 0,2
+                 + 1  # point 2,7, removed 7      must be in top of cell 0,2
+                 + 1  # point 2,8, removed 7      must be in top of cell 0,2
+
+        >>> actions
+        12
+        >>> trials[0,:,:]  # doctest: +REPORT_UDIFF
+        array([[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, 5, 0, 0, 0, 0],
+               [0, 0, 0, 4, 0, 6, 0, 0, 0],
+               [0, 0, 0, 4, 5, 6, 0, 0, 0],
+               [0, 0, 0, 0, 0, 0, 7, 8, 9],
+               [0, 0, 0, 0, 0, 0, 7, 8, 9],
+               [0, 0, 0, 0, 0, 0, 7, 8, 9]])
+        """
+        actions = 0
+        for _type,slice,index in self._slices():
+            assert slice.size == 9, slice
+            trial_slice = self._trial_slice(trials, _type, index)
+            missing = set(self._nonempty(trial_slice.flat))
+            for possible in power_set(missing):
+                possible = set(possible)
+                if possible in [set(), missing]:
+                    continue
+                points = []
+                for k in range(slice.size):
+                    trial_set = set(self._nonempty(trial_slice[k,:]))
+                    if trial_set.issubset(possible):
+                        points.append(k)
+                if len(points) == len(possible):
+                    possible_trial_slice = [0]*9
+                    for p in possible:
+                        possible_trial_slice[p-1] = p
+                    for k in range(slice.size):
+                        if k in points:
+                            ts = numpy.array(possible_trial_slice)
+                            for i,p in enumerate(ts):
+                                if trial_slice[k,i] == self._empty:
+                                    ts[i] = self._empty
+                        else:
+                            ts = trial_slice[k,:].copy()
+                            for i,p in enumerate(ts):
+                                if p in possible:
+                                    ts[i] = self._empty
+                        if _type == 'cell':
+                            cell_row,cell_col = index
+                            row,col = self._cell_to_point_coords(
+                                cell_row, cell_col, k)
+                            if (ts != trials[row,col,:]).any():
                                 actions += 1
-                                trials[row,col,x-1] = self._empty
-                            self._puzzle[row,col] = self._empty
-                        if len(self._nonempty(trials[row,col,:])) == 1:
-                            self._puzzle[row][col] = (
-                                self._nonempty(trials[row,col,:])[0])
-                            if self._num_solved() == 81:
-                                self.status = 'solved in %d steps' % actions
-                                return  # puzzle solved
-            if actions == start_actions:
-                self.status = 'aborted after %d steps' % actions
-                break  # puzzle too hard to solve
+                                #print _type, index, k, row, col, trial_slice[k,:], ts
+                                trials[row,col,:] = ts
+                                trial_slice[k,:] = ts
+                        else:  # row or column
+                            if (ts != trial_slice[k,:]).any():
+                                actions += 1
+                                #print _type, index, k, trial_slice[k,:], ts
+                                trial_slice[k,:] = ts
+        return (actions, trials)
+
 
 def test():
     import doctest
     doctest.testmod()
 
 if __name__ == '__main__':
+    import optparse
     import sys
 
-    if len(sys.argv) > 1:
-        assert sys.argv[1] == '--test', sys.argv
+    p = optparse.OptionParser()
+    p.add_option('--test', dest='test', default=False, action='store_true',
+                 help='Run unit tests and exit.')
+    p.add_option('-d', '--disable-direct', dest='direct', default=True,
+                 action='store_false',
+                 help='Disable the direct elimination solver')
+    p.add_option('-c', '--disable-completion', dest='completion', default=True,
+                 action='store_false',
+                 help='Disable the slice completion solver')
+
+    options,args = p.parse_args()
+
+    if options.test:
         test()
         sys.exit(0)
 
     s = Sudoku()
+    if not options.direct:
+        s.solvers.remove(s._direct_elimination_solver)
+    if not options.completion:
+        s.solvers.remove(s._slice_completion_solver)
+
     puzzle = sys.stdin.read()
     s.load(puzzle)
     try: