@wsabol/sudoku-solver 0.1.10 → 0.1.12
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/sudokuSolver.d.ts +47 -1
- package/dist/sudokuSolver.d.ts.map +1 -1
- package/dist/sudokuSolver.js +301 -122
- package/package.json +1 -1
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 {
|
|
@@ -43,6 +43,12 @@ export default class SudokuSolver {
|
|
|
43
43
|
private findHiddenSingleInCol;
|
|
44
44
|
private findHiddenSingleInBox;
|
|
45
45
|
private findPointingPairTriple;
|
|
46
|
+
/**
|
|
47
|
+
* Box/Line Reduction (Claiming): if all candidates for a digit in a row or column lie within
|
|
48
|
+
* a single box, that digit can be eliminated from the rest of that box outside the row/column.
|
|
49
|
+
* This is the complement of Pointing Pairs/Triples.
|
|
50
|
+
*/
|
|
51
|
+
private findBoxLineReduction;
|
|
46
52
|
/**
|
|
47
53
|
* Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
|
|
48
54
|
* Returns only empty cells where `digit` is still a candidate.
|
|
@@ -56,18 +62,58 @@ export default class SudokuSolver {
|
|
|
56
62
|
*/
|
|
57
63
|
private findFishOfSize;
|
|
58
64
|
private cellsSeeEachOther;
|
|
65
|
+
/** Empty cells in `house` that have `digit` as a candidate. */
|
|
66
|
+
private getCandidateCellsInHouse;
|
|
67
|
+
/**
|
|
68
|
+
* All empty cells with exactly two candidates (ALS of size 1), as a convenience view over
|
|
69
|
+
* `enumerateALS(1)`. The `a`/`b` fields are the two candidates in sorted order.
|
|
70
|
+
* Used by XY-Wing and W-Wing; future ALS-based techniques should consume `enumerateALS()`.
|
|
71
|
+
*/
|
|
72
|
+
private getBivalueCells;
|
|
59
73
|
/**
|
|
60
74
|
* XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
|
|
61
75
|
* sees both pincers. Any cell that sees both pincers cannot contain Z.
|
|
62
76
|
*/
|
|
63
77
|
private findXYWing;
|
|
78
|
+
/**
|
|
79
|
+
* W-Wing: two bi-value cells A and D sharing the same candidate pair {W, X}, connected by a
|
|
80
|
+
* strong link on one of those digits (X) through cells B and C (A sees B, B=X=C strong link,
|
|
81
|
+
* C sees D). Whatever value A takes, W must appear in one of the two endpoints, so W can be
|
|
82
|
+
* eliminated from any cell seen by both A and D.
|
|
83
|
+
*/
|
|
84
|
+
private findWWing;
|
|
64
85
|
private eachHouseInOrder;
|
|
86
|
+
/**
|
|
87
|
+
* Enumerate all Almost Locked Sets (ALS) of sizes 1..maxSize across all 27 houses.
|
|
88
|
+
*
|
|
89
|
+
* An ALS of size N is a set of N cells (all lying in one house) whose union of candidates
|
|
90
|
+
* has exactly N+1 digits. A bi-value cell is an ALS of size 1. Results are deduplicated:
|
|
91
|
+
* if the same cell set is found via multiple houses (e.g. a bi-value cell appears in its row,
|
|
92
|
+
* column, and box), it appears only once.
|
|
93
|
+
*
|
|
94
|
+
* The returned collection is the shared primitive for ALS-based techniques (ALS-XZ Rule,
|
|
95
|
+
* ALS-XY-Wing, ALS-Chain) and makes `getBivalueCells()` a special case: `enumerateALS(1)`
|
|
96
|
+
* returns all bi-value cells.
|
|
97
|
+
*/
|
|
98
|
+
private enumerateALS;
|
|
65
99
|
private findNakedSubsetElimination;
|
|
66
100
|
/** `houseCells` is one full row, column, or box (9 cells). */
|
|
67
101
|
private getHouseContext;
|
|
102
|
+
/**
|
|
103
|
+
* Enumerate all naked locked sets of size k in `houseCells`: groups of k empty cells whose
|
|
104
|
+
* candidate union is exactly k digits. The result drives elimination in `tryNakedSubsetInHouse`
|
|
105
|
+
* and can be queried independently by future algorithms.
|
|
106
|
+
*/
|
|
107
|
+
private findNakedLockedSetsInHouse;
|
|
68
108
|
private tryNakedSubsetInHouse;
|
|
69
109
|
private findHiddenSubsetElimination;
|
|
70
110
|
private findNakedHiddenQuadsElimination;
|
|
111
|
+
/**
|
|
112
|
+
* Enumerate all hidden locked sets of size k in `houseCells`: groups of k digits that appear
|
|
113
|
+
* only in exactly k empty cells of the house. The result drives elimination in
|
|
114
|
+
* `tryHiddenSubsetInHouse` and can be queried independently by future algorithms.
|
|
115
|
+
*/
|
|
116
|
+
private findHiddenLockedSetsInHouse;
|
|
71
117
|
private tryHiddenSubsetInHouse;
|
|
72
118
|
private findHiddenSingle;
|
|
73
119
|
}
|
|
@@ -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
|
];
|
|
@@ -325,10 +327,14 @@ export default class SudokuSolver {
|
|
|
325
327
|
return this.findHiddenSingle();
|
|
326
328
|
case "Pointing":
|
|
327
329
|
return this.findPointingPairTriple();
|
|
330
|
+
case "BoxLineReduction":
|
|
331
|
+
return this.findBoxLineReduction();
|
|
328
332
|
case "Fish":
|
|
329
333
|
return this.findFishOfSize(2);
|
|
330
334
|
case "XYWing":
|
|
331
335
|
return this.findXYWing();
|
|
336
|
+
case "WWing":
|
|
337
|
+
return this.findWWing();
|
|
332
338
|
case "Swordfish":
|
|
333
339
|
return this.findFishOfSize(3);
|
|
334
340
|
case "NakedSubset":
|
|
@@ -435,14 +441,9 @@ export default class SudokuSolver {
|
|
|
435
441
|
return null;
|
|
436
442
|
}
|
|
437
443
|
findHiddenSingleInBox(ibox) {
|
|
444
|
+
const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
|
|
438
445
|
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
|
-
}
|
|
446
|
+
const candidates = this.getCandidateCellsInHouse(boxHouse, value);
|
|
446
447
|
if (candidates.length === 1) {
|
|
447
448
|
const { row: r, col: c } = candidates[0];
|
|
448
449
|
let algorithm = "Hidden Single";
|
|
@@ -461,14 +462,9 @@ export default class SudokuSolver {
|
|
|
461
462
|
const boxStartRow = Math.floor(ibox / 3) * 3;
|
|
462
463
|
const boxStartCol = (ibox % 3) * 3;
|
|
463
464
|
const boxNum = ibox + 1;
|
|
465
|
+
const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
|
|
464
466
|
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
|
-
}
|
|
467
|
+
const cells = this.getCandidateCellsInHouse(boxHouse, digit);
|
|
472
468
|
if (cells.length < 2)
|
|
473
469
|
continue;
|
|
474
470
|
if (cells.every((c) => c.row === cells[0].row)) {
|
|
@@ -507,19 +503,44 @@ export default class SudokuSolver {
|
|
|
507
503
|
}
|
|
508
504
|
return null;
|
|
509
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* Box/Line Reduction (Claiming): if all candidates for a digit in a row or column lie within
|
|
508
|
+
* a single box, that digit can be eliminated from the rest of that box outside the row/column.
|
|
509
|
+
* This is the complement of Pointing Pairs/Triples.
|
|
510
|
+
*/
|
|
511
|
+
findBoxLineReduction() {
|
|
512
|
+
for (const byRow of [true, false]) {
|
|
513
|
+
const lineLabel = byRow ? "row" : "column";
|
|
514
|
+
for (let lineIdx = 0; lineIdx < 9; lineIdx++) {
|
|
515
|
+
const lineHouse = Array.from({ length: 9 }, (_, crossIdx) => byRow ? { row: lineIdx, col: crossIdx } : { row: crossIdx, col: lineIdx });
|
|
516
|
+
for (let digit = 1; digit <= 9; digit++) {
|
|
517
|
+
const cells = this.getCandidateCellsInHouse(lineHouse, digit);
|
|
518
|
+
if (cells.length < 2)
|
|
519
|
+
continue;
|
|
520
|
+
const firstBox = this.boxIndex(cells[0].row, cells[0].col);
|
|
521
|
+
if (!cells.every((c) => this.boxIndex(c.row, c.col) === firstBox))
|
|
522
|
+
continue;
|
|
523
|
+
const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(firstBox, idx));
|
|
524
|
+
const eliminations = this.getCandidateCellsInHouse(boxHouse, digit)
|
|
525
|
+
.filter(({ row, col }) => byRow ? row !== lineIdx : col !== lineIdx)
|
|
526
|
+
.map(({ row, col }) => ({ row, col, value: digit }));
|
|
527
|
+
if (eliminations.length > 0) {
|
|
528
|
+
const boxNum = firstBox + 1;
|
|
529
|
+
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}.`;
|
|
530
|
+
return this.finalizeElimination(eliminations, "Box/Line Reduction", reasoning);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
510
537
|
/**
|
|
511
538
|
* Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
|
|
512
539
|
* Returns only empty cells where `digit` is still a candidate.
|
|
513
540
|
*/
|
|
514
541
|
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;
|
|
542
|
+
const house = Array.from({ length: 9 }, (_, crossIdx) => byRow ? { row: lineIdx, col: crossIdx } : { row: crossIdx, col: lineIdx });
|
|
543
|
+
return this.getCandidateCellsInHouse(house, digit).map(({ row, col }) => byRow ? col : row);
|
|
523
544
|
}
|
|
524
545
|
/**
|
|
525
546
|
* Generic N-fish detector (X-Wing = 2, Swordfish = 3).
|
|
@@ -578,20 +599,28 @@ export default class SudokuSolver {
|
|
|
578
599
|
cellsSeeEachOther(r1, c1, r2, c2) {
|
|
579
600
|
return r1 === r2 || c1 === c2 || this.boxIndex(r1, c1) === this.boxIndex(r2, c2);
|
|
580
601
|
}
|
|
602
|
+
/** Empty cells in `house` that have `digit` as a candidate. */
|
|
603
|
+
getCandidateCellsInHouse(house, digit) {
|
|
604
|
+
return house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit));
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* All empty cells with exactly two candidates (ALS of size 1), as a convenience view over
|
|
608
|
+
* `enumerateALS(1)`. The `a`/`b` fields are the two candidates in sorted order.
|
|
609
|
+
* Used by XY-Wing and W-Wing; future ALS-based techniques should consume `enumerateALS()`.
|
|
610
|
+
*/
|
|
611
|
+
getBivalueCells() {
|
|
612
|
+
return this.enumerateALS(1).map(({ cells, digits }) => {
|
|
613
|
+
const [a, b] = [...digits].sort((x, y) => x - y);
|
|
614
|
+
const { row, col } = cells[0];
|
|
615
|
+
return { row, col, a, b };
|
|
616
|
+
});
|
|
617
|
+
}
|
|
581
618
|
/**
|
|
582
619
|
* XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
|
|
583
620
|
* sees both pincers. Any cell that sees both pincers cannot contain Z.
|
|
584
621
|
*/
|
|
585
622
|
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
|
-
}
|
|
623
|
+
const bivalue = this.getBivalueCells();
|
|
595
624
|
for (const pivot of bivalue) {
|
|
596
625
|
const X = pivot.a;
|
|
597
626
|
const Y = pivot.b;
|
|
@@ -652,6 +681,92 @@ export default class SudokuSolver {
|
|
|
652
681
|
}
|
|
653
682
|
return null;
|
|
654
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* W-Wing: two bi-value cells A and D sharing the same candidate pair {W, X}, connected by a
|
|
686
|
+
* strong link on one of those digits (X) through cells B and C (A sees B, B=X=C strong link,
|
|
687
|
+
* C sees D). Whatever value A takes, W must appear in one of the two endpoints, so W can be
|
|
688
|
+
* eliminated from any cell seen by both A and D.
|
|
689
|
+
*/
|
|
690
|
+
findWWing() {
|
|
691
|
+
const bivalue = this.getBivalueCells();
|
|
692
|
+
if (bivalue.length < 2)
|
|
693
|
+
return null;
|
|
694
|
+
const strongLinks = Array.from({ length: 10 }, () => []);
|
|
695
|
+
for (const house of this.eachHouseInOrder()) {
|
|
696
|
+
for (let d = 1; d <= 9; d++) {
|
|
697
|
+
const cands = house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d));
|
|
698
|
+
if (cands.length === 2) {
|
|
699
|
+
strongLinks[d].push([cands[0], cands[1]]);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
for (let i = 0; i < bivalue.length; i++) {
|
|
704
|
+
for (let j = i + 1; j < bivalue.length; j++) {
|
|
705
|
+
const A = bivalue[i];
|
|
706
|
+
const D = bivalue[j];
|
|
707
|
+
if (A.a !== D.a || A.b !== D.b)
|
|
708
|
+
continue;
|
|
709
|
+
const W = A.a;
|
|
710
|
+
const X = A.b;
|
|
711
|
+
// Try each digit as the link digit; the other is the digit to eliminate.
|
|
712
|
+
for (const [linkDigit, elimDigit] of [[X, W], [W, X]]) {
|
|
713
|
+
for (const [B, C] of strongLinks[linkDigit]) {
|
|
714
|
+
// B and C must not coincide with A or D (endpoints already have both digits).
|
|
715
|
+
if ((B.row === A.row && B.col === A.col) ||
|
|
716
|
+
(B.row === D.row && B.col === D.col) ||
|
|
717
|
+
(C.row === A.row && C.col === A.col) ||
|
|
718
|
+
(C.row === D.row && C.col === D.col))
|
|
719
|
+
continue;
|
|
720
|
+
// The chain is A -(weak)- B =X= C -(weak)- D.
|
|
721
|
+
// Check orientation: A sees B and C sees D, or A sees C and B sees D.
|
|
722
|
+
let nearA = null;
|
|
723
|
+
let nearD = null;
|
|
724
|
+
if (this.cellsSeeEachOther(A.row, A.col, B.row, B.col) &&
|
|
725
|
+
this.cellsSeeEachOther(C.row, C.col, D.row, D.col)) {
|
|
726
|
+
nearA = B;
|
|
727
|
+
nearD = C;
|
|
728
|
+
}
|
|
729
|
+
else if (this.cellsSeeEachOther(A.row, A.col, C.row, C.col) &&
|
|
730
|
+
this.cellsSeeEachOther(B.row, B.col, D.row, D.col)) {
|
|
731
|
+
nearA = C;
|
|
732
|
+
nearD = B;
|
|
733
|
+
}
|
|
734
|
+
if (!nearA || !nearD)
|
|
735
|
+
continue;
|
|
736
|
+
const eliminations = [];
|
|
737
|
+
for (let row = 0; row < 9; row++) {
|
|
738
|
+
for (let col = 0; col < 9; col++) {
|
|
739
|
+
if (row === A.row && col === A.col)
|
|
740
|
+
continue;
|
|
741
|
+
if (row === D.row && col === D.col)
|
|
742
|
+
continue;
|
|
743
|
+
if (this.board[row][col] !== 0)
|
|
744
|
+
continue;
|
|
745
|
+
if (!this.possiblesGrid[row][col].includes(elimDigit))
|
|
746
|
+
continue;
|
|
747
|
+
if (this.cellsSeeEachOther(row, col, A.row, A.col) &&
|
|
748
|
+
this.cellsSeeEachOther(row, col, D.row, D.col)) {
|
|
749
|
+
eliminations.push({ row, col, value: elimDigit });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (eliminations.length > 0) {
|
|
754
|
+
const reasoning = `W-Wing: r${A.row + 1}c${A.col + 1} and r${D.row + 1}c${D.col + 1} ` +
|
|
755
|
+
`both have candidates {${W}/${X}}. ` +
|
|
756
|
+
`r${nearA.row + 1}c${nearA.col + 1} and r${nearD.row + 1}c${nearD.col + 1} ` +
|
|
757
|
+
`form a strong link on ${linkDigit}. ` +
|
|
758
|
+
`r${A.row + 1}c${A.col + 1} sees r${nearA.row + 1}c${nearA.col + 1} ` +
|
|
759
|
+
`and r${D.row + 1}c${D.col + 1} sees r${nearD.row + 1}c${nearD.col + 1}, ` +
|
|
760
|
+
`so ${elimDigit} must appear in one of the two wing cells, ` +
|
|
761
|
+
`eliminating ${elimDigit} from any cell seen by both.`;
|
|
762
|
+
return this.finalizeElimination(eliminations, "W-Wing", reasoning);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
655
770
|
eachHouseInOrder() {
|
|
656
771
|
const houses = [];
|
|
657
772
|
for (let r = 0; r < 9; r++) {
|
|
@@ -669,6 +784,66 @@ export default class SudokuSolver {
|
|
|
669
784
|
}
|
|
670
785
|
return houses;
|
|
671
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* Enumerate all Almost Locked Sets (ALS) of sizes 1..maxSize across all 27 houses.
|
|
789
|
+
*
|
|
790
|
+
* An ALS of size N is a set of N cells (all lying in one house) whose union of candidates
|
|
791
|
+
* has exactly N+1 digits. A bi-value cell is an ALS of size 1. Results are deduplicated:
|
|
792
|
+
* if the same cell set is found via multiple houses (e.g. a bi-value cell appears in its row,
|
|
793
|
+
* column, and box), it appears only once.
|
|
794
|
+
*
|
|
795
|
+
* The returned collection is the shared primitive for ALS-based techniques (ALS-XZ Rule,
|
|
796
|
+
* ALS-XY-Wing, ALS-Chain) and makes `getBivalueCells()` a special case: `enumerateALS(1)`
|
|
797
|
+
* returns all bi-value cells.
|
|
798
|
+
*/
|
|
799
|
+
enumerateALS(maxSize = 4) {
|
|
800
|
+
const seen = new Set();
|
|
801
|
+
const result = [];
|
|
802
|
+
for (const house of this.eachHouseInOrder()) {
|
|
803
|
+
const empties = house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].length > 0);
|
|
804
|
+
const indices = [];
|
|
805
|
+
const digits = new Set();
|
|
806
|
+
const dfs = (start) => {
|
|
807
|
+
if (indices.length > 0) {
|
|
808
|
+
if (digits.size === indices.length + 1) {
|
|
809
|
+
const cells = indices.map((i) => empties[i]);
|
|
810
|
+
const key = cells
|
|
811
|
+
.map(({ row, col }) => row * 9 + col)
|
|
812
|
+
.sort((a, b) => a - b)
|
|
813
|
+
.join(",");
|
|
814
|
+
if (!seen.has(key)) {
|
|
815
|
+
seen.add(key);
|
|
816
|
+
result.push({ cells, digits: new Set(digits) });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// Prune: digit count already exceeds ALS threshold; adding more cells can only
|
|
820
|
+
// increase both the cell count and the digit count by the same amount or more,
|
|
821
|
+
// so the invariant digits.size === cells.length + 1 cannot be recovered.
|
|
822
|
+
if (digits.size > indices.length + 1)
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (indices.length === maxSize)
|
|
826
|
+
return;
|
|
827
|
+
for (let i = start; i < empties.length; i++) {
|
|
828
|
+
const { row, col } = empties[i];
|
|
829
|
+
const added = [];
|
|
830
|
+
for (const d of this.possiblesGrid[row][col]) {
|
|
831
|
+
if (!digits.has(d)) {
|
|
832
|
+
digits.add(d);
|
|
833
|
+
added.push(d);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
indices.push(i);
|
|
837
|
+
dfs(i + 1);
|
|
838
|
+
indices.pop();
|
|
839
|
+
for (const d of added)
|
|
840
|
+
digits.delete(d);
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
dfs(0);
|
|
844
|
+
}
|
|
845
|
+
return result;
|
|
846
|
+
}
|
|
672
847
|
findNakedSubsetElimination() {
|
|
673
848
|
for (const houseCells of this.eachHouseInOrder()) {
|
|
674
849
|
for (let k = 2; k <= 3; k++) {
|
|
@@ -688,57 +863,69 @@ export default class SudokuSolver {
|
|
|
688
863
|
const h = houseCells[0];
|
|
689
864
|
return { wherePhrase: `box ${boxNumber(h.row, h.col)}`, sameKindWord: "box" };
|
|
690
865
|
}
|
|
691
|
-
|
|
866
|
+
/**
|
|
867
|
+
* Enumerate all naked locked sets of size k in `houseCells`: groups of k empty cells whose
|
|
868
|
+
* candidate union is exactly k digits. The result drives elimination in `tryNakedSubsetInHouse`
|
|
869
|
+
* and can be queried independently by future algorithms.
|
|
870
|
+
*/
|
|
871
|
+
findNakedLockedSetsInHouse(houseCells, k) {
|
|
692
872
|
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;
|
|
873
|
+
if (empties.length < k)
|
|
874
|
+
return [];
|
|
875
|
+
const { wherePhrase, sameKindWord: house } = this.getHouseContext(houseCells);
|
|
876
|
+
const result = [];
|
|
699
877
|
const indices = [];
|
|
878
|
+
const union = new Set();
|
|
700
879
|
const dfs = (start) => {
|
|
701
880
|
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
|
-
}
|
|
708
|
-
}
|
|
709
|
-
if (union.size !== k) {
|
|
710
|
-
return null;
|
|
881
|
+
if (union.size === k) {
|
|
882
|
+
result.push({ cells: indices.map((i) => empties[i]), digits: new Set(union), house, wherePhrase });
|
|
711
883
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
// Prune: digit count already exceeds k; adding more cells can't reduce it.
|
|
887
|
+
if (union.size > k)
|
|
888
|
+
return;
|
|
889
|
+
for (let i = start; i < empties.length; i++) {
|
|
890
|
+
const { row, col } = empties[i];
|
|
891
|
+
const added = [];
|
|
892
|
+
for (const v of this.possiblesGrid[row][col]) {
|
|
893
|
+
if (!union.has(v)) {
|
|
894
|
+
union.add(v);
|
|
895
|
+
added.push(v);
|
|
723
896
|
}
|
|
724
897
|
}
|
|
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
898
|
indices.push(i);
|
|
734
|
-
|
|
899
|
+
dfs(i + 1);
|
|
735
900
|
indices.pop();
|
|
736
|
-
|
|
737
|
-
|
|
901
|
+
for (const v of added)
|
|
902
|
+
union.delete(v);
|
|
738
903
|
}
|
|
739
|
-
return null;
|
|
740
904
|
};
|
|
741
|
-
|
|
905
|
+
dfs(0);
|
|
906
|
+
return result;
|
|
907
|
+
}
|
|
908
|
+
tryNakedSubsetInHouse(houseCells, k) {
|
|
909
|
+
const algorithm = k === 2 ? "Naked Pair" : k === 3 ? "Naked Triple" : "Naked Quad";
|
|
910
|
+
for (const ls of this.findNakedLockedSetsInHouse(houseCells, k)) {
|
|
911
|
+
const subsetKeys = new Set(ls.cells.map(({ row, col }) => row * 9 + col));
|
|
912
|
+
const eliminations = [];
|
|
913
|
+
for (const { row, col } of houseCells) {
|
|
914
|
+
if (this.board[row][col] !== 0 || subsetKeys.has(row * 9 + col))
|
|
915
|
+
continue;
|
|
916
|
+
for (const d of ls.digits) {
|
|
917
|
+
if (this.possiblesGrid[row][col].includes(d)) {
|
|
918
|
+
eliminations.push({ row, col, value: d });
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (eliminations.length > 0) {
|
|
923
|
+
const digitStr = [...ls.digits].sort((a, b) => a - b).join("/");
|
|
924
|
+
const reasoning = `${algorithm} ${digitStr} in ${ls.wherePhrase} means those digits can be eliminated from the other cells in that same ${ls.house}.`;
|
|
925
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return null;
|
|
742
929
|
}
|
|
743
930
|
findHiddenSubsetElimination() {
|
|
744
931
|
for (const houseCells of this.eachHouseInOrder()) {
|
|
@@ -761,67 +948,59 @@ export default class SudokuSolver {
|
|
|
761
948
|
}
|
|
762
949
|
return null;
|
|
763
950
|
}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
951
|
+
/**
|
|
952
|
+
* Enumerate all hidden locked sets of size k in `houseCells`: groups of k digits that appear
|
|
953
|
+
* only in exactly k empty cells of the house. The result drives elimination in
|
|
954
|
+
* `tryHiddenSubsetInHouse` and can be queried independently by future algorithms.
|
|
955
|
+
*/
|
|
956
|
+
findHiddenLockedSetsInHouse(houseCells, k) {
|
|
957
|
+
const { wherePhrase, sameKindWord: house } = this.getHouseContext(houseCells);
|
|
958
|
+
const result = [];
|
|
767
959
|
const digitIndices = [];
|
|
768
|
-
const
|
|
960
|
+
const dfs = (start) => {
|
|
769
961
|
if (digitIndices.length === k) {
|
|
770
962
|
const digits = digitIndices.map((i) => COMPLETE[i]);
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
return null;
|
|
782
|
-
}
|
|
963
|
+
// Every chosen digit must appear in at least one empty cell of the house.
|
|
964
|
+
const allPresent = digits.every((d) => houseCells.some(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d)));
|
|
965
|
+
if (!allPresent)
|
|
966
|
+
return;
|
|
967
|
+
// Cells that contain at least one of the k digits — the "reserved" cells.
|
|
968
|
+
const cells = houseCells.filter(({ row, col }) => this.board[row][col] === 0 &&
|
|
969
|
+
digits.some((d) => this.possiblesGrid[row][col].includes(d)));
|
|
970
|
+
// Hidden set condition: the k digits are confined to exactly k cells.
|
|
971
|
+
if (cells.length === k) {
|
|
972
|
+
result.push({ cells, digits: new Set(digits), house, wherePhrase });
|
|
783
973
|
}
|
|
784
|
-
|
|
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;
|
|
798
|
-
}
|
|
799
|
-
const eliminations = [];
|
|
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);
|
|
974
|
+
return;
|
|
814
975
|
}
|
|
815
976
|
for (let i = start; i < 9; i++) {
|
|
816
977
|
digitIndices.push(i);
|
|
817
|
-
|
|
978
|
+
dfs(i + 1);
|
|
818
979
|
digitIndices.pop();
|
|
819
|
-
if (res)
|
|
820
|
-
return res;
|
|
821
980
|
}
|
|
822
|
-
return null;
|
|
823
981
|
};
|
|
824
|
-
|
|
982
|
+
dfs(0);
|
|
983
|
+
return result;
|
|
984
|
+
}
|
|
985
|
+
tryHiddenSubsetInHouse(houseCells, k) {
|
|
986
|
+
const algorithm = k === 2 ? "Hidden Pair" : k === 3 ? "Hidden Triple" : "Hidden Quad";
|
|
987
|
+
for (const ls of this.findHiddenLockedSetsInHouse(houseCells, k)) {
|
|
988
|
+
const eliminations = [];
|
|
989
|
+
for (const { row, col } of ls.cells) {
|
|
990
|
+
for (const v of this.possiblesGrid[row][col]) {
|
|
991
|
+
if (!ls.digits.has(v)) {
|
|
992
|
+
eliminations.push({ row, col, value: v });
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (eliminations.length > 0) {
|
|
997
|
+
const digitStr = [...ls.digits].sort((a, b) => a - b).join("/");
|
|
998
|
+
const cellPhrase = ls.cells.map(({ row, col }) => `r${row + 1}c${col + 1}`).join(", ");
|
|
999
|
+
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.`;
|
|
1000
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return null;
|
|
825
1004
|
}
|
|
826
1005
|
findHiddenSingle() {
|
|
827
1006
|
for (let row = 0; row < 9; row++) {
|
package/package.json
CHANGED