@wsabol/sudoku-solver 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -8
- package/dist/sudokuSolver.d.ts +4 -1
- package/dist/sudokuSolver.d.ts.map +1 -1
- package/dist/sudokuSolver.js +140 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,11 +30,12 @@ console.log(solved.board); // number[][]
|
|
|
30
30
|
|
|
31
31
|
const next = Sudoku.nextMove(board);
|
|
32
32
|
console.log(next.status); // "In progress"
|
|
33
|
-
console.log(next.message); // e.g. "Place 4 in r1c3
|
|
33
|
+
console.log(next.message); // same as next.move?.message when a move exists, e.g. "Place 4 in r1c3 (Naked Single)"
|
|
34
34
|
if (next.move?.type === "placement") {
|
|
35
|
-
console.log(next.move.row, next.move.col, next.move.value);
|
|
35
|
+
console.log(next.move.row, next.move.col, next.move.value, next.move.reasoning);
|
|
36
36
|
} else if (next.move?.type === "elimination") {
|
|
37
37
|
console.log(next.move.eliminations); // [{ row, col, value }, ...]
|
|
38
|
+
// multi-value eliminations: message uses sorted set like "2/5/7" in the Eliminate … part
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
const check = Sudoku.validate(board);
|
|
@@ -81,16 +82,32 @@ Returns:
|
|
|
81
82
|
|
|
82
83
|
```ts
|
|
83
84
|
// digit placement
|
|
84
|
-
{
|
|
85
|
+
{
|
|
86
|
+
type: "placement";
|
|
87
|
+
row: number;
|
|
88
|
+
col: number;
|
|
89
|
+
value: number;
|
|
90
|
+
algorithm: Algorithm;
|
|
91
|
+
message: string;
|
|
92
|
+
reasoning: string;
|
|
93
|
+
}
|
|
85
94
|
|
|
86
|
-
// candidate elimination (e.g. Pointing Pair
|
|
87
|
-
{
|
|
95
|
+
// candidate elimination (e.g. Pointing Pair or Pointing Triple)
|
|
96
|
+
{
|
|
97
|
+
type: "elimination";
|
|
98
|
+
eliminations: Array<{ row: number; col: number; value: number }>;
|
|
99
|
+
algorithm: Algorithm;
|
|
100
|
+
message: string;
|
|
101
|
+
reasoning: string;
|
|
102
|
+
}
|
|
88
103
|
```
|
|
89
104
|
|
|
90
105
|
Notes:
|
|
91
106
|
- `move` is `null` when the board is complete or invalid.
|
|
92
107
|
- `move.type` must be checked before accessing placement-specific fields (`row`, `col`, `value`).
|
|
93
|
-
- `message
|
|
108
|
+
- `message`: placement uses 1-based coords in text, e.g. `Place 4 in r1c3 (Naked Single)`. Elimination counts distinct cells and names a common house when all targets share one row, column, or 3×3 box, e.g. `Eliminate 5 from 2 cells in row 4 (Pointing Pair)` or `Eliminate 2/7/9 from 3 cells in box 7 (Naked Triple)`; several distinct values use `{2/5/7}` in the digit part.
|
|
109
|
+
- `reasoning`: one sentence describing why the move is sound (technique-specific).
|
|
110
|
+
- Top-level `message` matches `move.message` when `move` is non-null.
|
|
94
111
|
|
|
95
112
|
### `Sudoku.validate(boardInput)`
|
|
96
113
|
|
|
@@ -117,12 +134,12 @@ Returns:
|
|
|
117
134
|
isValid: boolean;
|
|
118
135
|
isComplete: boolean;
|
|
119
136
|
message: string;
|
|
120
|
-
difficulty: "Easy" | "Medium" | "Hard" | "Diabolical" | "Impossible";
|
|
137
|
+
difficulty: "Easy" | "Medium" | "Hard" | "Diabolical" | "Impossible" | null;
|
|
121
138
|
solutions: number;
|
|
122
139
|
}
|
|
123
140
|
```
|
|
124
141
|
|
|
125
|
-
`difficulty` is derived from empty cell count
|
|
142
|
+
`difficulty` is derived from empty cell count when the board is valid; `null` when `isValid === false`. `solutions` is `0` when the board is invalid/unsolvable, `1` when a unique solution exists.
|
|
126
143
|
|
|
127
144
|
## Development
|
|
128
145
|
|
package/dist/sudokuSolver.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ValidationResult } from "./validate.js";
|
|
2
2
|
import type { Board } from "./boardGeo.js";
|
|
3
3
|
import type { Move, EliminationMove } from "./move.js";
|
|
4
|
-
export type Algorithm = "Last Digit" | "Full House" | "Naked Single" | "Hidden Single" | "Pointing Pair
|
|
4
|
+
export type Algorithm = "Last Digit" | "Full House" | "Naked Single" | "Hidden Single" | "Pointing Pair" | "Pointing Triple" | "Naked Pair" | "Naked Triple" | "Naked Quad" | "Hidden Pair" | "Hidden Triple" | "Hidden Quad";
|
|
5
5
|
export type DifficultyLevel = "Easy" | "Medium" | "Hard" | "Diabolical" | "Impossible";
|
|
6
6
|
export type ValidationReasonType = "duplicate_in_row" | "duplicate_in_column" | "duplicate_in_box" | "invalid_value" | "invalid_board_length" | "invalid_board_characters" | "empty_cell_no_candidates" | "too_many_empty_cells";
|
|
7
7
|
export default class SudokuSolver {
|
|
@@ -48,6 +48,9 @@ export default class SudokuSolver {
|
|
|
48
48
|
/** `houseCells` is one full row, column, or box (9 cells). */
|
|
49
49
|
private getHouseContext;
|
|
50
50
|
private tryNakedSubsetInHouse;
|
|
51
|
+
private findHiddenSubsetElimination;
|
|
52
|
+
private findNakedHiddenQuadsElimination;
|
|
53
|
+
private tryHiddenSubsetInHouse;
|
|
51
54
|
private findHiddenSingle;
|
|
52
55
|
}
|
|
53
56
|
//# sourceMappingURL=sudokuSolver.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sudokuSolver.d.ts","sourceRoot":"","sources":["../src/sudokuSolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,gBAAgB,EAAoB,MAAM,eAAe,CAAC;AACrF,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,IAAI,EAAiB,eAAe,EAAE,MAAM,WAAW,CAAC;AAEtE,MAAM,MAAM,SAAS,GACf,YAAY,GACZ,YAAY,GACZ,cAAc,GACd,eAAe,GACf,
|
|
1
|
+
{"version":3,"file":"sudokuSolver.d.ts","sourceRoot":"","sources":["../src/sudokuSolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,gBAAgB,EAAoB,MAAM,eAAe,CAAC;AACrF,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,IAAI,EAAiB,eAAe,EAAE,MAAM,WAAW,CAAC;AAEtE,MAAM,MAAM,SAAS,GACf,YAAY,GACZ,YAAY,GACZ,cAAc,GACd,eAAe,GACf,eAAe,GACf,iBAAiB,GACjB,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,aAAa,GACb,eAAe,GACf,aAAa,CAAC;AAEpB,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,YAAY,GAAG,YAAY,CAAC;AAEvF,MAAM,MAAM,oBAAoB,GAC1B,kBAAkB,GAClB,qBAAqB,GACrB,kBAAkB,GAClB,eAAe,GACf,sBAAsB,GACtB,0BAA0B,GAC1B,0BAA0B,GAC1B,sBAAsB,CAAC;AA+C7B,MAAM,CAAC,OAAO,OAAO,YAAY;IAC7B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAOnC;IAEF,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,aAAa,CAAe;gBAExB,KAAK,EAAE,MAAM,GAAG,KAAK;IAOjC,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI;IAS5B,OAAO,IAAI,KAAK;IAIhB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE;IAIhD,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,IAAI;IAIjE,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAmB7D,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI;IAM7C,SAAS,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI;IAQ3B,UAAU,IAAI,OAAO;IAIrB,eAAe,IAAI,MAAM;IAIzB,WAAW,CAAC,KAAK,GAAE,MAAU,GAAG,MAAM;IAOtC,QAAQ,IAAI,gBAAgB;IAiG5B,OAAO,IAAI,OAAO;IAKlB,UAAU,IAAI,eAAe;IAiB7B,WAAW,IAAI,IAAI,GAAG,IAAI;IAO1B,KAAK,IAAI,OAAO;IAchB,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,MAAM;IAYd,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,mBAAmB;IAU3B,OAAO,CAAC,aAAa;IAQrB,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,mBAAmB;IAqB3B,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,gBAAgB;IAmBxB,OAAO,CAAC,eAAe;IA+CvB,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,qBAAqB;IAsB7B,OAAO,CAAC,qBAAqB;IAsB7B,OAAO,CAAC,qBAAqB;IAuB7B,OAAO,CAAC,sBAAsB;IAqD9B,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,0BAA0B;IAUlC,8DAA8D;IAC9D,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,qBAAqB;IAwD7B,OAAO,CAAC,2BAA2B;IAUnC,OAAO,CAAC,+BAA+B;IAWvC,OAAO,CAAC,sBAAsB;IAgE9B,OAAO,CAAC,gBAAgB;CAe3B"}
|
package/dist/sudokuSolver.js
CHANGED
|
@@ -1,12 +1,47 @@
|
|
|
1
1
|
import { cloneBoard, assertBoardShape, parseBoardString, duplicateValues, boxNumber } from "./utils.js";
|
|
2
2
|
import { pushUniqueReason } from "./validate.js";
|
|
3
3
|
const COMPLETE = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
4
|
+
function uniqueCellCoordinates(items) {
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const { row, col } of items) {
|
|
8
|
+
const k = `${row},${col}`;
|
|
9
|
+
if (!seen.has(k)) {
|
|
10
|
+
seen.add(k);
|
|
11
|
+
out.push({ row, col });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* If every cell lies in the same row, same column, or same 3×3 box, return that house.
|
|
18
|
+
* Row wins over column when both apply (e.g. a single cell). Otherwise `null`.
|
|
19
|
+
*/
|
|
20
|
+
function sharedHouseContainingCells(cells) {
|
|
21
|
+
if (cells.length === 0) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const first = cells[0];
|
|
25
|
+
if (cells.every((c) => c.row === first.row)) {
|
|
26
|
+
return { kind: "row", wherePhrase: `row ${first.row + 1}` };
|
|
27
|
+
}
|
|
28
|
+
if (cells.every((c) => c.col === first.col)) {
|
|
29
|
+
return { kind: "column", wherePhrase: `column ${first.col + 1}` };
|
|
30
|
+
}
|
|
31
|
+
const box = boxNumber(first.row, first.col);
|
|
32
|
+
if (cells.every((c) => boxNumber(c.row, c.col) === box)) {
|
|
33
|
+
return { kind: "box", wherePhrase: `box ${box}` };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
4
37
|
export default class SudokuSolver {
|
|
5
38
|
static SEARCH_PHASES = [
|
|
6
39
|
"NakedSingle",
|
|
7
40
|
"HiddenSingle",
|
|
8
41
|
"Pointing",
|
|
9
42
|
"NakedSubset",
|
|
43
|
+
"HiddenSubset",
|
|
44
|
+
"NakedHiddenQuads",
|
|
10
45
|
];
|
|
11
46
|
board;
|
|
12
47
|
possiblesGrid;
|
|
@@ -257,12 +292,17 @@ export default class SudokuSolver {
|
|
|
257
292
|
}
|
|
258
293
|
finalizeElimination(eliminations, algorithm, reasoning) {
|
|
259
294
|
const digits = [...new Set(eliminations.map((e) => e.value))].sort((a, b) => a - b);
|
|
260
|
-
const digitPart = digits.length === 1 ? String(digits[0]) :
|
|
295
|
+
const digitPart = digits.length === 1 ? String(digits[0]) : `${digits.join("/")}`;
|
|
296
|
+
const uniqueCells = uniqueCellCoordinates(eliminations);
|
|
297
|
+
const uniqueCellCount = uniqueCells.length;
|
|
298
|
+
const cellWord = uniqueCellCount === 1 ? "cell" : "cells";
|
|
299
|
+
const housePhrase = sharedHouseContainingCells(uniqueCells)?.wherePhrase;
|
|
300
|
+
const wherePart = housePhrase ? ` in ${housePhrase}` : "";
|
|
261
301
|
return {
|
|
262
302
|
type: "elimination",
|
|
263
303
|
eliminations,
|
|
264
304
|
algorithm,
|
|
265
|
-
message: `Eliminate ${digitPart} from ${
|
|
305
|
+
message: `Eliminate ${digitPart} from ${uniqueCellCount} ${cellWord}${wherePart} (${algorithm})`,
|
|
266
306
|
reasoning,
|
|
267
307
|
};
|
|
268
308
|
}
|
|
@@ -284,6 +324,12 @@ export default class SudokuSolver {
|
|
|
284
324
|
return this.findPointingPairTriple();
|
|
285
325
|
case "NakedSubset":
|
|
286
326
|
return this.findNakedSubsetElimination();
|
|
327
|
+
case "HiddenSubset":
|
|
328
|
+
return this.findHiddenSubsetElimination();
|
|
329
|
+
case "NakedHiddenQuads":
|
|
330
|
+
return this.findNakedHiddenQuadsElimination();
|
|
331
|
+
default:
|
|
332
|
+
return null;
|
|
287
333
|
}
|
|
288
334
|
}
|
|
289
335
|
findNakedSingle() {
|
|
@@ -427,8 +473,9 @@ export default class SudokuSolver {
|
|
|
427
473
|
}
|
|
428
474
|
}
|
|
429
475
|
if (eliminations.length > 0) {
|
|
476
|
+
const algorithm = cells.length === 2 ? "Pointing Pair" : "Pointing Triple";
|
|
430
477
|
const reasoning = `In box ${boxNum}, every candidate for ${digit} lies in row ${sharedRow + 1}, so ${digit} cannot appear elsewhere in that row outside the box.`;
|
|
431
|
-
return this.finalizeElimination(eliminations,
|
|
478
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
432
479
|
}
|
|
433
480
|
}
|
|
434
481
|
if (cells.every((c) => c.col === cells[0].col)) {
|
|
@@ -442,8 +489,9 @@ export default class SudokuSolver {
|
|
|
442
489
|
}
|
|
443
490
|
}
|
|
444
491
|
if (eliminations.length > 0) {
|
|
492
|
+
const algorithm = cells.length === 2 ? "Pointing Pair" : "Pointing Triple";
|
|
445
493
|
const reasoning = `In box ${boxNum}, every candidate for ${digit} lies in column ${sharedCol + 1}, so ${digit} cannot appear elsewhere in that column outside the box.`;
|
|
446
|
-
return this.finalizeElimination(eliminations,
|
|
494
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
447
495
|
}
|
|
448
496
|
}
|
|
449
497
|
}
|
|
@@ -469,7 +517,7 @@ export default class SudokuSolver {
|
|
|
469
517
|
}
|
|
470
518
|
findNakedSubsetElimination() {
|
|
471
519
|
for (const houseCells of this.eachHouseInOrder()) {
|
|
472
|
-
for (let k = 2; k <=
|
|
520
|
+
for (let k = 2; k <= 3; k++) {
|
|
473
521
|
const move = this.tryNakedSubsetInHouse(houseCells, k);
|
|
474
522
|
if (move)
|
|
475
523
|
return move;
|
|
@@ -479,13 +527,11 @@ export default class SudokuSolver {
|
|
|
479
527
|
}
|
|
480
528
|
/** `houseCells` is one full row, column, or box (9 cells). */
|
|
481
529
|
getHouseContext(houseCells) {
|
|
482
|
-
const
|
|
483
|
-
if (
|
|
484
|
-
return { wherePhrase:
|
|
485
|
-
}
|
|
486
|
-
if (houseCells.every((c) => c.col === h.col)) {
|
|
487
|
-
return { wherePhrase: `column ${h.col + 1}`, sameKindWord: "column" };
|
|
530
|
+
const shared = sharedHouseContainingCells(houseCells);
|
|
531
|
+
if (shared) {
|
|
532
|
+
return { wherePhrase: shared.wherePhrase, sameKindWord: shared.kind };
|
|
488
533
|
}
|
|
534
|
+
const h = houseCells[0];
|
|
489
535
|
return { wherePhrase: `box ${boxNumber(h.row, h.col)}`, sameKindWord: "box" };
|
|
490
536
|
}
|
|
491
537
|
tryNakedSubsetInHouse(houseCells, k) {
|
|
@@ -540,6 +586,89 @@ export default class SudokuSolver {
|
|
|
540
586
|
};
|
|
541
587
|
return dfs(0);
|
|
542
588
|
}
|
|
589
|
+
findHiddenSubsetElimination() {
|
|
590
|
+
for (const houseCells of this.eachHouseInOrder()) {
|
|
591
|
+
for (let k = 2; k <= 3; k++) {
|
|
592
|
+
const move = this.tryHiddenSubsetInHouse(houseCells, k);
|
|
593
|
+
if (move)
|
|
594
|
+
return move;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
findNakedHiddenQuadsElimination() {
|
|
600
|
+
for (const houseCells of this.eachHouseInOrder()) {
|
|
601
|
+
const moveNaked = this.tryNakedSubsetInHouse(houseCells, 4);
|
|
602
|
+
if (moveNaked)
|
|
603
|
+
return moveNaked;
|
|
604
|
+
const moveHidden = this.tryHiddenSubsetInHouse(houseCells, 4);
|
|
605
|
+
if (moveHidden)
|
|
606
|
+
return moveHidden;
|
|
607
|
+
}
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
tryHiddenSubsetInHouse(houseCells, k) {
|
|
611
|
+
const algorithm = k === 2 ? "Hidden Pair" : k === 3 ? "Hidden Triple" : "Hidden Quad";
|
|
612
|
+
const { wherePhrase, sameKindWord } = this.getHouseContext(houseCells);
|
|
613
|
+
const digitIndices = [];
|
|
614
|
+
const dfsDigits = (start) => {
|
|
615
|
+
if (digitIndices.length === k) {
|
|
616
|
+
const digits = digitIndices.map((i) => COMPLETE[i]);
|
|
617
|
+
const digitSet = new Set(digits);
|
|
618
|
+
for (const d of digits) {
|
|
619
|
+
let seen = false;
|
|
620
|
+
for (const { row, col } of houseCells) {
|
|
621
|
+
if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d)) {
|
|
622
|
+
seen = true;
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (!seen) {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const reserved = [];
|
|
631
|
+
for (const { row, col } of houseCells) {
|
|
632
|
+
if (this.board[row][col] !== 0) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
for (const d of digits) {
|
|
636
|
+
if (this.possiblesGrid[row][col].includes(d)) {
|
|
637
|
+
reserved.push({ row, col });
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (reserved.length !== k) {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
const eliminations = [];
|
|
646
|
+
for (const { row, col } of reserved) {
|
|
647
|
+
for (const v of this.possiblesGrid[row][col]) {
|
|
648
|
+
if (!digitSet.has(v)) {
|
|
649
|
+
eliminations.push({ row, col, value: v });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (eliminations.length === 0) {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
const digitStr = [...digits].sort((a, b) => a - b).join("/");
|
|
657
|
+
const cellPhrase = reserved.map(({ row, col }) => `r${row + 1}c${col + 1}`).join(", ");
|
|
658
|
+
const reasoning = `${algorithm} ${digitStr} in ${wherePhrase}: in that ${sameKindWord}, those digits appear only in ${cellPhrase}, so candidates other than ${digitStr} can be removed from those cells.`;
|
|
659
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
660
|
+
}
|
|
661
|
+
for (let i = start; i < 9; i++) {
|
|
662
|
+
digitIndices.push(i);
|
|
663
|
+
const res = dfsDigits(i + 1);
|
|
664
|
+
digitIndices.pop();
|
|
665
|
+
if (res)
|
|
666
|
+
return res;
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
};
|
|
670
|
+
return dfsDigits(0);
|
|
671
|
+
}
|
|
543
672
|
findHiddenSingle() {
|
|
544
673
|
for (let row = 0; row < 9; row++) {
|
|
545
674
|
const move = this.findHiddenSingleInRow(row);
|
package/package.json
CHANGED