1. Github项目地址:https://github.com/ZiJiaW/SudokuGame
GUI在BIN目录下的SudokuGUI.rar中,解压后打开SudokuGame.exe即可。
2. PSP表格:
PSP2.1 | Personal Software Process Stages | 预估耗时 | 实际耗时 |
Planning | 计划 | 2h | 2h |
.Estimate | 估计这个任务需要多少时间 | 1h | 1h |
Development | 开发 | 35h | 37h |
.Analysis | 需求分析 | 2h | 3h |
.Design Spec | 生成设计文档 | 1h | 0.5h |
.Design Review | 设计复审 | 1h | 1.5h |
.Design | 代码规范 | 0.5h | 0.5h |
.Coding | 具体设计 | 5h | 6h |
.Code Review | 具体编码 | 20h | 23h |
.Test | 代码复审 | 2.5h | 2.5h |
Reporting | 测试 | 3h | 2h |
.Test Report | 报告 | 1h | 1h |
.Size Measurement | 测试报告 | 1h | 1h |
.Coding Standard | 计算工作量 | 0.5h | 0.5h |
.Postmeortem &ProcessImprovementPlan | 事后总结,并提出过程改进计划 | 0.5h | 1h |
合计 | 40h | 43h |
3. 看教科书和其它资料中关于Information Hiding, Interface Design, Loose Coupling的章节,说明在结对编程中是如何利用这些方法对接口进行设计的
Information Hiding顾名思义就是信息隐藏,也就是说对于函数的使用者来说,不必考虑函数的内部实现就可以顺利地使用它,在结对编程中这是十分必要的,我觉得就是一种Interface Design,两人甚至多人合作下能够很好的分工靠的就是良好的函数封装。LosseCoupling就是松耦合,要求各个类的接口不甚复杂,这样互相配合的时候不需要更多的考虑,简单来说就是两个函数之间的依赖程度,比如一个函数要调用的三个函数之中有一个函数做了修改,那么另外两个函数能够不需要修改正常工作,就达到了松耦合的基本目的:降低代码修改的难度。我们对类的外部接口的设计完全采用信息隐藏的手段,所有涉及到底层实现的函数和变量均为私有的,这样调用者不需要考虑函数细节。对于模块的松耦合,我们的Core模块主要调用Table模块进行数独的生成,下面是两个模块的类设计:
// Core _declspec(dllexport) int sum(int a, int b); _declspec(dllexport) void generate(int number, int mode, int result[][81]); _declspec(dllexport) void generate(int number, int lower, int upper, bool unique, int result[][81]); _declspec(dllexport) bool solve(int puzzle[], int solution[]); _declspec(dllexport) void generate(int number, int result[][81]); // Table extern const unsigned int BufferSize; class Table { private: int cells[9][9]; public: /* 1.This class is used to generate or solve sudokus in large amount. 2.Use Generate to generate and it overrides with different destinations.Solve also overrides. 3.Solve(SdkBuffer*) will modify the input to tell you the result.If an sudoku is not solvable,it will remain as it was.The Solve function will return the number of sudokus tha can be solved. */ Table(); void Generate(unsigned int total, SdkBuffer* sdb); void GenerateRandomly(unsigned int total, SdkBuffer * sdb); void Generate(unsigned int total, FileHandler* fh); void GenerateRandomly(unsigned int total, FileHandler* fh); int Solvable(SdkBuffer* sdb, int index); unsigned int Solve(SdkBuffer* pBoard); //Modified:Solve(SdkBuffer* src,SdkBuffer* dst)==> Solve(SdkBuffer* pBoard); void Solve(FileHandler* src, FileHandler*dst); void DigRandomCells(SdkBuffer* pBoards,unsigned int lower, unsigned int upper,bool isAnswerUnique); ~Table(); private: /* */ void setZero(); void digSpecNum(int[][9],unsigned int num); void digSpecNumUniquely(int[][9], unsigned int num); unsigned int startSolving(unsigned int maxAnswer,SdkBuffer*pResult); void solve(int subt, int num,unsigned int &total,unsigned int &top,SdkBuffer* pResult); int* lookUp(int rst, int cst, int num); };
可以看到Table类的对外函数均是高度封装的,这样Core调用的时候是很方便的,同样对于Table的内部实现来说,主要使用dig方法,这些私有方法的实际长度均很短,也就是说要改变其中一个函数是很方便的事,其他的调用基本不需要改变。以及下面的随机生成数独的代码也体现了松耦合的思想。
4. 计算模块接口的设计与实现过程
1. 需求分析
- -c,-s的要求不变
- 新增-n要求,可带参数组合:-n –r –u; –n –r; –n –u; –n -m;
- -n参数范围1-10000
- -r参数格式:lower~upper 范围20~55
- -m参数范围1-3
- -u不带参数
2. 设计实现
由于我们俩在个人项目中生成数独的方法都是回溯,因此是不满足考虑用户体验的随机生成的,因此在-n的实现中,我们采用了随机生成第一行排列的方法,生成这样的数独终局再进行挖空满足-r要求,这里会出现数量一大就生成重复排列的问题,于是设置了判重函数,下面给出代码解释这里的运作模式:
bool IsDiffer(int line[9], int(*record)[9], int nowsize) { for (int i = 0; i < nowsize; i++) { bool r = true; for (int j = 0; j < 9; j++) { r &= line[j] == record[i][j]; } if (r) return false; } return true; } void Shuffle(int line[9], int(*record)[9], int nowsize) { int r, mid; do { for (int i = 8; i >= 0; i--) { r = rand() % (i + 1);//0-i mid = line[i]; line[i] = line[r]; line[r] = mid; } } while (!IsDiffer(line, record, nowsize)); } void Table::GenerateRandomly(unsigned int total, SdkBuffer* sdb) { srand(clock()); int firstLine[] = { 1,2,3,4,5,6,7,8,9 }; int(*record)[9] = new int[total][9]; setZero(); for (unsigned int i = 0; i < total; i++) { Shuffle(firstLine,record,i); for (int j = 0; j < 9; j++) { cells[0][j] = firstLine[j]; record[i][j] = firstLine[j]; } startSolving(1, sdb); } delete[] record; }
这里实现了三个函数以实现松耦合的设计模式,第一个函数IsDiffer用于判断line是否和记录中生成的排列重复,重复则返回false;第二个函数Shuffle用于生成一个和之前没有重复的排列,利用do-while循环进行判断,其中生成随机排列的算法是用的交换法;最后GenerateRandomly函数调用Shuffle实现生成随机数独终局。
这样我们就生成了可供挖空的并且解不重复的数独终局若干个;对于不指定-u的-r指令来说,十分简单,只需要按顺序取数独终局然后随机进行挖空就可;对于指定生成唯一解的-r要求,经过我们的分析讨论没有什么好方法,只能通过每次挖空都是用solve函数判断是否有多解,在这里我们实现了一个指定最大生成解数量的solve重载,指定最大生成解为2,判断返回值即可确定一个局是否唯一解,代码十分trivial,就不贴了,并没有高深的算法。
接下去我们考虑如何生成一定难度的数独题目,首先考虑用户体验,我们决定所有生成的数独题目都是唯一解的,这也是标准数独的要求,非唯一解的数独都是不标准的,而且解得过程中无法确定化,必须猜解;因此最简单的生成难度就是指定挖空数量范围,使用-r,-u参数就可以完成。但是实际上,挖空数量多的数独并不一定比挖空数量少的数独难(我俩解了几个数独亲身体验了一下),因此考虑在挖了一定数量的空以后在判断这样一个数独是否是我们要求的难度,我们需要一个难度评估模块;
那么这个模块怎么评估难度呢?我们一开始认为可以通过解数独的过程中回溯的次数来评估,然后否决了这个方式,因为这和正常人解数独的过程是不同的,正常人不会采用反人类式的试填,而是会通观大局,先寻找最好填的,即某格只有一个候选数,或是某数在某行只在一个位置作为候选数出现了,前者在数独策略中称为NakedSingle,后者为HiddenSingle,我们觉得可以通过让计算机使用人解数独的策略来解数独,判断各种策略使用的比例,数量,以及这些策略能否解出数独来判断难度。
于是我们实现了下面这个类:
class DifficultyEvaluation { public: DifficultyEvaluation(); ~DifficultyEvaluation(); Difficulty Evaluate(int p[][9]); void GetPuzzle(int p[][9]); private: int puzzle[9][9]; // candidate[i][j][num]==1 means num is candidate to puzzle[i][j]. // candidate[i][j][0]==k means puzzle[i][j] has k candidates. int candidate[9][9][10]; // 唯余法排除:某位置出现某数,则同行/宫/列其他位置该数排除候选 void UpdateSingle(); // 宫内排除:宫内某数只出现在某行/列,则该行/列其他位置该数排除候选 void UpdateCell(); // 行/列/宫显性数对排除法: // 行/列/宫某两个位置只出现两个相同的候选数,则若行列内则排除同行列的其余位置这两个数的候选 // 若这两个位置同宫,则还可排除宫内与两个位置同行列的其余位置这两个数的候选 void UpdateNakedPair(); // 行/列/宫隐性数对排除法: // 某两个数只出现在某行/列/宫的两个位置,则两位置其余候选数排除 void UpdateHiddenPair(); // X-Wing排除法: // 若某数在某两行/列中只出现在该两行/列的两个相同位置,则构成X-Wing,排除四边形边上所有位置的该数候选 void UpdateXWing(); void CandidateDelete(int row, int col, int num); void PuzzleInit(int p[][9]); bool IsFinished(); void Fill(int row, int col, int num); bool FillNakedSingle(); bool FillHiddenSingle(); bool TryToFill(int &updateCount, int &fillCount1, int &fillCount2); };
由于时间有限,我们只实现了以上的几种解题策略,大致的评估算法如下:
1. 两种填数策略(NakedSingle, HiddenSingle)用于根据现有候选数表进行填数,如果成功填数,则用数独规则更新候选数表,再次尝试;
2. 如果填数失败,依次使用更高级的候选数表更新方法排除候选数,如上面声明的几个Update函数,每Update一次尝试一次填数,成功的做法同2;
3. 如果一次循环中用尽了所有更新方法都没办法填一个数,我们认为这个数独题目是Hard难度;
4. 对于现有方法能解出的数独,我们通过判断各种方法生效的次数和两种填数法使用的比例界定难度;
我们认为HiddenSingle难于NakedSingle,实际上我们使用网上的简单难度数独进行测试,发现可以直接全部使用规则排除和NakedSingle解出来;
而找到一些中等难度的数独可能需要十几次的NakedSingle解出,而一些Hard难度的题一部分可以通过高级策略解出,还有一部分我们实现的方法解不出。
因此根据这个大纲我们实现了数独难度评估模块,每次挖出一个数独后传入Evaluate函数进行判断,如果不满足条件则重新生成一个。实际上这样的方法如果穷尽所有解数独的策略,我们就可以不使用回溯(包括DLX)解数独了……软件SudokuExplainer(http://diuf.unifr.ch/pai/people/juillera/Sudoku/Sudoku.html)就能通过确定的方法给出解任何唯一解数独的每一个步骤并评估难度。我们在实现的过程中多次用这个软件玩数独==
这样做的缺点是生成数独的速度会特别慢,比如生成中等难度的数独100个需要5s,Hard难度的数独100个需要18s……感觉要炸。
以上是完成了命令行参数输入部分,对于core的接口,因为上面的实现使用的类遵循松耦合的设计思路,我们只需要调用SdkBuffer的方法将存储的数独终局输出到数组而不是文件中即可,十分方便。
5. 画出UML图显示计算模块部分各个实体之间的关系(画一个图即可)
6. 计算模块接口部分的性能改进
具体按照上面的思路实现完成后,我们发现实现的-r参数在生成唯一解数独时速度过慢,特别是挖超过50个空并且要求唯一解的时候,经过我们讨论,我们的生成方法是每次随机挖一个空,然后对这个数独进行求解,若求解出来有多个解,则将这个空填上,再从剩余的位置随机挖;这里有一个问题就是一旦我们的程序挖出一个唯一解的x空数独,这x个空是不可能被回填的,这就有可能出现对于给定的x个空,任意多挖一个空都会导致数独多解,进入死循环。
因此,我们考虑了一个算法,如果生成的数独多解,我们把这些数独的前十个(小于10就把所有解)存入数组,判断这些数独挖的空中填的数差别最大(重复数最少)的一个格子,将这个格子填上解中该格子出现次数最少的数,这样可以减少这个数独的解,这是StackOverFlow上一个人提到的方法。这样改进后我们就解决了这个问题。对于生成指定难度的数独,我们的生成速度也很慢,这是由于我们除了要生成唯一解的数独以外,还要将这一数独放入评估程序进行评估,两重筛选,但是由于这次作业重视用户体验,我们认为这是可以接受的,实际上在数独游戏调用dll的时候我们只需要生成一个数独就可以了。
在这上面我们花费了大约3个多小时的时间,下面是一张性能分析图,参数为-n 1000 –r 40~55 -u
可以看到solve函数占比最多,这是因为每次挖空都需要解数独来判断是否是唯一解,在这里暂时没有什么好的解决方法。
7. 看Design by Contract, Code Contract的内容:描述这些做法的优缺点, 说明你是如何把它们融入结对作业中
CodeContract即代码契约,就是要两个人约定函数,类的接口设计,并说明注意事项,例如:
- 可以接受和不可接受的参数类型以及取值范围,以及它们的意义
- 返回值的范围和类型及其意义
- 可能出现的异常和错误以及其意义
- 函数的副作用,比如改变了什么指针的值
- 函数使用的先决条件是什么
- 等等
这样约定接口设计的优点在于,函数调用者使用的时候不需要考虑函数的具体实现就能够根据其用法说明来顺利地调用(当然前提是这个函数写对了),缺点在于按这种方式编程在过程中会比较繁琐,对于一些功能简单的函数也这么写的话会显得杂乱(有的函数一看函数名就能够了解它的意思了),在实际的结对编程中,我们简化了维基百科上的要求,在比较复杂的函数之前加了注释告诉调用者函数做了什么,返回值是什么,接受参数等等,对于比较简单简短的函数则省略这一过程。
8. 计算模块部分单元测试展示
首先是命令行参数的单元测试,例如:
TEST_METHOD(TestMethod5) { // -r 23~29 -u -n 124 int argc = 6; char *argv[] = { "sudoku.exe", "-r", "23~29", "-u" ,"-n","124" }; ArgumentHandler ah; ah.ParseInput(argc, argv); bool r = ah.GetCount() == 124 & ah.GetState() == State::GEG_RU& ah.GetLower() == 23 & ah.GetUpper() == 29 & ah.GetPathName() == NULL& ah.GetDifficulty() == Difficulty::UNS; Assert::AreEqual(r, true); }
这样测试ArgumentHandler是否能完成参数分析的工作,上面的这个测试用例将参数输入的顺序打乱,还有一系列的参数输入都与之类似,这里就不放代码了;
接下去是对于Core中solve函数的正确性测试,例如:
// test API generetor TEST_METHOD(TestMethod11) { int p[9][9] = { { 0,0,0,0,0,0,8,0,0 }, { 0,1,0,2,3,5,4,0,0 }, { 0,0,9,0,6,0,0,5,3 }, { 0,6,0,3,0,0,0,4,0 }, { 0,9,5,0,8,0,1,3,0 }, { 0,7,0,0,0,2,0,8,0 }, { 9,5,0,0,2,0,3,0,0 }, { 0,0,3,1,7,6,0,9,0 }, { 0,0,7,0,0,0,0,0,0 } }; int puzzle[81]; Transform(p, puzzle); int solution[81]; bool r = solve(puzzle, solution); r &= IsTrueAnswer(puzzle, solution); r &= IsValid(solution); Assert::AreEqual(r, true); }
给出一个数独题,解之,判断其是否是有效的解;
之后是对于API中generate函数的测试,主要是对生成的数独终局的判重和生成的数独题目的判重和唯一解性的测试:
TEST_METHOD(TestMethod13) { int size = 1000; int(*result)[81] = new int[size][81]; generate(size, result); bool r = IsDiffer(result, size); for (int i = 0; i < size; i++) r &= IsValid(result[i]); delete[] result; Assert::AreEqual(r, true); } TEST_METHOD(Test14) { // -n 100 -r 23~29 int size = 1000; int(*result)[81] = new int[size][81]; generate(size, 23, 29, false, result); bool r = IsInRange(23, 29, result, size); int solution[81]; for (int i = 0; i < size; i++) { r &= solve(result[i], solution); r &= IsValid(solution); } Assert::AreEqual(r, true); }
上面测试生成数独终局的判重和生成随机挖空数独的测试,下面是生成唯一解数独的测试:
TEST_METHOD(Test15) { // -n 100 -r 40~50 -u int size = 100; int(*result)[81] = new int[size][81]; generate(size, 40, 50, true, result); bool r = IsInRange(40, 50, result, size); int solution[81]; for (int i = 0; i < size; i++) { r &= solve(result[i], solution); r &= IsValid(solution); } Table table; for (int k = 0; k < size; k++) { for (int i = 0; i < 81; i++) { table.cells[i / 9][i % 9] = result[k][i]; } r &= table.startSolving(2, NULL) == 1; } Assert::AreEqual(r, true); }
下面是单元测试运行的结果和代码覆盖率:
9. 计算模块部分异常处理说明
异常处理模块分两部分,一部分在ArgumentHandler之中对命令行参数进行判断,我这里设置了18种不同的异常,下面给出参数分析函数(其中进行了异常处理):
void ArgumentHandler::ParseInput(int argc, char** args) { if (argc < 3) { cout << "The number of arguments is not correct!" << endl; return; } if (strcmp(args[1], "-c") == 0) { state = State::GEN; try { if (argc != 3) { throw invalid_argument("Argument "-c" shouldn't be used with other arguments!"); } else if (!IsDigit(args[2])) { throw invalid_argument("The argument of "-c" should be a positive integer!"); } sscanf_s(args[2], "%d", &count); if (count > maxCounts||count<=0) { throw invalid_argument("The argument of "-c" should be in range [1,1000000]!"); } } catch (invalid_argument err) { state = State::INV; cout << err.what() << endl; } } else if (strcmp(args[1], "-s") == 0) { state = State::SOV; try { if (argc != 3) { throw invalid_argument("Argument "-s" shouldn't be used with other arguments!"); } pathname = args[2]; } catch (invalid_argument err) { state = State::INV; cout << err.what() << endl; } } else { state = GEG_M; int nUsed = 0; int mUsed = 0; int rUsed = 0; int uUsed = 0; for (int i = 1; i < argc; ++i) { if (strcmp(args[i], "-n") == 0) { nUsed++; try { if (i + 1 == argc) { throw invalid_argument("Required argument of "-n" missing!"); } if (!IsDigit(args[i + 1])) { throw invalid_argument("The argument of "-n" should be a positive integer!"); } sscanf_s(args[i + 1], "%d", &count); if (count > maxN||count<=0)//test int.max+1 { throw invalid_argument("The argument of "-n" should be in range [1,10000]!"); } i++; } catch (invalid_argument err) { cout << err.what() << endl; state = State::INV; break; } } else if (strcmp(args[i], "-m") == 0) { mUsed++; try { if (i + 1 == argc) { throw invalid_argument("Required argument of "-m" missing!"); } if (args[i+1][1]!='