Skip to content

Commit

Permalink
Version 0.5.0
Browse files Browse the repository at this point in the history
Refactored loading to enable arbirtary size of Sudoku.
Brute Force methodology is now operational, but not flawless.
Added tests from external source.
Added test for flat format of Sudoku files as well.
More docstrings.
Made __eq__ into oneliner.
  • Loading branch information
hbldh committed Oct 11, 2015
1 parent 691439b commit 4e6baa3
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 81 deletions.
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

[![Build Status](https://travis-ci.org/hbldh/hbldhdoku.svg?branch=master)](https://travis-ci.org/hbldh/hbldhdoku)

Sudoku Solver in Python.
Sudoku Solver written in pure Python with no dependencies except
[Six: Python 2 and 3 Compatibility Library](https://pythonhosted.org/six/).

It can solve `9 x 9` Sudokus, by pure induction from possible values but also
with an optional Brute Force methodology which is used for difficult specimens.

It is designed for solution of Sudokus of arbitrary order, but this is as of yet
rather untested.

## Installation

Expand All @@ -16,16 +23,19 @@ Tests can be run using `nosetests`:

nosetests tests

The tests make a HTTP request to a fiel containign several Sudokus on
[Project Euler]("https://projecteuler.net/project/resources/p096_sudoku.txt").

## Usage

An Sudoku can be solved as such:

```python
from hbldhdoku import Sudoku

s = Sudoku.load('path/to/sudoku.sud')
s = Sudoku.load_file('path/to/sudoku.sud')
print(s)
s.solve()
s.solve(verbose=True, allow_brute_force=True)
print(s)

```
Expand All @@ -42,3 +52,10 @@ A Sudoku file should be structured in the following manner:
***9**6**
**3*72*9*
*6*843*7*

or as a one-liner with optional comment:

# Optional comment or metadata.
030467050920010006067300148301006027400850600090200400005624001203000504040030702

Any character other than `[1-9]` may be used as a placeholder for unknowns.
222 changes: 161 additions & 61 deletions hbldhdoku/sudoku.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,46 @@
from __future__ import print_function

import os
import re
import random
import copy
import math

import six

from hbldhdoku.exceptions import SudokuException, SudokuHasNoSolutionError, SudokuTooDifficultError
from hbldhdoku.exceptions import SudokuHasNoSolutionError, SudokuTooDifficultError
from hbldhdoku import utils


class Sudoku(object):
"""Sudoku solver.
def __init__(self, n=3):
self.order = n
self.side = n**2
Uses set methods to discern possible values in each cell of the Sudoku and then
selects Naked and Hidden singles based on these possibility sets.
Uses Brute Force methods when faced with a Sudoku too advanced for the simple tools
described above. Note that this can take some time for Sudokus of order greater than 3!
"""

def __init__(self, order=3):
self.comment = ''
self.order = order
self.side = order**2
self.solution_steps = []

self._matrix = utils.get_list_of_lists(self.side, self.side, fill_with=0)
self._values = tuple(six.moves.range(0, (n ** 2) + 1))
self._values = tuple(six.moves.range(0, (order ** 2) + 1))
self._poss_rows = {}
self._poss_cols = {}
self._poss_mats = {}
self._poss_box = {}
self._possibles = {}

def __str__(self):
return "\n".join(["".join([str(v) for v in row]).replace('0', '*') for row in self.row_iter()])
if self.comment:
prefix = "{0}".format(self.comment)
else:
prefix = ''
return prefix + "\n".join(["".join([str(v) for v in row]).replace('0', '*') for row in self.row_iter()])

def __repr__(self):
return str(self)
Expand Down Expand Up @@ -79,65 +95,122 @@ def col_iter(self):
yield self.col(k)

def box(self, row, col):
"""Get the box of specified row and column of the Sudoku"""
"""Get the values of the box pertaining to the specified row and column of the Sudoku"""
box = []
for i in six.moves.range(row * self.order, (row * self.order) + self.order):
for j in six.moves.range(col * self.order, (col * self.order) + self.order):
box_i = (row // self.order) * self.order
box_j = (col // self.order) * self.order
for i in six.moves.range(box_i, box_i + self.order):
for j in six.moves.range(box_j, box_j + self.order):
box.append(self[i][j])
return box

def box_iter(self):
"""Get an iterator over all boxes in the Sudoku"""
for i in six.moves.range(self.order):
for j in six.moves.range(self.order):
yield self.box(i, j)
yield self.box(i * 3, j * 3)

def set_cell(self, i, j, value):
"""Set a cell's value, with a series of safety checks.
:param i: The row number
:type i: int
:param j: The column number
:type j: int
:param value: The value to set
:type value: int
:raises: :py:class:`hbldhdoku.exceptions.SudokuHasNoSolutionError`
"""
bool_tests = [
value in self._possibles[i][j],
value in self._poss_rows[i],
value in self._poss_cols[j],
value in self._poss_box[(i // self.order) * self.order + (j // self.order)],
value not in self.row(i),
value not in self.col(j),
value not in self.box(i, j)
]

if all(bool_tests):
self[i][j] = value
else:
raise SudokuHasNoSolutionError("This value cannot be set here!")

def random_guess(self):
"""Make a random guess on first encountered cell with fewest possibles.
Used by Brute Force method.
"""
n_to_look_for = 2
while True:
for i in six.moves.range(self.side):
for j in six.moves.range(self.side):
if len(self._possibles[i][j]) == n_to_look_for:
self.set_cell(
i, j, list(self._possibles[i][j])[random.randint(0, len(self._possibles[i][j])) - 1])
self.solution_steps.append(self._format_step("RANDOM", (i, j), self[i][j]))
return
n_to_look_for += 1

@property
def is_solved(self):
"""Returns ``True`` if all cells are filled with a number."""
for row in self.row_iter():
for value in row:
if value == 0:
return False
return True
return all([(0 not in row) for row in self.row_iter()])

@classmethod
def load(cls, file_path, n=3):
def load_file(cls, file_path):
"""Load a Sudoku from file.
:param file_path: The path to the file to load.
:param file_path: The path to the file to load_file.
:type file_path: str, unicode
:param n: The order of the Sudoku.
:type n: int
:return: A Sudoku instance with the parsed information from the file.
:rtype: :py:class:`hbldhdoku.sudoku.Sudoku`
"""
# TODO: Works for n=3. Make it work for aritrary order of Sudoku as well...
# TODO: Work out size of Sudoku from file data.
out = cls(n)
with open(os.path.abspath(file_path), 'rt') as f:
read_lines = f.read()

# TODO: Parse metadata in textfile as well.
read_lines = read_lines.split('\n')
sudoku = utils.get_list_of_lists(n * n, n * n, fill_with=0)
s_lines = []
pattern = re.compile(r"([1-9\*]{9,9})")
for line in read_lines:
res = pattern.search(line)
if res:
s_lines.append(res.groups()[0])

if len(s_lines) != 9:
raise SudokuException("File did not contain a correctly defined Sudoku.")
for i, row in enumerate(s_lines):
for j, value in enumerate([int(c) for c in row.replace('*', '0')]):
sudoku[i][j] = value
out._matrix = sudoku
s = cls.parse_from_file_object(f)
return s

@classmethod
def parse_from_file_object(cls, f):
"""Reads a Sudoku from a FileType object and parses it into a Sudoku instance.
:param f: Any FileType object containing readable data.
:type f: :py:class:`types.FileType`
:return: The parsed Sudoku
:rtype: :py:class:`hbldhdoku.sudoku.Sudoku`
"""
read_lines = f.readlines()
# Check if comment line is present.
if read_lines[0].startswith('#'):
comment = read_lines.pop(0)
else:
comment = ''

if len(read_lines) > 1:
# Assume that Sudoku is defined over several rows.
order = int(math.sqrt(len(read_lines)))
else:
# Sudoku is defined on one line.
order = int(math.sqrt(math.sqrt(len(read_lines[0]))))
read_lines = filter(lambda x: len(x) == (order ** 2), [read_lines[0][i:(i + order ** 2)] for
i in six.moves.xrange(len(read_lines[0])) if i % (order ** 2) == 0])

out = cls(order)
out.comment = comment
for i, line in enumerate(read_lines):
line = line.strip()
for j, value in enumerate(line):
if value.isdigit() and int(value):
out._matrix[i][j] = int(value)
else:
out._matrix[i][j] = 0
return out

def solve(self, verbose=False):
def solve(self, verbose=False, allow_brute_force=True):
while not self.is_solved:
# Update possibles arrays.
self._update()
Expand All @@ -146,11 +219,37 @@ def solve(self, verbose=False):
singles_found = False or self._fill_naked_singles() or self._fill_hidden_singles()

# If singles_found is False, then no new uniquely defined cells were found
# and this solver cannot solve the Sudoku. Else, run another iteration to
# see if new ones have shown up.
# and this solver cannot solve the Sudoku. We either use brute force or throw an error.
# Else, if singles_found is True, run another iteration to see if new singles have shown up.
if not singles_found:
print(self)
raise SudokuTooDifficultError("This Sudoku requires more advanced methods!")
if allow_brute_force:
n = 0
while True:
# Brute Force method can get stuck if no backtracking is allowed.
# This cap on how many times it is allowed to try on any one level
# might lead to a failed solution, but it is improbable.
if n > 10:
raise SudokuHasNoSolutionError("Brute Force method failed.")
s = Sudoku(self.order)
s._matrix = copy.deepcopy(self._matrix)
s._update()
s.random_guess()
try:
s.solve()
except SudokuHasNoSolutionError:
# Incorrect guess. Try once again.
n += 1
pass
else:
# The guess was correct and a solution could be found. Copy the solution
# back to this instance along with the solution steps and break this
# brute force loop.
self._matrix = copy.deepcopy(s._matrix)
self.solution_steps += s.solution_steps
break
else:
print(self)
raise SudokuTooDifficultError("This Sudoku requires more advanced methods!")
if verbose:
print("Sudoku solved in {0} iterations!\n{1}".format(len(self.solution_steps), self))
for step in self.solution_steps:
Expand All @@ -164,21 +263,21 @@ def _update(self):
self._poss_rows[i] = set(self._values).difference(self.row(i))
# Update possible values for each of the boxes.
for i, box in enumerate(self.box_iter()):
self._poss_mats[i] = set(self._values).difference(set(box))
self._poss_box[i] = set(self._values).difference(set(box))

# Iterate over the entire Sudoku and combine information about possible values
# from rows, columns and boxes to get a set of possible values for each cell.
for i in six.moves.range(self.side):
self._possibles[i] = {}
mat_i = (i // self.order)
box_i = (i // self.order)
for j in six.moves.range(self.side):
self._possibles[i][j] = set()
mat_j = (j // self.order)
this_box_index = (mat_i * self.order) + mat_j
box_j = (j // self.order)
this_box_index = (box_i * self.order) + box_j
if self[i][j] > 0:
continue
self._possibles[i][j] = self._poss_rows[i].intersection(
self._poss_cols[j]).intersection(self._poss_mats[this_box_index])
self._poss_cols[j]).intersection(self._poss_box[this_box_index])

def _fill_naked_singles(self):
"""Look for naked singles, i.e. cells with ony one possible value.
Expand All @@ -194,7 +293,7 @@ def _fill_naked_singles(self):
continue
p = self._possibles[i][j]
if len(p) == 1:
self[i][j] = p.pop()
self.set_cell(i, j, list(p)[0])
self.solution_steps.append(self._format_step("NAKED", (i, j), self[i][j]))
simple_found = True
elif len(p) == 0:
Expand All @@ -210,9 +309,9 @@ def _fill_hidden_singles(self):
"""
for i in six.moves.range(self.side):
mat_i = (i // self.order) * self.order
box_i = (i // self.order) * self.order
for j in six.moves.range(self.side):
mat_j = (j // self.order) * self.order
box_j = (j // self.order) * self.order
# Skip if this cell is determined already.
if self[i][j] > 0:
continue
Expand All @@ -225,7 +324,7 @@ def _fill_hidden_singles(self):
p = p.difference(self._possibles[i][k])
if len(p) == 1:
# Found a hidden single in a row!
self[i][j] = p.pop()
self.set_cell(i, j, p.pop())
self.solution_steps.append(self._format_step("HIDDEN-ROW", (i, j), self[i][j]))
return True

Expand All @@ -237,25 +336,26 @@ def _fill_hidden_singles(self):
p = p.difference(self._possibles[k][j])
if len(p) == 1:
# Found a hidden single in a column!
self[i][j] = p.pop()
self.set_cell(i, j, p.pop())
self.solution_steps.append(self._format_step("HIDDEN-COL", (i, j), self[i][j]))
return True

# Look for hidden single in box
p = self._possibles[i][j]
for k in six.moves.range(mat_i, mat_i + self.order):
for kk in six.moves.range(mat_j, mat_j + self.order):
for k in six.moves.range(box_i, box_i + self.order):
for kk in six.moves.range(box_j, box_j + self.order):
if k == i and kk == j:
continue
p = p.difference(self._possibles[k][kk])
if len(p) == 1:
# Found a hidden single in a column!
self[i][j] = p.pop()
# Found a hidden single in a box!
self.set_cell(i, j, p.pop())
self.solution_steps.append(self._format_step("HIDDEN-BOX", (i, j), self[i][j]))
return True

return False

def _format_step(self, step_name, indices, value):
"""Help method for formatting solution step history."""
return "[{0},{1}] = {2}, {3}".format(indices[0] + 1, indices[1] + 1, value, step_name)

Loading

0 comments on commit 4e6baa3

Please sign in to comment.