Implement Algorithm X for solving and generating sudokus

This commit is contained in:
Matt Low 2021-02-21 20:01:36 -07:00
parent 7ac3e6ad06
commit 8f3217f3cc
6 changed files with 486 additions and 330 deletions

View File

@ -15,20 +15,11 @@ export const typeDefs = gql`
"The height of each region."
regionHeight: Int!
"The total number of cells in the board."
cells: Int!
"The 'raw' board, an array of cells in region-first order."
raw: [Cell!]!
"The number of cells in the board."
size: Int!
"The rows of the board, from top to bottom."
rows: [[Cell!]!]!
"The columns of the board, from left to right."
columns: [[Cell!]!]!
"The regions of the board, book-ordered."
regions: [[Cell!]!]!
cells: [[Cell!]!]!
}
type Query {

195
src/sudoku/dlx.ts Normal file
View File

@ -0,0 +1,195 @@
export class DNode {
up: DNode;
down: DNode;
left: DNode;
right: DNode;
column: CNode;
meta: any;
constructor(column: CNode) {
this.up = this.down = this.left = this.right = this;
this.column = column;
}
}
export class CNode extends DNode {
left: CNode;
right: CNode;
size: number;
name: string | undefined;
constructor(name = undefined) {
super(null!);
this.column = this;
this.size = 0;
this.name = name;
this.left = this.right = this;
}
}
type SolutionCallback = (output: DNode[]) => boolean;
type ColumnSelector = (header: CNode) => CNode;
function selectColumnSizeHeuristic(header: CNode): CNode {
let minSize = Infinity;
let minColumn: CNode | undefined;
let curColumn = header;
while ((curColumn = curColumn.right) !== header) {
if (curColumn.column.size < minSize) {
minSize = curColumn.column.size;
minColumn = curColumn;
}
}
if (!minColumn) {
throw new Error("minColumn is undefined, this shouldn't be possible.");
}
return minColumn;
}
export function linkNodesLR(nodes: DNode[]) {
// given an array of nodes, link them together left-to-right circularly
let last = nodes[0];
for (let j = 1; j < nodes.length; j++) {
const node = nodes[j];
last.right = node;
node.left = last;
last = node;
}
nodes[0].left = last;
last.right = nodes[0];
return nodes;
}
export function addNodeToColumn(column: CNode, meta: any) {
column.size++;
let node = new DNode(column);
node.down = column; // new last node, points down to column
node.up = column.up; // new last node points up to previous last node
// previous last node points down and column points up to new node,
// repairing the circle
column.up.down = column.up = node;
node.meta = meta;
return node;
}
export function maskRow(node: DNode) {
let row = node;
do {
row.column.size--;
row.down.up = row.up;
row.up.down = row.down;
} while ((row = row.right) !== node);
}
export function unmaskRow(node: DNode) {
let row = node;
do {
row.column.size++;
row.down.up = row.up.down = row;
} while ((row = row.right) !== node);
}
export class DLX {
header: CNode;
updates: number = 0;
callback: SolutionCallback;
columnSelector: ColumnSelector;
constructor(
header: CNode,
callback: SolutionCallback = () => false,
columnSelector: ColumnSelector = selectColumnSizeHeuristic
) {
this.header = header;
this.callback = callback;
this.columnSelector = columnSelector;
}
cover(c: CNode) {
// remove c from column header list
c.right.left = c.left;
c.left.right = c.right;
this.updates++;
let row = c as DNode;
while ((row = row.down) !== c) {
// traverse DOWN the rows this column contained
let col = row;
while ((col = col.right) !== row) {
// traverse the columns of this row (to the RIGHT)
// remove this node from its column, and shrink its column's size
this.updates++;
col.up.down = col.down;
col.down.up = col.up;
col.column.size--;
}
}
}
uncover(c: CNode) {
let row = c as DNode;
while ((row = row.up) !== c) {
// traverse UP the rows this column contained
let col = row;
while ((col = col.left) !== row) {
// traverse the columns of this row (to the LEFT)
// do the inverse of cover()
this.updates++;
col.up.down = col.down.up = col;
col.column.size++;
}
}
// insert c back into column header list
this.updates++;
c.left.right = c.right.left = c;
}
solution: DNode[] = [];
_search(k: number): boolean {
if (this.header.right === this.header) {
return this.callback(this.solution);
}
const column = this.columnSelector(this.header);
this.cover(column);
let complete = false;
let row = column as DNode;
while ((row = row.down) !== column) {
// add this row to the partial solution
this.solution[k] = row;
// cover the columns of every other node on this row
let curCol = row;
while ((curCol = curCol.right) !== row) {
this.cover(curCol.column);
}
complete = this._search(k + 1);
curCol = row;
while ((curCol = curCol.left) !== row) {
this.uncover(curCol.column);
}
if (complete) break;
}
this.uncover(column);
return complete;
}
search() {
this.updates = 0;
this._search(0);
}
}

View File

@ -1,15 +1,9 @@
import { SudokuMath } from "./math";
import { StaticPool, isTimeoutError } from "node-worker-threads-pool";
import WORKERS from "physical-cpu-count";
const TIMEOUT = 5000;
const TIMEOUT = 20000;
export type Cell = { value: number | null };
export type CellArray = Cell[];
export type Row = CellArray;
export type Column = CellArray;
export type Region = CellArray;
export type Puzzle = CellArray;
export type GenerateArguments = {
regionWidth: number;
@ -20,21 +14,16 @@ export type GenerateArguments = {
export type Sudoku = {
regionWidth: number;
regionHeight: number;
cells: number;
raw: Puzzle;
rows: () => Row[];
columns: () => Column[];
regions: () => Region[];
size: number;
cells: Cell[][];
};
const pool = new StaticPool<GenerateArguments, [any, Puzzle]>({
const pool = new StaticPool<GenerateArguments, Cell[][]>({
size: WORKERS,
task: "./src/sudoku/worker.js",
});
const _math = new SudokuMath(0, 0);
let activeWorkers = 0;
export async function generate(
regionWidth: number,
regionHeight: number,
@ -46,7 +35,7 @@ export async function generate(
try {
activeWorkers++;
const [math, puzzle] = await pool.exec(
const puzzle = await pool.exec(
{
regionWidth,
regionHeight,
@ -55,16 +44,11 @@ export async function generate(
TIMEOUT
);
Object.assign(_math, math);
return {
regionWidth,
regionHeight,
cells: math.boardCells,
raw: puzzle,
rows: () => _math.regionsToRows(puzzle, true),
columns: () => _math.regionsToCols(puzzle, true),
regions: () => _math.chunkRegions(puzzle),
size: (regionWidth * regionHeight) ** 2,
cells: puzzle,
};
} catch (err) {
if (isTimeoutError(err)) {

View File

@ -1,326 +1,278 @@
import { Cell, Row, Column, Region, Puzzle } from "./index";
import {
clone,
range,
shuffle,
chunkify,
mapArray,
addCellValuesToSet,
} from "./util";
DLX,
DNode,
CNode,
linkNodesLR,
addNodeToColumn,
maskRow,
unmaskRow,
} from "./dlx";
import { shuffle, range } from "./util";
import { Cell } from "./index";
function getTakenValues(region: Region, row: Row, col: Column) {
const filter = new Set<number>();
addCellValuesToSet(filter, region);
addCellValuesToSet(filter, row);
addCellValuesToSet(filter, col);
return filter;
}
type NodeMeta = {
row: number;
col: number;
region: number;
value: number;
};
type BoardInfo = [number, number, number, number, number];
export class SudokuMath {
regionWidth: number;
regionHeight: number;
boardWidth: number;
boardHeight: number;
width: number;
height: number;
boardCells: number;
regionCells: number;
legalValues: number[];
_regionsFromIndex: number[];
_rowsFromIndex: number[];
_colsFromIndex: number[];
_rowIndexToRegionIndex: number[];
_colIndexToRegionIndex: number[];
_regionIndexToRowIndex: number[];
_regionIndexToColIndex: number[];
values: number;
values2: number;
indexes: number[];
candidates: BoardInfo[];
constructor(regionWidth: number, regionHeight: number) {
this.regionWidth = regionWidth;
this.regionHeight = regionHeight;
this.boardWidth = regionHeight;
this.boardHeight = regionWidth;
this.width = regionWidth * this.boardWidth;
this.height = regionHeight * this.boardHeight;
this.boardCells = this.width * this.height;
this.regionCells = regionWidth * regionHeight;
this.legalValues = range(1, this.regionCells);
this._regionsFromIndex = Array(this.boardCells);
this._rowsFromIndex = Array(this.boardCells);
this._colsFromIndex = Array(this.boardCells);
this._rowIndexToRegionIndex = Array(this.boardCells);
this._colIndexToRegionIndex = Array(this.boardCells);
this._regionIndexToRowIndex = Array(this.boardCells);
this._regionIndexToColIndex = Array(this.boardCells);
this.values = regionWidth * regionHeight;
this.values2 = this.values * this.values;
for (let i = 0; i < this.boardCells; i++) {
this._regionsFromIndex[i] = this._regionFromRegionIndex(i);
this.indexes = Array(this.values2)
.fill(null)
.map((_, i) => i);
const [row, col] = this._rowColFromRegionIndex(i);
this._rowsFromIndex[i] = row;
this._colsFromIndex[i] = col;
const rowIndex = row * this.width + col;
const colIndex = col * this.height + row;
this._rowIndexToRegionIndex[rowIndex] = i;
this._colIndexToRegionIndex[i] = rowIndex;
this._regionIndexToRowIndex[i] = rowIndex;
this._regionIndexToColIndex[colIndex] = i;
}
this.candidates = Array.from(Array(this.values ** 3), (_, i) =>
this.getRowColRegionValFromCandidate(i)
);
}
_regionFromRegionIndex(i: number) {
return Math.trunc(i / this.regionCells);
}
regionFromRegionIndex(i: number) {
return this._regionsFromIndex[i];
}
_rowColFromRegionIndex(i: number) {
const region = this.regionFromRegionIndex(i);
const cell = i % this.regionCells;
const regionRow = Math.trunc(region / this.boardWidth);
const regionCol = region % this.boardWidth;
const cellRow = Math.trunc(cell / this.regionWidth);
const cellCol = cell % this.regionWidth;
getConstraintIDs(val: number, row: number, col: number, region: number) {
return [
regionRow * this.regionHeight + cellRow,
regionCol * this.regionWidth + cellCol,
// each cell has a value
row * this.values + col,
// each row has one of each value
this.values2 + row * this.values + val,
// each col has one of each value
this.values2 * 2 + col * this.values + val,
// each region has one of each value
this.values2 * 3 + region * this.values + val,
];
}
rowColFromRegionIndex(i: number) {
return [this._rowsFromIndex[i], this._colsFromIndex[i]];
getRowColRegionValFromCandidate(candidate: number): BoardInfo {
const boardIndex = Math.floor(candidate / this.values);
const row = Math.floor(boardIndex / this.values);
const col = boardIndex % this.values;
const region =
Math.floor(row / this.regionHeight) * this.regionHeight +
Math.floor(col / this.regionWidth);
const val = candidate % this.values;
return [candidate, row, col, region, val];
}
regionIndexToRowIndex(i: number) {
return this._regionIndexToRowIndex[i];
}
rowIndexToRegionIndex(i: number) {
return this._rowIndexToRegionIndex[i];
}
regionIndexToColIndex(i: number) {
return this._regionIndexToColIndex[i];
}
colIndexToRegionIndex(i: number) {
return this._colIndexToRegionIndex[i];
}
chunkRegions(cells: Cell[]) {
return chunkify(cells, this.regionCells);
}
regionsToRows(cells: Cell[], split = false) {
const rows = mapArray(cells, this._regionIndexToRowIndex);
return split ? chunkify(rows, this.width) : rows;
}
regionsToCols(cells: Cell[], split = false) {
const cols = mapArray(cells, this._regionIndexToColIndex);
return split ? chunkify(cols, this.height) : cols;
}
rowsToRegions(cells: Cell[], split = false) {
const regions = mapArray(cells, this._rowIndexToRegionIndex);
return split ? chunkify(regions, this.regionCells) : regions;
}
colsToRegions(cells: Cell[], split = false) {
const regions = mapArray(cells, this._colIndexToRegionIndex);
return split ? chunkify(regions, this.regionCells) : regions;
}
getBlankPuzzle(): Puzzle {
return Array(this.boardCells)
.fill(null)
.map((value) => ({ value }));
}
/**
* Returns the remaining legal values given a set of taken values.
*
* @param taken a set of taken values
*/
getLegalValues(taken: Set<number>) {
return this.legalValues.filter((value) => !taken.has(value));
}
/**
* Returns which values are unavailable at a given location, looking at the
* row, column, and region of the given cell index.
*
* @param cell
* @param puzzle
* @param regions
*/
getTakenValues(cell: number, puzzle: Puzzle, regions: Region[]) {
const rows = this.regionsToRows(puzzle, true);
const cols = this.regionsToCols(puzzle, true);
const [row, col] = this.rowColFromRegionIndex(cell);
const region = this.regionFromRegionIndex(cell);
return getTakenValues(regions[region], rows[row], cols[col]);
}
/**
* Returns whether a puzzle has only one solution.
*
* @param puzzle
*/
hasOneSolution(puzzle: Cell[]) {
const optimistic = clone(puzzle);
if (this.optimisticSolver(optimistic)) {
return true;
}
return this.backTrackingSolver(optimistic, 2) === 1;
}
/**
* Generates a single-solution sudoku puzzle with
*
* @param clues the number of cells to have pre-filled
*/
generatePuzzle(clues: number) {
const puzzle = this.getBlankPuzzle();
this.backTrackingSolver(puzzle, 1, shuffle);
if (clues === -1 || clues >= puzzle.length) return puzzle;
const orig = clone(puzzle);
const toRemove = puzzle.length - clues;
let removed: number[] = [];
let removeNext: number[] = shuffle(puzzle.map((_, i) => i));
const remove = () => {
const x = removeNext.shift() as any;
removed.push(x);
puzzle[x].value = null;
};
const replace = () => {
const x = removed.pop() as any;
removeNext.push(x);
puzzle[x].value = orig[x].value;
};
const removeCell = () => {
remove();
if (this.hasOneSolution(puzzle)) {
return true;
}
replace();
return false;
};
let fails = 0;
while (removed.length < toRemove) {
if (!removeCell()) {
fails++;
} else {
console.log(`Removed ${removed.length} cells.`);
fails = 0;
}
if (fails > removeNext.length) {
fails = 0;
Array(removed.length)
.fill(null)
.forEach(() => replace());
shuffle(removeNext);
_checkInput(cells: Cell[][]) {
if (cells.length !== this.values || cells[0].length !== this.values) {
throw new Error(
"Given cells array does not match regionWidth & regionHeight"
);
}
}
return puzzle;
}
// this takes a bit of time and the value may need to be cached
getDLXHeader(
cells: undefined | Cell[][] = undefined,
randomSearch = false
): [CNode, DNode[]] {
if (cells) this._checkInput(cells);
/**
* Attempt to solve the puzzle "optimistically". Only sets values which are
* certain, i.e. no guesses are made.
*
* Useful as a first pass.
*
* @param puzzle a region-ordered array of cells (each cell an object with
* a `value` key.
* @returns whether the puzzle was completely solved
*/
optimisticSolver(puzzle: Puzzle) {
const regions = this.chunkRegions(puzzle);
const rows = this.regionsToRows(puzzle, true);
const cols = this.regionsToCols(puzzle, true);
const header = new CNode();
header.name = "h";
const solve = (): boolean => {
let foundValue = false;
let foundEmpty = false;
const constraints = new Array<CNode>(this.values2 * 4)
.fill(null!)
.map((_, i) => {
const column = new CNode();
column.name = `${i}`;
column.meta = i;
return column;
});
for (let i = 0, len = puzzle.length; i < len; i++) {
const cell = puzzle[i];
if (!!cell.value) continue;
foundEmpty = true;
const region = this.regionFromRegionIndex(i);
const [row, col] = this.rowColFromRegionIndex(i);
const taken = getTakenValues(regions[region], rows[row], cols[col]);
if (taken.size === this.regionCells - 1) {
cell.value = this.getLegalValues(taken)[0];
foundValue = true;
// link together the header and constraint columns
linkNodesLR([header, ...constraints]);
const candidates = randomSearch
? shuffle(Array.from(this.candidates))
: this.candidates;
const dlxRows: DNode[] = [];
candidates.forEach(([i, row, col, region, val]) => {
if (cells) {
const exist = cells[row][col].value;
if (exist && exist - 1 !== val) {
// skip candidates matching this constraint's position, but not its value
// the effect is the exisitng value is preserved in the output
return;
}
}
return foundValue && foundEmpty ? solve() : !foundEmpty;
};
return solve();
}
/**
* Backtracking solver. Mutates the puzzle during solve but eventually returns
* it to its initial state.
*
* @param puzzle see optimisticSolver
* @param stopAfter stop looking after this many solutions
* @param guessStrategy a function which takes an array of possible
* values for a cell, and returns the same values (in any order)
* @returns the number of solutions found
*/
backTrackingSolver(
puzzle: Puzzle,
stopAfter: number = -1,
guessStrategy: (values: number[]) => number[] = (values: number[]) => values
) {
const regions = this.chunkRegions(puzzle);
const rows = this.regionsToRows(puzzle, true);
const cols = this.regionsToCols(puzzle, true);
let solutions = 0;
const solve = (): boolean => {
for (let i = 0, len = puzzle.length; i < len; i++) {
const cell = puzzle[i];
if (!cell.value) {
const region = this.regionFromRegionIndex(i);
const [row, col] = this.rowColFromRegionIndex(i);
const avail = guessStrategy(
this.getLegalValues(
getTakenValues(regions[region], rows[row], cols[col])
const meta = { row, col, region, value: val + 1 };
const dlxRow = linkNodesLR(
this.getConstraintIDs(val, row, col, region).map((id) =>
addNodeToColumn(constraints[id], meta)
)
);
for (let j = 0; j < avail.length; j++) {
cell.value = avail[j];
if (solve() && solutions === stopAfter) {
return true;
dlxRows.push(dlxRow[0]);
});
return [header, dlxRows];
}
cell.value = null;
_baseBoard(): Cell[][] {
// return a sudoku board with a random set of values in the first row
// used in generateComplete for small speedup
const firstRow = range(1, this.values + 1);
shuffle(firstRow);
return [
firstRow.map((value) => ({ value })),
...Array(this.values - 1)
.fill(null)
.map(() =>
Array(this.values)
.fill(0)
.map((val) => ({ value: val > 0 ? val : null }))
),
];
}
return false;
}
}
solutions++;
generateComplete() {
const result = this._baseBoard();
const [header] = this.getDLXHeader(result, true);
const callback = (solution: DNode[]) => {
solution.forEach((node) => {
const meta: NodeMeta = node.meta;
result[meta.row][meta.col] = { value: meta.value };
});
// return the first solution
return true;
};
solve();
const dlx = new DLX(header, callback);
return solutions;
dlx.search();
return result;
}
generate(clues: number, attempts = Infinity, totalTime = Infinity) {
const completed = this.generateComplete();
const [header, dlxRows] = this.getDLXHeader(); // complete header - no candidates removed
let solutions = 0;
const dlx = new DLX(header, () => ++solutions >= 2);
const candidates: DNode[][][] = Array.from(Array(this.values), () =>
Array.from(Array(this.values), () => Array(this.values))
);
dlxRows.forEach((node) => {
const meta = node.meta;
candidates[meta.row][meta.col][meta.value - 1] = node;
});
// board positions which have been removed, in the order they've been removed
const removed: Set<number> = new Set();
const masked: DNode[] = [];
const hasOneSolution = () => {
solutions = 0;
dlx.search();
return solutions === 1;
};
const mask = () => {
// mask all DLX rows which are nullified by existing values
for (let n = 0; n < this.values2; n++) {
if (removed.has(n)) {
continue;
}
const row = Math.floor(n / this.values);
const col = n % this.values;
const existValue = completed[row][col].value;
const nodes = candidates[row][col];
// console.log(row, col);
// console.log(existValue);
nodes.forEach((node) => {
if (node.meta.value !== existValue) {
// console.log(node.meta);
// console.log("masking node");
masked.push(node);
maskRow(node);
}
});
}
};
const unmask = () => {
// unmask all DLX rows
while (masked.length > 0) {
unmaskRow(masked.pop()!);
}
};
const start = Date.now();
const elapsed = () => Date.now() - start;
const removeable = Array.from(this.indexes);
const attempt = () => {
// attempt remove cells until 'clues' cells remain
shuffle(removeable);
for (let n = 0; n < this.values2; n++) {
if (elapsed() > totalTime || this.values2 - removed.size == clues) {
break;
}
let toRemove = removeable[n];
removed.add(toRemove);
mask();
if (!hasOneSolution()) {
removed.delete(toRemove);
}
unmask();
}
};
while (
this.values2 - removed.size > clues &&
attempts > 0 &&
elapsed() < totalTime
) {
// try to reach the clue goal up to `attempts` times or as long as
// elapsed time is less than `totalTime`
attempts--;
removed.clear();
attempt();
}
removed.forEach((index) => {
completed[Math.floor(index / this.values)][
index % this.values
].value = null;
});
return completed;
}
solve(existing: Cell[][]): void {
const [header] = this.getDLXHeader(existing);
const callback = (solution: DNode[]) => {
solution.forEach((node) => {
const meta: NodeMeta = node.meta;
existing[meta.row][meta.col] = { value: meta.value };
});
// return the first solution
return true;
};
new DLX(header, callback).search();
}
}

View File

@ -1,14 +1,14 @@
import { Cell } from "./index";
function randInt(lower: number, upper: number) {
return lower + Math.trunc(Math.random() * (upper - lower + 1));
export function randInt(lower: number, upper: number) {
return Math.floor(Math.random() * (upper - lower)) + lower;
}
export function shuffle(arr: any[]) {
export function shuffle<T>(arr: T[]) {
const length = arr.length;
let lastIndex = length - 1;
const last = length;
for (let i = 0; i < length; i++) {
const rand = randInt(i, lastIndex);
const rand = randInt(i, last);
const tmp = arr[rand];
arr[rand] = arr[i];
@ -17,8 +17,8 @@ export function shuffle(arr: any[]) {
return arr;
}
export function chunkify(arr: any[], chunkSize: number) {
const chunks = Array(arr.length / chunkSize);
export function chunkify<T>(arr: T[], chunkSize: number) {
const chunks = Array<T[]>(arr.length / chunkSize);
for (let i = 0, len = chunks.length; i < len; i++) {
const start = i * chunkSize;
chunks[i] = arr.slice(start, start + chunkSize);
@ -27,7 +27,7 @@ export function chunkify(arr: any[], chunkSize: number) {
}
export function range(start: number, end: number) {
return Array(1 + end - start)
return Array(end - start)
.fill(null)
.map((_, i) => start + i);
}
@ -57,3 +57,30 @@ export function addCellValuesToSet(set: Set<number>, cells: Cell[]) {
});
return set;
}
export function prettyPrint(puzzle: Cell[][]) {
let width = Math.sqrt(puzzle[0].length);
let height = Math.sqrt(puzzle.length);
puzzle.forEach((row, i) => {
let line = "";
row.forEach(({ value: cell }, j) => {
if (j > 0 && j % width == 0) {
line += "| ";
}
line += ((cell ? cell : " ") + " ").padStart(3, " ");
});
if (i > 0 && i % height == 0) {
let divider = "";
row.forEach((_, j) => {
if (j > 0 && j % width == 0) {
divider += " ";
}
divider += "-- ";
});
console.log(divider);
}
console.log(line);
});
}

View File

@ -1,8 +1,15 @@
const { SudokuMath } = require("./math");
const { parentPort } = require("worker_threads");
const maths = {};
parentPort.on("message", ({ regionWidth, regionHeight, clues }) => {
const math = new SudokuMath(regionWidth, regionHeight);
const puzzle = math.generatePuzzle(clues);
parentPort.postMessage([math, puzzle]);
const math =
maths[`${regionWidth}:${regionHeight}`] ||
(maths[`${regionWidth}:${regionHeight}`] = new SudokuMath(
regionWidth,
regionHeight
));
const puzzle = math.generate(clues);
parentPort.postMessage(puzzle);
});