Implement Algorithm X for solving and generating sudokus
This commit is contained in:
parent
7ac3e6ad06
commit
8f3217f3cc
@ -15,20 +15,11 @@ export const typeDefs = gql`
|
|||||||
"The height of each region."
|
"The height of each region."
|
||||||
regionHeight: Int!
|
regionHeight: Int!
|
||||||
|
|
||||||
"The total number of cells in the board."
|
"The number of cells in the board."
|
||||||
cells: Int!
|
size: Int!
|
||||||
|
|
||||||
"The 'raw' board, an array of cells in region-first order."
|
|
||||||
raw: [Cell!]!
|
|
||||||
|
|
||||||
"The rows of the board, from top to bottom."
|
"The rows of the board, from top to bottom."
|
||||||
rows: [[Cell!]!]!
|
cells: [[Cell!]!]!
|
||||||
|
|
||||||
"The columns of the board, from left to right."
|
|
||||||
columns: [[Cell!]!]!
|
|
||||||
|
|
||||||
"The regions of the board, book-ordered."
|
|
||||||
regions: [[Cell!]!]!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
|
195
src/sudoku/dlx.ts
Normal file
195
src/sudoku/dlx.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -1,15 +1,9 @@
|
|||||||
import { SudokuMath } from "./math";
|
|
||||||
import { StaticPool, isTimeoutError } from "node-worker-threads-pool";
|
import { StaticPool, isTimeoutError } from "node-worker-threads-pool";
|
||||||
|
|
||||||
import WORKERS from "physical-cpu-count";
|
import WORKERS from "physical-cpu-count";
|
||||||
const TIMEOUT = 5000;
|
const TIMEOUT = 20000;
|
||||||
|
|
||||||
export type Cell = { value: number | null };
|
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 = {
|
export type GenerateArguments = {
|
||||||
regionWidth: number;
|
regionWidth: number;
|
||||||
@ -20,21 +14,16 @@ export type GenerateArguments = {
|
|||||||
export type Sudoku = {
|
export type Sudoku = {
|
||||||
regionWidth: number;
|
regionWidth: number;
|
||||||
regionHeight: number;
|
regionHeight: number;
|
||||||
cells: number;
|
size: number;
|
||||||
raw: Puzzle;
|
cells: Cell[][];
|
||||||
rows: () => Row[];
|
|
||||||
columns: () => Column[];
|
|
||||||
regions: () => Region[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pool = new StaticPool<GenerateArguments, [any, Puzzle]>({
|
const pool = new StaticPool<GenerateArguments, Cell[][]>({
|
||||||
size: WORKERS,
|
size: WORKERS,
|
||||||
task: "./src/sudoku/worker.js",
|
task: "./src/sudoku/worker.js",
|
||||||
});
|
});
|
||||||
|
|
||||||
const _math = new SudokuMath(0, 0);
|
|
||||||
let activeWorkers = 0;
|
let activeWorkers = 0;
|
||||||
|
|
||||||
export async function generate(
|
export async function generate(
|
||||||
regionWidth: number,
|
regionWidth: number,
|
||||||
regionHeight: number,
|
regionHeight: number,
|
||||||
@ -46,7 +35,7 @@ export async function generate(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
activeWorkers++;
|
activeWorkers++;
|
||||||
const [math, puzzle] = await pool.exec(
|
const puzzle = await pool.exec(
|
||||||
{
|
{
|
||||||
regionWidth,
|
regionWidth,
|
||||||
regionHeight,
|
regionHeight,
|
||||||
@ -55,16 +44,11 @@ export async function generate(
|
|||||||
TIMEOUT
|
TIMEOUT
|
||||||
);
|
);
|
||||||
|
|
||||||
Object.assign(_math, math);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
regionWidth,
|
regionWidth,
|
||||||
regionHeight,
|
regionHeight,
|
||||||
cells: math.boardCells,
|
size: (regionWidth * regionHeight) ** 2,
|
||||||
raw: puzzle,
|
cells: puzzle,
|
||||||
rows: () => _math.regionsToRows(puzzle, true),
|
|
||||||
columns: () => _math.regionsToCols(puzzle, true),
|
|
||||||
regions: () => _math.chunkRegions(puzzle),
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isTimeoutError(err)) {
|
if (isTimeoutError(err)) {
|
||||||
|
@ -1,326 +1,278 @@
|
|||||||
import { Cell, Row, Column, Region, Puzzle } from "./index";
|
|
||||||
import {
|
import {
|
||||||
clone,
|
DLX,
|
||||||
range,
|
DNode,
|
||||||
shuffle,
|
CNode,
|
||||||
chunkify,
|
linkNodesLR,
|
||||||
mapArray,
|
addNodeToColumn,
|
||||||
addCellValuesToSet,
|
maskRow,
|
||||||
} from "./util";
|
unmaskRow,
|
||||||
|
} from "./dlx";
|
||||||
|
import { shuffle, range } from "./util";
|
||||||
|
import { Cell } from "./index";
|
||||||
|
|
||||||
function getTakenValues(region: Region, row: Row, col: Column) {
|
type NodeMeta = {
|
||||||
const filter = new Set<number>();
|
row: number;
|
||||||
addCellValuesToSet(filter, region);
|
col: number;
|
||||||
addCellValuesToSet(filter, row);
|
region: number;
|
||||||
addCellValuesToSet(filter, col);
|
value: number;
|
||||||
return filter;
|
};
|
||||||
}
|
|
||||||
|
type BoardInfo = [number, number, number, number, number];
|
||||||
|
|
||||||
export class SudokuMath {
|
export class SudokuMath {
|
||||||
regionWidth: number;
|
regionWidth: number;
|
||||||
regionHeight: number;
|
regionHeight: number;
|
||||||
boardWidth: number;
|
|
||||||
boardHeight: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
boardCells: number;
|
|
||||||
regionCells: number;
|
|
||||||
legalValues: number[];
|
|
||||||
|
|
||||||
_regionsFromIndex: number[];
|
values: number;
|
||||||
_rowsFromIndex: number[];
|
values2: number;
|
||||||
_colsFromIndex: number[];
|
|
||||||
_rowIndexToRegionIndex: number[];
|
indexes: number[];
|
||||||
_colIndexToRegionIndex: number[];
|
|
||||||
_regionIndexToRowIndex: number[];
|
candidates: BoardInfo[];
|
||||||
_regionIndexToColIndex: number[];
|
|
||||||
|
|
||||||
constructor(regionWidth: number, regionHeight: number) {
|
constructor(regionWidth: number, regionHeight: number) {
|
||||||
this.regionWidth = regionWidth;
|
this.regionWidth = regionWidth;
|
||||||
this.regionHeight = regionHeight;
|
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.values = regionWidth * regionHeight;
|
||||||
this._rowsFromIndex = Array(this.boardCells);
|
this.values2 = this.values * this.values;
|
||||||
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);
|
|
||||||
|
|
||||||
for (let i = 0; i < this.boardCells; i++) {
|
this.indexes = Array(this.values2)
|
||||||
this._regionsFromIndex[i] = this._regionFromRegionIndex(i);
|
.fill(null)
|
||||||
|
.map((_, i) => i);
|
||||||
|
|
||||||
const [row, col] = this._rowColFromRegionIndex(i);
|
this.candidates = Array.from(Array(this.values ** 3), (_, i) =>
|
||||||
this._rowsFromIndex[i] = row;
|
this.getRowColRegionValFromCandidate(i)
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_regionFromRegionIndex(i: number) {
|
getConstraintIDs(val: number, row: number, col: number, region: 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;
|
|
||||||
return [
|
return [
|
||||||
regionRow * this.regionHeight + cellRow,
|
// each cell has a value
|
||||||
regionCol * this.regionWidth + cellCol,
|
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) {
|
getRowColRegionValFromCandidate(candidate: number): BoardInfo {
|
||||||
return [this._rowsFromIndex[i], this._colsFromIndex[i]];
|
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) {
|
_checkInput(cells: Cell[][]) {
|
||||||
return this._regionIndexToRowIndex[i];
|
if (cells.length !== this.values || cells[0].length !== this.values) {
|
||||||
}
|
throw new Error(
|
||||||
|
"Given cells array does not match regionWidth & regionHeight"
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
/**
|
const header = new CNode();
|
||||||
* Attempt to solve the puzzle "optimistically". Only sets values which are
|
header.name = "h";
|
||||||
* 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 solve = (): boolean => {
|
const constraints = new Array<CNode>(this.values2 * 4)
|
||||||
let foundValue = false;
|
.fill(null!)
|
||||||
let foundEmpty = false;
|
.map((_, i) => {
|
||||||
|
const column = new CNode();
|
||||||
|
column.name = `${i}`;
|
||||||
|
column.meta = i;
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
for (let i = 0, len = puzzle.length; i < len; i++) {
|
// link together the header and constraint columns
|
||||||
const cell = puzzle[i];
|
linkNodesLR([header, ...constraints]);
|
||||||
if (!!cell.value) continue;
|
|
||||||
foundEmpty = true;
|
const candidates = randomSearch
|
||||||
const region = this.regionFromRegionIndex(i);
|
? shuffle(Array.from(this.candidates))
|
||||||
const [row, col] = this.rowColFromRegionIndex(i);
|
: this.candidates;
|
||||||
const taken = getTakenValues(regions[region], rows[row], cols[col]);
|
|
||||||
if (taken.size === this.regionCells - 1) {
|
const dlxRows: DNode[] = [];
|
||||||
cell.value = this.getLegalValues(taken)[0];
|
candidates.forEach(([i, row, col, region, val]) => {
|
||||||
foundValue = true;
|
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();
|
const meta = { row, col, region, value: val + 1 };
|
||||||
}
|
const dlxRow = linkNodesLR(
|
||||||
|
this.getConstraintIDs(val, row, col, region).map((id) =>
|
||||||
/**
|
addNodeToColumn(constraints[id], meta)
|
||||||
* 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])
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
for (let j = 0; j < avail.length; j++) {
|
dlxRows.push(dlxRow[0]);
|
||||||
cell.value = avail[j];
|
});
|
||||||
if (solve() && solutions === stopAfter) {
|
|
||||||
return true;
|
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;
|
|
||||||
}
|
generateComplete() {
|
||||||
}
|
const result = this._baseBoard();
|
||||||
solutions++;
|
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;
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { Cell } from "./index";
|
import { Cell } from "./index";
|
||||||
|
|
||||||
function randInt(lower: number, upper: number) {
|
export function randInt(lower: number, upper: number) {
|
||||||
return lower + Math.trunc(Math.random() * (upper - lower + 1));
|
return Math.floor(Math.random() * (upper - lower)) + lower;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shuffle(arr: any[]) {
|
export function shuffle<T>(arr: T[]) {
|
||||||
const length = arr.length;
|
const length = arr.length;
|
||||||
let lastIndex = length - 1;
|
const last = length;
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
const rand = randInt(i, lastIndex);
|
const rand = randInt(i, last);
|
||||||
const tmp = arr[rand];
|
const tmp = arr[rand];
|
||||||
|
|
||||||
arr[rand] = arr[i];
|
arr[rand] = arr[i];
|
||||||
@ -17,8 +17,8 @@ export function shuffle(arr: any[]) {
|
|||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chunkify(arr: any[], chunkSize: number) {
|
export function chunkify<T>(arr: T[], chunkSize: number) {
|
||||||
const chunks = Array(arr.length / chunkSize);
|
const chunks = Array<T[]>(arr.length / chunkSize);
|
||||||
for (let i = 0, len = chunks.length; i < len; i++) {
|
for (let i = 0, len = chunks.length; i < len; i++) {
|
||||||
const start = i * chunkSize;
|
const start = i * chunkSize;
|
||||||
chunks[i] = arr.slice(start, start + 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) {
|
export function range(start: number, end: number) {
|
||||||
return Array(1 + end - start)
|
return Array(end - start)
|
||||||
.fill(null)
|
.fill(null)
|
||||||
.map((_, i) => start + i);
|
.map((_, i) => start + i);
|
||||||
}
|
}
|
||||||
@ -57,3 +57,30 @@ export function addCellValuesToSet(set: Set<number>, cells: Cell[]) {
|
|||||||
});
|
});
|
||||||
return set;
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -1,8 +1,15 @@
|
|||||||
const { SudokuMath } = require("./math");
|
const { SudokuMath } = require("./math");
|
||||||
const { parentPort } = require("worker_threads");
|
const { parentPort } = require("worker_threads");
|
||||||
|
|
||||||
|
const maths = {};
|
||||||
|
|
||||||
parentPort.on("message", ({ regionWidth, regionHeight, clues }) => {
|
parentPort.on("message", ({ regionWidth, regionHeight, clues }) => {
|
||||||
const math = new SudokuMath(regionWidth, regionHeight);
|
const math =
|
||||||
const puzzle = math.generatePuzzle(clues);
|
maths[`${regionWidth}:${regionHeight}`] ||
|
||||||
parentPort.postMessage([math, puzzle]);
|
(maths[`${regionWidth}:${regionHeight}`] = new SudokuMath(
|
||||||
|
regionWidth,
|
||||||
|
regionHeight
|
||||||
|
));
|
||||||
|
const puzzle = math.generate(clues);
|
||||||
|
parentPort.postMessage(puzzle);
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user