204 lines
5.6 KiB
TypeScript
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;
|
|
}
|