数独的暴力回溯解法和Python GUI

数独起源于18世纪初瑞士数学家欧拉等人研究的拉丁方阵,20世纪70年代,经过美国及日本学者的推广和改良,定名为数独(Sudoku),大致的意思是“独个的数字”或“只出现一次的数字”。 标准的九宫格数独包含9×9个格子,且每3×3的区域组成一宫,数独的规则要求在空出来的格子里填如1~9的数字,要满足每行、每列和每宫内的数字都不重复,也就是行、列及宫里都是由不重复的1~9构成。数独还包含了一些6×6、不规则九宫等个性数独,本篇仅讨论标准九宫格数独的情况。

各种数独

手动解的技巧有唯余解法、基础排除法、区块排除法、数对唯余法等,进阶的有唯一矩形法、数对占位法、双分支匹配等。

标准九宫格数独及常用手动解法

用电脑解最通用的还是穷举整个解空间,根据数独规则进行剪枝和回溯。效率和递归深度、需要缓存的中间过程有关,递归深度主要由挖空的个数决定。最简单的穷举算法是对每个单元格都用1~9分别尝试,满足条件继续尝试下一个挖空的格,直到所有单元格都填了合适的数字,且检查符合数独规则就算找到一个解。唯一解要求当前盘面有且只有这一个解。

进一步的做法是为每个挖空的格子维护一个候选数列表,用这个列表中的值进行试数,出现矛盾就回溯,很暴力但其实挺有效的。更高级一点的舞蹈链法及利用模拟退火等方法,也还是离不开试数和回溯的思路。因此下面主要实现的是基于候选数的回溯解法。

首先数独中的数值我们可以用一个一维长度为81的数组表示,也可以用二维9×9的数组表示,下面采用9×9的数组表示,例如一个数独,其盘面用二维数组表示如下:

数独及其二维数组表示

回溯的思路是:从第一个挖空的单元格开始,根据其相关20格(本行、本列及所在宫内的单元格)生成候选数列表lst,lst的生成直接地利用了唯余法进行排除,对列表lst中的值进行向下尝试,尝试下一个挖空的单元格,当不满足数独规则时,回退到上一个挖空的单元格。

写成代码如下:

回溯思路及伪码

再把getPrem(x,y,board)cheackNotSame(x,y,v,board)实现后,就可以变成完整的代码了,

class sovSudoku:  #求解数独
    def __init__(self,board=[]):
        self._b=board.copy()  #直接写=board会直接修改传进来的列表
    def trysxy(self,x,y): #主循环,尝试x,y处的解答
        if self._b[x][y]==0: #不等于0的情况在调用外处理
            pv=self.getPrem(x,y)
            #for v in range(1,10): #从1到9尝试
            for v in pv:
                self._t+=1 #递归次数+1
                if self.checkNotSame(x,y,v):# 符合 行列宫均满足v符合条件 的
                    self._b[x][y]=v
                    nx,ny=self.getNext(x,y) #得到下一个0值格
                    if nx==-1: #没有下一个0格了;and ny==-1可以写但没必要
                        return True
                    else:
                        _end=self.trysxy(nx,ny) #向下尝试,递归
                        if not _end:
                            self._b[x][y]=0 #回溯,继续for v循环
                            #只需要改x,y处的值,不改其他值
                        else:
                            return True
    
    def checkNotSame(self,x,y,val):#检查每行,列及宫内是否有和b[x,y]相同项
        for row_item in self._b[x]: #第x行
            if row_item==val:
                return False
        for rows in self._b:#y所在列
            if rows[y]==val:
                return False
        ax=x//3*3 #把0~3中的值映射到[0,3]
        ab=y//3*3
        for r in range(ax,ax+3):
            for c in range(ab,ab+3):#注意r==x & c==y的情况下,其实没必要,val不会是0
                if self._b[r][c]==val:
                    return False
        return True
    def getNext(self,x,y): #得到下一个未填项,从x,y往下数,值等于0就返回新下标
        for ny in range(y+1,9): #下标是[0,8]
            if self._b[x][ny]==0:
                return (x,ny)
        for row in range(x+1,9):
            for ny in range(0,9):
                if self._b[row][ny]==0:
                    return (row,ny)
        return (-1,-1) #不存在下一个未填项的情况
    def getPrem(self,x,y): #得到x,y处可以填的值
        prem=[]
        rows=list(self._b[x])
        rows.extend([self._b[i][y] for i in range(9)])
        cols=set(rows)
        for i in range(1,10):
            if i not in cols:
                prem.append(i)
        return prem
    def solve(self):
        #x,y=(0,0) if self._b[0][0]==0 else self.getNext(0,0)
        if self._b[0][0]==0:#更容易理解的写法
            self.trysxy(0,0)
        else:
            x,y=self.getNext(0,0)
            self.trysxy(x,y)
            #以下为辅助代码
    def updateSudo(self,cb): #传入一个数独盘面
        if len(cb)==9 and len(cb[0])==9:
            self._b=cb
        else:
            print('files size not shape',len(cb),len(cb[0]))
    def __str__(self): #print(sovSudoku)
        return '{0}{1}{2}'.format('[',',\n'.join([str(i) for i in self._b]),']')

对于上面的最难数独,在本机上求解效果如下,耗时在秒级,回溯性能也不是很差。

最难数独的求解耗时

网上再找几个数独进行测试,各自耗时如下:

3个测试案例

在leetcode上有两道数独相关的题目,第37题就是根据输入的数独(用9×9的二维数组表示)求结果。它是用'.'代表挖空,把上面的代码改一下,提交运行的效果如下:

Leetcode 37题运行结果

运行时间在秒级以下,因为回溯会有多次栈调用,内存花费在10多MB。大于平常的一些练习题。

第36题是检查当前盘面的合法性,不考虑该数独能否求解,只需要根据数独规则判断是否满足数独条件,将以上代码修改后提交的结果如下:

第36题提交结果

数独游戏GUI

有了上面的检查数独是否合法以及解数独的代码后,再加上生成数独的代码就可以写一个小游戏训练自己了。81个单元格,假设每次挖掉n 个数字形成一个数独题目,根据排列组合的算法,一共有C(81,n)种挖法。要保证数独有唯一解,则至少要保留17个提示数,也就是说n 最多只能是81-17=64。n取1,2这种数也没什么好玩的,只挖一两个空太好解了,因此n应该有个合理的最小值,如果每行挖两个空,那就是18个空,因此n可以取[18,64],从量级上我们就能看出,就算我们每天接触1万个数独,穷尽一生接触到的数独题目数量也只占冰山一角,因此不需要担心会刷到重复的数独,概率太低。直接随机某个位置随机填入一个数字再随机其他位置来生成数独效率并不高,比较合理的做法是程序内部有几个完整的数独,通过数字置换随机挖空来产生新的数独。

考虑数独的特点,如果我们有一个数组[6,8,5,1,9,4,3,2,7],表示将数独中的数字1变成数字6,把2变成8,以此类推……,类似凯撒加密的做法。由数独的特点可以推出新生成的数独也是符合规则的。

挖空操作就是随机挖去n处的值,再验证是否有唯一解,就可以生成一个数独题目了。

GUI程序的流程还是遵从:

导入tk库,创建主窗体->添加控件->处理交互->进入主事件循环

最后实现的GUI如下:

几个盘面以及交互,初始数独,填写验证,电脑解

各按钮交互简介:

  • 生成数独: 随机生成一个数独;

  • 验证答案: 没填完的情况下,根据当前所填进行验证;填完了,不满足条件则提示,满足说明解答正确,进行弹窗;

  • 电脑解:电脑对当前基础盘面进行计算,把值渲染到数独上(可以对字体、颜色进行进一步个性化);

  • 清空:把所有值都清空,方便用户输入一个盘面。

代码如下,继续用内置的tkinter库实现。

root=tk.Tk()
root.geometry('310x350+400+100') #大小和位置
root.title('Sudoku')
btnlst=[] # 每一个输入框  全局变量
evs=[] #和btnlst对应的变量列表 仅get,set操作

def initOneSudo(s0): #根据初始数独和挖空个数,生成一个一维的数独列表
    s1=xyTo81(s0) #s0是二维的
    u=randint(0,8)
    if u!=0:s1=resetsd(s1,u) #对s1中的数进行“替换加密”
    m=randint(18,41) #挖空个数。目前允许多解
    wlst=[] #挖空位置 
    while len(wlst)<m:
        i=randint(0,80)
        while i in wlst:
            i+=2 #或者这里继续用随机数
            if i>80:
                i-=30
        s1[i]=0
        wlst.append(i)
    return s1


def initSudo(s1,evs):
    sk1=xyTo81(s1)
    if evs==[]:
        for j in range(0,81):
            evs.append(tk.IntVar(root,sk1[j]))
    else:
        for j in range(0,81):
            evs[j].set(sk1[j])
    return evs

def isValidSudoku(board):
    if board[0][0]!=0:
        return validCheck(0,0,board)
    else:
        nx,ny=getNext(0,0,board)
        if nx==-1:
            return -1
        return validCheck(nx,ny,board)
def validCheck(x,y,b):#检查数独是否合法
    v=b[x][y]
    for r in range(0,9):
        if r!=x:
            if b[r][y]==v:
                return False
        if r!=y:
            if b[x][r]==v:
                return False
    ax=x//3*3
    ab=y//3*3
    for r in range(ax,ax+3):
        for c in range(ab,ab+3):
            if b[r][c]==v and r!=x and c!=y:
                return False
    nx,ny=getNext(x,y,b)
    if nx==-1:
        return True
    return validCheck(nx,ny,b)

### 主函数   s0:内置的基础数独
s1=initOneSudo(s0)  #81
s1=nighty2xy(s1,n=9)

s1cp=s1.copy()
s2=zeroToNAstr(s1,0) #9*9
evs=initSudo(s2,[])

i=0 #用81个输入框表示每个数独单元格
for r in range(9):
    for c in range(9):
        if r>2 and r<6 and c>2 and c<6:
            btnlst.append(tk.Entry(root,textvariable=evs[i],justify='center'))
        elif r>2 and r<6:
            btnlst.append(tk.Entry(root,textvariable=evs[i],justify='center'))
            btnlst[i]['background']='#AFEEEE'
        elif c>2 and c<6:
            btnlst.append(tk.Entry(root,textvariable=evs[i],justify='center'))
            btnlst[i]['background']='#AFEEEE'
        else:
            btnlst.append(tk.Entry(root,textvariable=evs[i],justify='center'))
        btnlst[i].place(x=5+c*30,y=0+r*30,width=30,height=30)
        i+=1

def btnClick(x): #按钮点击的回调函数
    global evs,s1,s1cp
    if x=='c': #清空
        for i in range(81):
            evs[i].set('')
    elif x=='n':
        s1=initOneSudo(s0) #81
        s1cp=nighty2xy(s1,n=9)
        for k1 in range(81):
            if s1[k1]==0:
                evs[k1].set('')
            else:
                evs[k1].set(s1[k1])
    elif x=='m': #电脑解;这里涉及用户输入,确实需要的约束判断挺多的
        s5,msg=getSudo(evs,0) #9*9
        if msg[0]!='':
            messagebox.showinfo('提示',msg[0])
        elif msg[1]!='': #有空值来验证
            s6=s5.copy()
            isvs=isValidSudoku(s6)
            if isvs==-1:
                messagebox.showinfo('提示','当前盘面为空,请先手动输入一个合法盘面或点生成数独')
            elif isvs:
                ss=sovSudoku(s1cp)
                ss.solve()
                s3=ss.getSudoku() #[[1,2],[3,4]]
                s4=xyTo81(s3)
                for k1 in range(81): #从s3写入盘面
                    if s4[k1]==0:
                        evs[k1].set('')
                    else:
                        evs[k1].set(s4[k1])
            else:
                messagebox.showinfo('提示','当前盘面包含不满足数独条件的值,请检查')
        else:#填完了的情况
            s6=s5.copy()
            isvs=isValidSudoku(s6)
            if isvs==-1:
                messagebox.showinfo('提示','当前盘面为空,请先手动输入一个合法盘面或点生成数独')
            elif isvs:
                messagebox.showinfo('恭喜','恭喜,当前数独已解答正确!')
            else:
                messagebox.showinfo('提示','当前盘面包含不满足数独条件的值,请检查')
    elif x=='s': #验证盘面
        s5,msg=getSudo(evs,0) #9*9
        if msg[0]!='':
            messagebox.showinfo('提示',msg[0])
        elif msg[1]!='': #有空值来验证
            s6=s5.copy()
            isvs=isValidSudoku(s6)
            if isvs==-1:messagebox.showinfo('提示','当前盘面为空,请先手动输入一个合法盘面或点生成数独')
            elif isvs:messagebox.showinfo('提示','当前盘面满足数独条件,请继续作答或选择电脑解答')
            else:messagebox.showinfo('提示','当前盘面包含不满足数独条件的值,请检查')
        else:#填完了的情况
            s6=s5.copy()
            isvs=isValidSudoku(s6)
            if isvs:messagebox.showinfo('恭喜','恭喜,当前数独已解答正确!')
            else:messagebox.showinfo('提示','当前盘面包含不满足数独条件的值,请检查')

#随机生成 电脑解 验证答案
ranInitBtn=tk.Button(root,text='生成数独',command=lambda x='n':btnClick(x)) #new one sudo
ranInitBtn.place(x=5,y=310,width=60,height=30)
#……
root.mainloop()

打包GUI为exe文件

还是用pyinstaller把程序变成exe可执行文件,大小是8点多M,作为Python导出的exe文件,这个大小是有优势的,结果如下:

导出exe盘面

小结

本文从解数独的手动解法引入,讲到解数独常用的回溯法,并且按照思路实现回溯代码,通过这一思路去解两个LeetCode题,为了可玩性增加随机生成一个数独的代码,并把以上功能整合为一个GUI程序,用于平时的数独训练,并且把这一GUI脚本打包为一个exe文件,在Windows系统下使用。

项目完整代码更新于https://github.com/QLWeilcf/sudokupyGUI

蛰虫始航(focus on 数据分析,地理可视化,Python)

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容