项目Github地址:https://github.com/JerryYouxin/sudoku.git
PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 30 | 10 |
· Estimate | · 估计这个任务需要多少时间 | 400 | 720 |
Development | 开发 | 380 | |
· Analysis | · 需求分析 (包括学习新技术) | 40 | 20 |
· Design Spec | · 生成设计文档 | 30 | 30 |
· Design Review | · 设计复审 (和同事审核设计文档) | 20 | 10 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 30 | 10 |
· Design | · 具体设计 | 60 | 30 |
· Coding | · 具体编码 | 90 | 100 + 400(优化) |
· Code Review | · 代码复审 | 20 | 30 |
· Test | · 测试(自我测试,修改代码,提交修改) | 60 | 20 |
Reporting | 报告 | 60 | 30 |
· Test Report | · 测试报告 | 30 | 20 |
· Size Measurement | · 计算工作量 | 15 | 10 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 15 | 10 |
|合计 | 400 | 720
实际开发
需求分析
软件关键分为四个模块:一个是输入参数的解析;一个是数独生成器,生成若干数独终局;一个是数独求解器,根据输入数独求解一个可行解;一个是终端,在命令行程序中为文件输出,在附加题中为GUI界面显示。
现在一个模块一个模块进行细化。首先输入参数解析:有参数-c和-s,需要做到合法性检验,即参数必须是下面中的一个。
参数 | 类型/范围 |
---|---|
-c | 整数N,1<=N<=10000000 |
-s | 数独题目的路径path |
数独生成器:需要输入一个参数N,意义为生成数独终局数量,其中1<=N<=1000000,且不重复。生成数独矩阵时,左上角的第一个数为:(学号后两位相加)%9 + 1。
数独求解器:需要输入一个9x9的数独矩阵,并解出一个可行解。
终端:命令行程序终端有两类功能,一是按格式输出数独文件,二是按格式读取数独文件。
设计模块
命令行程序使用C++进行编写。分为五个类:SudokuGame类,对应于上文中的终端;SudokuCreator,对应于上文中的数独生成器;SudokuSolution,对应于上文中的数独求解器;ArgumentParser,对应与上文中的参数解析器;Sudoku类对应于数独存储的矩阵数据结构。
其中Sudoku中有generate(int N)方法,可以生成不重复的N个数独中盘矩阵,一个比较原始的生成算法为:
利用回溯法解出N个空数独的解
但这种算法不可避免的在中间不可解的数独状态搜索一段时间,比较耗时。还有一个感觉更好一些的生成算法为:
Step 0:
根据随机法初始化一个数独A。
Step 1:
根据随机初始化的数独A,由
1.同列网格中的列与列的互换(每列有三种互换方式 + 不变换)
2.同行网格中的行与行的互换(每行有三种互换方式 + 不变换)
所以每个数独可以通过有限次1,2变换得到最多一共 44444*4 = 4096种不同的数独。其中也包含了特例:如果互换两列/行相同,则互换前后数独是不变的,所以这种情况下会不计入生成的数独当中。
Step 2:
然后对原始的数独A进行下面两种变换之一得到新的数独A':
1. 进行对角翻转,即A'[i][j]=A[9-i][9-j]。
2. 进行数替换:将所有1变为2,2变为3,……,9变为1。
再将生成的所有数独A'按照Step 1的方式进行扩增,这样最终最多能达到 1024096 = 81920种不同的数独。
其中Sudoku中有solve()方法,可以求解数独,求解算法为:
1. 遍历一遍数独,在行、列、块的标记数组中标记已经填好的数,表示该数在所在行、列、块中已经用过,不能再用。
2. 从第一个没有填数字的空白开始,选择一个还没有在该行、列、块中用过并还未尝试过的数字,并标记在对应标记数组中。
3. 若没有数字可以填入该空白,则回溯到上一个填上的空白,并回标(即反过程)对应的标记数组中。重复过程2.
也就是简单的回溯算法。
设计实现过程
由于数独本身并不复杂,主要有三个要点功能:存储数独,生成数独,解数独。这三个操作都是对数独数据的操作,所以都可以归到一个数独类中,其中包括:
print -- 打印数独数据
generate -- 生成数独
solve -- 求解数独
Sudoku(filename) -- 从文件filename中读取数独数据
Sudoku(N) -- 初始化N个数独
check() -- 数独查重(检测用)
其中辅助函数有:
parse(argc, argv, arg) -- 解析argv中的参数列表,并将解析出来的参数存到arg中
hash_code() -- 数独查重用的哈希函数,用来初步检测两个数独有没有可能相等
-- 哈希函数选择为:数独中所有块中第i项的和的乘积,i=1~9。
单元测试主要就测两个功能即可:
1. 读取不同大小的数独,并求解输出。大小、难度各异的测试数据5组。最大数据要有1000000个。
2. 生成不同数量的数独终局,并输出。大小要有两个极限值(1,1000000),中间(1000),奇数(3,一个随手写的大于10000的奇数)。
这样初步的设计就完成了,就可以开始编码了。
初步编码&测试
首先编写了数独的生成部分的函数。上面通过生成随机模板并进行等价变换的方法由于变换并不能保证每个变换出来的数独都是不同的,也就是在生成较多数独的时候会不可避免的产生大量的重复数独,而为了消去重复数独就需要在一个新的数独生成后与生成好的其他数独进行比较来进行去重操作,而这种查重操作是非常耗时的,即便用了哈希的方法(可能我选的哈希函数并不好)进行查重也是非常的耗费计算资源,使得使用这个方法优化起来感觉非常困难。由此我后来还是选择了回溯算法进行批量生成。
生成部分的正确性检验正确后,开始开发数独求解器部分。总体算法上跟数独的生成是一样的,都是回溯法进行可能解的搜索。
性能分析及优化方案
终于来到了性能分析阶段。在这个阶段上我花费的时间比较长(虽然并没有太大本质上的效果)。性能优化之前先得分析性能瓶颈在哪里。我们先来测试数独生成1000000个的时候的性能分析图:
由分析图可以看出主要有两个热点函数:generate函数和print函数。其中输出是主要的瓶颈。为什么呢,我们看一看原来写的代码(示意):
for n = 0, N:
for i = 0, 9:
for j = 0,9:
fprintf a number
fprintf '
'
fprintf '
'
可以看到是一个一个数进行输出的,一个一个写磁盘是非常低效的,写磁盘如果能够批量块存储会快许多,那么优化一下,让这些数据缓存到一个buffer里面,再统一输出就会好多了:
for n = 0, N:
for i = 0, 9:
for j = 0,9:
sprintf a number to buffer
sprintf '
' to buffer
sprintf '
' to buffer
fwrite buffer
发现速度有些许提高,但用性能分析工具分析发现fwrite并不耗时,格式转换到缓存却耗时多了。那现在只能想想改变数据存储结构来减少中间的格式转换过程了:数独数据直接用字符进行存储。
由于数据数据只有0-9这十个数字就可以表示,而要求的输出格式是很有规律的,所以用字符进行存储完全不会影响算法的速度与实现,存储空间也由原来的每个数字short变为一个数字字符和格式字符(空格或者换行符),所以空间消耗也完全不变——也就是跟原来相比基本没有效率上的损失。所以原来的输出就变为了:
for n = 0, N:
fwrite sudoku_data(n)
fwrite '
'
实际测试后,写1000000个数独的时间从60多秒减到了大概2s,大概有30x的加速,应该是差不多了。如果还想加速的话,写内存文件也是一个好方法,从物理层面上能比写磁盘快多了。但是windows的写内存文件比较麻烦,而现在的主要瓶颈在生成算法上,就没有实现。
生成/求解算法上,可以用跳舞链进行优化。数独可以解释为精确覆盖问题,跳舞链是解决精确覆盖问题的有效算法,但是实现的时候出了点问题,由于时间限制就没有实现。
数独求解上,性能瓶颈跟数独生成比较类似,也是算法和I/O比较耗时。输出的解决方法已经说过了,输入也是大同小异,就不深入说明了。
数独求解的算法也是回溯,但是数独求解有多个数独需要求解,而这些数独之间并没有关联,即可以并行处理多个数独来提高求解速度。现在CPU一般都有多核,所以可以用openmp指导编译语句进行多核多线程并行运算,进一步提高计算性能。即在程序中加入一句:
#pragma omp parallel for
for(int n=0; n<N; ++n) {
Solve n-th sudoku puzzle
...
}
这样便可以利用现代处理器的多核架构,在我的计算机中,有四核并行,计算的时候利用率可以达到将近100%,与原来相比也有将近4倍的加速比。
这个项目的最主要的代码就是回溯法求解的代码了。回溯法求解思路上面也说过,主要有三个重要的缓存数组来加速回溯过程:
empty_value -- 第 i 行/列/块 是否已经被使用。
trying_value -- 第 i 个数填了哪个数字。
fill -- 第 i 个空白是数独中的第几个数。(空白位置)由初始化得到后不再改变。
他们可以用来快速决定在哪里、哪个数是可以填入的。在回溯过程中会随着搜索进行不断地更新这些数组。