结对项目-数独程序扩展
Github 地址:https://github.com/JerryYouxin/sudoku
PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 30 | 10 |
· Estimate | · 估计这个任务需要多少时间 | 1200 | 1600 |
Development | 开发 | 800 | |
· Analysis | · 需求分析 (包括学习新技术) | 40 | 20 |
· Design Spec | · 生成设计文档 | 60 | 60 |
· Design Review | · 设计复审 (和同事审核设计文档) | 60 | 60 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 30 | 10 |
· Design | · 具体设计 | 300 | 400 |
· Coding | · 具体编码 | 300 | 500 |
· Code Review | · 代码复审 | 20 | 30 |
· Test | · 测试(自我测试,修改代码,提交修改) | 300 | 400 |
Reporting | 报告 | 60 | 70 |
· Test Report | · 测试报告 | 30 | 30 |
· Size Measurement | · 计算工作量 | 15 | 10 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 15 | 10 |
|合计 | 1200 | 1600
看教科书和其它资料中关于Information Hiding, Interface Design, Loose Coupling的章节,说明你们在结对编程中是如何利用这些方法对接口进行设计的
在实现中,我们通过接口设计,将不同模块之间用同一个接口进行交互,使得接口的实现方法等与上层调用无关,上层调用并不用关心接口的具体实现而不影响使用。而信息隐藏可以让上层不用关心也不能介入底层的实现,使得程序间的独立性有了保障。
在我的解题函数solve中,原来使用的方法是回溯法,后来改为DLX重新定义了一个DLX_Solver进行解题,而接口还是用solve进行交互,只是里面的实现改为了调用DLX_Solver进行解题。而这些实现上的改变并没有影响上层GUI,这是有效的信息隐藏(实现方法以及相关数据结构的隐藏)、接口设计的结果,让我们的模块间具有了松耦合性。
计算模块接口的设计与实现过程
计算模块接口如下(Core类中的成员函数):
void generate(int number,int mode,int result[][81]);
void generate(int number,int lower,int upper,bool unique,int result[][81]);
void generate(int number,int result[][81]); // generate final states
bool solve(int puzzle[],int solution[]);
void solve(int number,int *puzzle,int *solution);
int read_sudoku(int **puzzle,const char* filename);
int write_sudoku(int number,int *puzzle,const char* filename);
bool check_valid(int *solution);
int check_valid(int number,int *solution);
bool check_same(int number,int *solution);
其中generate(int number, int result)用回溯法进行终局的搜索(可以参考我的个人项目的博客)。在实现生成只有唯一解的数独时,先用上述终局生成函数生成数独终局,再进行挖空,每次挖空后用解数独函数来求解数独,该函数可以返回数独解的数量,由此可以判断是否为唯一解。如果不是唯一解的数独则回溯不挖空,并再查看下一个空是否可以挖(这是一个递归)。代码如下:
bool Core::__generate_unique(int num, int maxNum, int index, int result[]) {
if(index>=81||maxNum<=num) return maxNum<=num;
int x = result[index];
result[index] = 0;
if(isUniqueSolve(result)) {
return __generate_unique(num+1,maxNum,index+1,result);
}
else {
result[index] = x;
return __generate_unique(num,maxNum,index+1,result);
}
}
void Core::generate(int number,int lower,int upper,bool unique,int result[][81]) {
if(number<1||number>10000) throw NumException();
if(lower>upper||lower<0||upper>64) throw RangeException();
if(unique) {
generate(number,result);
#pragma omp parallel for
for(int n=0;n<number;++n) {
int num = lower;//rand()%(upper-lower+1)+lower;
__generate_unique(0,num,0,result[n]);
}
} else {
generate(number,result);
for(int n=0;n<number;++n) {
int num = rand()%(upper-lower+1)+lower;
for(int i=0;i<81;++i) {
if(81-i+1<=num) {
result[n][i] = 0;
--num;
}
else {
double r = rand()/(double)RAND_MAX;
if(r<0.5) {
result[n][i] = 0;
--num;
}
}
}
}
}
}
在检查函数上,Core类提供了三个接口以供检查数独合法性等信息:
// check functions
bool check_valid(int *solution);
int check_valid(int number,int *solution);
bool check_same(int number,int *solution);
其中check_valid为检查输入数独是否为合法数独,即每个数字都是0~9之间的整数且符合数独的规则要求。如果是合法的bool check_valid(int)会返回true,int check_valid(int,int)会返回0,否则为非法数独。其中bool类型的需要保证输入的数独是只有一个的,且所有的函数输入都至少分配好足够的内存空间。
check_same函数是检查数独是否有重的,使用字符串表示+STL map实现,查重效率较高。这三个函数的代码如下。
typedef std::map<std::string,int> hashMap;
// same is false
bool Core::check_same(int number,int *solution) {
if(solution==0||number<=0) return false;
hashMap hmap;
char ph[82];
for(int i=0;i<number;++i) {
int* ptr = solution+i*81;
memset(ph,0,82);
for(int j=0;j<81;++j) {
ph[j] = ptr[j]+'0';
}
ph[81] = '\0';
std::string x(ph);
// search if the number is already exist
hashMap::iterator it= hmap.find(x);
if(it == hmap.end()) {
hmap[x] = 1;
}
else {
return false;
}
}
return true;
}
// valid is true
bool Core::check_valid(int *solution) {
if(solution==0) return false;
bool empty_value[9][9][3]={ 0 }; // (value, r/c/b number, row/col/block)
for(int r=0;r<9;++r) {
for(int c=0;c<9;++c) {
int b = 3*(r/3)+(c/3);
int i = r*9+c;
int v = solution[i]-1;
if(solution[i]>9||solution[i]<0) return false;
if(solution[i]==0) continue;
if(empty_value[v][r][0]||empty_value[v][c][1]||empty_value[v][b][2]) {
return false;
}
empty_value[v][r][0] = true;
empty_value[v][c][1] = true;
empty_value[v][b][2] = true;
}
}
return true;
}
int Core::check_valid(int number,int *solution) {
if(solution==0) return -1;
for(int i=0;i<number;++i) {
if(!check_valid(solution+i*81)) {
return i+1;
}
}
return 0;
}
-m生成难度的是基于挖的空的数量和空间自由度两个方面决定。
一般而言,肯定是挖空越多总体趋势上难度越大。
难度等级的增加,空格数总体趋势递增,不同难度等级的题目空格数也一样的情况。我们得出初步结论,简单按照空格的数目多少来划分数独题目的难易程度是不全面的。同样空格数的数独题目,空格数分布位置的不同对难度等级也造成影响。
在这种思维下,我们提出自由度的概念:,数独的难度等级与行、列、宫格内的空格数存在着联系。提出以空格自由度衡量数独的难度等级。数独的空格自由度,指除掉空格本身,空格所在行、列、九宫内的空格数总和。
我们通过这两个方面的思维,将空格从20到55分为3个区间,自由度从700到1300分为三个区间,难度的划分是只要一个条件达到了相应区间就可以认为达到了要求。
这样我们可以在第一次个人项目的基础上,随即进行挖空,直至满足了条件。
可以参考
http://www.cnblogs.com/candyhuang/archive/2011/12/21/2153668.html
Solve函数跟个人项目相比,改为用DLX算法求解数独。
UML图
计算模块接口部分的性能改进
原来的回溯算法性能很低,所以在求解上,由个人项目时的回溯法改为了快速求解精确覆盖问题的DLX算法。但是在实现后却发现加速并不明显,甚至还有些慢。在最耗时间的生成唯一解的情况下,用VS性能分析后得到如下图:
[1]!(http://images2017.cnblogs.com/blog/1224149/201710/1224149-20171015145603137-289738865.png)
由于我截图的时候代码有些改动导致函数名并不能正常显示。图中最耗时间的是DLXSolver中的solve函数,由于查找唯一解时挖空是递归搜索,所以这是很正常的现象。但是注意到第二位的那个new函数,是分配空间的函数,这个耗时也很长,这是为什么?看看代码,发现在将数独问题转换为精准覆盖问题时会申请内存空间来建立双向十字链表来存储稀疏矩阵。由于原来的实现为一个节点一个节点进行内存分配,所以只要改为刚开始统一分配好一批节点后,后面按需所配即可。改进后速度有了大大提高,如图。
优缺点
通过合同设计(DbC),也称为合同编程,按合同编程和按合同编程设计,是设计软件的一种方法。它规定软件设计人员应该为软件组件定义正式,精确和可验证的接口规范,这些规范扩展了具有前提条件,后置条件和不变量的抽象数据类型的普通定义。这些规范根据概念隐喻被称为“合同” 具有商业合同的条件和义务。
这段翻译自wiki。Design by Contract可以让多人合作通过严格规定的接口规范来简化多人合作时的沟通、合并成本,大大提高了合作效率。并且这样也使得内部实现方法跟上层使用接口相独立,使得软件结构松耦合,使得扩展、修改更容易,维护更方便。
我们在进行结对编程时规定了Core和GUI模块部分的接口规范,使得我们两个开发的两个模块可以迅速对接,具有松耦合性。
计算模块部分单元测试展示
单元测试思路为每个主要的函数都进行测试。I/O操作的单元测试,其中refRes是一个合法的数独数组:
TEST_METHOD(TestRead)
{
Core core;
int refRes[]={
...
};
int* result;
core.read_sudoku(&result,"sudokuexp.txt");
for(int i=0;i<162;++i)
Assert::AreEqual(refRes[i],result[i]);
delete[] result;
}
TEST_METHOD(TestWrite)
{
Core core;
int refRes[]={
...
};
int* result;
core.write_sudoku(2,refRes,"sudoku.txt");
core.read_sudoku(&result,"sudoku.txt");
for(int i=0;i<162;++i)
Assert::AreEqual(refRes[i],result[i]);
delete[] result;
}
对于generate,对于每个不同大小的输入、不同类型的接口进行测试,并在最后调用检查函数check_valid和check_same来验证正确性。
TEST_METHOD(TestGen...)
{
Core core;
const int N = ...; // MAXIMUM
//int result[N][81];
int* result = new int[N*81];
core.generate(N,(int(*)[81])result);
Assert::AreEqual(core.check_valid(N,(int*)result),0);
Assert::IsTrue(core.check_same(N,(int*)result));
delete[] result;
}
Solve函数同样,读入测试数据后调用solve接口进行解题,然后用check_valid进行正确性检查。
TEST_METHOD(TestSolveExp)
{
Core core;
const int N = 2; // MAXIMUM
int* result = new int[N*81];
//int result[N][81];
int* puzzle;
core.read_sudoku(&puzzle,"sudokuexp.txt");
core.solve(N,puzzle,(int*)result);
Assert::AreEqual(core.check_valid(N,(int*)result),0);
Assert::IsTrue(core.check_same(N,(int*)result));
delete[] result;
delete[] puzzle;
}
单元测试结果如下:
计算模块部分异常处理说明
计算模块异常有四类:RangeException,NumException,ModeException,InvalidSudokuException。每个异常都在函数刚开始进行参数检查时,如果参数异常则抛出相关异常。单元测试如下:
TEST_METHOD(TestInvalidSudokuException)
{
Core core;
const int N = 1; // MAXIMUM
int puzzle[] ={
9, 9, 8, 0, 6, 0, 1, 2, 4,
2, 3, 7, 4, 5, 1, 9, 6, 8,
1, 4, 6, 0, 2, 0, 3, 5, 7,
0, 1, 2, 0, 7, 0, 5, 9, 3,
0, 7, 3, 0, 1, 0, 4, 8, 2,
4, 8, 0, 0, 0, 5, 6, 0, 1,
7, 0, 4, 5, 9, 0, 8, 1, 6,
8, 1, 0, 7, 4, 6, 2, 0, 0,
3, 0, 5, 0, 8, 0, 7, 0, 9,
};
int result[81]={ 0 };
try {
Assert::IsFalse(core.check_valid(puzzle));
Assert::AreEqual(core.check_valid(1,puzzle),1);
core.solve(puzzle,(int*)result);
Assert::Fail();
}
catch(InvalidSudokuException e) {
}
}
TEST_METHOD(TestRangeException)
{
Core core;
const int N = 1000; // MAXIMUM
//int result[N][81];
int* result = new int[N*81];
try {
core.generate(N,-1,55,false,(int(*)[81])result);
Assert::Fail();
}
catch(RangeException e) {
}
delete[] result;
}
TEST_METHOD(TestNumException_R)
{
Core core;
const int N = 1000; // MAXIMUM
//int result[N][81];
int* result = new int[N*81];
try {
core.generate(-1,20,55,false,(int(*)[81])result);
Assert::Fail();
}
catch(NumException e) {
}
delete[] result;
}
TEST_METHOD(TestNumException_M)
{
Core core;
const int N = 1000; // MAXIMUM
//int result[N][81];
int* result = new int[N*81];
try {
core.generate(-1,1,(int(*)[81])result);
Assert::Fail();
}
catch(NumException e) {
}
delete[] result;
}
TEST_METHOD(TestModeException)
{
Core core;
const int N = 1000; // MAXIMUM
//int result[N][81];
int* result = new int[N*81];
try {
core.generate(N,0,(int(*)[81])result);
Assert::Fail();
}
catch(ModeException e) {
}
delete[] result;
}
结果如下:
界面模块的详细设计过程
首先是一个计时的工具,通过Qlabel来实现,在时间能够走得情况下(通过全局变量changable来控制),将time_show增长,并且将其的分钟数和秒数set到两个label的txt上面,就是先了记时的功能。
void MainWindow::time_change()
{
if(time_changable==true)
{
time_show++;
int minute=time_show/60;
int second=time_show%60;
char str1[3];
char str2[3];
sprintf(str1,"%02d",minute);
sprintf(str2,"%02d",second);
minute_->setText(str1);
second_->setText(str2);
}
接下来是各个控制按钮,它们的类型都是QPushButton,它们之间有一定的逻辑关系,比如在刚刚按下new_game按钮的时候,除了restart和new_game之外的按钮都是不能按下的,而一旦选择了数独中的按钮,那么根据选择的是原先就有的,新填上的还是现在为止还没有填上的,不同的按钮会分别转换为可选中和不可选中的状态,以下面的代码为例
void MainWindow::pause()
{
if(Pause->text().compare("Pause")==0)
{
time_changable=false;
for(int i=0;i<81;i++)
{
follow[i]->setEnabled(false);
}
for(int j=0;j<9;j++)
{
fillin[j]->setEnabled(false);
}
Remind_Me->setEnabled(false);
Check->setEnabled(false);
Wipe->setEnabled(false);
Pause->setText("Continue");
}
else
{
if(Pause->text().compare("Continue")==0)
{
time_changable=true;
for(int i=0;i<81;i++)
{
follow[i]->setEnabled(true);
}
Remind_Me->setEnabled(true);
Check->setEnabled(true);
Wipe->setEnabled(true);
Pause->setText("Pause");
}
}
for(int j=0;j<9;j++)
{
fillin[j]->setEnabled(false);
}
}
void MainWindow::wipe()
{
QString str1=last_button->objectName();
Wipe->setEnabled(false);
QString tmp;
for(int j = 0; j < str1.length(); j++)
{
if(str1[j] >= '0' && str1[j] <= '9')
tmp.append(str1[j]);
}
bool ok;
int index=tmp.toInt(&ok,10);
if((last_button->text().compare("")!=0)&&sudukuinto[index]==0)
{
follow[index]->setText("");
nowsuduku[index]=0;
}
for(int j=0;j<9;j++)
{
fillin[j]->setEnabled(false);
}
}
最后的成品大概是这样的,可以计时、选择难度、开新局,重新此局,删除填过的空。
界面模块与计算模块的对接
void Core::generate(int number,int mode,int result[][81]) {
if(number<1||number>10000) throw NumException();
if(mode!=1&&mode!=2&&mode!=3) throw ModeException();
int i = 0;
int free_degree = 0;
int sudoku_num = 0;
int num = 0;
int the_time = 0;
int hollow_num = 0;
for (sudoku_num = 0; sudoku_num < number; sudoku_num++)
{
generate(1,(int(*)[81])result[sudoku_num]);
free_degree = 0;
num = 0;
if (mode == 1)
{
hollow_num=rand() % 9 + 40;
if (the_time == 0)
{
for (int ii = 0;; ii = ii + 2)
{
上面这部分是实现难度生成的代码,而这部分的代码通过什么样的方式与UI的工程相联系起来呢,主要是通过动态链接库所生成的代码库,UI通过调用来实现的。在UI的显示类中将core定义为其中的一个属性,然后通过调用生成和求解的方法将相应的结果返回到UI模块的一个变量result中,再通过UI的settext将其显示在图形界面上。
具体代码思想大概是这样:
private:
QPushButton *follow[81];
QPushButton *fillin[10];
QPushButton *Remind_Me;
QPushButton *Restart;
QPushButton *New_Game;
QPushButton *Check;
QPushButton *Pause;
QPushButton *Wipe;
QComboBox *choose_;
QLabel *minute_;
QLabel *second_;
Core core;
core.generate(1,diff,result[i]);
QString mode=ui->centralWidget->findChild<QComboBox*>("choose")->currentText();
bool a=false;
switch( QMessageBox::question(this, "begin a new game?",
"you choose "+mode+" ,are you sure"),
QMessageBox::Ok | QMessageBox::Cancel,QMessageBox::Ok )
描述结对的过程
结对编程的优点和缺点
优点:
- 互相学习,相互提高
- 减少代码出错率
- 代码可读性会高
缺点:
- 如果两个人合作不默契可能会大大降低效率