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

845 lines
26 KiB
TypeScript

import {
PuzzleValidationError,
candidatesForCell,
compilePuzzle,
findConflicts,
isSolved,
normalizePuzzle,
type CellId,
type CompiledPuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
type SudokuUnit,
type ValidationIssue,
} from "../domain";
import { findAdvancedLogicalStep } from "./advancedLogical";
export type LogicalTechnique =
| "naked-single"
| "hidden-single"
| "naked-pair"
| "naked-triple"
| "naked-quad"
| "hidden-pair"
| "hidden-triple"
| "hidden-quad"
| "pointing"
| "claiming"
| "x-wing"
| "swordfish"
| "jellyfish"
| "finned-x-wing"
| "finned-swordfish"
| "skyscraper"
| "two-string-kite"
| "simple-colouring"
| "w-wing"
| "x-chain"
| "xy-chain"
| "aic"
| "unique-rectangle"
| "xy-wing"
| "xyz-wing"
| "killer-cage";
export interface LogicalPlacement {
readonly cell: CellId;
readonly value: number;
}
export interface LogicalElimination {
readonly cell: CellId;
readonly values: readonly number[];
}
export interface LogicalStep {
readonly technique: LogicalTechnique;
readonly placements: readonly LogicalPlacement[];
readonly eliminations: readonly LogicalElimination[];
readonly focusCells: readonly CellId[];
readonly explanation: string;
}
export type LogicalSolveStatus = "solved" | "stuck" | "invalid" | "step-limit";
export interface LogicalSolveOptions {
readonly values?: readonly number[];
/**
* Optional per-cell candidate restrictions to resume a logical solve after
* applying an elimination-only step. Each entry contains ordinary Sudoku
* digits (1 through the puzzle size), rather than an implementation-specific
* bit mask. Restrictions are intersected with the candidates that remain
* legal on the supplied board; entries for filled cells are ignored.
*/
readonly candidates?: readonly (readonly number[])[];
/**
* Enables uniqueness-dependent deductions only after the caller has proved
* exactly one solution with an exhaustive solver result. Never inferred.
*/
readonly uniquenessProven?: boolean;
readonly maxSteps?: number;
}
export interface LogicalSolveResult {
readonly status: LogicalSolveStatus;
readonly values: readonly number[];
readonly candidates: readonly (readonly number[])[];
readonly steps: readonly LogicalStep[];
}
interface LogicalState {
readonly compiled: CompiledPuzzle;
readonly values: number[];
readonly masks: number[];
}
function digitBit(value: number): number {
return 1 << value;
}
function popcount(mask: number): number {
let value = mask >>> 0;
let count = 0;
while (value !== 0) {
value &= value - 1;
count += 1;
}
return count;
}
function digits(mask: number, size: number): number[] {
const result: number[] = [];
for (let value = 1; value <= size; value += 1) {
if ((mask & digitBit(value)) !== 0) result.push(value);
}
return result;
}
function onlyDigit(mask: number, size: number): number {
return digits(mask, size)[0] ?? 0;
}
function combinations<T>(values: readonly T[], count: number): T[][] {
const result: T[][] = [];
const current: T[] = [];
const visit = (start: number): void => {
if (current.length === count) {
result.push([...current]);
return;
}
for (
let index = start;
index <= values.length - (count - current.length);
index += 1
) {
const value = values[index];
if (value === undefined) continue;
current.push(value);
visit(index + 1);
current.pop();
}
};
visit(0);
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: "contains an out-of-range value",
});
}
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) throw new PuzzleValidationError(issues);
}
function compileCandidateRestrictions(
normalized: NormalizedPuzzle,
input: unknown,
): number[] | undefined {
if (input === undefined) return undefined;
const issues: ValidationIssue[] = [];
const cellCount = normalized.size * normalized.size;
if (!Array.isArray(input)) {
throw new PuzzleValidationError([
{ path: "candidates", message: "must be an array of candidate arrays" },
]);
}
if (input.length !== cellCount) {
issues.push({
path: "candidates",
message: `must contain exactly ${cellCount} candidate arrays`,
});
}
const masks = new Array<number>(cellCount).fill(0);
for (let cell = 0; cell < Math.min(input.length, cellCount); cell += 1) {
const cellCandidates: unknown = input[cell];
if (!Array.isArray(cellCandidates)) {
issues.push({
path: `candidates[${cell}]`,
message: "must be an array of candidate digits",
});
continue;
}
let mask = 0;
for (let index = 0; index < cellCandidates.length; index += 1) {
const value: unknown = cellCandidates[index];
if (
typeof value !== "number" ||
!Number.isInteger(value) ||
value < 1 ||
value > normalized.size
) {
issues.push({
path: `candidates[${cell}][${index}]`,
message: `must be an integer from 1 to ${normalized.size}`,
});
continue;
}
const bit = digitBit(value);
if ((mask & bit) !== 0) {
issues.push({
path: `candidates[${cell}][${index}]`,
message: `contains duplicate candidate ${value}`,
});
continue;
}
mask |= bit;
}
masks[cell] = mask;
}
if (issues.length > 0) throw new PuzzleValidationError(issues);
return masks;
}
function initializeState(
normalized: NormalizedPuzzle,
values: readonly number[],
candidateRestrictions?: readonly number[],
): LogicalState {
const compiled = compilePuzzle(normalized);
return {
compiled,
values: [...values],
masks: values.map((value, cell) => {
if (value !== 0) return 0;
const legalMask = candidatesForCell(compiled, values, cell).reduce(
(mask, candidate) => mask | digitBit(candidate),
0,
);
return candidateRestrictions === undefined
? legalMask
: legalMask & (candidateRestrictions[cell] ?? 0);
}),
};
}
function eliminationStep(
technique: LogicalTechnique,
eliminations: readonly LogicalElimination[],
focusCells: readonly CellId[],
explanation: string,
): LogicalStep | undefined {
return eliminations.length === 0
? undefined
: { technique, placements: [], eliminations, focusCells, explanation };
}
function findNakedSingle(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (let cell = 0; cell < state.values.length; cell += 1) {
const mask = state.masks[cell] ?? 0;
if (state.values[cell] === 0 && popcount(mask) === 1) {
const value = onlyDigit(mask, size);
return {
technique: "naked-single",
placements: [{ cell, value }],
eliminations: [],
focusCells: [cell],
explanation: `Cell ${cell + 1} has only one candidate: ${value}.`,
};
}
}
return undefined;
}
function findHiddenSingle(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (const unit of state.compiled.units) {
for (let value = 1; value <= size; value += 1) {
if (unit.cells.some((cell) => state.values[cell] === value)) continue;
const cells = unit.cells.filter(
(cell) =>
state.values[cell] === 0 &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
);
if (cells.length === 1) {
const cell = cells[0] as number;
return {
technique: "hidden-single",
placements: [{ cell, value }],
eliminations: [],
focusCells: unit.cells,
explanation: `${value} has only one possible cell in this ${unit.kind}.`,
};
}
}
}
return undefined;
}
function subsetName(hidden: boolean, count: number): LogicalTechnique {
const suffix = count === 2 ? "pair" : count === 3 ? "triple" : "quad";
return `${hidden ? "hidden" : "naked"}-${suffix}` as LogicalTechnique;
}
function findNakedSubset(state: LogicalState): LogicalStep | undefined {
for (const unit of state.compiled.units) {
const empty = unit.cells.filter((cell) => state.values[cell] === 0);
for (let count = 2; count <= 4; count += 1) {
const eligible = empty.filter((cell) => {
const total = popcount(state.masks[cell] ?? 0);
return total >= 2 && total <= count;
});
for (const cells of combinations(eligible, count)) {
const union = cells.reduce(
(mask, cell) => mask | (state.masks[cell] ?? 0),
0,
);
if (popcount(union) !== count) continue;
const selected = new Set(cells);
const eliminations = empty
.filter(
(cell) =>
!selected.has(cell) && ((state.masks[cell] ?? 0) & union) !== 0,
)
.map((cell) => ({
cell,
values: digits(
(state.masks[cell] ?? 0) & union,
state.compiled.puzzle.size,
),
}));
const step = eliminationStep(
subsetName(false, count),
eliminations,
cells,
`${count} cells contain only the same ${count} candidates in this ${unit.kind}.`,
);
if (step !== undefined) return step;
}
}
}
return undefined;
}
function findHiddenSubset(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
const values = Array.from({ length: size }, (_, index) => index + 1);
for (const unit of state.compiled.units) {
const empty = unit.cells.filter((cell) => state.values[cell] === 0);
for (let count = 2; count <= 4; count += 1) {
for (const selectedValues of combinations(values, count)) {
const subsetMask = selectedValues.reduce(
(mask, value) => mask | digitBit(value),
0,
);
if (
selectedValues.some(
(value) =>
!empty.some(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
),
)
) {
continue;
}
const cells = empty.filter(
(cell) => ((state.masks[cell] ?? 0) & subsetMask) !== 0,
);
if (cells.length !== count) continue;
const eliminations = cells
.filter((cell) => ((state.masks[cell] ?? 0) & ~subsetMask) !== 0)
.map((cell) => ({
cell,
values: digits((state.masks[cell] ?? 0) & ~subsetMask, size),
}));
const step = eliminationStep(
subsetName(true, count),
eliminations,
cells,
`${selectedValues.join(", ")} can occur only in ${count} cells of this ${unit.kind}.`,
);
if (step !== undefined) return step;
}
}
}
return undefined;
}
function findPointingOrClaiming(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
const regions = state.compiled.units.filter((unit) => unit.kind === "region");
for (const region of regions) {
const regionSet = new Set(region.cells);
for (let value = 1; value <= size; value += 1) {
const cells = region.cells.filter(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
);
if (cells.length < 2) continue;
const rows = new Set(cells.map((cell) => Math.floor(cell / size)));
const columns = new Set(cells.map((cell) => cell % size));
const aligned =
rows.size === 1
? (["row", [...rows][0]] as const)
: columns.size === 1
? (["column", [...columns][0]] as const)
: undefined;
if (aligned === undefined || aligned[1] === undefined) continue;
const unit = state.compiled.units.find(
(candidate) =>
candidate.kind === aligned[0] && candidate.index === aligned[1],
);
if (unit === undefined) continue;
const eliminations = unit.cells
.filter(
(cell) =>
!regionSet.has(cell) &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"pointing",
eliminations,
cells,
`${value} is confined to one ${aligned[0]} inside a region.`,
);
if (step !== undefined) return step;
}
}
const lines = state.compiled.units.filter(
(unit) => unit.kind === "row" || unit.kind === "column",
);
for (const line of lines) {
for (let value = 1; value <= size; value += 1) {
const cells = line.cells.filter(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
);
if (cells.length < 2) continue;
const regionIds = new Set(
cells.map((cell) => state.compiled.puzzle.regions[cell]),
);
if (regionIds.size !== 1) continue;
const regionId = [...regionIds][0];
const region = regions.find((unit) => unit.index === regionId);
if (region === undefined) continue;
const lineSet = new Set(line.cells);
const eliminations = region.cells
.filter(
(cell) =>
!lineSet.has(cell) &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"claiming",
eliminations,
cells,
`${value} in this ${line.kind} is confined to a single region.`,
);
if (step !== undefined) return step;
}
}
return undefined;
}
function lineUnit(
compiled: CompiledPuzzle,
kind: "row" | "column",
index: number,
): SudokuUnit | undefined {
return compiled.units.find(
(unit) => unit.kind === kind && unit.index === index,
);
}
function findFish(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (const count of [2, 3]) {
for (const [baseKind, coverKind] of [
["row", "column"],
["column", "row"],
] as const) {
for (let value = 1; value <= size; value += 1) {
const eligible = Array.from(
{ length: size },
(_, index) => index,
).filter((index) => {
const unit = lineUnit(state.compiled, baseKind, index);
const total =
unit?.cells.filter(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
).length ?? 0;
return total >= 2 && total <= count;
});
for (const baseIndices of combinations(eligible, count)) {
const coverIndices = new Set<number>();
const focus: number[] = [];
for (const baseIndex of baseIndices) {
const unit = lineUnit(state.compiled, baseKind, baseIndex);
for (const cell of unit?.cells ?? []) {
if (((state.masks[cell] ?? 0) & digitBit(value)) === 0) continue;
focus.push(cell);
coverIndices.add(
coverKind === "column" ? cell % size : Math.floor(cell / size),
);
}
}
if (coverIndices.size !== count) continue;
const baseSet = new Set(baseIndices);
const eliminations: LogicalElimination[] = [];
for (const coverIndex of coverIndices) {
const unit = lineUnit(state.compiled, coverKind, coverIndex);
for (const cell of unit?.cells ?? []) {
const baseIndex =
baseKind === "row" ? Math.floor(cell / size) : cell % size;
if (
!baseSet.has(baseIndex) &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0
) {
eliminations.push({ cell, values: [value] });
}
}
}
const technique: LogicalTechnique =
count === 2 ? "x-wing" : "swordfish";
const step = eliminationStep(
technique,
eliminations,
focus,
`${value} forms a ${technique} across ${count} ${baseKind}s.`,
);
if (step !== undefined) return step;
}
}
}
}
return undefined;
}
function commonPeers(
compiled: CompiledPuzzle,
cells: readonly CellId[],
): Set<CellId> {
const first = cells[0];
if (first === undefined) return new Set();
const result = new Set(compiled.peers[first]);
for (const cell of cells.slice(1)) {
for (const candidate of result) {
if (!compiled.peers[cell]?.has(candidate)) result.delete(candidate);
}
}
for (const cell of cells) result.delete(cell);
return result;
}
function findXyWing(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (let pivot = 0; pivot < state.values.length; pivot += 1) {
const pivotMask = state.masks[pivot] ?? 0;
if (popcount(pivotMask) !== 2) continue;
const wings = [...(state.compiled.peers[pivot] ?? [])].filter(
(cell) => popcount(state.masks[cell] ?? 0) === 2,
);
for (const pair of combinations(wings, 2)) {
const a = pair[0];
const b = pair[1];
if (a === undefined || b === undefined) continue;
const aMask = state.masks[a] ?? 0;
const bMask = state.masks[b] ?? 0;
const sharedA = aMask & pivotMask;
const sharedB = bMask & pivotMask;
if (
popcount(sharedA) !== 1 ||
popcount(sharedB) !== 1 ||
sharedA === sharedB
)
continue;
const zMask = aMask & bMask & ~pivotMask;
if (popcount(zMask) !== 1) continue;
const value = onlyDigit(zMask, size);
const eliminations = [...commonPeers(state.compiled, [a, b])]
.filter((cell) => ((state.masks[cell] ?? 0) & zMask) !== 0)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"xy-wing",
eliminations,
[pivot, a, b],
`Cells ${pivot + 1}, ${a + 1}, and ${b + 1} form an XY-Wing eliminating ${value}.`,
);
if (step !== undefined) return step;
}
}
return undefined;
}
function findXyzWing(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (let pivot = 0; pivot < state.values.length; pivot += 1) {
const pivotMask = state.masks[pivot] ?? 0;
if (popcount(pivotMask) !== 3) continue;
const wings = [...(state.compiled.peers[pivot] ?? [])].filter((cell) => {
const mask = state.masks[cell] ?? 0;
return popcount(mask) === 2 && (mask & ~pivotMask) === 0;
});
for (const pair of combinations(wings, 2)) {
const a = pair[0];
const b = pair[1];
if (a === undefined || b === undefined) continue;
const aMask = state.masks[a] ?? 0;
const bMask = state.masks[b] ?? 0;
if ((aMask | bMask) !== pivotMask) continue;
const shared = aMask & bMask;
if (popcount(shared) !== 1) continue;
const value = onlyDigit(shared, size);
const eliminations = [...commonPeers(state.compiled, [pivot, a, b])]
.filter((cell) => ((state.masks[cell] ?? 0) & shared) !== 0)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"xyz-wing",
eliminations,
[pivot, a, b],
`Cells ${pivot + 1}, ${a + 1}, and ${b + 1} form an XYZ-Wing eliminating ${value}.`,
);
if (step !== undefined) return step;
}
}
return undefined;
}
function findKillerReduction(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (const constraint of state.compiled.puzzle.constraints) {
if (constraint.type !== "killer-cage" || constraint.negated === true)
continue;
const empty = constraint.cells.filter((cell) => state.values[cell] === 0);
if (empty.length === 0) continue;
const assigned = constraint.cells
.map((cell) => state.values[cell] ?? 0)
.filter((value) => value !== 0);
const target =
constraint.sum - assigned.reduce((sum, value) => sum + value, 0);
const used = new Set(assigned);
const allowed = new Map<CellId, number>();
empty.forEach((cell) => allowed.set(cell, 0));
let visits = 0;
let truncated = false;
const chosen = new Set<number>(used);
const visit = (index: number, remaining: number): void => {
if (visits >= 100_000) {
truncated = true;
return;
}
visits += 1;
if (index === empty.length) {
if (remaining === 0) {
empty.forEach((cell) => {
const value = state.values[cell] ?? 0;
// Temporary chosen values are stored just beyond the live board.
const selected = assignment[indexByCell.get(cell) ?? -1] ?? value;
allowed.set(cell, (allowed.get(cell) ?? 0) | digitBit(selected));
});
}
return;
}
const cell = empty[index];
if (cell === undefined) return;
const remainingCells = empty.length - index - 1;
for (const value of digits(state.masks[cell] ?? 0, size)) {
if (constraint.noRepeat !== false && chosen.has(value)) continue;
const next = remaining - value;
if (next < remainingCells || next > remainingCells * size) continue;
assignment[index] = value;
chosen.add(value);
visit(index + 1, next);
chosen.delete(value);
}
};
const assignment = new Array<number>(empty.length).fill(0);
const indexByCell = new Map(empty.map((cell, index) => [cell, index]));
visit(0, target);
if (truncated) continue;
const eliminations = empty
.filter(
(cell) => ((state.masks[cell] ?? 0) & ~(allowed.get(cell) ?? 0)) !== 0,
)
.map((cell) => ({
cell,
values: digits(
(state.masks[cell] ?? 0) & ~(allowed.get(cell) ?? 0),
size,
),
}));
const step = eliminationStep(
"killer-cage",
eliminations,
constraint.cells,
`Only sum-compatible assignments remain in the ${constraint.sum} cage.`,
);
if (step !== undefined) return step;
}
return undefined;
}
function findStep(
state: LogicalState,
uniquenessProven: boolean,
): LogicalStep | undefined {
return (
findNakedSingle(state) ??
findHiddenSingle(state) ??
findNakedSubset(state) ??
findHiddenSubset(state) ??
findPointingOrClaiming(state) ??
findFish(state) ??
findXyWing(state) ??
findXyzWing(state) ??
findAdvancedLogicalStep({
size: state.compiled.puzzle.size,
values: state.values,
masks: state.masks,
regions: state.compiled.puzzle.regions,
peers: state.compiled.peers,
units: state.compiled.units,
uniquenessProven,
uniquenessPatternsSafe: state.compiled.puzzle.constraints.length === 0,
}) ??
findKillerReduction(state)
);
}
function applyStep(state: LogicalState, step: LogicalStep): void {
for (const elimination of step.eliminations) {
let mask = state.masks[elimination.cell] ?? 0;
for (const value of elimination.values) mask &= ~digitBit(value);
state.masks[elimination.cell] = mask;
}
for (const placement of step.placements) {
state.values[placement.cell] = placement.value;
state.masks[placement.cell] = 0;
}
if (step.placements.length > 0) {
for (let cell = 0; cell < state.values.length; cell += 1) {
if (state.values[cell] !== 0) continue;
const raw = candidatesForCell(state.compiled, state.values, cell).reduce(
(mask, value) => mask | digitBit(value),
0,
);
state.masks[cell] = (state.masks[cell] ?? 0) & raw;
}
}
}
function exposedCandidates(
state: LogicalState,
): readonly (readonly number[])[] {
return state.masks.map((mask) => digits(mask, state.compiled.puzzle.size));
}
export function solveLogically(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: LogicalSolveOptions = {},
): LogicalSolveResult {
const normalized = normalizePuzzle(puzzle);
const values = options.values ?? normalized.givens;
validateStart(normalized, values);
const maxSteps = options.maxSteps ?? 1_000;
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) {
throw new RangeError("maxSteps must be an integer from 1 to 10000");
}
if (
options.uniquenessProven !== undefined &&
typeof options.uniquenessProven !== "boolean"
) {
throw new TypeError("uniquenessProven must be a boolean when supplied");
}
const candidateRestrictions = compileCandidateRestrictions(
normalized,
options.candidates,
);
const state = initializeState(normalized, values, candidateRestrictions);
const steps: LogicalStep[] = [];
if (findConflicts(state.compiled, state.values).length > 0) {
return {
status: "invalid",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
while (steps.length < maxSteps) {
if (isSolved(state.compiled, state.values)) {
return {
status: "solved",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
if (
state.values.some(
(value, cell) => value === 0 && (state.masks[cell] ?? 0) === 0,
)
) {
return {
status: "invalid",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
const step = findStep(state, options.uniquenessProven === true);
if (step === undefined) {
return {
status: "stuck",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
applyStep(state, step);
steps.push(step);
}
return {
status: isSolved(state.compiled, state.values) ? "solved" : "step-limit",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}