@wsabol/sudoku-solver 0.1.10 → 0.1.13
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/dist/boardGeo.d.ts +0 -0
- package/dist/boardGeo.d.ts.map +0 -0
- package/dist/boardGeo.js +0 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +56 -3
- package/dist/move.d.ts +0 -0
- package/dist/move.d.ts.map +0 -0
- package/dist/move.js +0 -0
- package/dist/sudoku.d.ts +0 -0
- package/dist/sudoku.d.ts.map +0 -0
- package/dist/sudoku.js +0 -0
- package/dist/sudokuSolver.d.ts +50 -1
- package/dist/sudokuSolver.d.ts.map +1 -1
- package/dist/sudokuSolver.js +325 -122
- package/dist/utils.d.ts +0 -0
- package/dist/utils.d.ts.map +0 -0
- package/dist/utils.js +0 -0
- package/dist/validate.d.ts +0 -0
- package/dist/validate.d.ts.map +0 -0
- package/dist/validate.js +0 -0
- package/package.json +1 -1
package/dist/boardGeo.d.ts
CHANGED
|
File without changes
|
package/dist/boardGeo.d.ts.map
CHANGED
|
File without changes
|
package/dist/boardGeo.js
CHANGED
|
File without changes
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ interface SolveResult {
|
|
|
7
7
|
board: Board;
|
|
8
8
|
}
|
|
9
9
|
interface DescribeResult {
|
|
10
|
+
countGivens: number;
|
|
11
|
+
countEmptyCells: number;
|
|
10
12
|
isValid: boolean;
|
|
11
13
|
isComplete: boolean;
|
|
12
14
|
message: string;
|
|
@@ -19,6 +21,7 @@ interface MoveResult {
|
|
|
19
21
|
message: string;
|
|
20
22
|
}
|
|
21
23
|
declare function solve(boardInput: string | Board): SolveResult;
|
|
24
|
+
export declare function bruteForceSolve(board: Board): Board | null;
|
|
22
25
|
declare function nextMove(boardInput: string | Board): MoveResult;
|
|
23
26
|
declare function validate(boardInput: string | Board): ValidationResult;
|
|
24
27
|
declare function describeBoard(boardInput: string | Board): DescribeResult;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,EAAE,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACvF,OAAO,EAA8C,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAC/G,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAElF,UAAU,WAAW;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;CAChB;AAED,UAAU,cAAc;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,UAAU;IAChB,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,IAAI,GAAG,IAAI,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,iBAAS,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,WAAW,CAOtD;AAED,iBAAS,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,UAAU,CA0BxD;AAED,iBAAS,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,gBAAgB,CAY9D;AAED,iBAAS,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,cAAc,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,EAAE,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACvF,OAAO,EAA8C,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAC/G,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAElF,UAAU,WAAW;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;CAChB;AAED,UAAU,cAAc;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,UAAU;IAChB,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,IAAI,GAAG,IAAI,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,iBAAS,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,WAAW,CAOtD;AAuCD,wBAAgB,eAAe,CAAC,KAAK,EAAE,KAAK,GAAG,KAAK,GAAG,IAAI,CAiB1D;AAED,iBAAS,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,UAAU,CA0BxD;AAED,iBAAS,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,gBAAgB,CAY9D;AAED,iBAAS,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,cAAc,CAsCjE;AAED,QAAA,MAAM,MAAM;;;;;CAKX,CAAC;AAEF,OAAO,EACH,YAAY,EACf,CAAA;AAGD,YAAY,EACR,SAAS,EACT,KAAK,EACL,IAAI,EACJ,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,WAAW,EACX,UAAU,EACV,UAAU,EACV,cAAc,EACjB,CAAC;AAEF,eAAe,MAAM,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,57 @@ function solve(boardInput) {
|
|
|
8
8
|
board: sudoku.toArray(),
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
|
+
function countSolutions(board) {
|
|
12
|
+
const copy = new SudokuSolver(board);
|
|
13
|
+
let count = 0;
|
|
14
|
+
function backtrack(startIdx = 0) {
|
|
15
|
+
// Prevent hanging on boards with many solutions (only need to know if 0, 1, or >1)
|
|
16
|
+
if (count >= 2)
|
|
17
|
+
return;
|
|
18
|
+
let emptyR = -1;
|
|
19
|
+
let emptyC = -1;
|
|
20
|
+
for (let i = startIdx; i < 81; i++) {
|
|
21
|
+
const r = Math.floor(i / 9);
|
|
22
|
+
const c = i % 9;
|
|
23
|
+
if (copy.isCellEmpty(r, c)) {
|
|
24
|
+
emptyR = r;
|
|
25
|
+
emptyC = c;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (emptyR === -1) {
|
|
30
|
+
count++;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const possibles = copy.getPossibles(emptyR, emptyC);
|
|
34
|
+
for (const num of possibles) {
|
|
35
|
+
copy.setSquareValue(emptyR, emptyC, num);
|
|
36
|
+
backtrack(emptyR * 9 + emptyC + 1);
|
|
37
|
+
copy.setCellEmpty(emptyR, emptyC);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
backtrack();
|
|
41
|
+
return count;
|
|
42
|
+
}
|
|
43
|
+
export function bruteForceSolve(board) {
|
|
44
|
+
const copy = new SudokuSolver(board);
|
|
45
|
+
for (let r = 0; r < 9; r++) {
|
|
46
|
+
for (let c = 0; c < 9; c++) {
|
|
47
|
+
if (copy.isCellEmpty(r, c)) {
|
|
48
|
+
for (let num = 1; num <= 9; num++) {
|
|
49
|
+
if (copy.getPossibles(r, c).includes(num)) {
|
|
50
|
+
copy.setSquareValue(r, c, num);
|
|
51
|
+
if (bruteForceSolve(copy.toArray()))
|
|
52
|
+
return copy.toArray();
|
|
53
|
+
copy.setCellEmpty(r, c);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return copy.toArray();
|
|
61
|
+
}
|
|
11
62
|
function nextMove(boardInput) {
|
|
12
63
|
const sudoku = new SudokuSolver(boardInput);
|
|
13
64
|
const validation = sudoku.validate();
|
|
@@ -50,6 +101,8 @@ function describeBoard(boardInput) {
|
|
|
50
101
|
const initValidation = sudoku.validate();
|
|
51
102
|
if (!initValidation.isValid) {
|
|
52
103
|
return {
|
|
104
|
+
countGivens: sudoku.countPlaced(),
|
|
105
|
+
countEmptyCells: sudoku.countEmptyCells(),
|
|
53
106
|
isValid: false,
|
|
54
107
|
isComplete: false,
|
|
55
108
|
message: initValidation.message,
|
|
@@ -58,20 +111,20 @@ function describeBoard(boardInput) {
|
|
|
58
111
|
};
|
|
59
112
|
}
|
|
60
113
|
let result = {
|
|
114
|
+
countGivens: sudoku.countPlaced(),
|
|
115
|
+
countEmptyCells: sudoku.countEmptyCells(),
|
|
61
116
|
isValid: true,
|
|
62
117
|
isComplete: sudoku.isComplete(),
|
|
63
118
|
message: '',
|
|
64
119
|
difficulty: sudoku.difficulty(),
|
|
65
|
-
solutions:
|
|
120
|
+
solutions: countSolutions(sudoku.toArray()),
|
|
66
121
|
};
|
|
67
122
|
if (result.isComplete) {
|
|
68
|
-
result.solutions = 1;
|
|
69
123
|
result.message = 'Solvable with a single solution';
|
|
70
124
|
}
|
|
71
125
|
else {
|
|
72
126
|
sudoku.solve();
|
|
73
127
|
if (sudoku.isComplete()) {
|
|
74
|
-
result.solutions = 1;
|
|
75
128
|
result.message = 'Unique Solution';
|
|
76
129
|
}
|
|
77
130
|
else {
|
package/dist/move.d.ts
CHANGED
|
File without changes
|
package/dist/move.d.ts.map
CHANGED
|
File without changes
|
package/dist/move.js
CHANGED
|
File without changes
|
package/dist/sudoku.d.ts
CHANGED
|
File without changes
|
package/dist/sudoku.d.ts.map
CHANGED
|
File without changes
|
package/dist/sudoku.js
CHANGED
|
File without changes
|
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" | "Pointing Triple" | "X-Wing" | "XY-Wing" | "Swordfish" | "Naked Pair" | "Naked Triple" | "Naked Quad" | "Hidden Pair" | "Hidden Triple" | "Hidden Quad";
|
|
4
|
+
export type Algorithm = "Last Digit" | "Full House" | "Naked Single" | "Hidden Single" | "Pointing Pair" | "Pointing Triple" | "Box/Line Reduction" | "X-Wing" | "XY-Wing" | "W-Wing" | "Swordfish" | "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 {
|
|
@@ -14,6 +14,9 @@ export default class SudokuSolver {
|
|
|
14
14
|
getPossibles(row: number, col: number): number[];
|
|
15
15
|
setPossibles(row: number, col: number, possibles: number[]): void;
|
|
16
16
|
setSquareValue(row: number, col: number, value: number): void;
|
|
17
|
+
setCellEmpty(row: number, col: number): void;
|
|
18
|
+
getValue(row: number, col: number): number;
|
|
19
|
+
isCellEmpty(row: number, col: number): boolean;
|
|
17
20
|
applyElimination(move: EliminationMove): void;
|
|
18
21
|
applyMove(move: Move): void;
|
|
19
22
|
isComplete(): boolean;
|
|
@@ -43,6 +46,12 @@ export default class SudokuSolver {
|
|
|
43
46
|
private findHiddenSingleInCol;
|
|
44
47
|
private findHiddenSingleInBox;
|
|
45
48
|
private findPointingPairTriple;
|
|
49
|
+
/**
|
|
50
|
+
* Box/Line Reduction (Claiming): if all candidates for a digit in a row or column lie within
|
|
51
|
+
* a single box, that digit can be eliminated from the rest of that box outside the row/column.
|
|
52
|
+
* This is the complement of Pointing Pairs/Triples.
|
|
53
|
+
*/
|
|
54
|
+
private findBoxLineReduction;
|
|
46
55
|
/**
|
|
47
56
|
* Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
|
|
48
57
|
* Returns only empty cells where `digit` is still a candidate.
|
|
@@ -56,18 +65,58 @@ export default class SudokuSolver {
|
|
|
56
65
|
*/
|
|
57
66
|
private findFishOfSize;
|
|
58
67
|
private cellsSeeEachOther;
|
|
68
|
+
/** Empty cells in `house` that have `digit` as a candidate. */
|
|
69
|
+
private getCandidateCellsInHouse;
|
|
70
|
+
/**
|
|
71
|
+
* All empty cells with exactly two candidates (ALS of size 1), as a convenience view over
|
|
72
|
+
* `enumerateALS(1)`. The `a`/`b` fields are the two candidates in sorted order.
|
|
73
|
+
* Used by XY-Wing and W-Wing; future ALS-based techniques should consume `enumerateALS()`.
|
|
74
|
+
*/
|
|
75
|
+
private getBivalueCells;
|
|
59
76
|
/**
|
|
60
77
|
* XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
|
|
61
78
|
* sees both pincers. Any cell that sees both pincers cannot contain Z.
|
|
62
79
|
*/
|
|
63
80
|
private findXYWing;
|
|
81
|
+
/**
|
|
82
|
+
* W-Wing: two bi-value cells A and D sharing the same candidate pair {W, X}, connected by a
|
|
83
|
+
* strong link on one of those digits (X) through cells B and C (A sees B, B=X=C strong link,
|
|
84
|
+
* C sees D). Whatever value A takes, W must appear in one of the two endpoints, so W can be
|
|
85
|
+
* eliminated from any cell seen by both A and D.
|
|
86
|
+
*/
|
|
87
|
+
private findWWing;
|
|
64
88
|
private eachHouseInOrder;
|
|
89
|
+
/**
|
|
90
|
+
* Enumerate all Almost Locked Sets (ALS) of sizes 1..maxSize across all 27 houses.
|
|
91
|
+
*
|
|
92
|
+
* An ALS of size N is a set of N cells (all lying in one house) whose union of candidates
|
|
93
|
+
* has exactly N+1 digits. A bi-value cell is an ALS of size 1. Results are deduplicated:
|
|
94
|
+
* if the same cell set is found via multiple houses (e.g. a bi-value cell appears in its row,
|
|
95
|
+
* column, and box), it appears only once.
|
|
96
|
+
*
|
|
97
|
+
* The returned collection is the shared primitive for ALS-based techniques (ALS-XZ Rule,
|
|
98
|
+
* ALS-XY-Wing, ALS-Chain) and makes `getBivalueCells()` a special case: `enumerateALS(1)`
|
|
99
|
+
* returns all bi-value cells.
|
|
100
|
+
*/
|
|
101
|
+
private enumerateALS;
|
|
65
102
|
private findNakedSubsetElimination;
|
|
66
103
|
/** `houseCells` is one full row, column, or box (9 cells). */
|
|
67
104
|
private getHouseContext;
|
|
105
|
+
/**
|
|
106
|
+
* Enumerate all naked locked sets of size k in `houseCells`: groups of k empty cells whose
|
|
107
|
+
* candidate union is exactly k digits. The result drives elimination in `tryNakedSubsetInHouse`
|
|
108
|
+
* and can be queried independently by future algorithms.
|
|
109
|
+
*/
|
|
110
|
+
private findNakedLockedSetsInHouse;
|
|
68
111
|
private tryNakedSubsetInHouse;
|
|
69
112
|
private findHiddenSubsetElimination;
|
|
70
113
|
private findNakedHiddenQuadsElimination;
|
|
114
|
+
/**
|
|
115
|
+
* Enumerate all hidden locked sets of size k in `houseCells`: groups of k digits that appear
|
|
116
|
+
* only in exactly k empty cells of the house. The result drives elimination in
|
|
117
|
+
* `tryHiddenSubsetInHouse` and can be queried independently by future algorithms.
|
|
118
|
+
*/
|
|
119
|
+
private findHiddenLockedSetsInHouse;
|
|
71
120
|
private tryHiddenSubsetInHouse;
|
|
72
121
|
private findHiddenSingle;
|
|
73
122
|
}
|
|
@@ -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,eAAe,GACf,iBAAiB,GACjB,QAAQ,GACR,SAAS,GACT,WAAW,GACX,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;
|
|
1
|
+
{"version":3,"file":"sudokuSolver.d.ts","sourceRoot":"","sources":["../src/sudokuSolver.ts"],"names":[],"mappings":"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"}
|
package/dist/sudokuSolver.js
CHANGED
|
@@ -39,10 +39,12 @@ export default class SudokuSolver {
|
|
|
39
39
|
"NakedSingle",
|
|
40
40
|
"HiddenSingle",
|
|
41
41
|
"Pointing",
|
|
42
|
+
"BoxLineReduction",
|
|
42
43
|
"NakedSubset",
|
|
43
44
|
"HiddenSubset",
|
|
44
45
|
"Fish",
|
|
45
46
|
"XYWing",
|
|
47
|
+
"WWing",
|
|
46
48
|
"Swordfish",
|
|
47
49
|
"NakedHiddenQuads",
|
|
48
50
|
];
|
|
@@ -70,6 +72,9 @@ export default class SudokuSolver {
|
|
|
70
72
|
this.possiblesGrid[row][col] = possibles.filter((v) => v > 0 && v <= 9).sort((a, b) => a - b);
|
|
71
73
|
}
|
|
72
74
|
setSquareValue(row, col, value) {
|
|
75
|
+
if (value < 1 || value > 9) {
|
|
76
|
+
throw new Error(`Invalid value ${value} at row ${row}, col ${col}`);
|
|
77
|
+
}
|
|
73
78
|
this.board[row][col] = value;
|
|
74
79
|
this.possiblesGrid[row][col] = [];
|
|
75
80
|
const ibox = this.boxIndex(row, col);
|
|
@@ -87,6 +92,27 @@ export default class SudokuSolver {
|
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
}
|
|
95
|
+
setCellEmpty(row, col) {
|
|
96
|
+
this.board[row][col] = 0;
|
|
97
|
+
for (let i = 0; i < 9; i++) {
|
|
98
|
+
this.possiblesGrid[row][i] = this.calcSquarePossibles(row, i);
|
|
99
|
+
this.possiblesGrid[i][col] = this.calcSquarePossibles(i, col);
|
|
100
|
+
}
|
|
101
|
+
const ibox = this.boxIndex(row, col);
|
|
102
|
+
const startRow = Math.floor(ibox / 3) * 3;
|
|
103
|
+
const startCol = (ibox % 3) * 3;
|
|
104
|
+
for (let r = startRow; r < startRow + 3; r++) {
|
|
105
|
+
for (let c = startCol; c < startCol + 3; c++) {
|
|
106
|
+
this.possiblesGrid[r][c] = this.calcSquarePossibles(r, c);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
getValue(row, col) {
|
|
111
|
+
return this.board[row][col];
|
|
112
|
+
}
|
|
113
|
+
isCellEmpty(row, col) {
|
|
114
|
+
return this.getValue(row, col) === 0;
|
|
115
|
+
}
|
|
90
116
|
applyElimination(move) {
|
|
91
117
|
for (const { row, col, value } of move.eliminations) {
|
|
92
118
|
this.possiblesGrid[row][col] = this.possiblesGrid[row][col].filter((v) => v !== value);
|
|
@@ -325,10 +351,14 @@ export default class SudokuSolver {
|
|
|
325
351
|
return this.findHiddenSingle();
|
|
326
352
|
case "Pointing":
|
|
327
353
|
return this.findPointingPairTriple();
|
|
354
|
+
case "BoxLineReduction":
|
|
355
|
+
return this.findBoxLineReduction();
|
|
328
356
|
case "Fish":
|
|
329
357
|
return this.findFishOfSize(2);
|
|
330
358
|
case "XYWing":
|
|
331
359
|
return this.findXYWing();
|
|
360
|
+
case "WWing":
|
|
361
|
+
return this.findWWing();
|
|
332
362
|
case "Swordfish":
|
|
333
363
|
return this.findFishOfSize(3);
|
|
334
364
|
case "NakedSubset":
|
|
@@ -435,14 +465,9 @@ export default class SudokuSolver {
|
|
|
435
465
|
return null;
|
|
436
466
|
}
|
|
437
467
|
findHiddenSingleInBox(ibox) {
|
|
468
|
+
const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
|
|
438
469
|
for (let value = 1; value <= 9; value++) {
|
|
439
|
-
const candidates =
|
|
440
|
-
for (let idx = 0; idx < 9; idx++) {
|
|
441
|
-
const { row, col } = this.boxToPuzzle(ibox, idx);
|
|
442
|
-
if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(value)) {
|
|
443
|
-
candidates.push({ row, col });
|
|
444
|
-
}
|
|
445
|
-
}
|
|
470
|
+
const candidates = this.getCandidateCellsInHouse(boxHouse, value);
|
|
446
471
|
if (candidates.length === 1) {
|
|
447
472
|
const { row: r, col: c } = candidates[0];
|
|
448
473
|
let algorithm = "Hidden Single";
|
|
@@ -461,14 +486,9 @@ export default class SudokuSolver {
|
|
|
461
486
|
const boxStartRow = Math.floor(ibox / 3) * 3;
|
|
462
487
|
const boxStartCol = (ibox % 3) * 3;
|
|
463
488
|
const boxNum = ibox + 1;
|
|
489
|
+
const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
|
|
464
490
|
for (let digit = 1; digit <= 9; digit++) {
|
|
465
|
-
const cells =
|
|
466
|
-
for (let idx = 0; idx < 9; idx++) {
|
|
467
|
-
const { row, col } = this.boxToPuzzle(ibox, idx);
|
|
468
|
-
if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
|
|
469
|
-
cells.push({ row, col });
|
|
470
|
-
}
|
|
471
|
-
}
|
|
491
|
+
const cells = this.getCandidateCellsInHouse(boxHouse, digit);
|
|
472
492
|
if (cells.length < 2)
|
|
473
493
|
continue;
|
|
474
494
|
if (cells.every((c) => c.row === cells[0].row)) {
|
|
@@ -507,19 +527,44 @@ export default class SudokuSolver {
|
|
|
507
527
|
}
|
|
508
528
|
return null;
|
|
509
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* Box/Line Reduction (Claiming): if all candidates for a digit in a row or column lie within
|
|
532
|
+
* a single box, that digit can be eliminated from the rest of that box outside the row/column.
|
|
533
|
+
* This is the complement of Pointing Pairs/Triples.
|
|
534
|
+
*/
|
|
535
|
+
findBoxLineReduction() {
|
|
536
|
+
for (const byRow of [true, false]) {
|
|
537
|
+
const lineLabel = byRow ? "row" : "column";
|
|
538
|
+
for (let lineIdx = 0; lineIdx < 9; lineIdx++) {
|
|
539
|
+
const lineHouse = Array.from({ length: 9 }, (_, crossIdx) => byRow ? { row: lineIdx, col: crossIdx } : { row: crossIdx, col: lineIdx });
|
|
540
|
+
for (let digit = 1; digit <= 9; digit++) {
|
|
541
|
+
const cells = this.getCandidateCellsInHouse(lineHouse, digit);
|
|
542
|
+
if (cells.length < 2)
|
|
543
|
+
continue;
|
|
544
|
+
const firstBox = this.boxIndex(cells[0].row, cells[0].col);
|
|
545
|
+
if (!cells.every((c) => this.boxIndex(c.row, c.col) === firstBox))
|
|
546
|
+
continue;
|
|
547
|
+
const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(firstBox, idx));
|
|
548
|
+
const eliminations = this.getCandidateCellsInHouse(boxHouse, digit)
|
|
549
|
+
.filter(({ row, col }) => byRow ? row !== lineIdx : col !== lineIdx)
|
|
550
|
+
.map(({ row, col }) => ({ row, col, value: digit }));
|
|
551
|
+
if (eliminations.length > 0) {
|
|
552
|
+
const boxNum = firstBox + 1;
|
|
553
|
+
const reasoning = `In ${lineLabel} ${lineIdx + 1}, all candidates for ${digit} lie within box ${boxNum}, so ${digit} cannot appear elsewhere in box ${boxNum} outside that ${lineLabel}.`;
|
|
554
|
+
return this.finalizeElimination(eliminations, "Box/Line Reduction", reasoning);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
510
561
|
/**
|
|
511
562
|
* Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
|
|
512
563
|
* Returns only empty cells where `digit` is still a candidate.
|
|
513
564
|
*/
|
|
514
565
|
coverIndicesForLine(lineIdx, digit, byRow) {
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
const [row, col] = byRow ? [lineIdx, crossIdx] : [crossIdx, lineIdx];
|
|
518
|
-
if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
|
|
519
|
-
result.push(crossIdx);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
return result;
|
|
566
|
+
const house = Array.from({ length: 9 }, (_, crossIdx) => byRow ? { row: lineIdx, col: crossIdx } : { row: crossIdx, col: lineIdx });
|
|
567
|
+
return this.getCandidateCellsInHouse(house, digit).map(({ row, col }) => byRow ? col : row);
|
|
523
568
|
}
|
|
524
569
|
/**
|
|
525
570
|
* Generic N-fish detector (X-Wing = 2, Swordfish = 3).
|
|
@@ -578,20 +623,28 @@ export default class SudokuSolver {
|
|
|
578
623
|
cellsSeeEachOther(r1, c1, r2, c2) {
|
|
579
624
|
return r1 === r2 || c1 === c2 || this.boxIndex(r1, c1) === this.boxIndex(r2, c2);
|
|
580
625
|
}
|
|
626
|
+
/** Empty cells in `house` that have `digit` as a candidate. */
|
|
627
|
+
getCandidateCellsInHouse(house, digit) {
|
|
628
|
+
return house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit));
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* All empty cells with exactly two candidates (ALS of size 1), as a convenience view over
|
|
632
|
+
* `enumerateALS(1)`. The `a`/`b` fields are the two candidates in sorted order.
|
|
633
|
+
* Used by XY-Wing and W-Wing; future ALS-based techniques should consume `enumerateALS()`.
|
|
634
|
+
*/
|
|
635
|
+
getBivalueCells() {
|
|
636
|
+
return this.enumerateALS(1).map(({ cells, digits }) => {
|
|
637
|
+
const [a, b] = [...digits].sort((x, y) => x - y);
|
|
638
|
+
const { row, col } = cells[0];
|
|
639
|
+
return { row, col, a, b };
|
|
640
|
+
});
|
|
641
|
+
}
|
|
581
642
|
/**
|
|
582
643
|
* XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
|
|
583
644
|
* sees both pincers. Any cell that sees both pincers cannot contain Z.
|
|
584
645
|
*/
|
|
585
646
|
findXYWing() {
|
|
586
|
-
const bivalue =
|
|
587
|
-
for (let row = 0; row < 9; row++) {
|
|
588
|
-
for (let col = 0; col < 9; col++) {
|
|
589
|
-
const p = this.possiblesGrid[row][col];
|
|
590
|
-
if (this.board[row][col] === 0 && p.length === 2) {
|
|
591
|
-
bivalue.push({ row, col, a: p[0], b: p[1] });
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
}
|
|
647
|
+
const bivalue = this.getBivalueCells();
|
|
595
648
|
for (const pivot of bivalue) {
|
|
596
649
|
const X = pivot.a;
|
|
597
650
|
const Y = pivot.b;
|
|
@@ -652,6 +705,92 @@ export default class SudokuSolver {
|
|
|
652
705
|
}
|
|
653
706
|
return null;
|
|
654
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* W-Wing: two bi-value cells A and D sharing the same candidate pair {W, X}, connected by a
|
|
710
|
+
* strong link on one of those digits (X) through cells B and C (A sees B, B=X=C strong link,
|
|
711
|
+
* C sees D). Whatever value A takes, W must appear in one of the two endpoints, so W can be
|
|
712
|
+
* eliminated from any cell seen by both A and D.
|
|
713
|
+
*/
|
|
714
|
+
findWWing() {
|
|
715
|
+
const bivalue = this.getBivalueCells();
|
|
716
|
+
if (bivalue.length < 2)
|
|
717
|
+
return null;
|
|
718
|
+
const strongLinks = Array.from({ length: 10 }, () => []);
|
|
719
|
+
for (const house of this.eachHouseInOrder()) {
|
|
720
|
+
for (let d = 1; d <= 9; d++) {
|
|
721
|
+
const cands = house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d));
|
|
722
|
+
if (cands.length === 2) {
|
|
723
|
+
strongLinks[d].push([cands[0], cands[1]]);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
for (let i = 0; i < bivalue.length; i++) {
|
|
728
|
+
for (let j = i + 1; j < bivalue.length; j++) {
|
|
729
|
+
const A = bivalue[i];
|
|
730
|
+
const D = bivalue[j];
|
|
731
|
+
if (A.a !== D.a || A.b !== D.b)
|
|
732
|
+
continue;
|
|
733
|
+
const W = A.a;
|
|
734
|
+
const X = A.b;
|
|
735
|
+
// Try each digit as the link digit; the other is the digit to eliminate.
|
|
736
|
+
for (const [linkDigit, elimDigit] of [[X, W], [W, X]]) {
|
|
737
|
+
for (const [B, C] of strongLinks[linkDigit]) {
|
|
738
|
+
// B and C must not coincide with A or D (endpoints already have both digits).
|
|
739
|
+
if ((B.row === A.row && B.col === A.col) ||
|
|
740
|
+
(B.row === D.row && B.col === D.col) ||
|
|
741
|
+
(C.row === A.row && C.col === A.col) ||
|
|
742
|
+
(C.row === D.row && C.col === D.col))
|
|
743
|
+
continue;
|
|
744
|
+
// The chain is A -(weak)- B =X= C -(weak)- D.
|
|
745
|
+
// Check orientation: A sees B and C sees D, or A sees C and B sees D.
|
|
746
|
+
let nearA = null;
|
|
747
|
+
let nearD = null;
|
|
748
|
+
if (this.cellsSeeEachOther(A.row, A.col, B.row, B.col) &&
|
|
749
|
+
this.cellsSeeEachOther(C.row, C.col, D.row, D.col)) {
|
|
750
|
+
nearA = B;
|
|
751
|
+
nearD = C;
|
|
752
|
+
}
|
|
753
|
+
else if (this.cellsSeeEachOther(A.row, A.col, C.row, C.col) &&
|
|
754
|
+
this.cellsSeeEachOther(B.row, B.col, D.row, D.col)) {
|
|
755
|
+
nearA = C;
|
|
756
|
+
nearD = B;
|
|
757
|
+
}
|
|
758
|
+
if (!nearA || !nearD)
|
|
759
|
+
continue;
|
|
760
|
+
const eliminations = [];
|
|
761
|
+
for (let row = 0; row < 9; row++) {
|
|
762
|
+
for (let col = 0; col < 9; col++) {
|
|
763
|
+
if (row === A.row && col === A.col)
|
|
764
|
+
continue;
|
|
765
|
+
if (row === D.row && col === D.col)
|
|
766
|
+
continue;
|
|
767
|
+
if (this.board[row][col] !== 0)
|
|
768
|
+
continue;
|
|
769
|
+
if (!this.possiblesGrid[row][col].includes(elimDigit))
|
|
770
|
+
continue;
|
|
771
|
+
if (this.cellsSeeEachOther(row, col, A.row, A.col) &&
|
|
772
|
+
this.cellsSeeEachOther(row, col, D.row, D.col)) {
|
|
773
|
+
eliminations.push({ row, col, value: elimDigit });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (eliminations.length > 0) {
|
|
778
|
+
const reasoning = `W-Wing: r${A.row + 1}c${A.col + 1} and r${D.row + 1}c${D.col + 1} ` +
|
|
779
|
+
`both have candidates {${W}/${X}}. ` +
|
|
780
|
+
`r${nearA.row + 1}c${nearA.col + 1} and r${nearD.row + 1}c${nearD.col + 1} ` +
|
|
781
|
+
`form a strong link on ${linkDigit}. ` +
|
|
782
|
+
`r${A.row + 1}c${A.col + 1} sees r${nearA.row + 1}c${nearA.col + 1} ` +
|
|
783
|
+
`and r${D.row + 1}c${D.col + 1} sees r${nearD.row + 1}c${nearD.col + 1}, ` +
|
|
784
|
+
`so ${elimDigit} must appear in one of the two wing cells, ` +
|
|
785
|
+
`eliminating ${elimDigit} from any cell seen by both.`;
|
|
786
|
+
return this.finalizeElimination(eliminations, "W-Wing", reasoning);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
655
794
|
eachHouseInOrder() {
|
|
656
795
|
const houses = [];
|
|
657
796
|
for (let r = 0; r < 9; r++) {
|
|
@@ -669,6 +808,66 @@ export default class SudokuSolver {
|
|
|
669
808
|
}
|
|
670
809
|
return houses;
|
|
671
810
|
}
|
|
811
|
+
/**
|
|
812
|
+
* Enumerate all Almost Locked Sets (ALS) of sizes 1..maxSize across all 27 houses.
|
|
813
|
+
*
|
|
814
|
+
* An ALS of size N is a set of N cells (all lying in one house) whose union of candidates
|
|
815
|
+
* has exactly N+1 digits. A bi-value cell is an ALS of size 1. Results are deduplicated:
|
|
816
|
+
* if the same cell set is found via multiple houses (e.g. a bi-value cell appears in its row,
|
|
817
|
+
* column, and box), it appears only once.
|
|
818
|
+
*
|
|
819
|
+
* The returned collection is the shared primitive for ALS-based techniques (ALS-XZ Rule,
|
|
820
|
+
* ALS-XY-Wing, ALS-Chain) and makes `getBivalueCells()` a special case: `enumerateALS(1)`
|
|
821
|
+
* returns all bi-value cells.
|
|
822
|
+
*/
|
|
823
|
+
enumerateALS(maxSize = 4) {
|
|
824
|
+
const seen = new Set();
|
|
825
|
+
const result = [];
|
|
826
|
+
for (const house of this.eachHouseInOrder()) {
|
|
827
|
+
const empties = house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].length > 0);
|
|
828
|
+
const indices = [];
|
|
829
|
+
const digits = new Set();
|
|
830
|
+
const dfs = (start) => {
|
|
831
|
+
if (indices.length > 0) {
|
|
832
|
+
if (digits.size === indices.length + 1) {
|
|
833
|
+
const cells = indices.map((i) => empties[i]);
|
|
834
|
+
const key = cells
|
|
835
|
+
.map(({ row, col }) => row * 9 + col)
|
|
836
|
+
.sort((a, b) => a - b)
|
|
837
|
+
.join(",");
|
|
838
|
+
if (!seen.has(key)) {
|
|
839
|
+
seen.add(key);
|
|
840
|
+
result.push({ cells, digits: new Set(digits) });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
// Prune: digit count already exceeds ALS threshold; adding more cells can only
|
|
844
|
+
// increase both the cell count and the digit count by the same amount or more,
|
|
845
|
+
// so the invariant digits.size === cells.length + 1 cannot be recovered.
|
|
846
|
+
if (digits.size > indices.length + 1)
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (indices.length === maxSize)
|
|
850
|
+
return;
|
|
851
|
+
for (let i = start; i < empties.length; i++) {
|
|
852
|
+
const { row, col } = empties[i];
|
|
853
|
+
const added = [];
|
|
854
|
+
for (const d of this.possiblesGrid[row][col]) {
|
|
855
|
+
if (!digits.has(d)) {
|
|
856
|
+
digits.add(d);
|
|
857
|
+
added.push(d);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
indices.push(i);
|
|
861
|
+
dfs(i + 1);
|
|
862
|
+
indices.pop();
|
|
863
|
+
for (const d of added)
|
|
864
|
+
digits.delete(d);
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
dfs(0);
|
|
868
|
+
}
|
|
869
|
+
return result;
|
|
870
|
+
}
|
|
672
871
|
findNakedSubsetElimination() {
|
|
673
872
|
for (const houseCells of this.eachHouseInOrder()) {
|
|
674
873
|
for (let k = 2; k <= 3; k++) {
|
|
@@ -688,57 +887,69 @@ export default class SudokuSolver {
|
|
|
688
887
|
const h = houseCells[0];
|
|
689
888
|
return { wherePhrase: `box ${boxNumber(h.row, h.col)}`, sameKindWord: "box" };
|
|
690
889
|
}
|
|
691
|
-
|
|
890
|
+
/**
|
|
891
|
+
* Enumerate all naked locked sets of size k in `houseCells`: groups of k empty cells whose
|
|
892
|
+
* candidate union is exactly k digits. The result drives elimination in `tryNakedSubsetInHouse`
|
|
893
|
+
* and can be queried independently by future algorithms.
|
|
894
|
+
*/
|
|
895
|
+
findNakedLockedSetsInHouse(houseCells, k) {
|
|
692
896
|
const empties = houseCells.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].length > 0);
|
|
693
|
-
if (empties.length < k)
|
|
694
|
-
return
|
|
695
|
-
}
|
|
696
|
-
const
|
|
697
|
-
const { wherePhrase, sameKindWord } = this.getHouseContext(houseCells);
|
|
698
|
-
const n = empties.length;
|
|
897
|
+
if (empties.length < k)
|
|
898
|
+
return [];
|
|
899
|
+
const { wherePhrase, sameKindWord: house } = this.getHouseContext(houseCells);
|
|
900
|
+
const result = [];
|
|
699
901
|
const indices = [];
|
|
902
|
+
const union = new Set();
|
|
700
903
|
const dfs = (start) => {
|
|
701
904
|
if (indices.length === k) {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
for (const { row, col } of subset) {
|
|
705
|
-
for (const v of this.possiblesGrid[row][col]) {
|
|
706
|
-
union.add(v);
|
|
707
|
-
}
|
|
905
|
+
if (union.size === k) {
|
|
906
|
+
result.push({ cells: indices.map((i) => empties[i]), digits: new Set(union), house, wherePhrase });
|
|
708
907
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
eliminations.push({ row, col, value: d });
|
|
722
|
-
}
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
// Prune: digit count already exceeds k; adding more cells can't reduce it.
|
|
911
|
+
if (union.size > k)
|
|
912
|
+
return;
|
|
913
|
+
for (let i = start; i < empties.length; i++) {
|
|
914
|
+
const { row, col } = empties[i];
|
|
915
|
+
const added = [];
|
|
916
|
+
for (const v of this.possiblesGrid[row][col]) {
|
|
917
|
+
if (!union.has(v)) {
|
|
918
|
+
union.add(v);
|
|
919
|
+
added.push(v);
|
|
723
920
|
}
|
|
724
921
|
}
|
|
725
|
-
if (eliminations.length === 0) {
|
|
726
|
-
return null;
|
|
727
|
-
}
|
|
728
|
-
const digitStr = [...union].sort((a, b) => a - b).join("/");
|
|
729
|
-
const reasoning = `${algorithm} ${digitStr} in ${wherePhrase} means those digits can be eliminated from the other cells in that same ${sameKindWord}.`;
|
|
730
|
-
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
731
|
-
}
|
|
732
|
-
for (let i = start; i < n; i++) {
|
|
733
922
|
indices.push(i);
|
|
734
|
-
|
|
923
|
+
dfs(i + 1);
|
|
735
924
|
indices.pop();
|
|
736
|
-
|
|
737
|
-
|
|
925
|
+
for (const v of added)
|
|
926
|
+
union.delete(v);
|
|
738
927
|
}
|
|
739
|
-
return null;
|
|
740
928
|
};
|
|
741
|
-
|
|
929
|
+
dfs(0);
|
|
930
|
+
return result;
|
|
931
|
+
}
|
|
932
|
+
tryNakedSubsetInHouse(houseCells, k) {
|
|
933
|
+
const algorithm = k === 2 ? "Naked Pair" : k === 3 ? "Naked Triple" : "Naked Quad";
|
|
934
|
+
for (const ls of this.findNakedLockedSetsInHouse(houseCells, k)) {
|
|
935
|
+
const subsetKeys = new Set(ls.cells.map(({ row, col }) => row * 9 + col));
|
|
936
|
+
const eliminations = [];
|
|
937
|
+
for (const { row, col } of houseCells) {
|
|
938
|
+
if (this.board[row][col] !== 0 || subsetKeys.has(row * 9 + col))
|
|
939
|
+
continue;
|
|
940
|
+
for (const d of ls.digits) {
|
|
941
|
+
if (this.possiblesGrid[row][col].includes(d)) {
|
|
942
|
+
eliminations.push({ row, col, value: d });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (eliminations.length > 0) {
|
|
947
|
+
const digitStr = [...ls.digits].sort((a, b) => a - b).join("/");
|
|
948
|
+
const reasoning = `${algorithm} ${digitStr} in ${ls.wherePhrase} means those digits can be eliminated from the other cells in that same ${ls.house}.`;
|
|
949
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return null;
|
|
742
953
|
}
|
|
743
954
|
findHiddenSubsetElimination() {
|
|
744
955
|
for (const houseCells of this.eachHouseInOrder()) {
|
|
@@ -761,67 +972,59 @@ export default class SudokuSolver {
|
|
|
761
972
|
}
|
|
762
973
|
return null;
|
|
763
974
|
}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
975
|
+
/**
|
|
976
|
+
* Enumerate all hidden locked sets of size k in `houseCells`: groups of k digits that appear
|
|
977
|
+
* only in exactly k empty cells of the house. The result drives elimination in
|
|
978
|
+
* `tryHiddenSubsetInHouse` and can be queried independently by future algorithms.
|
|
979
|
+
*/
|
|
980
|
+
findHiddenLockedSetsInHouse(houseCells, k) {
|
|
981
|
+
const { wherePhrase, sameKindWord: house } = this.getHouseContext(houseCells);
|
|
982
|
+
const result = [];
|
|
767
983
|
const digitIndices = [];
|
|
768
|
-
const
|
|
984
|
+
const dfs = (start) => {
|
|
769
985
|
if (digitIndices.length === k) {
|
|
770
986
|
const digits = digitIndices.map((i) => COMPLETE[i]);
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
return null;
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
const reserved = [];
|
|
785
|
-
for (const { row, col } of houseCells) {
|
|
786
|
-
if (this.board[row][col] !== 0) {
|
|
787
|
-
continue;
|
|
788
|
-
}
|
|
789
|
-
for (const d of digits) {
|
|
790
|
-
if (this.possiblesGrid[row][col].includes(d)) {
|
|
791
|
-
reserved.push({ row, col });
|
|
792
|
-
break;
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
if (reserved.length !== k) {
|
|
797
|
-
return null;
|
|
987
|
+
// Every chosen digit must appear in at least one empty cell of the house.
|
|
988
|
+
const allPresent = digits.every((d) => houseCells.some(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d)));
|
|
989
|
+
if (!allPresent)
|
|
990
|
+
return;
|
|
991
|
+
// Cells that contain at least one of the k digits — the "reserved" cells.
|
|
992
|
+
const cells = houseCells.filter(({ row, col }) => this.board[row][col] === 0 &&
|
|
993
|
+
digits.some((d) => this.possiblesGrid[row][col].includes(d)));
|
|
994
|
+
// Hidden set condition: the k digits are confined to exactly k cells.
|
|
995
|
+
if (cells.length === k) {
|
|
996
|
+
result.push({ cells, digits: new Set(digits), house, wherePhrase });
|
|
798
997
|
}
|
|
799
|
-
|
|
800
|
-
for (const { row, col } of reserved) {
|
|
801
|
-
for (const v of this.possiblesGrid[row][col]) {
|
|
802
|
-
if (!digitSet.has(v)) {
|
|
803
|
-
eliminations.push({ row, col, value: v });
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
if (eliminations.length === 0) {
|
|
808
|
-
return null;
|
|
809
|
-
}
|
|
810
|
-
const digitStr = [...digits].sort((a, b) => a - b).join("/");
|
|
811
|
-
const cellPhrase = reserved.map(({ row, col }) => `r${row + 1}c${col + 1}`).join(", ");
|
|
812
|
-
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.`;
|
|
813
|
-
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
998
|
+
return;
|
|
814
999
|
}
|
|
815
1000
|
for (let i = start; i < 9; i++) {
|
|
816
1001
|
digitIndices.push(i);
|
|
817
|
-
|
|
1002
|
+
dfs(i + 1);
|
|
818
1003
|
digitIndices.pop();
|
|
819
|
-
if (res)
|
|
820
|
-
return res;
|
|
821
1004
|
}
|
|
822
|
-
return null;
|
|
823
1005
|
};
|
|
824
|
-
|
|
1006
|
+
dfs(0);
|
|
1007
|
+
return result;
|
|
1008
|
+
}
|
|
1009
|
+
tryHiddenSubsetInHouse(houseCells, k) {
|
|
1010
|
+
const algorithm = k === 2 ? "Hidden Pair" : k === 3 ? "Hidden Triple" : "Hidden Quad";
|
|
1011
|
+
for (const ls of this.findHiddenLockedSetsInHouse(houseCells, k)) {
|
|
1012
|
+
const eliminations = [];
|
|
1013
|
+
for (const { row, col } of ls.cells) {
|
|
1014
|
+
for (const v of this.possiblesGrid[row][col]) {
|
|
1015
|
+
if (!ls.digits.has(v)) {
|
|
1016
|
+
eliminations.push({ row, col, value: v });
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (eliminations.length > 0) {
|
|
1021
|
+
const digitStr = [...ls.digits].sort((a, b) => a - b).join("/");
|
|
1022
|
+
const cellPhrase = ls.cells.map(({ row, col }) => `r${row + 1}c${col + 1}`).join(", ");
|
|
1023
|
+
const reasoning = `${algorithm} ${digitStr} in ${ls.wherePhrase}: in that ${ls.house}, those digits appear only in ${cellPhrase}, so candidates other than ${digitStr} can be removed from those cells.`;
|
|
1024
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return null;
|
|
825
1028
|
}
|
|
826
1029
|
findHiddenSingle() {
|
|
827
1030
|
for (let row = 0; row < 9; row++) {
|
package/dist/utils.d.ts
CHANGED
|
File without changes
|
package/dist/utils.d.ts.map
CHANGED
|
File without changes
|
package/dist/utils.js
CHANGED
|
File without changes
|
package/dist/validate.d.ts
CHANGED
|
File without changes
|
package/dist/validate.d.ts.map
CHANGED
|
File without changes
|
package/dist/validate.js
CHANGED
|
File without changes
|
package/package.json
CHANGED