【软件工程基础个人项目】一个数独终局生成和求解的控制台程序
Github 项目地址
任务
实现一个能够生成数独终局并能求解数独问题的控制台程序。
时间预估
PSP 2.1 | Personal Software Process Stages | 预估耗时(min) | 实际耗时(min) |
---|---|---|---|
Planning | 计划 | 30 | |
Estimate | 估计这个任务需要多少时间 | 3000 | |
Development | 开发 | 1500 | |
Analysis | 需求分析(包括学习新技术) | 200 | |
Design Spec | 生成设计文档 | 60 | |
Design Review | 设计复审(和同事审核审计文档) | 30 | |
Coding Standard | 代码规范(为目前的开发制定合适的规范) | 30 | |
Design | 具体设计 | 120 | |
Coding | 具体编码 | 500 | |
Code Review | 代码复审 | 60 | |
Test | 测试(自我测试,修改代码,提交修改) | 400 | |
Reporting | 报告 | 30 | |
Test Report | 测试报告 | 30 | |
Size Measurement | 计算工作量 | 10 | |
Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 60 | |
合计 | 3000 |
需求分析
本程序功能比较单一,并且两个功能模块各自独立,输入、输出格式和要求也已经给出,因此需求分析工作比较简单。
模块划分
程序从命令行得到命令与参数,并根据命令实现两个功能,因此把程序初步划分为以下模块:
- 命令判断与处理
从命令行获得命令后,判断命令类型是生成命令还是解决命令,并检查参数是否正确(如生成数独的参数必须是1-1e6范围内的数字),参数正确则调用并传参给相应的模块。 - 生成数独
生成指定数量的数独终局,并按格式写入 sudoku.txt 文件。 - 解决数独
从指定的路径读入待解决的数独题目,将可行解按格式写入 sudoku.txt 文件。
功能建模
通过数据流图来进行功能建模。
顶层图:
第 1 层图:
解题思路描述
生成算法思路
生成算法参考了 xxrxxr的博客 ,以 1 个终局为模版,通过以下两种方式生成新的终局:
- 数字的交换
因为当前的终局已经满足数独条件,且数字的交换并不会破坏数字间的位置关系,因此可以通过数字的交换来生成其他终局。
第 1 行第 1 个数字固定为学号末两位模 9 加 1 ,因此只能交换剩下 8 个数字,可以生成8! = 40,320
种终局。 - 行的交换
数独终局 1-3、4-6、7-9 行之间可以交换,且不破坏数独条件,因此可以通过行的交换生成其他终局。
第 1 行第 1 个数字固定为学号末两位模 9 加 1 ,因此只能交换 2-3、4-6、7-9行,可以生成2! * 3! * 3! = 72
种终局
两种方式结合,共生成 8! * 2! * 3! * 3! = 2,903,040
种终局,满足最大要求 1e6 。
求解算法思路
参考 暴力算法之美:如何在1毫秒内解决数独问题?| 暴力枚举法+深度优先搜索 POJ 2982
基本求解思路是暴力枚举和深度优先搜索。但是由于求解的数独数目最高可以达到 1,000,000 个,且空白数目较多,因此需要对算法进行优化。
优化思路是将数独中的空白按照可填数字数目从低到高的顺序进行排序,优先选择可填数字少的格子,可以减少大量递归调用函数自身时间。
由于每行每列的数字都不相同,且这是一个 9 x 9 的数独,因此每个格子可填的数字数目就是
(9 - max ( 所在行已填数字数目 , 所在列已填数字数目 )
更精准的优化可以通过每个 3 x 3 的小宫格来优化,优化后每个格子可填的数字数目就是
(9 - max ( 所在行已填数字数目 , 所在列已填数字数目,所在宫格已填数字数目)
设计实现
按照功能建模环节的规划,整个程序大体分为3个部分:
- 命令判断与处理
- 解决数独
- 生成数独
其中,命令判断与处理集成在主函数中,因此除主函数之外,还有两个主要的函数:
解决数独函数SolveSudoku()
生成数独函数CreateSudoku()
函数调用关系图如下:
性能优化
第一个版本生成 1,000,000 个数独,在 macOS 下运行时间 57 s ,在 Windows 10 环境下运行时间 577 s 。
通过 Visual Studio 2017 的性能分析工具进行分析:
发现耗时最多的主要是写入文件的部分,包括
std:endl
: 插入换行符并且 冲入输出序列。std::basic_filebuf
: 函数underflow()
、overflow()
、sync()
进行文件和缓冲区的获取放置区之间的实际 I/O 。
第一个版本的 I/O 方式是在开始生成数独终局前,打开一个文件,每生成一行写入一次。
查询 cppreference.com ,结合代码分析出性能瓶颈主要为以下两点:
- 每行结束都插入换行符,
std::endl
操纵符插入换行之后都会flush()
,由于采用的是生成一行写入一行的 I/O 方式,所以频繁flush()
耗费较多时间。 - 生成一行写入一行的 I/O 方式导致
std::basic_filebuf
为了维护文件位置会对指针进行频繁操作。文件在生成终局过程中始终保持打开,sync
函数耗费大量资源保持同步。
针对以上两点,性能提升主要通过改进 I/O 方式。
- 对
std::endl
插入换行符后flush()
的性能问题,不再在每行结束后插入endl
,改为插入'\n'
。 - 对
std::basic_filebuf
的性能问题,更改 I/O 方式,不再生成一行写入一行,通过一个大的字符数组缓存所有要写入文件的字符,包括空格和换行符,在生成结束后一次性写入文件。
通过上面两次修改后,第二个版本在 macOS 环境下运行时间 0.78 s ,在 Windows 10 环境下运行时间 3.354 s,速度大幅提升。
通过 Visual Studio 2017 的性能分析工具进行分析:
第二个版本各部分消耗比较均衡,其中最耗时部分是 std::next_permutation
函数,这是用于生成下一个数字排列顺序的全排列函数,由于数独的生成主要依赖与数字顺序的变化和行列的交换,因此生成排列的函数调用次数较多,耗时符合预期,性能瓶颈基本解决。
代码质量分析
利用 Visual Studio 的代码质量分析工具,对代码质量进行检查,发现一个警告:
出现警告的代码行:
cout << "time = " << double(finish - start) / CLOCKS_PER_SEC << "s" << endl;
出现的问题是把用于计时的变量 finish - start
的结果强制转换为 double
型。
clock_t
是 4 byte 的变量,double
是 8 byte的变量,因此代码质量分析工具提示应该在运算前 (before calling operator ‘-’) 就进行转换 (cast the value to the wider type) ,避免出现溢出 (avoid overflow) 。
按照代码质量分析工具的提示修改该行代码:
cout << "time = " << (double(finish) - double(start)) / CLOCKS_PER_SEC << "s" << endl;
修改后,所有警告消除,代码质量检查结束。
关键代码
生成算法核心代码
do
{
for (int i = 0; i < 9; ++i) //生成数字交换对应表
trans[g_row[0][i] - 49] = arr[i];
for (int i = 0; i < 9; ++i) //按对应表把模版转换为新的数独终局
for (int j = 0; j < 9; ++j)
newRow[i][j] = trans[g_row[i][j] - 49];
for (int i = 0; i < 2 && n; i++) //以下三个循环分别交换2—3,4-6,7-9行
{
for (int j = 0; j < 6 && n; j++)
{
for (int k = 0;k < 6 && n; k++)
{
for (int m = 0; m < 9; ++m)
{
for (int n = 0; n < 9; ++n)
{
g_output[tempPointer++] = newRow[order[m]][n] +'0';
if (n == 8)
g_output[tempPointer++] = '\n';
else
g_output[tempPointer++] = ' ';
}
}
if (--n)
g_output[tempPointer++] = '\n';
else
return;
next_permutation(order+6,order+9);
}
next_permutation(order+3,order+6);
}
next_permutation(order+1,order+3);
}
}
while(next_permutation(arr+1,arr+9)); //生成下一个数字交换的排列
解决算法核心代码
预处理部分:
if(unsolvedSudoku[r][c] == 0) //如果是0(代表空白的格子),则记录下位置
{
blank[blankCounter][0] = r;
blank[blankCounter][1] = c;
blankCounter++;
}
else //否则标记数字,并在用于计算可填数字的每行每列每
{ //块的对应数组记录中增加1
SetMark(r, c, unsolvedSudoku[r][c], 1);
row[r]++;
col[c]++;
block[BlockNum(r, c)]++;
}
DFS部分:
bool DFS(int deep)
{
if(deep==blankCounter) //深度与空白格子数量相同,则数独被成功解决
{
return true;
}
int r = blank[deep][0], c = blank[deep][1];
for(int i = 1; i < 10; i++)
{
if(!rowMark[r][i] && !colMark[c][i] && !blockMark[BlockNum(r, c)][i]) //枚举并判断是否可以填入1-9数字
{
unsolvedSudoku[r][c]=i;
SetMark(r, c, unsolvedSudoku[r][c], 1);//填入数字
if(DFS(deep+1))return true;
SetMark(r, c, unsolvedSudoku[r][c], 0);//恢复原样准备填入下一个
unsolvedSudoku[r][c]=0;
}
}
return false;
}
时间统计
PSP 2.1 | Personal Software Process Stages | 预估耗时(min) | 实际耗时(min) |
---|---|---|---|
Planning | 计划 | 30 | 30 |
Estimate | 估计这个任务需要多少时间 | 30 | 30 |
Development | 开发 | 1500 | 1000 |
Analysis | 需求分析(包括学习新技术) | 200 | 100 |
Design Spec | 生成设计文档 | 60 | 60 |
Design Review | 设计复审(和同事审核审计文档) | 30 | 30 |
Coding Standard | 代码规范(为目前的开发制定合适的规范) | 30 | 30 |
Design | 具体设计 | 120 | 60 |
Coding | 具体编码 | 500 | 600 |
Code Review | 代码复审 | 60 | 60 |
Test | 测试(自我测试,修改代码,提交修改) | 400 | 500 |
Reporting | 报告 | 30 | 60 |
Test Report | 测试报告 | 30 | 20 |
Size Measurement | 计算工作量 | 10 | 10 |
Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 60 | 120 |
合计 | 3080 | 2710 |