LeetCode Sudoku Solver with Scipy Integer Programming
Published on
There’s a catch though: It’s not available yet.
Note
This page requires MathML support
This page relies on MathML to display math formulae to you the reader. Data suggests that Firefox has been supporting MathML since more than 15 years ago, and that even the Chromium-based browsers (that I couldn’t care less about) has gained support for it.
If you see unrendered garbage: get Firefox, get your browser updated, or get off my lawn.
Preface
I’ve never been an avid user of LeetCode until lately. People occasionally ask me about some of the interesting questions from there, to which I reply with some thoughts, without taking a deep look into what else the website had to offer. Things changed recently, since I’ve started to apply for internship this summer, and the hiring process may involve some pre-interview coding tests.
As I was powering through the list of problems, the 37. Sudoku Solver caught my eyes. While it was labeled as a hard problem, all it takes to solve the problem is a simple backtracking search over the possible combinations.
Interestingly, under the Stochastic search / optimization methods heading of the Wikipedia entry for Sudoku solving algorithms, it’s stated that the problem of solving Sudoku can be reduced to an integer linear programming problem. The Wikipedia entry lead to me implementing it with scipy.optimize.milp
. Sadly though, my code ended up not running on LeetCode due to their version of Scipy being slightly out of date1, but they’re probably gonna update it in the future.
Encoding Sudoku as Integer Programming Problem
Bartlett et al. [1] provides an integer programming formulation for the Sudoku problem. The key step is to map the puzzle on a matrix to a puzzle on a matrix called , indexed by tuples of value, row, and column. The element at is iff the number at is on the original puzzle2.
This representation for the puzzle is used for other combinatorial optimization methods, such as using SAT solvers, as well. See Sudoku as a SAT Problem [2] for a SAT formulation that encodes the puzzle in the same way.
The integer programming formulation is for the encoding is shown below. Note that the objective function is zero, as the goal is to find any solution satisfying the constraints. The constraints 2-4 are symmetric.
The bounds — Every element is either or .
Every position of the original puzzle is filled.
Every column has exactly one .
Every row has exactly one .
Every 3-by-3 block has exactly one .
The preset values on the puzzle.
, for each preset at position on the puzzle.
The formulation maps pretty much directly to the API provided by scipy.optimize.milp
.
The bounds can be specified using Bounds(lb=np.zeros(729), ub=np.ones(729))
.
Each constraint can be specified using LinearConstraint(A, lb=1, ub=1)
,
where A
is an array of length where the elements at index is iff appears on the LHS of the constraint.
It’s Not Fast
It’s not fast. Well, actually it’s fast, but only for the hardest puzzles.
I’ve made a quick benchmark comparing the total execution time of my MILP solution against some of the most voted solutions on LeetCode.
- Python Easiest Recursive Solution is the top voted Python solution. It’s implemented using simple backtracking search, with no additional bookkeeping other than the board itself.
- 🐍 97% faster || Clean & Concise || Well-Explained 📌📌 3 is the second voted Python solution. As with the first solution, it’s also implemented using backtracking search, but it makes use of additional sets to keep track of the constraints.
The set of LeetCode’s official test cases contains only 6 instances of the puzzle, and they are subjectively easy as I was able to hand solve an instance in less than twenty minutes. Therefore, another set of harder instances was brought into place as a fun experiment as well.
The execution times for the LeetCode test cases are shown in this figure. While the Scipy MILP solution runs slightly faster (25%) than the simple DFS solution, the more optimized version of DFS crashes the MILP solution by a 8x speedup.
Code Listing
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import ticker
= np.load('./results/leetcode_exec_secs.npy').tolist()
exec_secs
'bmh')
plt.style.use(
= plt.subplots()
fig, ax
= ['ILP', 'DFS', 'Faster DFS']
labels 1, 2, 3])
ax.set_xticks([
ax.set_xticklabels(labels)
ax.set_axis_on()'Execution Time (secs)')
ax.set_ylabel(
=True, which='major')
ax.grid(visible=True)
ax.violinplot(exec_secs, showmeans
"./leetcode_bench.svg") plt.savefig(
The execution times for the harder cases are shown in this figure. I’ve plotted them in logarithmic scale, because otherwise the outlier values (up to 7 minutes!) make the rest of the data points degenerate into tiny horizontal bars at the bottom.
It’s interesting to see that for these hard puzzles, the MILP solution runs notably faster than the DFS solutions. The MILP solution also has less dispersed execution times compared to the others.
Code Listing
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import ticker
= np.load('./results/hard_exec_secs.npy')
exec_secs = exec_secs.mean(axis=1)
mean_exec_secs = np.log10(exec_secs).tolist()
log_exec_secs
'bmh')
plt.style.use(
= plt.subplots()
fig, ax
= ['ILP', 'DFS', 'Faster DFS']
labels 1, 2, 3])
ax.set_xticks([
ax.set_xticklabels(labels)
ax.set_axis_on()'Execution Time (secs, log10 scale)')
ax.set_ylabel('$\mathdefault{{10^{{{x:.0f}}}}}$'))
ax.yaxis.set_major_formatter(ticker.StrMethodFormatter(
=True, which='major')
ax.grid(visible= ax.violinplot(log_exec_secs, showmeans=True)
r
# Fix mean bars for log10 scale
= r['cmeans'].get_paths()
paths for i in range(3):
= paths[i].vertices
vertices 1] = np.log10(mean_exec_secs[i])
vertices[:, = vertices
paths[i].vertices
"./hard_bench.svg") plt.savefig(
Code Listing
Code Listing
from typing import List
from itertools import product
from scipy.optimize import milp, LinearConstraint, Bounds
import numpy as np
class IlpSolution():
def solveSudoku(self, board: List[List[str]]) -> None:
= create_constraints(board)
constraints = milp(
milp_output 729),
np.zeros(=Bounds(lb=np.zeros(729), ub=np.ones(729)),
bounds=constraints,
constraints# 1 means that the element must be an integer
=np.full(729, 1),
integrality
)return set_board_to_solution(board, milp_output.x)
def set_board_to_solution(board: List[List[str]], solution: np.ndarray):
for r, c in product(range(9), repeat=2):
for v in range(9):
if solution[index(v, r, c)] == 1.0:
= str(v + 1)
board[r][c] break
def create_constraints(board: List[List[str]]) -> List[LinearConstraint]:
= create_rule_constraints()
rule_constraints = create_instance_constraints(board)
instance_constraints return rule_constraints + instance_constraints
def create_instance_constraints(board: List[List[str]]) -> List[LinearConstraint]:
= []
constraints for r, c in product(range(9), repeat=2):
= board[r][c]
ch if ch == '.':
continue
= int(ch) - 1
v = np.zeros(729)
A = 1
A[index(v, r, c)] =1, ub=1))
constraints.append(LinearConstraint(A, lb
return constraints
def create_rule_constraints() -> List[LinearConstraint]:
= []
constraints for r, c in product(range(9), repeat=2):
= np.zeros(729)
A for v in range(9):
= 1
A[index(v, r, c)] =1, ub=1))
constraints.append(LinearConstraint(A, lb
for v, r in product(range(9), repeat=2):
= np.zeros(729)
A for c in range(9):
= 1
A[index(v, r, c)] =1, ub=1))
constraints.append(LinearConstraint(A, lb
for v, c in product(range(9), repeat=2):
= np.zeros(729)
A for r in range(9):
= 1
A[index(v, r, c)] =1, ub=1))
constraints.append(LinearConstraint(A, lb
for x, y, v in product([0, 3, 6], [0, 3, 6], range(9)):
= np.zeros(729)
A for r, c in product(range(x, x + 3), range(y, y + 3)):
= 1
A[index(v, r, c)] =1, ub=1))
constraints.append(LinearConstraint(A, lb
return constraints
def index(v: int, r: int, c: int) -> int:
return v * 81 + r * 9 + c
As of the time of writing,
scipy.version.version
is1.7.3
on the judging system. Thescipy.optimize.milp
function was introduced in Scipy 1.9.0.↩︎Assuming zero-based indexing for and .↩︎
Why do people write uninformative titles like this?↩︎