@wsabol/sudoku-solver 0.1.9 → 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 +53 -1
- package/dist/sudokuSolver.d.ts.map +1 -1
- package/dist/sudokuSolver.js +372 -113
- 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" | "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.
|
|
@@ -55,13 +61,59 @@ export default class SudokuSolver {
|
|
|
55
61
|
* Checks row-based orientation first, then column-based.
|
|
56
62
|
*/
|
|
57
63
|
private findFishOfSize;
|
|
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;
|
|
73
|
+
/**
|
|
74
|
+
* XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
|
|
75
|
+
* sees both pincers. Any cell that sees both pincers cannot contain Z.
|
|
76
|
+
*/
|
|
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;
|
|
58
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;
|
|
59
99
|
private findNakedSubsetElimination;
|
|
60
100
|
/** `houseCells` is one full row, column, or box (9 cells). */
|
|
61
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;
|
|
62
108
|
private tryNakedSubsetInHouse;
|
|
63
109
|
private findHiddenSubsetElimination;
|
|
64
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;
|
|
65
117
|
private tryHiddenSubsetInHouse;
|
|
66
118
|
private findHiddenSingle;
|
|
67
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,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,9 +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",
|
|
46
|
+
"XYWing",
|
|
47
|
+
"WWing",
|
|
45
48
|
"Swordfish",
|
|
46
49
|
"NakedHiddenQuads",
|
|
47
50
|
];
|
|
@@ -324,8 +327,14 @@ export default class SudokuSolver {
|
|
|
324
327
|
return this.findHiddenSingle();
|
|
325
328
|
case "Pointing":
|
|
326
329
|
return this.findPointingPairTriple();
|
|
330
|
+
case "BoxLineReduction":
|
|
331
|
+
return this.findBoxLineReduction();
|
|
327
332
|
case "Fish":
|
|
328
333
|
return this.findFishOfSize(2);
|
|
334
|
+
case "XYWing":
|
|
335
|
+
return this.findXYWing();
|
|
336
|
+
case "WWing":
|
|
337
|
+
return this.findWWing();
|
|
329
338
|
case "Swordfish":
|
|
330
339
|
return this.findFishOfSize(3);
|
|
331
340
|
case "NakedSubset":
|
|
@@ -432,14 +441,9 @@ export default class SudokuSolver {
|
|
|
432
441
|
return null;
|
|
433
442
|
}
|
|
434
443
|
findHiddenSingleInBox(ibox) {
|
|
444
|
+
const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
|
|
435
445
|
for (let value = 1; value <= 9; value++) {
|
|
436
|
-
const candidates =
|
|
437
|
-
for (let idx = 0; idx < 9; idx++) {
|
|
438
|
-
const { row, col } = this.boxToPuzzle(ibox, idx);
|
|
439
|
-
if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(value)) {
|
|
440
|
-
candidates.push({ row, col });
|
|
441
|
-
}
|
|
442
|
-
}
|
|
446
|
+
const candidates = this.getCandidateCellsInHouse(boxHouse, value);
|
|
443
447
|
if (candidates.length === 1) {
|
|
444
448
|
const { row: r, col: c } = candidates[0];
|
|
445
449
|
let algorithm = "Hidden Single";
|
|
@@ -458,14 +462,9 @@ export default class SudokuSolver {
|
|
|
458
462
|
const boxStartRow = Math.floor(ibox / 3) * 3;
|
|
459
463
|
const boxStartCol = (ibox % 3) * 3;
|
|
460
464
|
const boxNum = ibox + 1;
|
|
465
|
+
const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
|
|
461
466
|
for (let digit = 1; digit <= 9; digit++) {
|
|
462
|
-
const cells =
|
|
463
|
-
for (let idx = 0; idx < 9; idx++) {
|
|
464
|
-
const { row, col } = this.boxToPuzzle(ibox, idx);
|
|
465
|
-
if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
|
|
466
|
-
cells.push({ row, col });
|
|
467
|
-
}
|
|
468
|
-
}
|
|
467
|
+
const cells = this.getCandidateCellsInHouse(boxHouse, digit);
|
|
469
468
|
if (cells.length < 2)
|
|
470
469
|
continue;
|
|
471
470
|
if (cells.every((c) => c.row === cells[0].row)) {
|
|
@@ -504,19 +503,44 @@ export default class SudokuSolver {
|
|
|
504
503
|
}
|
|
505
504
|
return null;
|
|
506
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
|
+
}
|
|
507
537
|
/**
|
|
508
538
|
* Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
|
|
509
539
|
* Returns only empty cells where `digit` is still a candidate.
|
|
510
540
|
*/
|
|
511
541
|
coverIndicesForLine(lineIdx, digit, byRow) {
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
const [row, col] = byRow ? [lineIdx, crossIdx] : [crossIdx, lineIdx];
|
|
515
|
-
if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
|
|
516
|
-
result.push(crossIdx);
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
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);
|
|
520
544
|
}
|
|
521
545
|
/**
|
|
522
546
|
* Generic N-fish detector (X-Wing = 2, Swordfish = 3).
|
|
@@ -572,6 +596,177 @@ export default class SudokuSolver {
|
|
|
572
596
|
}
|
|
573
597
|
return null;
|
|
574
598
|
}
|
|
599
|
+
cellsSeeEachOther(r1, c1, r2, c2) {
|
|
600
|
+
return r1 === r2 || c1 === c2 || this.boxIndex(r1, c1) === this.boxIndex(r2, c2);
|
|
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
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
|
|
620
|
+
* sees both pincers. Any cell that sees both pincers cannot contain Z.
|
|
621
|
+
*/
|
|
622
|
+
findXYWing() {
|
|
623
|
+
const bivalue = this.getBivalueCells();
|
|
624
|
+
for (const pivot of bivalue) {
|
|
625
|
+
const X = pivot.a;
|
|
626
|
+
const Y = pivot.b;
|
|
627
|
+
// Candidate pincers: bi-value cells that see the pivot and share exactly one digit with it.
|
|
628
|
+
const pincersX = [];
|
|
629
|
+
const pincersY = [];
|
|
630
|
+
for (const cell of bivalue) {
|
|
631
|
+
if (cell.row === pivot.row && cell.col === pivot.col)
|
|
632
|
+
continue;
|
|
633
|
+
if (!this.cellsSeeEachOther(pivot.row, pivot.col, cell.row, cell.col))
|
|
634
|
+
continue;
|
|
635
|
+
const { a, b } = cell;
|
|
636
|
+
// Cell has {X, Z} where Z ≠ Y
|
|
637
|
+
if (a === X && b !== Y)
|
|
638
|
+
pincersX.push({ row: cell.row, col: cell.col, Z: b });
|
|
639
|
+
if (b === X && a !== Y)
|
|
640
|
+
pincersX.push({ row: cell.row, col: cell.col, Z: a });
|
|
641
|
+
// Cell has {Y, Z} where Z ≠ X
|
|
642
|
+
if (a === Y && b !== X)
|
|
643
|
+
pincersY.push({ row: cell.row, col: cell.col, Z: b });
|
|
644
|
+
if (b === Y && a !== X)
|
|
645
|
+
pincersY.push({ row: cell.row, col: cell.col, Z: a });
|
|
646
|
+
}
|
|
647
|
+
for (const p1 of pincersX) {
|
|
648
|
+
for (const p2 of pincersY) {
|
|
649
|
+
if (p1.Z !== p2.Z)
|
|
650
|
+
continue;
|
|
651
|
+
if (p1.row === p2.row && p1.col === p2.col)
|
|
652
|
+
continue;
|
|
653
|
+
const Z = p1.Z;
|
|
654
|
+
const eliminations = [];
|
|
655
|
+
for (let row = 0; row < 9; row++) {
|
|
656
|
+
for (let col = 0; col < 9; col++) {
|
|
657
|
+
if (row === p1.row && col === p1.col)
|
|
658
|
+
continue;
|
|
659
|
+
if (row === p2.row && col === p2.col)
|
|
660
|
+
continue;
|
|
661
|
+
if (this.board[row][col] !== 0)
|
|
662
|
+
continue;
|
|
663
|
+
if (!this.possiblesGrid[row][col].includes(Z))
|
|
664
|
+
continue;
|
|
665
|
+
if (this.cellsSeeEachOther(row, col, p1.row, p1.col) &&
|
|
666
|
+
this.cellsSeeEachOther(row, col, p2.row, p2.col)) {
|
|
667
|
+
eliminations.push({ row, col, value: Z });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (eliminations.length > 0) {
|
|
672
|
+
const reasoning = `XY-Wing: pivot r${pivot.row + 1}c${pivot.col + 1} (${X}/${Y}) links ` +
|
|
673
|
+
`pincers r${p1.row + 1}c${p1.col + 1} (${X}/${Z}) and ` +
|
|
674
|
+
`r${p2.row + 1}c${p2.col + 1} (${Y}/${Z}). ` +
|
|
675
|
+
`Whatever value the pivot takes, ${Z} must appear in one of the pincers, ` +
|
|
676
|
+
`so ${Z} cannot appear in any cell seen by both.`;
|
|
677
|
+
return this.finalizeElimination(eliminations, "XY-Wing", reasoning);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
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
|
+
}
|
|
575
770
|
eachHouseInOrder() {
|
|
576
771
|
const houses = [];
|
|
577
772
|
for (let r = 0; r < 9; r++) {
|
|
@@ -589,6 +784,66 @@ export default class SudokuSolver {
|
|
|
589
784
|
}
|
|
590
785
|
return houses;
|
|
591
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
|
+
}
|
|
592
847
|
findNakedSubsetElimination() {
|
|
593
848
|
for (const houseCells of this.eachHouseInOrder()) {
|
|
594
849
|
for (let k = 2; k <= 3; k++) {
|
|
@@ -608,57 +863,69 @@ export default class SudokuSolver {
|
|
|
608
863
|
const h = houseCells[0];
|
|
609
864
|
return { wherePhrase: `box ${boxNumber(h.row, h.col)}`, sameKindWord: "box" };
|
|
610
865
|
}
|
|
611
|
-
|
|
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) {
|
|
612
872
|
const empties = houseCells.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].length > 0);
|
|
613
|
-
if (empties.length < k)
|
|
614
|
-
return
|
|
615
|
-
}
|
|
616
|
-
const
|
|
617
|
-
const { wherePhrase, sameKindWord } = this.getHouseContext(houseCells);
|
|
618
|
-
const n = empties.length;
|
|
873
|
+
if (empties.length < k)
|
|
874
|
+
return [];
|
|
875
|
+
const { wherePhrase, sameKindWord: house } = this.getHouseContext(houseCells);
|
|
876
|
+
const result = [];
|
|
619
877
|
const indices = [];
|
|
878
|
+
const union = new Set();
|
|
620
879
|
const dfs = (start) => {
|
|
621
880
|
if (indices.length === k) {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
for (const { row, col } of subset) {
|
|
625
|
-
for (const v of this.possiblesGrid[row][col]) {
|
|
626
|
-
union.add(v);
|
|
627
|
-
}
|
|
881
|
+
if (union.size === k) {
|
|
882
|
+
result.push({ cells: indices.map((i) => empties[i]), digits: new Set(union), house, wherePhrase });
|
|
628
883
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
eliminations.push({ row, col, value: d });
|
|
642
|
-
}
|
|
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);
|
|
643
896
|
}
|
|
644
897
|
}
|
|
645
|
-
if (eliminations.length === 0) {
|
|
646
|
-
return null;
|
|
647
|
-
}
|
|
648
|
-
const digitStr = [...union].sort((a, b) => a - b).join("/");
|
|
649
|
-
const reasoning = `${algorithm} ${digitStr} in ${wherePhrase} means those digits can be eliminated from the other cells in that same ${sameKindWord}.`;
|
|
650
|
-
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
651
|
-
}
|
|
652
|
-
for (let i = start; i < n; i++) {
|
|
653
898
|
indices.push(i);
|
|
654
|
-
|
|
899
|
+
dfs(i + 1);
|
|
655
900
|
indices.pop();
|
|
656
|
-
|
|
657
|
-
|
|
901
|
+
for (const v of added)
|
|
902
|
+
union.delete(v);
|
|
658
903
|
}
|
|
659
|
-
return null;
|
|
660
904
|
};
|
|
661
|
-
|
|
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;
|
|
662
929
|
}
|
|
663
930
|
findHiddenSubsetElimination() {
|
|
664
931
|
for (const houseCells of this.eachHouseInOrder()) {
|
|
@@ -681,67 +948,59 @@ export default class SudokuSolver {
|
|
|
681
948
|
}
|
|
682
949
|
return null;
|
|
683
950
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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 = [];
|
|
687
959
|
const digitIndices = [];
|
|
688
|
-
const
|
|
960
|
+
const dfs = (start) => {
|
|
689
961
|
if (digitIndices.length === k) {
|
|
690
962
|
const digits = digitIndices.map((i) => COMPLETE[i]);
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
return null;
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
const reserved = [];
|
|
705
|
-
for (const { row, col } of houseCells) {
|
|
706
|
-
if (this.board[row][col] !== 0) {
|
|
707
|
-
continue;
|
|
708
|
-
}
|
|
709
|
-
for (const d of digits) {
|
|
710
|
-
if (this.possiblesGrid[row][col].includes(d)) {
|
|
711
|
-
reserved.push({ row, col });
|
|
712
|
-
break;
|
|
713
|
-
}
|
|
714
|
-
}
|
|
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 });
|
|
715
973
|
}
|
|
716
|
-
|
|
717
|
-
return null;
|
|
718
|
-
}
|
|
719
|
-
const eliminations = [];
|
|
720
|
-
for (const { row, col } of reserved) {
|
|
721
|
-
for (const v of this.possiblesGrid[row][col]) {
|
|
722
|
-
if (!digitSet.has(v)) {
|
|
723
|
-
eliminations.push({ row, col, value: v });
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
if (eliminations.length === 0) {
|
|
728
|
-
return null;
|
|
729
|
-
}
|
|
730
|
-
const digitStr = [...digits].sort((a, b) => a - b).join("/");
|
|
731
|
-
const cellPhrase = reserved.map(({ row, col }) => `r${row + 1}c${col + 1}`).join(", ");
|
|
732
|
-
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.`;
|
|
733
|
-
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
974
|
+
return;
|
|
734
975
|
}
|
|
735
976
|
for (let i = start; i < 9; i++) {
|
|
736
977
|
digitIndices.push(i);
|
|
737
|
-
|
|
978
|
+
dfs(i + 1);
|
|
738
979
|
digitIndices.pop();
|
|
739
|
-
if (res)
|
|
740
|
-
return res;
|
|
741
980
|
}
|
|
742
|
-
return null;
|
|
743
981
|
};
|
|
744
|
-
|
|
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;
|
|
745
1004
|
}
|
|
746
1005
|
findHiddenSingle() {
|
|
747
1006
|
for (let row = 0; row < 9; row++) {
|
package/package.json
CHANGED