数独
代码链接
GitHub仓库:https://github.com/gcrth/sudoku
图形界面的附加题同样在这个库的GUIBIN下,只有一个文件。
代码工程说明
运行说明
在根目录下有BIN文件夹,其中有soduku.exe即为主工程的可执行文件。
在根目录下有GUIBIN文件夹,其中的python文件为图形界面可执行文件。
图形界面为了方便测试可以注释倒数第二行的next函数生成只有一个空的题目。
请注意python调用了tkinter以及numpy库,python版本为3.6。
代码组织结构
主目录下的soduku文件夹放的是主工程文件,UnitTestForsoduku放的是单元测试文件。
PSP表格估计时间
PSP2.1 | Personal Software Process Stages | 预估耗时(min) | 实际耗时(min) |
---|---|---|---|
Planning | 计划 | 30 | |
· Estimate | 估计这个任务需要多少时间 | 10 | |
Development | 开发 | 1000 | |
· Analysis | 需求分析(包括学习新技术) | 100 | |
· Design Spec | 生成设计文档 | 100 | |
· Design Review | 设计复审(和同事审核设计文档) | 50 | |
· Coding Standard | 代码规范(为目前的开发制定合适的规范) | 50 | |
· Design | 具体设计 | 100 | |
· Coding | 具体编码 | 300 | |
· Code Review | 代码复审 | 100 | |
· Test | 测试(自我测试,修改代码,提交修改) | 200 | |
Reporting | 报告 | 200 | |
· Test Report | 测试报告 | 50 | |
· Size Measurement | 计算工作量 | 50 | |
· Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 100 |
解题思路
概要
这个项目有两个主要功能。第一个是生成数独终局;第二个是求解数独。这部分说明思路,具体算法查看后面的核心代码部分。
生成终局
拿到题目的第一反应是利用随机加上搜索来进行生成。但是这种方式难以判重,速度也比较慢。经过搜索,我找到了一种确定性的生成算法。
这种算法是基于下面这种终局的基础上交换得到的。
1
2
3
4
5
6
7
8
9
1
1
2
3
4
5
6
7
8
9
2
7
8
9
1
2
3
4
5
6
3
4
5
6
7
8
9
1
2
3
4
9
1
2
3
4
5
6
7
8
5
6
7
8
9
1
2
3
4
5
6
3
4
5
6
7
8
9
1
2
7
8
9
1
2
3
4
5
6
7
8
5
6
7
8
9
1
2
3
4
9
2
3
4
5
6
7
8
9
1
\begin{array}{c|lllllllll} { }&{1}&{2}&{3}&{4}&{5}&{6}&{7}&{8}&{9}\\ \hline {1}&{1}&{2}&{3}&{4}&{5}&{6}&{7}&{8}&{9}\\ {2}&{7}&{8}&{9}&{1}&{2}&{3}&{4}&{5}&{6}\\ {3}&{4}&{5}&{6}&{7}&{8}&{9}&{1}&{2}&{3}\\ {4}&{9}&{1}&{2}&{3}&{4}&{5}&{6}&{7}&{8}\\ {5}&{6}&{7}&{8}&{9}&{1}&{2}&{3}&{4}&{5}\\ {6}&{3}&{4}&{5}&{6}&{7}&{8}&{9}&{1}&{2}\\ {7}&{8}&{9}&{1}&{2}&{3}&{4}&{5}&{6}&{7}\\ {8}&{5}&{6}&{7}&{8}&{9}&{1}&{2}&{3}&{4}\\ {9}&{2}&{3}&{4}&{5}&{6}&{7}&{8}&{9}&{1}\\ \end{array}
123456789117496385222851749633396285174441739628555284173966639528417774163952888527416399963852741
不难发现第二行到第九行是在第一行的基础上右移3,6,1,4,7,2,5,8位得到的。交换第一行的任意两个数,后续行依次改变,终局依旧有效。我们交换2,3行/4,5,6行之间交换/7,8,9行之间交换/将4,5,6行与7,8,9行整体交换都可以得到不一样的终局。
求解数独
求解数独并没有太多的技巧可言,就是基本的回溯法搜索。为了可以比较快地得到结果,我们需要尽可能地剪枝。由于没有更多的评估函数,我们无法使用A*等算法进行加速。
设计
各模块设计
设计与许多元素有关,其中最重要的是选择是面向对象还是面向过程,以及用什么语言进行实现。需求中多次提到了性能的要求,所以我最后选择使用C++,设计中部分采用了面向对象,以求在不影响性能的前提下,尽可能地易于理解。
工程的主体由四部分构成。分别是主控模块命名为soduku,生成模块generate,求解模块solve,以及将IO部分进行一定封装的IO模块。各个模块的主要类以及相互之间的关系参见下面的包图。
在soduku主模块中,我主要处理了命令行参数,并根据命令行参数的具体内容来调用generate以及solve这两个真正的处理模块。在处理命令行参数以及文件操作时,无可避免会可能出现异常,所以我使用了try catch机制来处理异常,并在可能出现异常的地方加入检测以及抛出异常的代码。
在需求中有严格的性能要求,所以我采用了C语言中的文件函数,而没有采用c++中的输出流。为了适应c++中try catch机制,我对这些文件函数做了一些封装,使得对象在离开try的部分之后会自动调用析构函数关闭文件。在此基础上,我加上了使用文件必要的函数作为接口将其封装为OutFile以及InFile这两个类,为其他模块提供输入输出的支持。
生成模块中有生成器一个类,主模块会在使用生成功能时实例化一个匿名的生成器,并将生成的数量,输出对象,以及左上角元素的值传递给生成器的构造函数。构造构造函数会在初始化相应字段之后,调用生成的主函数run。主函数会生成终局,并调用类内对于输出对象的适配函数output进行输出。
求解模块的构建思路相似,构造函数会收到一个输入对象以及一个输出对象。构造构造函数会在初始化相应字段之后,调用生成的主函数run。在run中,它会用输入适配函数接收一个求解的局,用求解函数求解,然后用输出适配函数进行输出。
测试设计
在这个项目中,最重要的是三个模块,主控模块,生成模块,以及求解模块。我分三部分进行测试。
在生成测试中,我实例化一个生成器,随后读入生成的文件,测试其是否是合法的输出。具体来说会有一个小样例方便调试和一个大样例确认边界情况。
在求解测试中,我在生成的终局基础上挖空,生成题目,然后实例化求解器进行求解。随后读入输出文件,测试其是否是合格的输出。样例设置同上。
主控模块会接收一系列合法以及不合法的参数,测试其是否能够正确应对。样例主要关注异常情况。异常情况包括多参数,功能参数不存在,参数格式出错,输入文件不存在(打不开)。
性能改进
性能改进大概用时100分钟。
用例说明
我们选用比较大例子进行测试。生成我们会生成1e6个终局,求解我们会求解1e5个题目。我们测试版本均为release版。
生成
原性能
改进
不难看出大部分时间用在了输出上,我们尝试利用内存缓冲,然后一次性输出,以减少对文件的频繁写入,提高性能。缓冲区大小根据生成数量一次确定。
改进前代码
bool generator::output(bool withExtralEndl )
{
for (int i = 0; i < 9; i++)
{
for (int j = 0; j < 9; j++)
{
lineBuf[2 * j] = tableBuf[i][j] + '0';
}
if (writeFile.puts(lineBuf) == EOF)throw runtime_error("写入文件失败");
if (writeFile.puts("\n") == EOF)throw runtime_error("写入文件失败");
}
if (withExtralEndl)
{
if (writeFile.puts("\n") == EOF)throw runtime_error("写入文件失败");
}
return true;
}
改进后代码
bool generator::output(bool withExtralEndl )
{
for (int i = 0; i < 9; i++)
{
for (int j = 0; j < 9; j++)
{
lineBuf[2 * j] = tableBuf[i][j] + '0';
}
outbuf += lineBuf;
outbuf += '\n';
}
if (withExtralEndl)
{
outbuf += '\n';
}
return true;
}
bool generator::flush()
{
if (writeFile.puts(outbuf.c_str()) == EOF)throw runtime_error("写入文件失败");
return 0;
}
在主函数里要加上flush函数调用
改进后的性能
效果明显。
求解
原性能
改进
IO部分按照之前的改进办法进行改进。由于缓冲区大小无法确定,我们只能按照最大的情况进行考虑。同时改进递归部分的代码,减少递归深度,减少压栈出栈的时间。
原递归代码
bool solver::test(char i)
{
if (i == 81)return true;
if (tableBuf[i / 9][i % 9] != 0)return test(i+1);
char list[10];
searchAvalibleNumber(i / 9, i % 9, list);
queue<char> avalibleNumber;
for (int i = 0; i < 9; i++)
{
if (list[i + 1] == 1)avalibleNumber.push(i + 1);
}
while (avalibleNumber.empty() != true)
{
tableBuf[i / 9][i % 9] = avalibleNumber.front();
avalibleNumber.pop();
if (test(i + 1) == true)return true;
}
tableBuf[i / 9][i % 9] = 0;
return false;
}
改进后代码
bool solver::test(char i)
{
if (i == 81)return true;
if (tableBuf[i / 9][i % 9] != 0)
{
int j;
for (j = i + 1; j < 81; j++)
{
if (tableBuf[j / 9][j % 9] == 0)break;
}
return test(j);
}
char list[10];
searchAvalibleNumber(i / 9, i % 9, list);
queue<char> avalibleNumber;
for (int i = 0; i < 9; i++)
{
if (list[i + 1] == 1)avalibleNumber.push(i + 1);
}
while (avalibleNumber.empty() != true)
{
tableBuf[i / 9][i % 9] = avalibleNumber.front();
avalibleNumber.pop();
if (test(i + 1) == true)return true;
}
tableBuf[i / 9][i % 9] = 0;
return false;
}
改进后性能
核心代码
生成
我们根据之前找到的规律得到以下算法。
1. 选取一个包含1到9这九个整数的全排列作为数独终局的第一行。
2. 选取一组0到8的合法排序。根据这个序列在第一行上向左移相应位生成第一行到第九行。合法定义见下。
3. 根据上面的两组序列即可唯一确定一个合法的终局
想要生成一个合法的终局,第一步的排序没有任何要求。
由于需求中规定了左上角的元素的值,第一行的排序有
8
!
=
40320
8!=40320
8!=40320种组合。
第二步的要求如下。
- 第一个数为0,否则终局会重复。
- 第二三个数为1与2的全排序。
- 后面的六个数分为两组。一组为3,4,5;另一组为6,7,8。
- 上述的两组书可以在组内任意交换,两组的位置也可以任意交换。
根据上面的要求,我们可以生成
2
×
2
×
3
!
×
3
!
=
144
2 \times 2 \times 3! \times3!=144
2×2×3!×3!=144种组合。
所以根据这个算法我们可以生成
40320
×
144
=
5806080
40320\times144=5806080
40320×144=5806080种终局超过1e6,可以满足要求。核心算法如下。
int numOfRow;
numOfRow = numToGen / 144;
for (int i = 0; i < numOfRow; i++)
{
for (int j = 0; j < 144; j++)
{
for (int k = 0; k < 9; k++)
{
for (int l = 0; l < 9; l++)
{
tableBuf[k][l] = theFirstLine[(l + changelist[j][k]) % 9];
}
}
output(true);
}
next_permutation(theFirstLine + 1, theFirstLine + 8);
}
numOfRow = numToGen % 144;
for (int i = 0; i < numOfRow; i++)
{
for (int k = 0; k < 9; k++)
{
for (int l = 0; l < 9; l++)
{
tableBuf[k][l] = theFirstLine[(l + changelist[i][k]) % 9];
}
}
if (i == numOfRow - 1)
output(false);
else output(true);
}
我将第二类144个9位的序列记录在了changelist中,希望可以用空间交换一些时间。
求解
算法的基本框架如下。按照行优先的顺序递归遍历整个棋盘。如果元素值不是零(元素给定),就跳过这个元素。遇到一个空我们会先寻找目前可行的值,将其中一个值填入,继续向下递归试探。若是没有值可以填写就返回之前的位置,尝试另一个值。递归深度达到81层(0为第一层)时,棋盘上的值就形成了一个合法的终局。核心代码如下。
bool solver::test(char i)
{
if (i == 81)return true;
if (tableBuf[i / 9][i % 9] != 0)return test(i + 1);
char list[10];
searchAvalibleNumber(i / 9, i % 9, list);
queue<char> avalibleNumber;
for (int i = 0; i < 9; i++)
{
if (list[i + 1] == 1)avalibleNumber.push(i + 1);
}
while (avalibleNumber.empty() != true)
{
tableBuf[i / 9][i % 9] = avalibleNumber.front();
avalibleNumber.pop();
if (test(i + 1) == true)return true;
}
tableBuf[i / 9][i % 9] = 0;
return false;
}
我们通过test(0)即可调用上面的函数。
实现过程细节
我还依靠vs自带的代码分析工具进行了代码质量的分析,并做了更正。我犯的错主要是有一些变量未在一开始做初始化。
PSP表格实际时间
PSP2.1 | Personal Software Process Stages | 预估耗时(min) | 实际耗时(min) |
---|---|---|---|
Planning | 计划 | 30 | 30 |
· Estimate | 估计这个任务需要多少时间 | 10 | 10 |
Development | 开发 | 1000 | 1130 |
· Analysis | 需求分析(包括学习新技术) | 100 | 300 |
· Design Spec | 生成设计文档 | 100 | 120 |
· Design Review | 设计复审(和同事审核设计文档) | 50 | 30 |
· Coding Standard | 代码规范(为目前的开发制定合适的规范) | 50 | 30 |
· Design | 具体设计 | 100 | 100 |
· Coding | 具体编码 | 300 | 200 |
· Code Review | 代码复审 | 100 | 50 |
· Test | 测试(自我测试,修改代码,提交修改) | 200 | 300 |
Reporting | 报告 | 200 | 240 |
· Test Report | 测试报告 | 50 | 60 |
· Size Measurement | 计算工作量 | 50 | 60 |
· Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 100 | 120 |