Files
sudoku-tools/src/solver/exact.ts
T

204 lines
5.6 KiB
TypeScript

import {
PuzzleValidationError,
candidatesForCell,
compilePuzzle,
findConflicts,
normalizePuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
type ValidationIssue,
} from "../domain";
import { seededRandom, shuffled, type RandomSource } from "./random";
export interface ExactSolveOptions {
readonly values?: readonly number[];
/** Stop after this many solutions. Defaults to 2 so uniqueness can be tested. */
readonly maxSolutions?: number;
readonly maxNodes?: number;
readonly timeoutMs?: number;
/** Randomizes equal choices deterministically when supplied. */
readonly seed?: string | number;
}
export type ExactLimitReason = "solution-cap" | "node-cap" | "timeout";
export interface ExactSolveResult {
readonly solutions: readonly (readonly number[])[];
readonly count: number;
readonly truncated: boolean;
readonly limitReason?: ExactLimitReason;
readonly nodes: number;
readonly elapsedMs: number;
}
function boundedInteger(
value: number | undefined,
fallback: number,
minimum: number,
maximum: number,
name: string,
): number {
const result = value ?? fallback;
if (!Number.isInteger(result) || result < minimum || result > maximum) {
throw new RangeError(
`${name} must be an integer from ${minimum} to ${maximum}.`,
);
}
return result;
}
function validateStart(
normalized: NormalizedPuzzle,
values: readonly number[],
): void {
const issues: ValidationIssue[] = [];
if (values.length !== normalized.size * normalized.size) {
issues.push({
path: "values",
message: `must contain exactly ${normalized.size ** 2} values`,
});
} else {
values.forEach((value, cell) => {
if (!Number.isInteger(value) || value < 0 || value > normalized.size) {
issues.push({
path: `values[${cell}]`,
message: `must be an integer from 0 to ${normalized.size}`,
});
}
const given = normalized.givens[cell] ?? 0;
if (given !== 0 && value !== given) {
issues.push({
path: `values[${cell}]`,
message: "must preserve the given value",
});
}
});
}
if (issues.length === 0) {
for (const conflict of findConflicts(compilePuzzle(normalized), values)) {
issues.push({ path: "values", message: conflict.message });
}
}
if (issues.length > 0) throw new PuzzleValidationError(issues);
}
export function solveExact(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: ExactSolveOptions = {},
): ExactSolveResult {
const normalized = normalizePuzzle(puzzle);
const board = [...(options.values ?? normalized.givens)];
validateStart(normalized, board);
const maxSolutions = boundedInteger(
options.maxSolutions,
2,
1,
100,
"maxSolutions",
);
const maxNodes = boundedInteger(
options.maxNodes,
2_000_000,
1,
100_000_000,
"maxNodes",
);
const timeoutMs = boundedInteger(
options.timeoutMs,
10_000,
1,
120_000,
"timeoutMs",
);
const compiled = compilePuzzle(normalized);
const preferredSolution =
normalized.solution !== undefined &&
board.every(
(value, cell) => value === 0 || value === normalized.solution?.[cell],
)
? normalized.solution
: undefined;
const random: RandomSource | undefined =
options.seed === undefined ? undefined : seededRandom(options.seed);
const solutions: number[][] = [];
const started = Date.now();
let nodes = 0;
let limitReason: ExactLimitReason | undefined;
const search = (): void => {
if (solutions.length >= maxSolutions) {
limitReason = "solution-cap";
return;
}
if (nodes >= maxNodes) {
limitReason = "node-cap";
return;
}
if (Date.now() - started >= timeoutMs) {
limitReason = "timeout";
return;
}
nodes += 1;
const tied: Array<readonly [number, number[]]> = [];
let minimum = normalized.size + 1;
for (let cell = 0; cell < board.length; cell += 1) {
if (board[cell] !== 0) continue;
const candidates = candidatesForCell(compiled, board, cell);
if (candidates.length === 0) return;
if (candidates.length < minimum) {
minimum = candidates.length;
tied.length = 0;
tied.push([cell, candidates]);
} else if (candidates.length === minimum) {
tied.push([cell, candidates]);
}
if (minimum === 1 && random === undefined) break;
}
if (tied.length === 0) {
solutions.push([...board]);
return;
}
const selection =
random === undefined ? tied[0] : tied[Math.floor(random() * tied.length)];
if (selection === undefined) return;
const chosen = selection[0];
let choices = selection[1];
if (random !== undefined) choices = shuffled(choices, random);
else {
const preferred = preferredSolution?.[chosen];
if (preferred !== undefined && choices.includes(preferred)) {
choices = [
preferred,
...choices.filter((value) => value !== preferred),
];
}
}
for (const value of choices) {
board[chosen] = value;
search();
board[chosen] = 0;
if (limitReason !== undefined) return;
}
};
search();
const result: ExactSolveResult = {
solutions,
count: solutions.length,
truncated: limitReason !== undefined,
nodes,
elapsedMs: Date.now() - started,
...(limitReason === undefined ? {} : { limitReason }),
};
return result;
}
export function countSolutions(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: Omit<ExactSolveOptions, "maxSolutions"> & {
readonly maxSolutions?: number;
} = {},
): number {
return solveExact(puzzle, options).count;
}