【软件工程——个人项目 】
一、Github的地址
(https://github.com/Wang-zuozheng/sudoku)
二、PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时 (分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 45 | 73 |
· Estimate | · 估计这个任务需要多少时间 | 3362 | 3185 |
Development | 开发 | 2977 | 2777 |
· Analysis | · 需求分析(包括学习新技术) | 60 | 53 |
· Design Spec | · 生成设计文档 | 40 | 47 |
· Design Review | · 设计复审(和同事审核设计文档) | 45 | 35 |
· Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 30 | 25 |
· Design | · 具体设计 | 72 | 97 |
· Coding | · 具体编码 | 2130 | 1920 |
· Code Review | · 代码复审 | 300 | 280 |
· Test | · 测试(自我测试,修改代码,提交修改) | 360 | 320 |
Reporting | 报告 | 340 | 335 |
· Test Report | · 测试报告 | 160 | 100 |
· Size Measurement | · 计算工作量 | 60 | 45 |
· Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 120 | 190 |
- | 合计 | 3362 | 3185 |
三、生成终局
▶ 思路描述
1) 需求分析:
输入: 根据命令行输入中参数-c达到选择生成终局的功能,根据输入参数N获取终局数量需求。
异常处理功能: 对于错误的操作要有报错处理。错误的操作有:① 输入的字符不为数字;② 输入的数字不在N的范围内,即0到1000000;③ 参数个数不对,④第二个参数不为“-c”。
生成终局功能: 根据输入的数量N,涉及相应算法以运算出N这么多的数独终局。
输出: 文件输出N这么多的数独终局,并且要保证正确性。格式上,第一行第一个数字为数字3,即我学号后两位“2”,“9”做运算得出。每个数字之间有空行,每个终局之间有空行。输出到的文件名为“sudoku.txt”。
基本方法: 计划采用演化型原型法,先通过DevC++编译环境用C语言实现一个scanf输入printf输出,只有生成终局功能的代码,在这个基础上修改不足的地方,包括修改为C++代码,修改输入输出方式,和增加异常处理功能。
2) 思考过程:
- 题目中要求生成终局数量最多为1000000,即100万个。
- 根据观察网上查找的某一个数独终局,我发现在该数独终局的基础上,随意交换1-3行之间的顺序最终还是一个数独终局。而且这同样也适用于4-6行、7-9行、1-3列、4-6列、7-9列之间的交换。由此计算出已知一个数独终局便可以生成 26626*6=5184 个不重复的数独终局。
- 这里解释一下原因,数独终局的性质为每行、每列、每33的九宫格里都没有重复的数字,对于 1-3 行中的某一数字,其所处列在交换前,交换后,该列里所有的数只是位置变了,同理33九宫格里的数字也只是位置变化了,因此每列、每3*3九宫格里没有重复数字,对于每行交换前后没变,因此每行没有重复数字。又因为我的学号后两位为29,(2+9)%9+1=3,所以第一行第一列不动。
- 题目要求最多生成100万个终局,因此需要找出1000000/5184≈200个完全不一样的终局。
- 这时我又发现,对每一行来说所有数字都是不一样的话,把它们截成三节,即1-3个为一组、4-6个为一组、7-9个为一组,这三组组成一个3*3九宫格也满足数独终局的性质。
例:
a,b,c一组,d,e,f一组,g,h,i一组。
- 这样我们不难发现,第二行是第一行左移三个位置所产生,第三行又是第二行左移三个位置所产生。这时考虑第四行,如果使第四行与上述三行都不一样的最简单的方法即左移一个位置。
- 这样对于同一列下,第一行和第四行的数字在第一行处于相邻位置,所以不会重复,这时5、6行按照分三组策略生成4-6行的九宫格。
- 第五行不光是第四行左移三个的结果,也是第二行左移一个的结果。同理,第六行不光是第五行左移三个的结果,也是第三行左移一个的结果。因此每列不会有重复的数字。
按上述规律最终生成数独如下:
- 这样只需要第一行的数排列顺序不一样,生成的终局就不一样。由于第一个数定为3,所以共有8!个不同的终局,即40320个。远远大于200个。
3) 找资料过程:
在生成终局的整个代码中一次一次地生成不同的排列数很关键,因此在这个过程中我查找了生成全排列的算法,一种是使用递归函数,第二种是使用STL中的next_permutation或pre_permutation函数。显然第二种比较方便。之后我还查阅了有关VS 2017性能分析工具的使用和单元测试工具的使用。同时也搜索了对于命令行输入如何调试的方法。
▶ 设计实现过程
1)设计一
按照上述思路描述,换行换列策略通过循环函数解决,这需要六个嵌套循环函数以实现换行换列。生成排列数在第一次设计时候因为只学习到了递归方法,即排列1,2,3,…,n,先给定前n-2个数,全排列后面的数列,再给定前n-3个数,全排列后面的数列,依次递归下去。这种设计产生了一些缺陷,首先多重函数的嵌套增加了程序的时间复杂性,而且递归的方法每次产生了一个全排列想要输出需不断经历返回函数值部分,增大了代码的开销。
2) 设计二
由于全排列数共有40320个,因此只需要进行换行操作就能满足1000000个数独终局的需求。这样采用三层嵌套循环就可以完成换行策略。生成全排列则采用next_permutation函数以减少编写其他函数的工作量。
在使用C++语言后,我在生成终局代码中建立了一个类(Build),该类中包括两个主要的函数,其他三个函数不怎么主要。
- 其中主要的函数
Permutation_Change(char *ans, int &cnt, int &n)
进行了换行策略的选择和排列数的变化功能。调用void BuildSudoku(int Row_change1, int Row_change2, int Row_change3, char *ans, int &cnt)
函数,被主函数调用。
程序流程图如下:
- 第二个主要函数
BuildSudoku(int Row_change1, int Row_change2, int Row_change3, char *ans, int &cnt)
根据传进来的变换策略参数左移排列数生成数独终局放入数组ans中。由于数组ans太大不能放入类中,因此作为全局变量放在sudoku.cpp中。被Permutation_Change(char *ans, int &cnt, int &n)
调用。
▶ 改进思路
1) 单元测试:
采用VS 2017中自带的本机单元测试项目。根据类中所有的函数设计了单元测试,测试范围覆盖了所有的函数。
- 测试方法1
测试在执行一次生成终局功能后,是否一个终局包含163个字符。以确保不是只生成了一半或没有放进去换行符。
代码部分:
TEST_METHOD(TestMethod1)
{
// TODO: 在此输入测试代码
char ans_test[200];
int cnt = 0;
int n = 1;
int expect = 163;
Build new_Build_test;
memset(ans_test, 0, sizeof(ans_test));
new_Build_test.Permutation_Change(ans_test, cnt, n);
Assert::AreEqual(cnt, expect);
}
- 测试方法2
测试生成一个数独终局的正确性。
代码部分:
TEST_METHOD(TestMethod2)
{
char ans_test[200];
int cnt = 0;
int n = 1;
char expect_test[200] = { "3 1 2 4 5 6 7 8 9\n4 5 6 7 8 9 3 1 2\n7 8 9 3 1 2 4 5 6\n2 4 5 6 7 8 9 3 1\n6 7 8 9 3 1 2 4 5\n9 3 1 2 4 5 6 7 8\n1 2 4 5 6 7 8 9 3\n5 6 7 8 9 3 1 2 4\n8 9 3 1 2 4 5 6 7\n\n"};
Build new_Build_test;
memset(ans_test, 0, sizeof(ans_test));
new_Build_test.Permutation_Change(ans_test, cnt, n);
Assert::AreEqual(ans_test, expect_test);
}
- 测试方法3
测试加数函数和计数变量加过数后函数的返回值是否正确。
代码部分:
TEST_METHOD(TestMethod3)
{
Build new_Build_test;
int real;;
int expect = 3;
new_Build_test.add(3);
real = new_Build_test.get_cnt();
Assert::AreEqual(real, expect);
}
- 测试方法4
测试对于随机的一组换行策略和给定的排列数,是否能输出正确的数独终局。
代码部分:
TEST_METHOD(TestMethod4)
{
Build new_Build_test;
char ans9[200];
memset(ans9, 0, sizeof(ans9));
int cnt = 0;
int permutation[9] = { 3,1,2,4,5,6,7,8,9 };
char expect_test[200] = { "3 1 2 4 5 6 7 8 9\n7 8 9 3 1 2 4 5 6\n4 5 6 7 8 9 3 1 2\n9 3 1 2 4 5 6 7 8\n2 4 5 6 7 8 9 3 1\n6 7 8 9 3 1 2 4 5\n5 6 7 8 9 3 1 2 4\n8 9 3 1 2 4 5 6 7\n1 2 4 5 6 7 8 9 3\n\n" };
int Row_change1 = 1;
int Row_change2 = 4;
int Row_change3 = 3;
new_Build_test.BuildSudoku(Row_change1, Row_change2, Row_change3, ans9, cnt);
Assert::AreEqual(ans9, expect_test);
}
- 测试方法5
测试当对于某个排列数进行完所有的换行策略后,变换下一个排列数是否成功工作,并生成不一样的排列数。
代码部分:
TEST_METHOD(TestMethod5)
{
Build new_Build_test;
int n = 2;
int *p;
int a[9];
int expect[9] = { 3,1,2,4,5,6,7,9,8 };
do
{
n--;
if (n == 0)break;
}
while(next_permutation(new_Build_test.get_permutation() + 1, new_Build_test.get_permutation() + 9));
p=new_Build_test.get_permutation();
for (int i = 0; i < 9; i++)
{
a[i] = *p;
p++;
}
for (int i = 0; i < 9; i++)
Assert::AreEqual(a[i], expect[i]);
}
- 运行所有测试以后,都成功了。😃
2) 改进(性能分析图):
选择每生成一个终局就输出到文件中的输出方法显然能节省CPU的分配空间,即输出答案的数组不用开的特别大,但采取这样的方式进行输出的话,打开文件,写入文件的函数的调用次数将会占用很大比例,显然速度会慢下来。我认为对空间的优化和速度的优化在这一情况下不能两全,最终我还是选择了建立大数组一次性输出的方式,以追求速度上的快。
下面是进行了性能分析后的结果。根据网上资料的查阅与思考,我选择了性能(监测)向导。输入参数为1000000,即生成1000000个数独终局的性能分析。
Functions(函数统计)视图:
Summary(概要)视图:
▶ 代码说明
- 下图代码展示了异常处理功能。当从命令行中读取的第三个参数不为数字时,输出
Error!请输入数字!
,当输入的参数N的范围不在规定范围之内时,输出请确认输入的数在0到1000000之间!
。
ps:对于少参数的,第二个输入参数不为“-c”的在主函数中通过else语句判断,并输出“请检查输入!”来报错。
int len = strlen(argv[2]);
for (int i = 0; i < len; i++)
{
if (argv[2][i] >= '0'&&argv[2][i] <= '9')
{
n = (n + (argv[2][i] - '0')) * 10;
}
else
{
cout << "Error!请输入数字!" << endl;
return 0;
}
}
n = n / 10;
if (n == 0 || n > 1000000)
{
cout << "请确认输入的数在0到1000000之间!" << endl;
return 0;
}
- 下图代码展示了生成数独终局功能的部分代码。变换策略被存放在三个二维数组中,函数参数Row_change1,Row_change2,Row_change3,代表传入进来了1-3行怎么变化的策略,4-6行怎么变化的策略,7-9行怎么变化的策略。根据策略里的数字来进行第一行左移工作。
void BuildSudoku(int Row_change1, int Row_change2, int Row_change3, char *ans, int &cnt)
{
int i, k, j;
for (i = 0; i < 9; i++)//将排列数存放在第一行
{
ans[cnt++] = permutation[i] + '0';
if (i != 8)
ans[cnt++] = ' ';
}
ans[cnt++] = '\n';
for (k = 0; k < 3; k++)
{
int p;
p = Change[k];
for (i = 0; i < p; i++)
{
for (j = 0; j < 9; j++)
{
if (k == 0)//变换1-3行,由于第一行不动,所以p为2
{//根据排列数和变换策略左移排列数
ans[cnt++] = permutation[(j + (rule1[Row_change1][i] - '0')) % 9] + '0';
if (j != 8)
ans[cnt++] = ' ';
}
if (k == 1)//变换4-6行
{
ans[cnt++] = permutation[(j + (rule2[Row_change2][i] - '0')) % 9] + '0';
if (j != 8)
ans[cnt++] = ' ';
}
if (k == 2)//变换7-9行
{
ans[cnt++] = permutation[(j + (rule3[Row_change3][i] - '0')) % 9] + '0';
if (j != 8)
ans[cnt++] = ' ';
}
}
ans[cnt++] = '\n';
}
}
ans[cnt++] = '\n';
}
四、求解数独
▶ 思路描述
1) 需求分析:
输入: 文件输入,即在文件中已存放好需要求解的题目若干个,且符合格式要求。挖空的地方为“0”,每个数之间有空格,每个题目之间有空行。
异常处理功能: 如果对于存放数独题目的文件的文件名的形式输入错误的话,将会报错。如果少参数或第二个参数不是“-s”也报错。
求解数独功能: 读取文件中的题目,完成求解工作。
输出: 将求解后的答案输出到文件“sudoku.txt”中。
基本方法: 计划采用演化型原型法,用C语言实现一个只有求解数独功能的代码,在这个基础上修改不足的地方,包括修改为C++代码,修改输入方式,和增加异常处理功能。
2) 思考过程:
- 平常求解数独时,人脑的工作方法普遍为,找一个能满足条件的数的数量最少的空格,满足的条件即该空格所在行、列、3 * 3九宫格中没有出现的数。填上这个假设数,在找下一个空格,按上述步骤循环,如果遇到填什么数都不对的情况,就将上一个有选择性的空格数换一个,再往下求解。
- 因此我想将程序模拟人脑的思考过程,于是采用了回溯法。只不过每次先填的空格为按行搜索第一个为“0”的地方。查找该空格所在列、行、3 * 3九宫格中没有的数按从小到大排的第一个填写。一行一行的填写,如果出现没有数满足条件的空格,则回溯到上一个填过数的空格。当最后一行填写完了,返回答案输出到文件,求解下一题。
3) 找资料过程:
查找了如何进行文件输入的方法,也是因为自己文件这方面学的不扎实。回看了回溯法的讲解过程。查找了怎样将结构体转换为类中的表现形式。还有对于命令行输入怎么调试的方法,性能分析工具的使用方法和单元测试操作方法。
▶ 设计实现过程
建立了两个类(node和Solve),包括6个函数。类node用来存放访问大表的,其中有三个数组,如果某行或某列或某个九宫格中有这个数字,则在相应的数组位置置“1”。类Solve中的函数,三个为主要的函数,剩下的函数中有一个是为了方便单元测试而建立的。
由于文件按行读取,因此在读完整个数独题目后,再通过函数进行求解运算,这里通过计数变量控制何时读完9行数据。
主要的函数void SaveProblem(int cnt, string buf)
将每次读取的题目转换为方便求解,即没有空格符号并数字用int型存方转换后的数独数组。被主函数调用。
另一个主要函数void GetAns(int r, int col)
中参数 r 表示行,col 表示列,从0行0列开始回溯,如果该位置的数不为“0”,则跳过看下一个。如果是“0”,查找访问大表,表中该空格所在列、行、3 * 3九宫格中没有的数都为“0”(未访问状态),表的数据结构为类(node),虽然可以用三个二位数组表示,但为了体现他们的整体性,我还是选择了用一个类表示。根据访问大表填写数据。一层一层地填写,遇到无法填写状态时,跳回上一级。有点类似于暴力方法,但采用了剪枝条件,即这个数必须是该空格所在列、行、3 * 3九宫格中没有的数。被主函数调用。
主要函数void Change()
将最后答案转换成要求的格式,即加入了空格。被void GetAns(int r, int col)
调用。
程序流程图:
▶ 改进思路
1) 单元测试:
采用VS 2017中自带的本机单元测试项目。根据类中所有的函数设计了单元测试,测试范围覆盖了所有的分支。
ps: 由于能看分支覆盖率的插件需要钱,所以我没下载。
- 测试方法6
测试函数SaveProblem(int cnt, string buf)
是否实现了将一行数据转换为方便计算的格式,即没有空格。
TEST_METHOD(TestMethod6)
{
Solve new_Solve_test;
string expect="312456789",actual;
int cnt = 0;
string buf = "3 1 2 4 5 6 7 8 9";
new_Solve_test.SaveProblem(cnt, buf);
actual=new_Solve_test.get_save(cnt);
Assert::AreEqual(actual, expect);
}
- 测试方法7
测试函数Change()
是否将数独从便于计算的格式(没有空格)转换成了副业要求的输出格式(含空格)
TEST_METHOD(TestMethod7)
{
Solve new_Solve_test;
int cnt = 0;
string actual;
string buf = "3 1 2 4 5 6 7 8 9";
new_Solve_test.SaveProblem(cnt, buf);
new_Solve_test.Change();
actual = new_Solve_test.get_ans(cnt);
Assert::AreEqual(actual, buf);
}
- 测试方法8
测试最后回溯结束后到输出答案之间过程的正确性,即是否破坏了数据的格式与内容。
TEST_METHOD(TestMethod8)
{
Solve new_Solve_test;
int cnt = 0;
string actual;
string buf = "3 1 2 4 5 6 7 8 9";
new_Solve_test.SaveProblem(cnt, buf);
new_Solve_test.GetAns(9,0);
actual = new_Solve_test.get_ans(cnt);
Assert::AreEqual(actual, buf);
}
- 测试方法9
我设计了一个用例使得程序不会产生回溯到上一级函数的情况,而是一直填写的假设对的答案就是对的的情况。观察输出结果是否与所期待的一致。
TEST_METHOD(TestMethod9)
{
Solve new_Solve_test;
int cnt = 0, cnt1 = 1, cnt2 = 2, cnt3 = 3, cnt4 = 4, cnt5 = 5, cnt6 = 6, cnt7 = 7, cnt8 = 8;
string actual6, actual7, actual8;
string buf = "3 1 2 4 5 6 7 8 9", buf1 = "7 8 9 3 1 2 4 5 6", buf2 = "4 5 6 7 8 9 3 1 2", buf3 = "9 3 1 2 4 5 6 7 8", buf4 = "2 4 5 6 7 8 9 3 1", buf5 = "6 7 8 9 3 1 2 4 5", buf6 = "5 6 7 8 9 3 0 0 4", buf7 = "8 9 0 1 2 0 0 0 0", buf8 = "1 2 4 5 6 7 0 0 3";
string expect6 = "5 6 7 8 9 3 1 2 4", expect7 = "8 9 3 1 2 4 5 6 7", expect8 = "1 2 4 5 6 7 8 9 3";
new_Solve_test.Init();
new_Solve_test.SaveProblem(cnt, buf);
new_Solve_test.SaveProblem(cnt1, buf1);
new_Solve_test.SaveProblem(cnt2, buf2);
new_Solve_test.SaveProblem(cnt3, buf3);
new_Solve_test.SaveProblem(cnt4, buf4);
new_Solve_test.SaveProblem(cnt5, buf5);
new_Solve_test.SaveProblem(cnt6, buf6);
new_Solve_test.SaveProblem(cnt7, buf7);
new_Solve_test.SaveProblem(cnt8, buf8);
new_Solve_test.GetAns(0, 0);
actual6 = new_Solve_test.get_ans(cnt6);
actual7 = new_Solve_test.get_ans(cnt7);
actual8 = new_Solve_test.get_ans(cnt8);
Assert::AreEqual(actual6, expect6);
Assert::AreEqual(actual7, expect7);
Assert::AreEqual(actual8, expect8);
}
- 测试方法10
我设计了一个测试用例,使得程序会回溯到上一级函数进行改写上一个填过的空格的答案。观察输出结果是否与所期待的一致。
TEST_METHOD(TestMethod10)
{
Solve new_Solve_test;
int cnt = 0, cnt1 = 1, cnt2 = 2, cnt3 = 3, cnt4 = 4, cnt5 = 5, cnt6 = 6, cnt7 = 7, cnt8 = 8;
string actual1, actual2;
string buf = "3 1 2 4 5 6 7 8 9", buf1 = "7 8 0 0 1 2 4 5 0", buf2 = "4 5 0 7 8 0 0 1 2", buf3 = "9 3 1 2 4 5 6 7 8", buf4 = "2 4 5 6 7 8 9 3 1", buf5 = "6 7 8 9 3 1 2 4 5", buf6 = "5 6 7 8 9 3 1 2 4", buf7 = "8 9 3 1 2 4 5 6 7", buf8 = "1 2 4 5 6 7 8 9 3";
string expect1 = "7 8 9 3 1 2 4 5 6", expect2 = "4 5 6 7 8 9 3 1 2";
new_Solve_test.Init();
new_Solve_test.SaveProblem(cnt, buf);
new_Solve_test.SaveProblem(cnt1, buf1);
new_Solve_test.SaveProblem(cnt2, buf2);
new_Solve_test.SaveProblem(cnt3, buf3);
new_Solve_test.SaveProblem(cnt4, buf4);
new_Solve_test.SaveProblem(cnt5, buf5);
new_Solve_test.SaveProblem(cnt6, buf6);
new_Solve_test.SaveProblem(cnt7, buf7);
new_Solve_test.SaveProblem(cnt8, buf8);
new_Solve_test.GetAns(0, 0);
actual1 = new_Solve_test.get_ans(cnt1);
actual2 = new_Solve_test.get_ans(cnt2);
Assert::AreEqual(actual1, expect1);
Assert::AreEqual(actual2, expect2);
}
- 运行所有测试后,都成功了 😃
2) 改进(性能分析图):
输入参数中的shudu.txt文件中存放了由编写的题目代码执行后输出的题目1000个。
根据网上资料的查阅与思考,我选择了性能(监测)向导。下面是进行了性能分析后的结果。可以看到回溯函数Solve::GetAns
占用时间最多。由于没有什么额外的剪枝条件了,所以基本没有修改回溯函数。
Functions(函数统计)视图:
Summary(概要)视图:
▶ 代码说明
- 下图展示了异常处理功能,当输入的文件名的形式有错误时,报错“请保证输入正确!”
ps: 对于少参数的,第二个输入参数不为“-s”的通过主函数中else语句判断,并输出“请检查输入!”来报错。
if (!in)
{
cout << "请保证输入正确!" << endl;
}
- 下图展示了主要函数中体现了回溯法的函数。
void GetAns(int r, int col)
{
int i;
while (save[r][col] != '0')//跳过不是“0”的地方
{
if (col < 8)
col++;
else
{
col = 0;
r++;
}
if (r == 9)//所有行都填写完了
{
getit = 1;
Change();
return;//get the answer!
}
}
int flag = 0;
for (i = 1; i < 10; i++)
{
if (Point[0].row[r][i] == 0 && Point[1].column[col][i] == 0 && Point[2].area[(r / 3) * 3 + col / 3][i] == 0)
{//查表,找出可以填写的数字
flag = 1;
Point[0].row[r][i] = 1;
Point[1].column[col][i] = 1;
Point[2].area[(r / 3) * 3 + col / 3][i] = 1;
save[r][col] = i + '0';
GetAns(r, col);//看下一个未填数的地方
}
if (flag)//如果回溯回来了
{
flag = 0;
if (getit)//是要输出答案的情况
return;
else//是要改写这一级填了数的位置的情况
{
save[r][col] = '0';
Point[0].row[r][i] = 0;
Point[1].column[col][i] = 0;
Point[2].area[(r / 3) * 3 + col / 3][i] = 0;
}
}
}
}
五、总结
以上的内容包涵了博客撰写要求的全部内容。通过这次的个人项目,我收获和学习到了许多东西,其中包括查找资料的能力,对新事物接纳学习的能力,最重要的是对于软件开发流程中每个步骤的认识变得更加深刻了。虽然此次个人项目相交于实际的开发情况会比较简单,但在经历了3周的自己编写、设计等环节让我体会到了整个过程的辛苦与有趣之处。希望之后的结组项目能够结合这次实践收获的经历,更上一层楼!