一.项目连接
项目连接:https://github.com/broken-dream/1120161758-sudoku
二.PSP表格
PSP | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
·Estimate | ·估计这个任务需要多长时间 | 3000 | |
Development | 开发 | ||
·Analysis | ·需求分析(包括学习新技术) | 300 | 300 |
·Design Spec | ·生成设计文档 | 120 | 240 |
·Design Review | ·设计复审(和同事审核设计文档) | 0 | 0 |
·Coding Standard | ·代码规范(为目前的开发制定合适的规范) | 20 | 20 |
·Design | ·具体设计 | 120 | 100 |
·Coding | ·具体编码 | 1200 | 900 |
·Code Review | ·代码复审 | 120 | 100 |
·Test | ·测试(自我测试,修改代码,提交修改) | 600 | 1000 |
Reporting | 报告 | ||
·Test Report | ·测试报告 | 120 | 80 |
·Size Measurement | ·计算工作量 | 30 | 30 |
·Postmortem & Process Improvement Plan | ·事后总结,并提出过程改进计划 | 120 | 90 |
合计 | 2480 | 2860 |
三.解题思路
题目中有两个要求,一个是生成给定数目的数独终局,一个是求解数独。考虑到代码重用的问题,我的想法是先实现数独求解的算法,有了求解算法,生成数独终局就可以通过随机初始化数独+解数独来实现,这样可以以较少的代码实现整个任务。但是之后意识到这样数独生成的效率会很低,于是决定分开实现两个功能。
针对生成数独,起初是希望生成的数独尽可能随机,想采取随机算法来实现数独终局生成,但在仔细分析之后发现这种方法和解数独的过程并没有太大区别,只是将深度搜索变为了随机搜索,遂放弃。之后考虑以一定的规则生成数独。对于一个合法的数独终局,在一定的约束条件下进行行列变换得到的数独终局依旧是合法的。但是对于数独终局的生成我依旧没有特别好的想法,于是考虑采取随机初始化数独终局+行列变换的方法。考虑到数独左上角的数字不能动,故不对前三行和前三列进行行列变换,这样对于每一个终局,一共有种变种,针对1e6的要求,约需要13900个终局。
但这个方法的效率依旧很低,因此去网上查阅资料。网上相关的资料很多,无论是计算机相关专业人士还是往届同学,都有相关的博客讨论这个问题。在查阅了资料后得知,对于一个1至9的全排列,可以按如下规则生成一个合法的数独终局:
(1)对于一个全排列,生成新的排列,其中,t为[1,8]之间的整数,原排列可视为t=0时得到的结果。
(2)根据t属于模三等价类的情况对九个排列进行划分,得到三组,即t=0,3,6为一组,t=1,4,7为一组,t=2,4,8为一组。
(3)将上述三组以任意顺序组合在一起即可得到一个合法的数独终局
针对题目的要求,左上角第一个数固定,故第一排共有8!=40320种情况,约定将t=0,3,6的组作为前三行,t=1,4,7作为中间三行,t=2,4,8的组作为最后三行,即可生成一个终局。只考虑对中间三行和最后三行分别进行行变换,共有种情况,36*40320=1451520,满足题目1e6的要求。
至于解数独,主要采取的是基于深度优先的回溯法+剪枝。剪枝的思路是开三个数组visit_row[9][10],visit_col[9][10],visit_squard[3][3][10],分别记录每行,每列,每个3×3矩阵内数字出现的情况,若当前的数字已经出现则该分支被剪掉。
四.设计实现过程
基于对题目的需求分析,主要的思路是将程序分为两个大的模块:生成模块和求解模块。因为题目中有对性能的要求,因此考虑采用C语言编写程序。
对于终局生成,由于采取的是排列组合法,因此可以将某个排列对应的36个组合作为一组进行生成。整个过程可以视为一个二重循环,内层循环为当前组内的数独终局;外层循环对应一个1-9的排列。当计数达到n时跳出循环完成生成。为了节约内存,采取逐行输出的方法,即不存储完整的数独终局,生成到哪行就输出哪行,之后被下一行覆盖。
对于数独求解,因为采取的是回溯法求解,故对于一次数独求解,设计一个递归函数即可。为了提高求解效率,需要对搜索过程进行剪枝,剪枝规则即为数独的规则,即每行、每列、每个3×3小矩形内不出现重复数字,因此设计一个judge函数来判断当前路径是否要被剪枝。同时对于数独,需要全部求解完成之后才可以输出,故需要保存下来,设计一个单独函数进行输出。
数据建模:
顶层图:
一层图:
二层图:
状态转换图:
函数整体流程图如下:
函数调用关系图如下:
对于单元测试,因为该项目对性能有较高的要求,因此在编写代码的过程中尽量避免函数的调用和参数的传递,使用了较多的全局变量,故难以对一个函数进行单元测试。可测性比较高的模块只有参数检查模块,故单元测试的设置主要考虑了各种参数的错误情况,然后针对生成和求解,各设置了一个测试用例。对于生成和求解,主要进行的是对正确性的验证,对于格式主要通过打开文件查看,没有设置单独的单元测试。单元测试结果如下:
其中,TestGenerate和TestSolve分别测试的是生成终局和数独求解的正确性,其余为测试参数有误能否正确处理。考虑的参数错误形式包括参数个数不符合要求,参数形式不符合要求,以及生成终局要求的生成个数不是正整数等情况。
可以看到,所进行的单元测试代码覆盖率达到98%,应该说比较完整的检测了项目代码。
使用VS自带的代码质量检测工具对代码进行检测。
主要警告是一些不安全的函数,按提示修改之后,消除了所有警告。
五.性能改进
最初生成1e6的终局用时在16s左右,通过查看性能分析,发现主要时间都花在了IO上面,故开始考虑更新输出方式。
最开始我的输出方式是使用fputc()函数一个字符一个字符输出。采取这种方式主要是因为处理的数独的时候为了运算简单。为了使代码更简洁,我采取了设置一个计数器,通过计数器/9和计数器%9来获取下标,一旦考虑空格会使下表运算比较复杂。但这样的缺点是只能用9×9的数组来处理数独;同时为了节约内存,没有选择重新生成一个字符数组来打印,因此选择逐字符输出,但结果证明效率很差。
之后将生成好的数独按照规定的输出格式转换到字符数组内,使用f_puts函数逐行输出,效率有了很大提升,生成1e6的终局时间缩短到4.5s。
考虑继续提升性能,目前的方法每生成一个终局都需要进行一次数组赋值,这个赋值可能会带来一些时间代价,因此考虑直接在含有空格的字符数组中进行操作。采取此种方法后,性能有了进一步提升,生成1e6终局时间在4s左右。
之后在和同学的交流中得知,采用fwrite函数进行输出比fputs函数效率更高,修改后生成1e6终局的时间缩短到3s。
可以看到,大部分时间都花在了make函数内,即输出部分。
为了减少时间消耗,应尽可能减少函数调用,而逐行输出的方式会多次调用输出函数,因此考虑将所有结果存到一个大缓冲中,全部生成完成后一次输出,以空间换时间,采取该方法后,生成1e6终局花费的时间降低到1.4s。
可以看到,生成部分generate只占用了15%左右的时间,而main中的外部代码即fwrite占用的时间高达接近80%。下面是进程过程的cpu使用率和函数调用关系。
六.关键代码说明
生成终局部分
unsigned int shift[9] = { 0,6,12,2,8,14,4,10,16 }; //位移量,因为考虑空格所以都进行了乘2
unsigned int permutation_change1[6][3] = { { 3,4,5 },{ 3,5,4 },{ 4,3,5 },{ 4,5,3 },{ 5,3,4 },{ 5,4,3 } }; //中间三行的行变换排列
unsigned int permutation_change2[6][3] = { { 6,7,8 },{ 6,8,7 },{ 7,6,8 },{ 7,8,6 },{ 8,6,7 },{ 8,7,6 } }; //最后三行的行变换排列
数独的生成算法是基于排列组合实现的,为了提高代码效率,将行位移的下标变化量和行变换的组合情况都提前进行了打表。
void make()
{
for (int row = 0;row < 9;row++) {
for (int col = 0;col < 9;col++) {
mp[row][(col * 2 + shift[row]) % 18] = '0' + first_line[col];
}
}
for (int com = 0;com < 36;com++) {
int cur_cnt = 0;
int idx1 = com / 6;
int idx2 = com % 6;
for (int row = 0;row < 3;row++) {
strcpy(temp + cnt * 163 + cur_cnt * 18, mp[row]);
cur_cnt++;
}
for (int row = 0;row < 3;row++) {
strcpy(temp + cnt * 163 + cur_cnt * 18, mp[permutation_change1[idx1][row]]);
cur_cnt++;
}
for (int row = 0;row < 2;row++) {
strcpy(temp + cnt * 163 + cur_cnt * 18, mp[permutation_change2[idx2][row]]);
cur_cnt++;
}
cnt++;
if (cnt == n) {
mp[permutation_change2[idx2][2]][17] = '\0';
strcpy(temp + (cnt - 1) * 163 + cur_cnt * 18, mp[permutation_change2[idx2][2]]);
return;
}
strcpy(temp + (cnt - 1) * 163 + cur_cnt * 18, mp[permutation_change2[idx2][2]]);
temp[(cnt - 1) * 163 + (cur_cnt + 1) * 18] = '\n';
}
}
make()函数是生成终局的主要函数,每次调用make()函数理论上会生成一组(36)个终局,如果已经达到要求则返回。
根据第一行,依据解题思路中提到的平移行来生成原始的数独终局。因为要考虑空格,所以下标和模都比不考虑空格扩大了两倍。
cur_cnt记录的是当前输出的为第几行,idx1和idx2指示当前中间三行和最后三行如何进行排列组合。
temp + cnt * 163 + cur_cnt * 18该公式为计算拷贝首地址的公式,temp表示缓冲区首地址;cnt*163表示已经存储的终局个数,每个终局对于每个数字,其后面跟随一个空格或换行符,每个终局结尾有一个换行符,故一个终局共占91*2+1=163个字符;cur_cnt*18表示当前终局已经存储的字符,每行18个字符。
求解部分
相关变量如下
char visit_squard[3][3][11] = { 0 }; //记录每个小矩形内数字出现情况
char visit_row[18][11] = { 0 }; //记录每行数字出现情况
char visit_col[18][11] = { 0 }; //记录每列数字出现情况
char init_exist[18][18] = { 0 }; //记录初始时存在的数字
bool flag_solve; //是否完成求解,完成为1,未完成为0
visit_squard[i][j][num]记录[i,j]位置的3×3矩形是否出现过num;visit_row[i][num]表示第i行是否已经存在数字num,visit_col同理;init_exist[i][j]表示初始化时该位置是否有数字。上述变量为1表示是,0表示否。
void solve(unsigned int cur_cnt)
{
if (cur_cnt == 162) {
flag_solve = 1;
}
if (flag_solve) {
return;
}
char row = cur_cnt / 18;
char col = cur_cnt % 18;
if (init_exist[row][col]) {
solve(cur_cnt + 2);
}
else {
for (unsigned int i = 1;i <= 9;i++) {
if (flag_solve) {
return;
}
if (judge(row, col, i)) {
mp[row][col] = i;
visit_row[row][i] = 1;
visit_col[col][i] = 1;
visit_squard[row / 3][col / 6][i] = 1;
solve(cur_cnt + 2);
visit_row[row][i] = 0;
visit_col[col][i] = 0;
visit_squard[row / 3][col / 6][i] = 0;
}
}
}
}
求解部分的核心是solve递归函数。该函数首先判断是否已经完成求解,如果是则直接返回;否则判断当前位置是否是初始时便有值,如果是则直接搜索下一个位置;否则对当前位置进行搜索。
cur_cnt记录的是当前数字的为整个数独的第几个,第一行为0-9,第二行为10-19,以此类推。因为要考虑空格,因此在此基础上乘以2,通过/18和%18来确定在数组中的下标。
七.GUI
GUI采用了winform进行设计,主界面如下:
一进入便会显示一个数度题目。分别有三个选项:
提交答案:提交数独并得到结果(正确与否)
生成新局:生成新的数独
重新开始:清空自己已经写的答案
通过颜色区分是题目数字还是自己作答的数字,红色为题目数字,黑色为答案。
提交后返回正确或错误
八.心得体会
通过本次数独项目的开发,让我对整个软件开发的流程有了更深刻的体会,对需求分析,设计,编码实现,测试等内容有了切实的体验,从中也感受到了开发项目和做编程题的区别。
感触最大的一点是关于版本控制,由于我自身的原因导致完成任务的时间比较紧迫,而且由于对GitHub使用不熟练导致使用github进行版本控制做的不太好。但几次提交到GitHub上的版本代码对我之后调试、优化、扩展思路都起到了很大的帮助,让我切身感受到了版本控制和开发流程记录的重要性。
此外通过这次项目的开发,让我对VS的使用有了进一步的提升。VS是我使用的第一个IDE,一直到现在只要开发C/C++相关的代码我都用的VS。以前只是觉得VS 调试功能很好用,通过这次作业让我认识到了VS功能的强大。
因为一些个人原因,本次作业完成的过程并不好,一方面,虽然进行了设计,但是在开发过程中进行了反复的修改,与最初的设计有一定的偏离,无论是设计存在缺陷导致编码环节出现问题还是编码出现问题后没有反过去重新设计都有违软件开发的原则;另一方面,尽管努力依照软件开发的流程去完成作业,之前那种以A题为目标的编程习惯和思维依旧没有彻底转变,并且渗透到了本次作业中。在今后的学习中,要尽量遵循软件开发原则和流程,摒弃之前那种简单粗暴的编程思维,成为一个合格的开发人员。