软件工程基础个人大作业 数独终局生成和求解
github项目地址https://github.com/lukal-new/new-potato/tree/sudoku
一、预计开发
| Personal Software Process Stages | 预估耗时(分钟) |
|---|---|
| 计划 | 60 |
| 估计任务所需时间 | 20 |
| 开发 | 1500 |
| 需求分析 | 30 |
| 生成设计文档 | 40 |
| 设计复审 | 40 |
| 代码规范 | 300 |
| 具体设计 | 300 |
| 具体代码 | 600 |
| 具体编码 | 600 |
| 代码复审 | 60 |
| 测试 | 300 |
| 报告 | 120 |
| 测试报告 | 120 |
| 计算工作量 | 30 |
| 事后总结并提出过程改进计划 | 120 |
| 合计 | 4240 |
二、解题思路描述
首先了解数独的规则,以下是摘自百度百科的内容。
数独(shù dú)是源自18世纪瑞士的一种数学游戏。是一种运用纸、笔进行演算的逻辑游戏。玩家需要根据9×9盘面上的已知数字,推理出所有剩余空格的数字,并满足每一行、每一列、每一个粗线宫(3*3)内的数字均含1-9,不重复 。数独盘面是个九宫,每一宫又分为九个小格。在这八十一格中给出一定的已知数字和解题条件,利用逻辑和推理,在其他的空格上填入1-9的数字。使1-9每个数字在每一行、每一列和每一宫中都只出现一次,所以又称“九宫格”。
根据题目意思,主要分为两部分。一是生成最多一百万个数独终局,并存入文件中,生成新文件要覆盖原文件。二是从某路径文件中读取最多一百万个数独,并求解存入文件中。以下内容主要描述算法和实现思路。
1.生成数独终局
数独的生成方法有很多种,比如随机法和行列变换法。但为了兼顾性能和输出时间,在此选择比较简单的一种算法。主要思路是随机生成第一行,然后从第二行开始,每一行是第一行右移3,6,1,4,7,2,5,8列的结果,这样就可以产生一个数独终局。
由于题目要求第一行第一个数字必须是固定的,我的固定数字为9,所以只用生成第一行的后八位数字。这样可以产生的数独终局个数为8!=40320种。为了达到要求的一百万种,可以将已生成的数独做行列交换,因为第一行已经固定,所以第二行和第三行可以交换,四五六行可以互换,七八九行可以互换。所以最终可以产生2!*3!*3!8!=2903040种,远超要求的一百万种。为了简化代码,只对四五六行、七八九行进行互换即可,二三行可以不用动。这样就有3!*3!*8!=1451520种,也达到了要求。
总结来说,使用的算法主要思路是,第一行的第一个数字固定为9,随机生成后8位数字,第一行右移3,6,1,4,7,2,5,8位依次得到剩下8行,可以互相交换四五六行、七八九行。最终输出每一行,可以得到题目要求的结果。
2.求解数独
求解数独的方法也有很多,比较典型的是回溯法,简单但效率比较低。回溯算法的主要思路是从根节点开始向下搜索,如该正在被搜索的节点有解,则向下继续搜索,如无解,则返回到该节点的父节点。对于数独求解问题,对所有空格,即为0的位置,可以依据全局找到可能填入的数字。从数字最少的空格开始,填入某一个数字,然后在数字第二少的空格填入数字,直到某一个空格无法填入位置,向上一个填入的空格返回,更换该空格填入的数字。
题目要求是找到一个可行解即可,所以不用搜索完所有解空间,直到找到一个可以将所有空格填入的方法,则不用继续搜索。并且可以设计合适的剪枝条件,来提高搜索效率。
三、设计实现过程
1.数独的表示形式
显而易见数独可以定义为int[9][9]这种形式。但对于终局生成来说,最多要打印1000000个数独终局,每个终局都是81个数字,如果用1000000个iint型二维数组肯定不行。所以在输出终局时要用一个足够长的字符串,包括了终局里的空格、换行符号。在此,我定义了一个长度为20000000的字符串,用来表示输出的所有数独终局。但由于这个字符串太大了,所以就作为全局变量,放在函数外面即可。
对求解数独来说,要先读取和得到的终局格式相同的文件,空格是用0表示,但要求解空格位置,就必须把读取到的字符串转化为int[9][9]的形式,才能进行求解,所以还是要在数独类中定义int[9][9]。
2.数独类的设计
由于整个程序是用c++写的,所以可以利用面向对象的封装性,把与数独有关的所有东西都封装在Sudoku类中,在主函数中对参数进行判别,定义一个类,然后调用相应的函数。
数独类里的私有变量是一个int[9][9]和两个文件指针,一个用来指向生成数独终局的文件的指针,一个用来指向求解数独存放文件的指针。
函数有一个生成数独终局函数createSudoku()函数,在这个函数外部,定义一个全局变量char[20000000],用来存放生成的终局。
class Sudoku
{
private:
char grid[9][9]h = { 0 };
FILE *resultfile1;
FILE *resultfile2;
public:
Sudoku();
~Sudoku();
void solveSudoku(string);
void createSudoku(int);
void backtrace(int );
bool isPlace(int );
};
3.函数流程
四、描述改进过程


由于软件配置的问题,导致在进行性能分析的时候,产生了一些奇怪的问题,经过网上查询也无果。改用同学的电脑得到的性能分析图如下。

由上图,可以得知消耗最大的函数如上,但由于符号文件不包含源信息,所以在界面里无法查询到具体函数。但根据上图含义,应该是createSudoku函数的fputs函数和全局变量matx[2000000000]。
char matx[200000000] = { 0 };
puts(matx, resultfile1);
在编译的时候,由于系统默认分配的栈过小,要在vs里更改系统栈的分配,但还是无法满足题目要求的生成一百万个数独终局,所以将数独字符串初始化为一个全局变量,这样就可以达到要求。
对fputs函数而言,是最后用来将生成的字符串导入sudoku.txt文本中的,这个函数比较方便,当指定参数较大时,使得要导入的字符串也比较大,所以fputs函数比较耗时。但其实并不影响整体性能。
五、代码说明
1.createSudoku函数
char matx[200000000] = { 0 };
void Sudoku::createSudoku(int num)
{
errno_t err;
err = fopen_s(&resultfile1, "sudoku.txt", "w");
if (err != 0)
{
printf("生成文档失败\n");
}
int count = 0;
int shift[9] = { 0, 3, 6, 1, 4, 7, 2, 5, 8 };
for (int i = 0; i < 6; i++)
{
if (count >= num)
{
//matx[count * 163 - 1] = '\0';
//cout << "i="<<i << endl;
break;
}
if (i)
{
next_permutation(shift + 3, shift + 6);
//shift[6] = 2, shift[7] = 5, shift[8] = 8;
//cout <<"sh1"<< shift << endl;
}
for (int j = 0; j < 6; j++)
{
if (count >= num)
{
//cout << "j="<<j << endl;
break;
}
if (j)
{
next_permutation(shift + 6, shift + 9);
//cout <<"sh2"<< shift << endl;
}
char row[10] = "912345678";
for (int k = 0; k < 40320; k++)
{
if (count >= num)
break;
if (k)
{
next_permutation(row + 1, row + 9);
//cout << "sh3" << row << endl;
}
int m = 0;
for (int t = 0; t < 9; t++)
{
for (int y = 0; y < 9; y++)
{
matx[m + count * 163] = row[(y + shift[t]) % 9];
matx[m + 1 + count * 163] = ' ';
//cout<<matx[m + count * 163] << endl;
m += 2;
}
//cout << matx << endl;
matx[(m - 1) + 163 * count] = '\n';
}
matx[162 + 163 * count] = '\n';
//cout << matx <<" 1"<< endl;
//cout << m << " 2" << endl;
count++;
//cout << count <<" 3"<< endl;
}
}
}
matx[count * 163 - 1] = '\0';
fputs(matx, resultfile1);
}
createSudoku函数首先打开并写入“sudoku.txt”文本,当存在这个文本时,覆盖掉,当不存在时,创建并写入。在之前的设计中,得知要产生大于一百万个数独,第一行右移3,6,1,4,7,2,5,8位依次得到剩下8行,可以互相交换四五六行、七八九行,共有3!*3!*8!=1451520种。则必须有一个3!3!8!即6640320的循环嵌套,通过next_permutation函数获得全排列组合。
2.solveSudoku函数
ifstream problemfile(path);
errno_t err;
err = fopen_s(&resultfile2, "sudoku.txt", "w+");
if (problemfile)
{
int total = 0;
string temp[9];
string str;
int line = 0;
bool exc = false;
while (total < 1000000 && getline(problemfile, str))
{
temp[line] = str;
line++;
if (line == 9)
{
for (int i = 0; i < 9; i++)
for (int j = 0; j < 9; j++)
{
grid[i][j] = temp[i][2 * j];
if (grid[i][j] < '0' || grid[i][j] > '9')
{
exc = true;
break;
}
}
getline(problemfile, str);
line = 0;
if (exc)
{
exc = false;
cout << "Error!" << endl;
continue;
}
total++;
// solve sudoku
long count = 0;
backtrace(0);
}
}
//resultfile.close();
}
else
cout << "Can't find such file:" << string(path) << endl;
solveSudoku函数首先打开指定路径的文件,若打开失败则报错。使用getline函数从文件中一行一行读取字符串,每9行为一个单位,进行循环。循环中,将读取到的字符串存入grid[9][9]中,并且判断存入的字符是否合法。若不合法,则报错。每读取完9行,需要将一个换行符吃掉。每一个grid[9][9]调用一个回溯函数,从0层开始回溯,在回溯函数中会调用判定函数。
六、实际花费时间
| Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟 |
|---|---|---|
| 计划 | 60 | 50 |
| 估计任务所需时间 | 20 | 20 |
| 开发 | 1500 | 480 |
| 需求分析 | 30 | 30 |
| 生成设计文档 | 40 | 60 |
| 设计复审 | 40 | 20 |
| 代码规范 | 300 | 60 |
| 具体设计 | 300 | 120 |
| 具体代码 | 600 | 480 |
| 具体编码 | 600 | 60 |
| 代码复审 | 60 | 60 |
| 测试 | 300 | 300 |
| 报告 | 120 | 150 |
| 测试报告 | 120 | 150 |
| 计算工作量 | 30 | 40 |
| 事后总结并提出过程改进计划 | 120 | 90 |
| 合计 | 4240 | 2170 |
七、测试和代码改进
1.单元测试概述
通过在课堂上的学习,我们知道测试可以分为:单元测试、集成测试、验收测试、系统测试。在这里我们需要进行的是单元测试和集成测试。单元测试是以模块为对象,对每个模块进行测试。集成测试是对几个模块的连接,将几个模块组合在一起,对组合的接口等进行测试。
在此处,因为vs上有现成的单元测试模板,要进行集成测试,需要安装NUnit框架,由于软件版本问题,我只进行了单元测试。如下图所示,设计了11个测试用例,主要是对输入的参数进行测试和对Sudoku类中的isplace函数进行的测试。测试1-8都是对输入参数的合法性进行的判断,测试9-11是对isplace函数的正确性判断。
2.设计单元测试
输入参数的格式为Project.exe -c N和Project.exe -s add,所以可以判定-c和-s参数类型,N是否为合法数字,并且大小在0-1000000之间,add是否为一个有效的地址。isplace函数的作用是判定每一行、每一列、每一宫的9个数字是否有重复,所以在此处判定给出一个行有重复和列有重复的,看是否能判定出来。还有就是借鉴网上的,可以对生成终局函数中使用到的生成随机数列的函数进行测试。这个函数的作用是在于对每一个终局,第一行经过平移生成剩下8行,随机函数用来生成除了第一个数字外的全排列。在这里的测试就是给出一行1-9排列的数字,然后调用随机生成函数,然后判定新生成的一行是否和原来的相同。
vs自带的单元测试模板中,是定义一个TEST类,类里有多个TEST_METHOD。一开始完全不知如何对我已经写好的代码进行测试,然后借鉴了网上,发现可以在类里定义一个argc和argv,为了将求解数独和生成数独终局区分开来,可以定义成argv_c和argv_s。然后,按照要求,给这些变量依次赋上要求的值,生成终局的个数为20。然后在每个TEST_METHOD中都要有Assert::AreEqual断言函数,参数为两个量,一个是预期输出,一个是实际输出,若这两个量逻辑值相等,则判定为相等。
输入值已经给出了,则主要的问题是在于,如何表示出预计和实际,网上的做法是他自己定义了一个CHECK类,专门用来判定参数。而我的程序里只有Sudoku类,于是我就在Sudoku类中添加了一些变量,type表示类型,num表示生成的终局数量,isint表示-c之后的参数为数字,isaddress表示-s之后的参数为有效的地址。而为了访问方便,将这些变量全都放在公有部分里。然后在每个TEST_METHOD中,构造一个Sukodu类,通过判断这个类的这些参数是否为预期值即可达到目的。结果如下。

3.改进代码
我一开始为了对各个模块进行测试,我对源代码多做的修改,光是在Sudoku类中添加变量是不够的,如上图,有6个测试实例失败。然后经过一番思考,我发现了要在这个TEST类中,调用了相应的函数才可以得到类的期望的变量值,而仅仅在主函数里有相应的判断和赋值的话,TEST类的函数无法调用,也就相当于无法读取相应的值。于是我就在Sudoku类中重新定义一个函数check,就是用来判断之前定义的几个参数的值,然后根据判断结果进行赋值。最终,11个测试用例均正确。

4.个人总结
到这里为止,可以说这次大作业基本完成了。从一开始不知道从何下手,到现在最终完成,也经过了漫长的时间。从本次大作业中,我对数独的生成和求解有了非常深刻的认识,但我自己的程序还是使用比较简单的由第一行平移得到剩下八行这样的简单的方法。因为要实现生成一百万个数独终局,又要保证效率,别的生成算法不太合适,实现起来也比较困难。
惭愧的是,虽然了解到了这种算法是如何实现的,但还是无法完全写得出来。还是得参考网上的代码,才知道全排列函数next_permutation(),而且在我自己写生成终局函数时,完全不敢相信要在for循环里套四五个for循环,居然还能跑。但经过自己的一番尝试之后,对这种生成算法的了解更为深刻了。求解数独就相对更困难一点了,虽然知道是一个回溯法实现,但是怎么组织回溯,怎么判断填入的数字对不对,怎么将文件里的数独读取出来,这些都让我头大。后来发现,其实对这个大作业来说,算法的实现不是真正的要点,重点时体会整个软件生成的全过程,所以就算没能自己实现这个算法其实也没什么。整个软件的开发不仅是代码的实现,更为重要的是把握整个流程。
经过这次大作业,我对整个软件开发的过程有了深刻的理解,对数独求解算法和生成算法也有了一定的了解。做了许多新鲜的事,管理自己的github仓库和博客,利用vs进行了单元测试和性能分析,怎么根据分析结果对代码进行优化等等,这些都是只有自己亲手实现才能收获的。这一次的软件开发虽然存在着很多不足,但我也收获到了很多,对今后的开发、编写代码等都有一定的帮助。
本文是关于软件工程基础课程的大作业,涉及数独终局的生成和求解。作业要求生成和求解最多一百万个数独终局。生成方法采用简单算法,固定首数字并随机生成其余部分,通过行列变换产生多种终局。求解方法使用回溯法,寻找可行解。数独表示为int[9][9]数组,并用面向对象的C++实现。单元测试确保代码正确性,性能分析优化了代码效率。

1077

被折叠的 条评论
为什么被折叠?



