关于本站
1、基于Django+Bootstrap开发
2、主要发表本人的技术原创博客
3、本站于 2015-12-01 开始建站
最近在学机器学习,其中numpy是必学的库。学习过程中,产生一个想法:用numpy解数独,锻炼numpy的用法。
查了一下程序解数独有暴力破解和Dancing Links等方法。感觉还是太慢了。自己写一个算法求解数独。
讲算法的东西,文章篇幅会很长,请做好心理准备。算法我命名为排除候选猜测法。完整的代码在文章底部,不想看过程可以直接跳到最尾处。
numpy可以创建多维的数组,并且可以快速得到对应行、列和九宫格的位置。那么我可以创建1个9*9的二维数组。
若其中某个坐标的值是确定的,则用数字填入;未确定下来,则用一个list列表,列表中是可能的值作为候选。拿一个简单的数独举例,如下:
这是1个9*9的二维数组,确定的数字直接填到对应的坐标上。没填上数字的坐标可能候选值是1~9,再根据确定的值排除候选值,如下结果:
接着利用数独的规则,一步一步排除候选值,直到候选值剩下1个为止。(这是我的算法中前半部分,大部分数独题目用该部分的逻辑就可以解出来了)
故,该算法先称为排除候选法。(加上后半部分的算法再改名)
创建一个类,初始化数据:
#coding:utf-8 #python2.7 #Create: 2016-09-27 #Complete: 2016-09-29 __author__ = u"杨仕航" import numpy as np from Queue import Queue, LifoQueue class Sudo(): def __init__(self, data): #数据初始化(二维的object数组,值可填数字和list) self.value = np.array([[0]*9]*9, dtype=object) self.new_points = Queue() #先进先出的新解坐标 #后半部分算法需要的属性 self.recoder = LifoQueue() #先进后出的回溯器 self.guess_times = 0 #猜测次数 #九宫格的基准列表 self.base_points=[[0,0],[0,3],[0,6],[3,0],[3,3],[3,6],[6,0],[6,3],[6,6]] #整理数据 #将81个元素的list转化为9*9的二维数组 data = np.array(data).reshape(9,-1) for r in range(0,9): for c in range(0,9): if data[r,c]: self.value[r,c] = data[r,c] #新点添加到列表中,以便遍历 self.new_points.put((r,c)) else: self.value[r,c] = [1,2,3,4,5,6,7,8,9]
若没有numpy库,就安装。另外Queue是列队(栈),先进先出。每新增一个确定的数字,则加入这个列队。后续处理则从这个列队取出数字,排除所在行列和九宫格其他候选值。
剔除候选值的方法如下(该方法在这个类中):
#剔除数字 def _cut_num(self, point): r, c = point val = self.value[r, c] #行 for i, item in enumerate(self.value[r]): if isinstance(item, list): if item.count(val): item.remove(val) #判断移除后,是否剩下一个元素 if len(item)==1: self.new_points.put((r, i)) self.value[r,i] = item[0] #列 for i, item in enumerate(self.value[:,c]): if isinstance(item, list): if item.count(val): item.remove(val) #判断移除后,是否剩下一个元素 if len(item)==1: self.new_points.put((i, c)) self.value[i,c] = item[0] #所在九宫格(3x3的数组) b_r, b_c = map(lambda x:x/3*3, point) #九宫格基准点 for m_r, row in enumerate(self.value[b_r:b_r+3, b_c:b_c+3]): for m_c, item in enumerate(row): if isinstance(item, list): if item.count(val): item.remove(val) #判断移除后,是否剩下一个元素 if len(item)==1: r = b_r + m_r c = b_c + m_c self.new_points.put((r, c)) self.value[r,c]=item[0]
传入坐标值,根据数独的规则:同行、同列和同个九宫格内不能填写同样的数字。遍历所在的行列、九宫格,去掉相同的候选值。
好吧,单单看代码有点复杂。简单画几个图解释一下:
这是刚才上面的图,可以确定A7单元格要填数字5。那么接下来需要把A7所在的第A行和第7列以及该九宫格上候选值有5的话,去除掉。
这样就去掉B7单元格和A9单元格上的候选值5,剩下1个数字。
剩下1个数字之后,就可以确定该单元格的数字。再循环上面的步骤。
只是剔除候选值一种方法还不够。有时候存在如下情况:
在第1个九宫格中,有4个单元格是确定了;有6个单元格未确定。其中B7单元格比较特殊,该单元格候选值[7,9]中的7在该九宫格是唯一存在的。
这意味在该九宫格中,其他单元格不可能填7,只有这个单元格才可以填7。所以可以确定该单元格的数字了。
同样道理,同一行和同一列也可以这样判断。判断代码如下:
#同一行、列或九宫格中1~9可能性只有一个的情况 def _check_one_possbile(self): #同一行只有一个数字的情况 for r in range(0,9): values = filter(lambda x:isinstance(x,list), self.value[r]) for c, item in enumerate(self.value[r]): if isinstance(item, list): for value in item: if sum(map(lambda x:x.count(value), values)) == 1: self.value[r,c] = value self.new_points.put((r,c)) return True #同一列只有一个数字的情况 for c in range(0,9): values = filter(lambda x:isinstance(x,list), self.value[:,c]) for r, item in enumerate(self.value[:,c]): if isinstance(item, list): for value in item: if sum(map(lambda x:x.count(value), values)) == 1: self.value[r,c] = value self.new_points.put((r,c)) return True #九宫格内的单元格只有一个数字的情况 for r,c in self.base_points: values = filter(lambda x:isinstance(x,list), self.value[r:r+3,c:c+3].reshape(1,-1)[0]) for m_r, row in enumerate(self.value[r:r+3,c:c+3]): for m_c, item in enumerate(row): if isinstance(item, list): for value in item: if sum(map(lambda x:x.count(value), values)) == 1: self.value[r+m_r, c+m_c] = value self.new_points.put((r+m_r, c+m_c)) return True
这里有个小细节,是我写这个算法的时候,后期调整的。当每找到唯一确定的值之后,添加到列表就返回True并跳出该方法。为了确保可以及时处理确定的值,排除其他候选值。避免判断出错。
去掉候选值,除了以上的方法之外。我还写了一个方法。同行列隐性排除,这个名称有点难理解。看图(该图的数据是模拟的):
在第二个九宫格中,候选值数字9只存在B4和B5单元格中。虽然我不确定9到底填在哪个单元格中,但我可以确定。第B行的数字9一定出现在这两个单元格中。则第B行除了第2个九宫格所在的列,其他该行的位置可以排除掉9。
即排除掉B3单元格上的9。
这个要熟悉掌握数独的规则就可以理解这种做法的原理。代码也可以实现,如下:
#同一个九宫格内数字在同一行或同一列处理 def _check_same_num(self): for b_r, b_c in self.base_points: block = self.value[b_r:b_r+3,b_c:b_c+3] #判断数字1~9在该九宫格的分布情况 data = block.reshape(1,-1)[0] for i in range(1,10): result = map(lambda x: 0 if not isinstance(x[1], list) else x[0]+1 if x[1].count(i) else 0, enumerate(data)) result = filter(lambda x:x>0,result) r_count = len(result) if r_count in [2, 3]: #2或3个元素才有可能同一行或同一列 rows = map(lambda x:(x-1)/3, result) cols = map(lambda x:(x-1)%3, result) if len(set(rows)) == 1: #同一行,去掉其他行的数字 result = map(lambda x: b_c + (x-1)%3, result) row = b_r + rows[0] for col in range(0,9): if not col in result: item = self.value[row, col] if isinstance(item, list): if item.count(i): item.remove(i) #判断移除后,是否剩下一个元素 if len(item)==1: self.new_points.put((row, col)) self.value[row, col] = item[0] return True elif len(set(cols)) == 1: #同一列 result = map(lambda x: b_r + (x-1)/3, result) col = b_c + cols[0] for row in range(0,9): if not row in result: item = self.value[row, col] if isinstance(item, list): if item.count(i): item.remove(i) #判断移除后,是否剩下一个元素 if len(item)==1: self.new_points.put((row, col)) self.value[row, col] = item[0] return True
这里判断是否是同一行列,需要逐个遍历九宫格中的候选值,提取数字1~9所在的位置。
首先,我把九宫格的二维数组变成一维列表。这样九宫格的位置可以顺序化。
有顺序之后,我就可以判断这些数字的分布是否是同一行或同一列。这样也许看不出什么东西,再把这些序号一一对应到原来的九宫格上。如下图:
同一行的话,这些序号除以3之后的值相同;
同一列的话,这些序号除以3之后的余数相同。
细心一点的话,可以发现这里同样只要有确定的值,就返回True跳出方法。
我把3种方法都讲解完成,是时候亮剑了。算法前半部分的算法逻辑如下:
#排除法解题 def solve_sudo(self): is_run_same = True is_run_one = True while is_run_same: while is_run_one: #剔除数字 while not self.new_points.empty(): point = self.new_points.get() #先进先出 self._cut_num(point) #检查单个数字的情况 is_run_one = self._check_one_possbile() #检查同行或列的情况 is_run_same = self._check_same_num() is_run_one = True
_cut_num方法是可以完全肯定把数字确定下来。所以要不断处理,直到列队没有值为止。
另外两种方法一旦发现可以确定的值,就需要跳出。避免判断出错。
其实排除候选的方法不止这三种,还有其他方法。若你觉得可以提高算法效率的话,可以试试加入进来。
更多排除方法可以参考:琳琅在线。
现在开始说说后半部分的算法。
因为有时候,排除到最后,还可能有些值模棱两可,无法确定下来。这时候就需要猜了。
从候选值中挑出一个值确定下来,再用前半部分的算法处理。
前半部分处理完成之后,有两种情况:解答完成和未解答完成。
解答完成就是全部数字都确定下来了。可以用该方法得到确认下来的数字:
#得到有多少个确定的数字 def get_num_count(self): return sum(map(lambda x: 1 if isinstance(x,int) else 0, self.value.reshape(1,-1)[0]))
因为确认下来的数字,填写的是数字。则只需统计int类型的个数即可。
若确定的数字不足9*9=81个的话,就说明未解答完成,还需要继续猜测。
不管解开和未解开,还需要对结果进行验证。毕竟这个结果是上一次猜测的结果。
验证方法如下:
#检查有没错误 def check_value(self): #行 for row in self.value: nums = [] lists = [] for item in row: (lists if isinstance(item,list) else nums).append(item) if len(set(nums)) != len(nums): return False #数字要不重复 if len(filter(lambda x: len(x)==0, lists)): return False #候选列表不能为空集 #列 for c in range(0,9): nums = [] lists = [] col = self.value[:,c] for item in col: (lists if isinstance(item,list) else nums).append(item) if len(set(nums)) != len(nums): return False #数字要不重复 if len(filter(lambda x: len(x)==0, lists)): return False #候选列表不能为空集 #九宫格 for b_r, b_c in self.base_points: nums = [] lists = [] block = self.value[b_r:b_r+3,b_c:b_c+3].reshape(1,-1)[0] for item in block: (lists if isinstance(item,list) else nums).append(item) if len(set(nums)) != len(nums): return False #数字要不重复 if len(filter(lambda x: len(x)==0, lists)): return False #候选列表不能为空集 return True
为了优化算法,进行猜测的位置也不是随便挑选的。
候选列表的数字越少越有利。so~ 可以对每个候选列表评分,取最高分的即可:
#评分,找到最佳的猜测坐标 def get_best_point(self): best_score = 0 best_point = (0,0) for r, row in enumerate(self.value): for c, item in enumerate(row): point_score = self._get_point_score((r,c)) if best_score < point_score: best_score = point_score best_point = (r,c) return best_point #计算某坐标的评分 def _get_point_score(self, point): #评分标准 (10-候选个数) + 同行确定数字个数 + 同列确实数字个数 r,c = point item = self.value[r,c] if isinstance(item, list): score = 10 - len(item) score += sum(map(lambda x: 1 if isinstance(x,int) else 0, self.value[r])) score += sum(map(lambda x: 1 if isinstance(x,int) else 0, self.value[:,c])) return score else: return 0
后半部分的逻辑理顺了,该算法完整的名称,命名为排除候选猜测法。
猜测就需要回溯,所以要引入“堆”先进后出。
from Queue import LifoQueue
涉及到对象的复制,要需要深度拷贝。numpy也有拷贝,不过还是会有影响。
import copy
另外,回溯需要保存的数据比较多。我创建一个类专门用于保存相应的数据:
class Recoder(): point = None #进行猜测的点所在坐标 point_index = 0 #猜测候选列表使用的值的索引 value = None #回溯记录的9*9数组
猜测的算法如下(同样写在Sudo类之中):
#猜测记录 def recode_guess(self, point, index=0): #记录 recoder = Recoder() recoder.point = point recoder.point_index = index #recoder.value = self.value.copy() #numpy的copy不行 recoder.value = copy.deepcopy(self.value) self.recoder.put(recoder) self.guess_times += 1 #记录猜测次数 #新一轮的排除处理 item = self.value[point] self.value[point] = item[index] self.new_points.put(point) self.solve_sudo() #回溯,需要先进后出 def reback(self): while True: if self.recoder.empty(): raise Exception('sudo is wrong') else: recoder = self.recoder.get() point = recoder.point index = recoder.point_index + 1 item = recoder.value[point] #判断索引是否超出范围。若超出,则再回溯一次 if index < len(item): break self.value = recoder.value self.recode_guess(point, index) #解题 def calc(self): #第一次解题 self.solve_sudo() #检查有没错误的,有错误的则回溯;没错误却未解开题目,则再猜测 while True: if self.check_value(): if self.get_num_count() == 81: break else: #获取最佳猜测点 point = self.get_best_point() #记录并处理 self.recode_guess(point) else: #出错,则回溯,尝试下一个猜测 self.reback()
这里再稍微讲解一下猜测-回溯的逻辑,举例说明。执行calc方法:
假如B3单元格候选列表[2,9]。
第1次:猜测B3第1个值,值为2;
排除候选计算之后,发现还未完成。继续猜测,选中D5,候选为[3,7];
第2次:猜测D5第1个值,值为3;
排除候选计算之后,发现有误。需要回溯到未猜测D5第1个值之前的状态。重新猜测;
第3次:发现D5还有第2个值未猜测,猜测D5第2个值,值为7;
排除候选计算之后,发现有误。需要回溯到未猜测D5第2个值之前的状态。重新猜测;
发现D5已经没有值可以猜测了,继续回溯。
回溯到猜测B3第1个值的状态,发现D5还有第2个值未猜测;
第4次:猜测B3第2个值,值为9。
...
这样一直下去,直到解答题目为止。
算法完成了,拿一些数独题目测试一下。数独题目大部分来自http://cn.sudokupuzzle.org/
if __name__ == '__main__': #数独题目来源 http://cn.sudokupuzzle.org/ #需要测试哪个就保留哪个 #2016-09-27 入门 data = [0,0,2, 8,0,1, 0,7,0, 4,0,0, 0,0,0, 0,0,1, 5,0,8, 7,0,0, 9,0,4, 0,0,0, 6,5,0, 1,4,0, 6,2,0, 0,0,0, 3,9,0, 1,7,0, 0,3,8, 2,6,0, 2,5,3, 4,8,7, 6,1,9, 0,0,6, 3,0,9, 7,5,2, 0,9,1, 5,0,0, 4,8,3] #2016-09-27 初级 data = [0,0,0, 0,0,0, 0,0,0, 0,6,0, 3,0,4, 0,2,0, 0,0,0, 0,0,0, 0,7,5, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 6,8,0, 3,0,0, 1,0,5, 0,7,0, 0,0,0, 0,0,0, 0,5,0, 0,0,0, 0,3,0, 0,0,0, 4,0,0, 8,0,0, 9,0,0, 0,0,0] #2016-09-26 中级 data = [0,0,0, 0,0,0, 0,5,9, 0,1,0, 0,0,4, 0,0,0, 0,8,0, 1,0,0, 0,0,0, 0,0,0, 0,0,0, 4,0,7, 5,0,3, 0,6,0, 0,0,0, 9,0,0, 0,0,0, 0,0,0, 6,0,0, 0,0,0, 0,3,0, 0,0,0, 0,0,1, 0,0,0, 0,7,0, 8,0,0, 2,0,0] #2016-09-30 高级 data = [0,8,0, 0,0,0, 0,0,0, 9,0,0, 5,0,0, 7,0,0, 0,0,1, 0,0,4, 0,0,2, 8,0,0, 0,0,1, 2,0,4, 0,0,0, 0,9,0, 0,0,0, 0,5,3, 7,0,0, 0,9,0, 0,6,0, 0,0,2, 0,8,0, 0,0,2, 1,0,0, 0,0,7, 0,0,0, 0,0,0, 6,0,0] #号称最难的数独 data = [8,0,0, 0,0,0, 0,0,0, 0,0,3, 6,0,0, 0,0,0, 0,7,0, 0,9,0, 2,0,0, 0,5,0, 0,0,7, 0,0,0, 0,0,0, 0,4,5, 7,0,0, 0,0,0, 1,0,0, 0,3,0, 0,0,1, 0,0,0, 0,6,8, 0,0,8, 5,0,0, 0,1,0, 0,9,0, 0,0,0, 4,0,0] try: t1 = time.time() sudo = Sudo(data) sudo.calc() t2=time.time() print(u"完成,猜测了%s次" % sudo.guess_times) print(sudo.value) print(u"耗时:%.3fs" % (t2-t1)) except Exception as e: print(e)
上面题目用时如下,我的电脑CPU是i3,内存8G:
2016-09-27 入门:0.004s,猜测0次
2016-09-27 初级:0.013s,猜测0次
2016-09-26 中级:0.210s,猜测74次
2016-09-30 高级:0.034s,猜测9次
号称最难的数独:0.330s,猜测109次
速度惊人,可见我这个算法效率还是可以的。
从上面的数据,可以发现:用时和猜测次数有关系,同时猜测次数也可以反应数独题目的难度。
完整代码如下(点击按钮):
相关专题: 我为数独迷