Sudoku Solver - Day 4

WELL turns out I had a glitch in my code. Let’s recap where the last installment left off. My algorithm thought that this was fully solved:

829|31 |475
5 7|489|123
1 4|275|689
---+---+---
2 3|64 |9 7
461|927|538
97 |138|246
---+---+---
713|852|694
 92|764|851
648|3  |7 2

So, that’s a miss. I went back through my code to identify the issue and discovered that there was a bug in my hiddenPairs code. Take a look and see if you can spot it:

# Original Code
def hiddenPairs(group):
  unsolved=[n for n in range(1,10) if not n in [cell.val for cell in group if type(cell.val)==int]]
  duos=[n for n in unsolved if len([cell for cell in group if type(cell.val)==list and n in cell.val])==2]
  if len(duos)<2: 
    # There can't be a hidden pair if there aren't two different values that appear exactly twice
    return
  # Now check to see if the same two values appear in the same two cells
  for a in range(len(duos)-1):
    pairA=[cell for cell in group if type(cell.val)==list and duos[a] in cell.val]
    for b in range(1,len(duos)):
      pairB=[cell for cell in group if type(cell.val)==list and duos[b] in cell.val]
      if pairA==pairB: 
        # This is it, folks!
        for cell in pairA:
          # Only need to do this once
          cell.val=[duos[a],duos[b]]
      else: 
        continue

Do you see it? No? Let me zoom in.

# Original Code
def hiddenPairs(group):
  ...
  for a in range(len(duos)-1):
    ...
    for b in range(1,len(duos)):
      ...

Oofta. That for b in range(1,len(duos)) was supposed to say for b in range(a+1,len(duos)). I wish I could say that I spotted that typo easily, but I very much did not. I had to add a lot of extra print statements to support debugging, and eventually discovered that instead of setting the candidates list for a hidden pair to something like [1,6], it was setting them to things like [6,6]. Here’s the corrected code:

def hiddenPairs(group):
  #print("HIDDEN PAIRS")
  unsolved=[n for n in range(1,10) if not n in [cell.val for cell in group if type(cell.val)==int]]
  #print("unsolved: ", unsolved)
  duos=[n for n in unsolved if len([cell for cell in group if type(cell.val)==list and n in cell.val])==2]
  #print("duos: ",duos)
  if len(duos)<2: 
    # There can't be a hidden pair if there aren't two different values that appear exactly twice
    return
  # Now check to see if the same two values appear in the same two cells
  for a in range(len(duos)-1):
    pairA=[cell for cell in group if type(cell.val)==list and duos[a] in cell.val]
    assert len(pairA)==2, "%s should be a list of length 2"%pairA
    for b in range(a+1,len(duos)):
      pairB=[cell for cell in group if type(cell.val)==list and duos[b] in cell.val]
      if pairA==pairB: 
        # This is it, folks!
        #print("hiddenPairs is doing something!")
        #for cell in group: 
        #  if type(cell.val)==list: print(cell)
        #print("unsolved: %s"%unsolved)
        #print("duos: %s"%duos)
        #print("cells: %s and %s"%(pairA[0],pairA[1]))
        #print("values: %d, %d"%(duos[a],duos[b]))
        for cell in pairA:
          if len(cell.val)==2: continue
          # Only need to do this once
          #print(cell)
          cell.val=[duos[a],duos[b]]
          #print(cell)
      else: 
        continue

You can see a LOT of extra print statements that I commented out once I was done debugging. I was really puzzled for a while. While debugging, I added a “sum” function to the Grid class to calculate the sum of solved cells - a full sudoku grid adds to 405, so if my count is less than 405, it’s not solved. Anyway, now that’s sorted out, I can go ahead and run that “hard” difficulty grid through again and get…

hard = [
  [0,0,9,0,0,0,4,0,0],
  [0,0,7,0,8,0,1,0,0],
  [1,0,0,2,0,5,0,0,9],
  [2,0,0,0,4,0,0,0,7],
  [0,6,0,9,0,7,0,3,0],
  [9,0,0,0,3,0,0,0,6],
  [7,0,0,8,0,2,0,0,4],
  [0,0,2,0,6,0,8,0,0],
  [0,0,8,0,0,0,7,0,0]]

Grid(hard).solve()
  9| 1 |47 
  7|489|1  
1  |275|  9
---+---+---
2  | 4 |9 7
 6 |927|53 
97 | 38|246
---+---+---
7  |8 2|  4
  2|76 |8  
  8|   |7  
Score: 116
Sum: 226

Ok so like, halfway solved? I guess these basic algorithms weren’t enough to get through a “hard” difficulty puzzle. That’s a shame, because I was considering running this algorithm against Project Euler problem # 96, which I’ve never successfully solved. So I guess it’s time to build a bit more complexity.

X-Wings Are Go

The next strategy that I think will help get this grid solved is called an X-wing. (I know, I know, I said that was “too extra” - I was wrong!). Here’s the basic idea: let’s say in row 0, you have the digit 1 confined to columns 2 and 4. In row 3, digit 1 is also confined to columns 2 and 4. That means that in those two rows, there are only two possibilities for digit 1 - columns 2 and 4. So we have narrowed down digit 1 in columns 2 and 4 - it must be in row 0 or 3. This lets us eliminate 1 as a possibility from every other cell in those columns.

Let’s take a look at that as a visual. In the grid below, ? represents the “X-wing” of cells that must contain a certain digit as a candidate. Assuming those two rows don’t contain any other cells that could contain this digit, each cell marked X can have this digit eliminated as a possibility

  ?| ? |   
  X| X |   
  X| X |   
---+---+---
  ?| ? |   
  X| X |   
  X| X |   
---+---+---
  X| X |   
  X| X |   
  X| X |   

So, time to write this as an algorithm. This one is a first for us because it looks at the grid as a whole rather than operating on individual groups. Here’s the first draft:

def xWings(grid):
  for d in range(1,10):
    # By Rows
    rows=[i for i in range(9) if len([cell for cell in grid.row(i) if type(cell.val)==list and d in cell.val])==2]
    # By Cols
    cols=[i for i in range(9) if len([cell for cell in grid.col(i) if type(cell.val)==list and d in cell.val])==2]

    if len(rows)>=2: # Check for locked columns in 2 rows
      pos={}
      for row in rows:
        pos[row]=[cell.col for cell in grid.row(row) if type(cell.val)==list and d in cell.val]
      for a in range(len(rows)-1):
        for b in range(a+1,len(rows)):
          if pos[rows[a]]==pos[rows[b]]:
            lockedrows=[rows[a],rows[b]]
            lockedcols=pos[rows[a]]
            #print("digit %d is locked in columns %s in rows %s" %(d,lockedcols,lockedrows))
            for col in lockedcols:
              for cell in grid.col(col):
                if cell.row not in lockedrows:
                  if type(cell.val)==list and d in cell.val: cell.remove(d)
    #TODO - check for locked rows in 2 cols

I got this chunk of code working the way I expected with a little trial and error and ran it against that same “hard” grid. Along the way, I identified a piece of the algorithm I wanted to refactor - I want to add a remove method on the Cell class so that when I remove a candidate from the cell, if there’s only one candidate left, it goes ahead and solves that cell. You can see that I’ve used it above: cell.remove(d) instead of cell.val.remove(d). Here’s what that method looks like:

class Cell():
  ...
  def remove(self,cand):
    assert type(self.val)==list, "Can't remove a candidate from a solved cell"
    if cand in self.val: self.val.remove(cand)
    if len(self.val)==1: self.solve(self.val[0])

And here’s the results:

hard = [
  [0,0,9,0,0,0,4,0,0],
  [0,0,7,0,8,0,1,0,0],
  [1,0,0,2,0,5,0,0,9],
  [2,0,0,0,4,0,0,0,7],
  [0,6,0,9,0,7,0,3,0],
  [9,0,0,0,3,0,0,0,6],
  [7,0,0,8,0,2,0,0,4],
  [0,0,2,0,6,0,8,0,0],
  [0,0,8,0,0,0,7,0,0]]

Grid(hard).solve()
829|613|475
657|489|123
134|275|689
---+---+---
283|546|917
461|927|538
975|138|246
---+---+---
716|852|394
392|764|851
548|391|762
Score: 0
Sum: 405

Woohoo! Feeling pretty confident, I run all 50 of the sudoku grids from Project Euler problem 96 through my algorithm. Here are the sums of the solved squares:

'Grid 01': 405, 'Grid 02': 405, 'Grid 03': 405, 'Grid 04': 405, 'Grid 05': 405,
'Grid 06': 160, 'Grid 07': 405, 'Grid 08': 405, 'Grid 09': 405, 'Grid 10': 405,
'Grid 11': 405, 'Grid 12': 405, 'Grid 13': 405, 'Grid 14': 405, 'Grid 15': 405,
'Grid 16': 405, 'Grid 17': 405, 'Grid 18': 405, 'Grid 19': 405, 'Grid 20': 405,
'Grid 21': 405, 'Grid 22': 405, 'Grid 23': 405, 'Grid 24': 405, 'Grid 25': 405,
'Grid 26': 405, 'Grid 27': 405, 'Grid 28': 405, 'Grid 29': 405, 'Grid 30': 405,
'Grid 31': 405, 'Grid 32': 405, 'Grid 33': 405, 'Grid 34': 405, 'Grid 35': 405,
'Grid 36': 405, 'Grid 37': 405, 'Grid 38': 405, 'Grid 39': 405, 'Grid 40': 405,
'Grid 41': 405, 'Grid 42': 223, 'Grid 43': 405, 'Grid 44': 405, 'Grid 45': 405,
'Grid 46': 405, 'Grid 47': 405, 'Grid 48': 405, 'Grid 49': 405, 'Grid 50': 405

Holy cow! Only two of the grids weren’t solved with these methods!! I guess I’ll try adding the X-wing functionality for columns. Here’s the new code for that:

    if len(cols)>=2:
      pos={}
      for col in cols:
        pos[col]=[cell.row for cell in grid.col(col) if type(cell.val)==list and d in cell.val]
      for a in range(len(cols)-1):
        for b in range(a+1,len(cols)):
          if pos[cols[a]]==pos[cols[b]]:
            lockedcols=[cols[a],cols[b]]
            lockedrows=pos[cols[a]]
            #print("digit %d is locked in rows %s in columns %s" %(d,lockedrows,lockedcols))
            for row in lockedrows:
              for cell in grid.row(row):
                if cell.col not in lockedcols:
                  if type(cell.val)==list and d in cell.val: cell.remove(d)

And that solved grid 06! All but grid 42 are now solved. I’m going to call it a day there - this is the farthest I’ve ever gotten in solving problem 96, so I feel really good about my progress. Before I do any more coding, I’m going to plug grid 42 into a sudoku app and see if I can figure out what my next step would be in solving it, because I don’t want to put too much time into building an alorithm that isn’t actually going to help. So I guess stay tuned!

 Share!