Published on

Micro Project | 填数独 Sudoku Solving

Authors
  • avatar
    Name
    Jiaqi Wang(Ramis)
    Twitter

[最后更新 于 2024-05-19]

最近在刷题遇到了解决数独这个题,我顿时来了兴趣,这种小游戏怎么少得了我。 尝试写了一个代码,由于最终的结果还是比较理想的。所以在此把思路和代码块在这里分享。

核心的想法

深度优先搜索 DFS

首先,基本确定采用深度优先搜索,因为广度优先搜索不到最后一刻不知道这个数独对不对,从而会耗费大量的时间和内存。

标记进栈次序的队列 A queue tracking the search order

接下来,还需要考虑怎么搜索,盲目的搜索和枚举。考虑到每格虽然只有九种可能,但是考虑到空白的格子很多,不仅要挨个枚举还要考虑堆栈的深度,这些会引发爆炸的时空复杂度。因而,我考虑通过一个有序队列来控制搜索的顺序:

回想自己做数独的经历,我们往往从已知数字最多的行、列和九宫格里来先一步进行尝试。同样的道理,我们对求解的数独做预处理,对行、列、九宫格中缺少的数字个数按照由少到多排序。那么,在搜索时,就能大幅度减少不必要的搜索分支(控制搜索栈栈底底的可能性尽量少)。

哈希表 Hash Table

此外,每次尝试填数时,我们都需要查询该行、该列、该九宫格内没有重复元素,如果傻蠢蠢用循环进行扫描十分影响效率。那么就必须简单使用一下我们的好朋友 哈希表,来记录某行、某列、某九宫格内某元素是否出现过。这样每次尝试本身的复杂度降低到O(1)。

代码讲解

下面具体来讲解一下代码:

我们先看初始化的部分

#Initiating:
#这三个数组分别对应某行(row)、某列(column)、某九宫格(square)中某元素出现过了没有,

        r = [[0 for _ in range(10)] for _ in range(10)]
        c = [[0 for _ in range(10)] for _ in range(10)]
        s = [[0 for _ in range(10)] for _ in range(10)]

	#我们用r[i][x](x=1 to 9)来标记x是否已经出现在i行,c和s同理。
	而r[i][0],c[i][0],和s[i][0]则用来记录该行中尚未填写数字的格数,并且将其坐标存在从r[i][10],c[i][10],s[i][10]以后的格子里。注意,这里的坐标对于行和列来说是单一的常数,而对于九宫格来说,则采用当前下标mod 3+1的元组来存储。

        visited = [[False for _ in range(10)] for _ in range(10)]
        for i,row in enumerate(board):
            for j,x in enumerate(row):
                if x!=".":
                    r[i+1][int(x)]=1
                    c[j+1][int(x)]=1
                    s[(i//3)*3+(j//3)+1][int(x)]=1
                if x==".":
                    r[i+1][0]+=1
                    c[j+1][0]+=1
                    s[(i//3)*3+(j//3)+1][0]+=1
                    r[i+1].append(j+1)
                    c[j+1].append(i+1)
                    s[(i//3)*3+(j//3)+1].append((i%3+1,j%3+1))

	#接着,我们将r[i][0],c[i][0],s[i][0]的值以及其对应的索引值汇入一个队列中。然后我们对该队列的第一个值,也就是未填数字的多少进行从小到大排序,获得队列q。
	
        q=[]
        for i in range(1,10):
                scode="r"+str(i)
                q.append((r[i][0],scode))
        for i in range(1,10):
                scode="c"+str(i)
                q.append((c[i][0],scode))
        for i in range(1,10):
                scode="s"+str(i)
                q.append((s[i][0],scode))

        q=sorted(q)
        
     #但这个q并没有具体到哪个坐标,因此还要让对应的坐标进队,因此我们建立新数组position queue(posque)。(位于同一行的坐标不再进行排序而是直接按照大小依次进队)。注意,这里需要对坐标进行判重,保证其不会二次进入队列。

		posque=[]
        for x in q:
            nums,sortcode=x #我们先取出上一个队列中的元组,sortcode用来判断当前值代表的行、列或九宫格。nums是改行中的空位数。
            for j in range(0,nums): #用循环让所有空格入队。
             if sortcode[0]=="r": #这里要根据首字母判断当前是行、列还是九宫格再计算坐标。 
                 pos_x=int(sortcode[1])
                 pos_y=(r[int(sortcode[1])][j+10]) #如果是行,则x坐标是标签内的值,y坐标是当前行坐标表索引10以后所存的列坐标。
             elif sortcode[0]=="c":
                 pos_x=(c[int(sortcode[1])][j+10])
                 pos_y=int(sortcode[1]) #同理,如果是列,则y坐标采用标签内的值,x坐标是所在列坐标表索引10以后所存的的行坐标。
             elif sortcode[0]=="s":
                 pos_x,pos_y=(s[int(sortcode[1])][j+10]) #如果是九宫格的话要麻烦一些,我们先把空格所在的九宫格内部坐标取出来
                 pos_x=pos_x+((int(sortcode[1])-1)//3)*3 #然后我们将其通过九宫格的标签值对应到9*9的数独格中。
                 pos_y=pos_y+((int(sortcode[1])-1)%3)*3
             if not visited[pos_x][pos_y]: #注意,这里在x y坐标入队前需要判重,因为一个空格势必是会出现三次的。
                 posque.append((pos_x,pos_y))
                 visited[pos_x][pos_y]=True    

#这样我们就完成了初始化的过程

对于以下一个数独,上述所有涉及到的变量在初始化后的状态应该如下:

board = [["5","3",".",".","7",".",".",".","."],
         ["6",".",".","1","9","5",".",".","."],
         [".","9","8",".",".",".",".","6","."],
         ["8",".",".",".","6",".",".",".","3"],
         ["4",".",".","8",".","3",".",".","1"],
         ["7",".",".",".","2",".",".",".","6"],
         [".","6",".",".",".",".","2","8","."],
         [".",".",".","4","1","9",".",".","5"],
         [".",".",".",".","8",".",".","7","9"]]
#row:
	  r= [ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
           [6, 0, 0, 1, 0, 1, 0, 1, 0, 0, 3, 4, 6, 7, 8, 9], 
           [5, 1, 0, 0, 0, 1, 1, 0, 0, 1, 2, 3, 7, 8, 9], 
           [6, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 4, 5, 6, 7, 9], 
           [6, 0, 0, 1, 0, 0, 1, 0, 1, 0, 2, 3, 4, 6, 7, 8], 
           [5, 1, 0, 1, 1, 0, 0, 0, 1, 0, 2, 3, 5, 7, 8], 
           [6, 0, 1, 0, 0, 0, 1, 1, 0, 0, 2, 3, 4, 6, 7, 8], 
           [6, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 3, 4, 5, 6, 9], 
           [5, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 2, 3, 7, 8], 
           [6, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 3, 4, 6, 7]]
		#由于r[0]所在行是空行,以之后的第一行(r[1]为例,第一位r[1][0]说明第一行有六个空格,而从第10位开始记录空格所在行:分别是第3, 4, 6, 7, 8, 9列。 

#column是一样的道理
	  c= [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
	  [4, 0, 0, 0, 1, 1, 1, 1, 1, 0, 3, 7, 8, 9], 
	  [6, 0, 0, 1, 0, 0, 1, 0, 0, 1, 2, 4, 5, 6, 8, 9], 
	  [8, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 2, 4, 5, 6, 7, 8, 9], 
	  [6, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 3, 4, 6, 7, 9], 
	  [3, 1, 1, 0, 0, 0, 1, 1, 1, 1, 3, 5, 7], 
	  [6, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 3, 4, 6, 7, 9], 
	  [8, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 8, 9], 
	  [6, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 2, 4, 5, 6, 8], 
	  [4, 1, 0, 1, 0, 1, 1, 0, 0, 1, 1, 2, 3, 7]]

#Square记录九宫格方法也类似,只是坐标记录中采用元组,此处坐标用123来表示其在九宫格中的位置。

九宫格序号:
   |11 12 13 14 15 16 17 18 19|     |.........|.........|.........|  
   |21 22 23 24 25 26 27 28 29|     |... 1 ...|... 2 ...|... 3 ...| 
   |31 32 33 34 35 36 37 38 39|     |_________|_________|_________|  
   |41 42 43 44 45 46 47 48 49|     |.........|.........|.........|   |11 12 13| 
   |51 52 53 54 55 56 57 58 59| --> |... 4 ...|... 5 ...|... 6 ...| ->|21 22 23|
   |61 62 63 64 65 66 67 68 69|     |_________|_________|_________|   |31 32 33|   
   |71 72 73 74 75 76 77 78 79|     |.........|.........|.........|
   |81 82 83 84 85 86 87 88 89|     |... 7 ...|... 8 ...|... 9 ...|
   |91 92 93 94 95 96 97 98 99|     |_________|_________|_________|  
                                              九宫格序号              九宫格内坐标
	比如,我们将44坐标映射到s[5],(1,1)的位置

s= [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
    [4, 0, 0, 1, 0, 1, 1, 0, 1, 1, (1, 3), (2, 2), (2, 3), (3, 1)], 
    [5, 1, 0, 0, 0, 1, 0, 1, 0, 1, (1, 1), (1, 3), (3, 1), (3, 2), (3, 3)], 
    [8, 0, 0, 0, 0, 0, 1, 0, 0, 0, (1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 3)], 
    [6, 0, 0, 0, 1, 0, 0, 1, 1, 0, (1, 2), (1, 3), (2, 2), (2, 3), (3, 2), (3, 3)], 
    [5, 0, 1, 1, 0, 0, 1, 0, 1, 0, (1, 1), (1, 3), (2, 2), (3, 1), (3, 3)], 
    [6, 1, 0, 1, 0, 0, 1, 0, 0, 0, (1, 1), (1, 2), (2, 1), (2, 2), (3, 1), (3, 2)], 
    [8, 0, 0, 0, 0, 0, 1, 0, 0, 0, (1, 1), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)], 
    [5, 1, 0, 0, 1, 0, 0, 0, 1, 1, (1, 1), (1, 2), (1, 3), (3, 1), (3, 3)], [4, 0, 1, 0, 0, 1, 0, 1, 1, 1, (1, 3), (2, 1), (2, 2), (3, 1)]]

排序后的队列q=

[(3, 'c5'), (4, 'c1'), (4, 'c9'), (4, 's1'), (4, 's9'), (5, 'r2'), (5, 'r5'), (5, 'r8'), (5, 's2'), (5, 's5'), (5, 's8'), (6, 'c2'), (6, 'c4'), (6, 'c6'), (6, 'c8'), (6, 'r1'), (6, 'r3'), (6, 'r4'), (6, 'r6'), (6, 'r7'), (6, 'r9'), (6, 's4'), (6, 's6'), (8, 'c3'), (8, 'c7'), (8, 's3'), (8, 's7')]

依次填数字的队列 posque=
[(3, 5), (5, 5), (7, 5), (3, 1), (7, 1), (8, 1), (9, 1), (1, 9), (2, 9), (3, 9), (7, 9), (1, 3), (2, 2), (2, 3), (8, 7), (8, 8), (9, 7), (2, 7), (2, 8), (5, 2), (5, 3), (5, 7), (5, 8), (8, 2), (8, 3), (1, 4), (1, 6), (3, 4), (3, 6), (4, 4), (4, 6), (6, 4), (6, 6), (7, 4), (7, 6), (9, 4), (9, 6), (4, 2), (6, 2), (9, 2), (1, 8), (4, 8), (6, 8), (1, 7), (3, 7), (4, 3), (4, 7), (6, 3), (6, 7), (7, 3), (9, 3)]

接下来就是搜索的过程。

    def solveSudoku(self, board):
        def Trysudoku(priority,rv,cv,sv,current_board): #此处priority是有待查询的空格队列,rv,cv,sv是当前尝试状态中对应的行、列、九宫格查询哈希表,current_board是当前棋盘。
          if len(priority)==1:     #若队列中仅剩一个元素,也就是数独内只剩一个空格,那么我们知道这是该搜索路径的最后一层,关系到是否成功完成数独以及退出搜索。
                xi,yi=priority[0]  #取出空格位置
                for t in range(1,10): #1-9进行尝试
                  sindex=((xi-1)//3)*3+(yi-1)//3+1 #这里的sindex是将当前空格的坐标x y映射到所在的九宫格序号
                  if (rv[xi][t]==0) and (cv[yi][t]==0) and(sv[sindex][t]==0): #若在行列九宫格中该尝试数字都未出现:那么我们将t填入current board,打印完成的数独,然后返回True。
                      current_board[xi-1][yi-1]=str(t)
                      print(current_board)
                      return(True)
          else: #否则,也就是待查询队列不止一个元素,那我们不仅要进行尝试,在尝试后还要继续搜索下一个元素。
                xi,yi=priority[0]                
                for t in range(1,10):
                     sindex=((xi-1)//3)*3+(yi-1)//3+1
                     if (rv[xi][t]==0) and (cv[yi][t]==0) and (sv[sindex][t]==0): #若该数字是可填的,我们填入后修改相关行、列、九宫格的查询表
                      current_board[xi-1][yi-1]=str(t) 
                      rv[xi][t]=1
                      cv[yi][t]=1
                      sv[sindex][t]=1
                      if Trysudoku(priority[1:],rv,cv,sv,current_board):return(True) #在递归调用搜索队列之后的元素,若沿着该路径最终查询成果,那么再向上层返回true。如果失败(没有return True结束搜索),我们就要将所有查询数组和数独盘还原到尝试前的状态,接着尝试填入其他数或返回上一层。
                      rv[xi][t]=0 
                      cv[yi][t]=0
                      sv[sindex][t]=0
                      current_board[xi-1][yi-1]='.'

这里是主程序中的递归调用,board是代填数独:

        Trysudoku(posque,r,c,s,board)

最后发图,说明这样的优化还是挺有效率的。

sudokuresult 1.png (合理怀疑本人为了发这张图而写了一篇博客,o( ̄▽ ̄)ブ)

Cat

这个星期三下了班熬夜写代码调试这个东西,还算写成了。 到头来其实不难,感觉九宫格的坐标应该可以更简单地存储,只是感觉这样写比较 "优美"。 周末还有一整天。 整个这半个月过得很充实。 遁走。

-鹅仔 2024/5/19 1:08