sudoku-api/src/sudoku/math.ts
Matt Low 15b69c510c Add solve endpoint
Also implement currently unused solveSync and generateSync methods,
useful for testing/debugging.

Add static get() to SudokuMath which returns cached SudokuMath
instances.
2021-03-05 12:32:02 -07:00

310 lines
8.2 KiB
TypeScript

import {
DLX,
DNode,
CNode,
linkNodesLR,
addNodeToColumn,
maskRow,
unmaskRow,
} from "./dlx.js";
import { shuffle, range } from "./util.js";
import { Cell } from "./index.js";
type NodeMeta = {
index: number;
row: number;
col: number;
region: number;
value: number;
};
type BoardInfo = [number, number, number, number, number];
export class SudokuMath {
/** The width of each region */
regionWidth: number;
/** The height of each region */
regionHeight: number;
/** The number of unqiue values in this sudoku */
values: number;
/** The squared number of unique values - also the total number of cells */
values2: number;
/** An array of board indexes (a range from 0 to `values2 - 1`) */
indexes: number[];
/** A cache of the possible cadidate rows of this sudoku. */
candidates: BoardInfo[];
constructor(regionWidth: number, regionHeight: number) {
this.regionWidth = regionWidth;
this.regionHeight = regionHeight;
this.values = regionWidth * regionHeight;
this.values2 = this.values * this.values;
this.indexes = Array(this.values2)
.fill(null)
.map((_, i) => i);
this.candidates = Array.from(Array(this.values ** 3), (_, i) =>
this.getRowColRegionValFromCandidate(i)
);
}
getConstraintIDs(val: number, row: number, col: number, region: number) {
return [
// 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,
];
}
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 [boardIndex, row, col, region, val];
}
_checkInput(cells: Cell[]) {
if (cells.length !== this.values2) {
throw new Error(
"Given cells array does not match regionWidth & regionHeight"
);
}
}
// 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();
header.name = "h";
const constraints = new Array<CNode>(this.values2 * 4)
.fill(null!)
.map((_, i) => {
const column = new CNode();
column.name = `${i}`;
column.meta = i;
return column;
});
// 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(([boardIndex, row, col, region, val]) => {
if (cells) {
const exist = cells[boardIndex];
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;
}
}
const meta = { index: boardIndex, row, col, region, value: val + 1 };
const dlxRow = linkNodesLR(
this.getConstraintIDs(val, row, col, region).map((id) =>
addNodeToColumn(constraints[id], meta)
)
);
dlxRows.push(dlxRow[0]);
});
return [header, dlxRows];
}
_baseBoard(): Cell[] {
// return a sudoku board with a random set of values in the first row
// used in generateComplete for small speedup
const firstRow = shuffle(range(1, this.values + 1));
return [...firstRow, ...Array(this.values2 - this.values).fill(0)];
}
generateComplete(): [number[], number] {
const result = this._baseBoard();
const [header] = this.getDLXHeader(result, true);
const dlx = new DLX(header, (solution: DNode[]) => {
solution.forEach((node) => {
const meta: NodeMeta = node.meta;
result[meta.index] = meta.value;
});
// stop after the first solution
return true;
});
dlx.search();
return [result, dlx.updates];
}
generate(clues: number, attempts = Infinity, totalTime = Infinity) {
if (clues === 0) {
return Array(this.values2).fill(0);
}
let [completed, updates] = 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.values2), () =>
Array.from(Array(this.values))
);
dlxRows.forEach((node) => {
const meta = node.meta;
candidates[meta.index][meta.value - 1] = node;
});
const hasOneSolution = () => {
solutions = 0;
dlx.search();
updates += dlx.updates;
return solutions === 1;
};
const start = Date.now();
const elapsed = () => Date.now() - start;
// masked rows in the order they were masked
const masked: DNode[] = [];
const maskAtIdx = (idx: number) => {
const nodes = candidates[idx];
nodes.forEach((node) => {
if (node.meta.value !== completed[idx]) {
masked.push(node);
maskRow(node);
}
});
};
const removed = new Set<number>();
const removeable = Array.from(this.indexes);
const maskUpto = (n: number) => {
for (let j = 0; j < n; j++) {
// process up to what we've handled
const idx = removeable[j];
if (removed.has(idx)) {
// if we've removed this cell, do not mask it
continue;
}
// otherwise, we had given up it; mask it leaving the original value
// we won't try this cell again
maskAtIdx(idx);
}
for (let j = this.values2 - 1; j >= n; j--) {
// for all those we haven't handled, mask leaving the original values
// for us to to attempt to unmask one cell at a time
maskAtIdx(removeable[j]);
}
};
const unmaskAll = () => {
let node;
while ((node = masked.pop())) {
unmaskRow(node);
}
};
const unmaskNextCell = () => {
for (let j = 0; j < this.values - 1; j++) {
unmaskRow(masked.pop()!);
}
};
let best = 0;
const attempt = () => {
// attempt to remove cells until 'clues' cells remain
attempts--;
removed.clear();
shuffle(removeable);
maskUpto(0);
for (let i = 0; i < this.values2; i++) {
if (elapsed() > totalTime || this.values2 - removed.size == clues) {
break;
}
unmaskNextCell();
if (!hasOneSolution()) {
// failed attempt. prepare for next
unmaskAll();
maskUpto(i + 1);
continue;
}
removed.add(removeable[i]);
}
if (removed.size > best) {
best = removed.size;
}
unmaskAll();
};
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`
attempt();
}
removed.forEach((index) => {
completed[index] = 0;
});
return completed;
}
solve(puzzle: Cell[]) {
const [header] = this.getDLXHeader(puzzle);
let solved = false;
const callback = (solution: DNode[]) => {
solution.forEach((node) => {
const meta: NodeMeta = node.meta;
puzzle[meta.index] = meta.value;
});
solved = true;
// return the first solution
return true;
};
new DLX(header, callback).search();
return solved;
}
static maths: { [key: string]: SudokuMath } = {};
static get(regionWidth: number, regionHeight: number) {
const key = `${regionWidth}:${regionHeight}`;
return (
SudokuMath.maths[key] ??
(SudokuMath.maths[key] = new SudokuMath(regionWidth, regionHeight))
);
}
}