Tags

, , , , ,

I’ve never been particularly interested in puzzle games. Figuring out software has filled that niche for me (plenty puzzling enough). So, I’ve never done a Sudoku puzzle. Recently I read a post about Tredoku, which is a kind of three-dimensional Sudoku.

In that post was an unsolved Tredoku puzzle. I wasn’t tempted to try to solve it myself, but I did think it might be fun to see if I could write some Python to do it.

Figure 1. Tredoku puzzle.

It took three tries to get a good algorithm. I needed a fourth pass because I didn’t know the Sudoku rule that the 3×3 blocks also must contain all nine digits. My version 3.0 code found a solution that works for rows and columns, but not within the 3×3 blocks.

Version 1.0 worked… I think. It was a brute force algorithm, and solving Sudoku is not amenable to brute force because of all the possible combinations. I ran that version for hours and it only scratched the surface.

Version 2.0 was an attempt to rethink the whole approach. I thought I’d start with the cells where a row and column intersect, check the clues in the intersecting paths, and see what digits remain that would be possible to fill the blank. I hoped to find at least one blank with only one possible digit. I thought filling that in might give me another, and so on.

That code found only one cell with only one possible digit. It’s the bottom row, in the center between the 3 and 2. That cell can only be a 1. But filling it in didn’t give me another cell with only one possibility. The best was several with two.

Version 2.0 was such a kludge I started over with a fresh approach. This version identifies all blank cells and what digits were possible for them (in some cases, quite a few). Here’s a listing sorted from least number of possibilities to most:

1,7: [0,9,14   ] {6       } [12345789]
3,1: [2,12     ] {1       } [23456789]
3,5: [2,7,13   ] {8       } [12345679]
7,1: [3,15     ] {7       } [12345689]
9,5: [5,10,16  ] {1       } [23456789]
2,2: [1,12     ] {19      } [2345678]
2,7: [1,9,14   ] {16      } [2345789]
3,4: [2,6,13   ] {58      } [1234679]
3,7: [2,9,14   ] {12      } [3456789]
4,4: [6,9,18   ] {69      } [1234578]
7,5: [3,10,16  ] {16      } [2345789]
8,4: [4,9,16   ] {67      } [1234589]
9,1: [5,15     ] {79      } [1234568]
1,9: [0,11,14  ] {368     } [124579]
2,4: [1,6,13   ] {356     } [124789]
5,4: [6,10,18  ] {368     } [124579]
8,5: [4,10,16  ] {456     } [123789]
8,6: [4,11,16  ] {456     } [123789]
8,7: [4,6,17   ] {689     } [123457]
8,9: [4,8,17   ] {678     } [123459]
9,8: [5,7,17   ] {179     } [234568]
1,5: [0,7,13   ] {3678    } [12459]
1,6: [0,8,13   ] {3678    } [12459]
2,8: [1,10,14  ] {1356    } [24789]
2,9: [1,11,14  ] {1356    } [24789]
3,9: [2,11,14  ] {1458    } [23679]
5,6: [8,10,18  ] {1368    } [24579]
6,5: [7,11,18  ] {1368    } [24579]
6,6: [8,11,18  ] {1368    } [24579]
7,8: [3,7,17   ] {1367    } [24589]
7,9: [3,8,17   ] {1367    } [24589]
8,8: [4,7,17   ] {6789    } [12345]
5,5: [7,10,18  ] {12368   } [4579]

From left-to-right: The pair of numbers is the cell row and column. The first square brackets contain the indexes of path definitions that cross the cell. The numbers in the curly braces are the possible digits. The second square brackets contain the digits found in the paths that cross the cell.

Note that five cells have only one possibility. Those five cells must have those digits.

As it turns out, plugging those values in reveals new cells that have only one possible digit. This continues all the way to a solution, at least for the Tredoku puzzle I have. It’s possible it’s an easy puzzle to solve.


There’s a lot to explain, starting with how we represent a three-dimensional Tredoku puzzle. We obviously can’t treat it as a square grid indexed by row and column. We need some way to represent the puzzle shape and the relevant paths.

Figure 2. Row and column labels. Blue numbers are row and columns. Red numbers are path definition indexes.

We’ll label the cells with rows and columns according to a reasonable scheme and use a dictionary to hold them. The dictionary keys are the (row, column) tuples that label the Tredoku cells. The dictionary items are the cell contents, a digit from one to nine. Blank cells are set to zero.

Figure 2 shows the labeling scheme. Compare it with Figure 1 above. The diamond cells in the middle are the visually horizontal 3×3 grid. The lines leading through them indicate the (vertical) paths.

Note that there are six readily apparent horizontal paths, three above, three below, each with nine cells. Each row needs to have one of each digit.

Less obvious are the six vertical paths on the right that run from the top row of the puzzle to the bottom row. Note how the left three cross the horizontal grid in a different direction from the three on the right. As with the row paths, each vertical path must have one of each digit.

These 12 paths comprise the straight nine-cell paths through the puzzle. We also treat as paths each 3×3 grid of the puzzle because these grids must also have one of each digit.

We’re going to solve the puzzle by making a list of all the blank cells (the ones set to zero). For each zero we find, we check the row and column that cross it for what digits have already been used. We also check the applicable 3×3 grid for digits already in play. When we do this for the first time, we get the listing shown above. Which, as mentioned, already shows five cells that — with the clues as given — can only have one value.

The general process is to analyze the puzzle as just described and plug in a possible value. Ideally a required value, but if only choices with multiple options exist, pick one with the smallest number of options and iterate over trying each in turn. Then recurse and repeat until — hopefully — the puzzle is filled in. If we get stuck, back up to a point that had multiple options and try one of the others.


Here’s how we’ll define the puzzle structure:

001| BlankPuzzle = {
002| (1,1):0,(1,2):0,(1,3):0,(1,4):0,(1,5):0,(1,6):0,(1,7):0,(1,8):0,(1,9):0,
003| (2,1):0,(2,2):0,(2,3):0,(2,4):0,(2,5):0,(2,6):0,(2,7):0,(2,8):0,(2,9):0,
004| (3,1):0,(3,2):0,(3,3):0,(3,4):0,(3,5):0,(3,6):0,(3,7):0,(3,8):0,(3,9):0,
005|                         (4,4):0,(4,5):0,(4,6):0,
006|                         (5,4):0,(5,5):0,(5,6):0,
007|                         (6,4):0,(6,5):0,(6,6):0,
008| (7,1):0,(7,2):0,(7,3):0,(7,4):0,(7,5):0,(7,6):0,(7,7):0,(7,8):0,(7,9):0,
009| (8,1):0,(8,2):0,(8,3):0,(8,4):0,(8,5):0,(8,6):0,(8,7):0,(8,8):0,(8,9):0,
010| (9,1):0,(9,2):0,(9,3):0,(9,4):0,(9,5):0,(9,6):0,(9,7):0,(9,8):0,(9,9):0,
011| }
012| 
013| PuzzlePaths = [
014|     [(1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(1,7),(1,8),(1,9)], # Rows
015|     [(2,1),(2,2),(2,3),(2,4),(2,5),(2,6),(2,7),(2,8),(2,9)],
016|     [(3,1),(3,2),(3,3),(3,4),(3,5),(3,6),(3,7),(3,8),(3,9)],
017|     [(7,1),(7,2),(7,3),(7,4),(7,5),(7,6),(7,7),(7,8),(7,9)],
018|     [(8,1),(8,2),(8,3),(8,4),(8,5),(8,6),(8,7),(8,8),(8,9)],
019|     [(9,1),(9,2),(9,3),(9,4),(9,5),(9,6),(9,7),(9,8),(9,9)],
020| 
021|     [(1,4),(2,4),(3,4),(4,4),(5,4),(6,4),(7,7),(8,7),(9,7)], # Columns
022|     [(1,5),(2,5),(3,5),(4,5),(5,5),(6,5),(7,8),(8,8),(9,8)],
023|     [(1,6),(2,6),(3,6),(4,6),(5,6),(6,6),(7,9),(8,9),(9,9)],
024|     [(1,7),(2,7),(3,7),(4,6),(4,5),(4,4),(7,4),(8,4),(9,4)],
025|     [(1,8),(2,8),(3,8),(5,6),(5,5),(5,4),(7,5),(8,5),(9,5)],
026|     [(1,9),(2,9),(3,9),(6,6),(6,5),(6,4),(7,6),(8,6),(9,6)],
027| 
028|     [(1,1),(1,2),(1,3),(2,1),(2,2),(2,3),(3,1),(3,2),(3,3)], # Squares
029|     [(1,4),(1,5),(1,6),(2,4),(2,5),(2,6),(3,4),(3,5),(3,6)],
030|     [(1,7),(1,8),(1,9),(2,7),(2,8),(2,9),(3,7),(3,8),(3,9)],
031|     [(7,1),(7,2),(7,3),(8,1),(8,2),(8,3),(9,1),(9,2),(9,3)],
032|     [(7,4),(7,5),(7,6),(8,4),(8,5),(8,6),(9,4),(9,5),(9,6)],
033|     [(7,7),(7,8),(7,9),(8,7),(8,8),(8,9),(9,7),(9,8),(9,9)],
034|     [(4,4),(4,5),(4,6),(5,4),(5,5),(5,6),(6,4),(6,5),(6,6)],
035| ]
036| 

The BlankPuzzle dictionary represents the shape of the puzzle as described above. It’s keyed with (row,col) tuples identifying the cell. All values are set to zero (no clues in a blank puzzle).

The  PuzzlePaths list contains the row, column, and square grid paths also described above. Each path is a list of (row,col) tuples identifying the cells of the path.

We also need some general data and helper functions:

001| from sys import stdout
002| 
003| Symbols = {n for n in range(1,10)}
004| RowSum = sum(Symbols)
005| 
006| GetRow = lambda pz,pd: [pz[k] for k in pd]
007| 
008| def join_puzzle (clues, blank=None) -> dict:
009|     ”’Create a puzzle from a spec and a blank.”’
010|     if blank is None: blank = BlankPuzzle
011| 
012|     # Copy the blank…
013|     new_puzl = blank.copy()
014| 
015|     # Load the clues into blank puzzle…
016|     for key in clues:
017|         new_puzl[key] = clues[key]
018| 
019|     # Return joined puzzle…
020|     return new_puzl
021| 
022| def test_puzzle (puzl, pdef=None) -> bool:
023|     ”’Test puzzle paths for being solved. Returns True or False.”’
024|     if pdef is None: pdef = PuzzlePaths
025| 
026|     # For every path definition…
027|     for px,pd in enumerate(pdef):
028| 
029|         # Get the values along the path…
030|         row = GetRow(puzl, pd)
031|         # And total them…
032|         tot = sum(row)
033| 
034|         # Fail on first mismatch…
035|         if tot != RowSum:
036|             return False
037| 
038|     # All good…
039|     return True
040| 
041| def print_puzzle (puzl, pdef=None, sout=None) -> list:
042|     ”’List and add puzzle rows and columns.”’
043|     fout = stdout if sout is None else sout
044|     if pdef is None: pdef = PuzzlePaths
045| 
046|     accum = []
047|     # For every path definition…
048|     for px,pd in enumerate(pdef):
049| 
050|         # Get the values along the path…
051|         row = GetRow(puzl, pd)
052| 
053|         # Total row values; add sum to accumulator…
054|         tot = sum(row)
055|         accum.append((tot, row))
056| 
057|         # Print the puzzle row…
058|         status = ‘Okay!’ if sum(row) == RowSum else ‘x’
059|         out = f’Path {px+1:2d}: {row} = {tot:2d} {status}’
060|         print(out, file=fout)
061| 
062|     print(file=fout)
063| 
064|     # Return list of rows…
065|     return accum
066| 

Symbols (line #3) is a set containing the nine digits. Its use becomes apparent later. RowSum (line #4) is the sum of the nine digits (1+ … +9=45). GetRow (line #6) is a lambda function that, given a puzzle (pz) and path definition (pd) returns the puzzle values from that path.

The join_puzzle function (lines #8 to #20) merges the clues of a puzzle specification with a blank puzzle to create the populated puzzle for solving. It defaults to using BlankPuzzle.

The test_puzzle function (lines #22 to #39) scans all the puzzle paths to see if they sum to RowSum.

The print_puzzle function (lines #41 to #65) lists the puzzle to an output stream. By default, stdout, but a file stream can be provided.

We’ll define a class for the zero-cell analysis data:

001| class cellinfo:
002|     def __init__ (self, key):
003|         self.key = key
004|         self.pos = set()
005|         self.pds = []
006|         self.gots = []
007| 
008|     @property
009|     def size (self):
010|         ”’Return number of possibilities.”’
011|         return len(self.pos)
012| 
013|     def __len__ (self):
014|         ”’Return length of path defs list.”’
015|         return len(self.pds)
016| 
017|     def __iter__ (self):
018|         ”’Return iterator for path defs list.”’
019|         return iter(self.pds)
020| 
021|     def __getitem__ (self, ix):
022|         ”’Return item from path defs list.”’
023|         self.pds[ix]
024| 
025|     def __repr__ (self):
026|         ”’Return simple string version.”’
027|         return f'{self.key}: {self.pos}’
028| 
029|     def __str__ (self):
030|         ”’Return string version.”’
031|         s1 = ‘,’.join(str(n) for n in self.key)
032|         s2 = ‘,’.join(str(n) for n in self.pds)
033|         s3 = .join(str(n) for n in sorted(self.pos))
034|         gs = set()
035|         for g in self.gots:
036|             gs |= g
037|         s4 = .join(str(n) for n in sorted(gs))
038|         return f'{s1}: [{s2:<9s}] {{{s3:8s}}} [{s4}]’
039| 

Object instances have five properties. The key property holds the cell’s (row,col) label. The pos property (possibles) is a set of possible digits for this cell. The pds property (path defs) is a list of indexes to the path definitions that apply to this cell. The gots property is a list of digits found in the paths for the cell. The size property (lines #8 to #11) returns the number of possible digits.

Each cell has at least two applicable path definitions, a row or column that crosses it and the 3×3 grid the cell is in. Most cells have a row and column crossing them, so have three applicable path definitions.

The dunder len method returns the number of path definitions, the dunder iter method returns an iterator over the path definitions, and dunder getitem allows read access to a specified one.


Here’s the function that scans for blank cells and determines what digits are possibilities:

001| from sys import stdout
002| from examples import Symbols, GetRow
003| 
004| def analyze_puzzle (puzl, pdef, sout=None) -> dict:
005|     ”’Find and analyse puzzle.”’
006|     fout = stdout if sout is None else sout
007| 
008|     zcells = dict()
009| 
010|     # For each path definition…
011|     for px,pd in enumerate(pdef):
012|         row = GetRow(puzl, pd)
013|         got = set(row)
014|         if 0 in got:
015|             got.discard(0)
016| 
017|         # For each cell in the path…
018|         for key in pd:
019|             # Call value (skip filled in cells)…
020|             val = puzl[key]
021|             if 0 < val:
022|                 continue
023|             # Add pdef index to cell info…
024|             if key not in zcells: zcells[key] = cellinfo(key)
025|             zcells[key].pds.append(px)
026|             zcells[key].gots.append(got)
027| 
028|     # Populate possible entries for each zero…
029|     for zcel in zcells.values():
030|         # Merge gots…
031|         got = set()
032|         for g in zcel.gots:
033|             got |= g
034|         # Possibles are what’s left…
035|         zcel.pos = Symbols  got
036| 
037|     # Print a listing…
038|     sortkey = lambda zc:(zc.size,zc.key)
039|     for zcel in sorted(zcells.values(), key=sortkey):
040|         print(zcel, file=fout)
041|     print(file=fout)
042| 
043|     # Return zero cell info objects…
044|     return zcells
045| 

The first loop (lines #10 to #26) iterates over the path definitions. It starts by getting the row values and making a set out of them (lines #12 and #13). If that set contains a zero, it’s discarded to make got the set of already-used digits.

Then we iterate over the keys of the path definition (lines #17 to #26). For each key, we get the puzzle value, and if it’s zero, we create a cellinfo object for zcells. We append the path def index and set of gots to pds and gots, respectively.

The second loop (lines #28 to #35) iterates over the zcells values. It turns the gots list into a set, then subtracts that from Symbols to get a set of possible digits for the cell.

Then we print the zcells (lines #37 to #41) and return them.

And now, here’s the puzzle solver:

001| from sys import stdout
002| from examples import analyze_puzzle, test_puzzle
003| 
004| def solve_puzzle (puzl:dict, pdef:list=None, sout=None) -> tuple:
005|     ”’Solve puzzle using logic.”’
006|     fout = stdout if sout is None else sout
007|     if pdef is None: pdef = PuzzlePaths
008| 
009|     zcells = analyze_puzzle(puzl, pdef, sout=sout)
010|     solved = False
011| 
012|     if len(zcells) == 0:
013|         solved = test_puzzle(puzl, pdef)
014|         return (solved, zcells)
015| 
016|     # List zero cells by least number of possible values…
017|     avail = list(sorted(zcells.values(), key=lambda ci:ci.size))
018| 
019|     # For each zero cell…
020|     for zcell in avail:
021| 
022|         # For each possible value, try it…
023|         for px,pos in enumerate(zcell.pos):
024|             puzl[zcell.key] = pos
025|             info = f'{px+1} of {zcell.size}’
026|             print(f’trying: {zcell.key} = {pos} ({info})\n’, file=fout)
027| 
028|             # RECURSE…
029|             solved,_ = solve_puzzle(puzl, pdef, sout=sout)
030|             if solved:
031|                 break
032| 
033|             # Restore zero…
034|             puzl[zcell.key] = 0
035| 
036|         # Didn’t solve it yet, try again…
037|         if solved:
038|             break
039| 
040|     # Return status and current zcell dictionary…
041|     return (solved, zcells)
042| 

It starts by calling analyze_puzzle to get the zero cells dictionary, zcells (line #9). If there are no zero cells, the puzzle is filled in, so test to see if it’s correctly solved and return (lines #12 to #14). Note that, if we got this far, the puzzle should be solved.

Otherwise, we iterate over zcells sorted by fewest possible options (#lines 19 to #37). For each, we iterate through the possible digits, plugging it in and recursing, hopefully all the way down with required fill-ins.

Ideally, at each level, there is at least one cell with only one option. If we can follow a path of single-option fill-ins throughout, then we solve the puzzle with pure logic (rather than any brute force or retries).


All we need now is the actual puzzle and a driver routine:

001| from examples import join_puzzle, print_puzzle, solve_puzzle
002| 
003| Clues1 = {
004|     (1,1):5, (1,2):2, (1,3):4, (1,4):1, (1,8):9,
005|     (2,1):7, (2,3):8, (2,5):4, (2,6):2,
006|     (3,2):6, (3,3):3, (3,6):9, (3,8):7,
007|     (4,5):5, (4,6):4,
008|     (6,4):7,
009|     (7,2):5, (7,3):4, (7,4):8, (7,6):9, (7,7):2,
010|     (8,1):3, (8,2):2, (8,3):1,
011|     (9,2):8, (9,3):6, (9,4):3, (9,6):2, (9,7):4, (9,9):5,
012| }
013| 
014| def do_puzzle_solve ():
015|     ”’Solve puzzle logically.”’
016|     # Make puzzle…
017|     puzzle = join_puzzle(Clues1)
018|     print_puzzle(puzzle)
019| 
020|     fn = ‘tredoku.list’
021|     fp = open(fn, mode=‘w’)
022|     try:
023|         solved,zcells = solve_puzzle(puzzle, sout=fp)
024|         if solved:
025|             print(‘Puzzle solved!’)
026|         else:
027|             print(‘Puzzle not solved.’)
028|         print()
029| 
030|         print_puzzle(puzzle)
031|         print_puzzle(puzzle, sout=fp)
032| 
033|     except:
034|         raise
035|     finally:
036|         fp.close()
037|         print(f’wrote: {fn}’)
038|         print()
039| 
040|     return (solved, puzzle, zcells)
041| 
042| do_puzzle_solve()
043| 

The Clues1 dictionary (lines #3 to #12) defines where the clue digits go. It has the same (row,col) tuple keys used throughout to identify puzzle cells. The dictionary values are the clue digits.

The do_puzzle_solve function (lines #14 to #40) first calls the join_puzzle function to create the puzzle by combining the clues with the blank puzzle. We want to write output to a disk file, so we open one (lines #20 and #21).

Inside the try block protecting the file, we call solve_puzzle to do the heavy lifting.

Finally, on line #42, we call the driver, which calls the solver, which calls itself (and the analyzer), many times during recursion.

When run, this prints:

Path  1: [5, 2, 4, 1, 0, 0, 0, 9, 0] = 21 x
Path  2: [7, 0, 8, 0, 4, 2, 0, 0, 0] = 21 x
Path  3: [0, 6, 3, 0, 0, 9, 0, 7, 0] = 25 x
Path  4: [0, 5, 4, 8, 0, 9, 2, 0, 0] = 28 x
Path  5: [3, 2, 1, 0, 0, 0, 0, 0, 0] =  6 x
Path  6: [0, 8, 6, 3, 0, 2, 4, 0, 5] = 28 x
Path  7: [1, 0, 0, 0, 0, 7, 2, 0, 4] = 14 x
Path  8: [0, 4, 0, 5, 0, 0, 0, 0, 0] =  9 x
Path  9: [0, 2, 9, 4, 0, 0, 0, 0, 5] = 20 x
Path 10: [0, 0, 0, 4, 5, 0, 8, 0, 3] = 20 x
Path 11: [9, 0, 7, 0, 0, 0, 0, 0, 0] = 16 x
Path 12: [0, 0, 0, 0, 0, 7, 9, 0, 2] = 18 x
Path 13: [5, 2, 4, 7, 0, 8, 0, 6, 3] = 35 x
Path 14: [1, 0, 0, 0, 4, 2, 0, 0, 9] = 16 x
Path 15: [0, 9, 0, 0, 0, 0, 0, 7, 0] = 16 x
Path 16: [0, 5, 4, 3, 2, 1, 0, 8, 6] = 29 x
Path 17: [8, 0, 9, 0, 0, 0, 3, 0, 2] = 22 x
Path 18: [2, 0, 0, 0, 0, 0, 4, 0, 5] = 11 x
Path 19: [0, 5, 4, 0, 0, 0, 7, 0, 0] = 16 x

Puzzle solved!

Path  1: [5, 2, 4, 1, 3, 7, 6, 9, 8] = 45 Okay!
Path  2: [7, 9, 8, 6, 4, 2, 1, 5, 3] = 45 Okay!
Path  3: [1, 6, 3, 5, 8, 9, 2, 7, 4] = 45 Okay!
Path  4: [7, 5, 4, 8, 6, 9, 2, 1, 3] = 45 Okay!
Path  5: [3, 2, 1, 7, 4, 5, 8, 9, 6] = 45 Okay!
Path  6: [9, 8, 6, 3, 1, 2, 4, 7, 5] = 45 Okay!
Path  7: [1, 6, 5, 9, 3, 7, 2, 8, 4] = 45 Okay!
Path  8: [3, 4, 8, 5, 2, 6, 1, 9, 7] = 45 Okay!
Path  9: [7, 2, 9, 4, 8, 1, 3, 6, 5] = 45 Okay!
Path 10: [6, 1, 2, 4, 5, 9, 8, 7, 3] = 45 Okay!
Path 11: [9, 5, 7, 8, 2, 3, 6, 4, 1] = 45 Okay!
Path 12: [8, 3, 4, 1, 6, 7, 9, 5, 2] = 45 Okay!
Path 13: [5, 2, 4, 7, 9, 8, 1, 6, 3] = 45 Okay!
Path 14: [1, 3, 7, 6, 4, 2, 5, 8, 9] = 45 Okay!
Path 15: [6, 9, 8, 1, 5, 3, 2, 7, 4] = 45 Okay!
Path 16: [7, 5, 4, 3, 2, 1, 9, 8, 6] = 45 Okay!
Path 17: [8, 6, 9, 7, 4, 5, 3, 1, 2] = 45 Okay!
Path 18: [2, 1, 3, 8, 9, 6, 4, 7, 5] = 45 Okay!
Path 19: [9, 5, 4, 3, 2, 8, 7, 6, 1] = 45 Okay!

wrote: tredoku.list

Which shows the unsolved puzzle paths first and the solved puzzle paths second. The output, the tredoku.list file, has over 3,000 lines, so I won’t reproduce it here. It’s included in the ZIP file available below.


The algorithm can be repurposed for other puzzles using this shape as well as puzzles with new Tredoku shapes. It even works with Sudoku puzzles.

For a new shape, redefine BlankPuzzle and PuzzlePaths. The former is basically a list of (row,col) cell specifications, the initializing zero can be assumed. The latter is a list of lists, each inner list consisting of the (row,col) cell spec of the cells on the path. The outer list includes actual horizontal and vertical paths as well as “paths” for the 3×3 squares. In the abstract, a “path” is any collection of nine cells that must contain among them all digits 1-9.

The clues for a specific puzzle are a set of cell specifications and the digit for that cell.

For more flexibility, use a JSON file to contain a variety of puzzle shape definitions and clue sets for different puzzles. A fully working script is included in the ZIP file linked below.


Link: Zip file containing all code fragments used in this post.