[软件工程基础]结对项目 数独程序扩展

Github 地址

Github 项目地址

PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划
- Estimate - 估计这个任务需要多少时间 10 20
Development 开发
- Analysis - 需求分析(包括学习新技术) 300 320
- Design Spec - 生成设计文档 30 40
- Design Review - 设计复审(和同事审核设计文档) 0 0
- Coding Standard - 代码规范(为目前的开发制定合适的规范) 10 10
- Design - 具体设计 100 130
- Coding - 具体编码 300 530
- Code Review - 代码复审 60 120
- Test - 测试(自我测试,修改代码,提交修改) 300 400
Reporting 报告
- Test Report - 测试报告 20 40
- Size Measurement - 计算工作量 20 20
- Postmortem & Process Improvement Plan - 是否总结,并提出过程改进计划 20 20
Total 总计 1150 1650

设计模式

Information Hiding, Interface Design 和 Loose Coupling

这三者其实说的是一回事情,都是要规定一系列职责明确的接口,然后以这些接口为对他人开发的部分的唯一假设进行自己部分的开发。

在结对的最开始阶段,由于需要研究 DLL 库,GUI,数独生成方式等问题,两人直接结对效率太低,因此我们将核心部分和 GUI 部分分开给不同的人,分别进行研究。

为了保证对接时的顺利,我们采用了作业要求的接口设计,并在最后对接时较为顺利的交接起来。

计算模块接口的设计与实现过程

类图

类图

Sudoku9

对数独终局和问题建模,提供数独终局有效性检查,数独问题有效性检查,转化成字符串等操作。针对这次等价数独要求,又提供了归一化的函数。

DLXSolver

通用的求解精确覆盖问题的采用 DLX 算法的求解器。为了效率原因,在求解之外,实现了一个特化的 DLX 以检查是否具有唯一解的函数 。

DLX 求解过程中,采用每次选择候选项最少的列进行覆盖的启发式策略加速。

Sudoku9DLXSolver

使用 DLX 算法求解数独。其主要功能是将数独问题转化为精确覆盖问题,然后通过通用的 DLXSolver 进行求解。

Sudoku9Generator

调用 Sudoku9DLXSolver 来实现生成策略。由于生成固定难度的数独耗时较大,因此保存为文件,每次生成时从文件中读取。

生成有空数限制的数独问题策略是调用 Sudoku9DLXSolver 对第一行固定为 123456789 的数独求解生成终局,这样保证生成的每个终局一定是互补等价的。然后对总共 81 个空随机一个排列,逐一敲除后求解,确定是否为唯一解,如果是唯一解,则敲除该空,否则保留,直到尝试完所有空或者挖空数目符合要求。如果一个终局最后的挖空数目不合要求,舍弃后继续,否则算为一个合法的问题。

经观察发现,对于采用启发式策略的 DLXSolver,大部分数独问题实际上都是可以每步通过必要条件唯一确定解的。而依靠必要条件这一过程实际上是数独游戏中的基本技巧。依据以所用技巧难度划分数独难度的分类法,无论数独的空数有多少,只用到低阶技巧的数独都是难度较低的。

根据这个分类法和实现难度,我们认为,依据空数和 DLXSolver 求解过程中经过的状态数来大致划分难度是合理的。因此难度策略如下:

  1. 简单难度:空数在 25 到 40 之间,只靠必要条件可唯一确定解。
  2. 中等难度:空数在 35 到 50 之间,DLXSolver 经过状态数为 82 到 100 之间。
  3. 困难难度:空数在 45 以上,DLXSolver 经过状态数在 120 以上。

实际测试,生成中等难度数独最为费时,需要 30 分钟才能生成 10000 个,因此对于 -m 参数,直接将已有的数独保存成文件,出题时直接从库里随机抽取。

由于库里的数独是固定的,虽然要求不能生成等价数独,但是等家属都的展现形式却是任意的,因此生成数独时会将数字进行重排列,避免记住终局以提高可玩性。

Core

作业要求的接口类,是对 Sudoku9DLXSolverSudoku9Generator 的简单封装。

计算模块接口部分的性能改进

最开始采用在 DLX 过程中根据深度产生空数在一定范围内的数独,但效果很糟糕。

后来和同学交流之后采用生成数独终局,然后随机挖空的方式,具体思路见上文 Sudoku9Generator 描述,效率有了很大提高。

由于之前在效率优化上已经做了很多,本次优化时间并不太长,大概 3h 左右。

性能分析图如下,测试命令为 -n 10000 -r 55~55 -u,耗时最长的函数为 DLX 求解函数。

性能

Design by Contract 和 Code Contract

Design by Contract 和 Code Contract 的优点:

  1. 明确代码职责,目标更明确。
  2. 易于检查与验证,增加代码可靠性。

Design by Contract 和 Code Contract 的缺点:

  1. 精确描述往往需要对问题有很深刻的理解,对复杂系统难以找到合适的描述形式。
  2. 由于较为耗时,因此对于正在快速开发的代码负担很大,甚至会严重拖累进度。

在设计单元测试时,实际上就是在做一个 Design by Contract 的明确化工作。在分开研究核心模块和 GUI 时,我们通过规定接口及其效果明确职责,为独立研究和之后的对接过程打下了较为坚实的基础。

计算模块单元测试

部分单元测试代码

以下代码测试的是 Core 类生成简单难度数独的功能。该单元测试依据简单数独的定义,对生成的最大数目的简单数独一次从空数,解是否唯一,是否有等价数独一次进行判断。

TEST_METHOD(CheckMode1Generate)
{
	Sudoku9 puzzle, ans;
	Sudoku9DLXSolver checkSolver;
	Core core;
	std::vector<Sudoku9Node> table;
	int i, j, k, blank, N = 10000, r;
	int(*result)[81] = new int[10000][81];
	core.generate(N, 1, result);
	char info[1000];
	for (i = 0; i < N; i += 1)
	{
		for (j = 0; j < 9; j += 1)
		{
			for (k = 0; k < 9; k += 1)
				puzzle.data[j][k] = result[i][j * 9 + k];
		}
		Assert::IsTrue(puzzle.isValidPuzzle());
		for (j = blank = 0; j < 9; j += 1)
		{
			for (k = 0; k < 9; k += 1)
			{
				if (!puzzle.data[j][k])
					blank += 1;
			}
		}
		Assert::IsTrue(25 <= blank && blank <= 40, L"The number of blank is inconsistent.");
		checkSolver.set(puzzle);
		Assert::IsTrue(checkSolver.solve(), L"The puzzle has no solution.");
		Assert::IsFalse(checkSolver.solve(), L"The puzzle has multiple solution.");
		ans = checkSolver.solution();
		Assert::IsTrue(ans.isValid());
		table.push_back(ans.normNode());
	}
	std::sort(table.begin(), table.end());
	table.erase(std::unique(table.begin(), table.end()), table.end());
	Assert::IsTrue(table.size() == N, L"The number is inconsistent.");
	delete[] result;
}

以下代码测试的是 Core 类的 solve 函数。随机挑选了一个 17 hint 唯一解的数独进行求解,对得到的解判断是否合法,以及是否确实是原来数独的一个解来验证求解功能的正确性。

TEST_METHOD(CheckCoreSolve1)
{
	int i, j;
	int origin[81], result[81];
	Core core;
	Sudoku9 puzzle(
		"800000000"
		"003600000"
		"070090200"
		"050007000"
		"000045700"
		"000100030"
		"001000068"
		"008500010"
		"090000400"), ans;
	for (i = 0; i < 9; i += 1)
	{
		for (j = 0; j < 9; j += 1)
			origin[i * 9 + j] = puzzle.data[i][j];
	}
	Assert::IsTrue(core.solve(origin, result));
	for (i = 0; i < 9; i += 1)
	{
		for (j = 0; j < 9; j += 1)
			ans.data[i][j] = result[i * 9 + j];
	}
	Assert::IsTrue(ans.isValid());
	for (i = 0; i < 9; i += 1)
	{
		for (j = 0; j < 9; j += 1)
		{
			if (puzzle.data[i][j] > 0)
				Assert::IsTrue(puzzle.data[i][j] == ans.data[i][j]);
		}
	}
}

总体覆盖率

覆盖率

计算模块异常处理

传入了错误的数独

该异常发生的原因是传入 solve 的数独有格子不在取值范围内。该异常不负责处理明显无解的情况,比如一行有两个同样的数,该种情况属于 solve 返回 False 的无解情况。

其中一个单元测试如下:

TEST_METHOD(CheckSolveException)
{
	int i, j;
	int origin[81], result[81];
	bool catchException = false;
	Core core;
	Sudoku9 puzzle(
		"800000000"
		"003600000"
		"070090200"
		"050007000"
		"000045700"
		"000100030"
		"001000068"
		"008500010"
		"090000400"), ans;
	for (i = 0; i < 9; i += 1)
	{
		for (j = 0; j < 9; j += 1)
			origin[i * 9 + j] = puzzle.data[i][j];
	}
	origin[80] = -1;
	try {
		core.solve(origin, result);
	}
	catch (std::invalid_argument e)
	{
		catchException = true;
		Logger::WriteMessage(e.what());
	}
	Assert::IsTrue(catchException);
}

传入了错误的困难模式

该异常发生的原因是在调用生成某一难度的数独时传入了错误的难度,即不是 1, 2, 3 的整数。

其中一个单元测试如下:

TEST_METHOD(CheckGenerateException1)
{
	int result[10][81];
	bool catchException = false;
	Core core;
	try {
		core.generate(10, 4, result);
	}
	catch (std::invalid_argument e)
	{
		catchException = true;
		Logger::WriteMessage(e.what());
	}
	Assert::IsTrue(catchException);
}

挖空数目不在范围内

该异常发生的原因是在调用生成挖空数目在一定范围内的数独时传入了不在范围内的上下界。

其中一个单元测试如下:

TEST_METHOD(CheckGenerateException3)
{
	int result[10][81];
	bool catchException = false;
	Core core;
	try {
		core.generate(10, -1, 0, true, result);
	}
	catch (std::invalid_argument e)
	{
		catchException = true;
		Logger::WriteMessage(e.what());
	}
	Assert::IsTrue(catchException);
}

挖空数目大小关系不正确

该异常发生的原因是在调用生成挖空数目在一定范围内的数独时传入的上下界大小关系错误。

其中一个单元测试如下:

TEST_METHOD(CheckGenerateException5)
{
	int result[10][81];
	bool catchException = false;
	Core core;
	try {
		core.generate(10, 30, 0, true, result);
	}
	catch (std::invalid_argument e)
	{
		catchException = true;
		Logger::WriteMessage(e.what());
	}
	Assert::IsTrue(catchException);
}

界面模块的详细设计过程

界面使用 Qt 插件,首先编辑 .ui 文件,设计好页面,再将 .ui 文件生成的 .h 文件 include 到主模块的头文件中,函数主要分为 3 类。

第一类,响应函数,响应点击按钮,键盘事件,鼠标事件,每个函数只响应一件事。

第二类,状态更新函数,通过实时监控某位置的变化,更新另一位置的状态,比如任何填数的操作都会触发更新每个数独格子的颜色更新函数,如果此时存在同一行/列/宫内有相同的的数,则会被同时标为红色。

第三类函数是具体实现功能的函数,这些函数在响应函数和状态更新函数中被调用。

设计详述

启动程序,首先绘制输入窗口,提供各种选项,

点击帮助按钮,触发 clicked 信号,调用 responseGetHelp 函数,弹出来关于各个选项的帮助手册,

点击右上角的“x”,触发 closeEvent 函数响应,这个函数是重写的,为避免程序崩溃,closeEvent 函数忽略关闭动作,并提示“点击 ok 进入游戏,点击 cancel 退出游戏”

点击 cancel,调用 reject 函数响应,并退出程序。
点击 ok,调用 responseOK 函数,从各控件上读取内容,若模式为自定义且最小空格数大于最大空格数,会提示重设。

之后调用计算模块提供的 generate 接口生成数独题目,为了之后传递方便,我改为了二重指针传递,生成结束后发出自己定义的 generateSuccesssfully 信号,传递参数给主模块,调用 receiveQues 函数接收参数,至此,输入模块任务结束。

responseOK 为核心函数,代码如下:

void GenetateNumber::responseOK()
{
	
	int i, j;
	iGenerateNumber = ui.generationNumber->text().toInt();
	if (ui.easy->isChecked())         //是简单模式?
	{
		iMode = 1;
		goto ACCEPT;
	}
	else if (ui.medium->isChecked())        //是入门模式?
	{
		iMode = 2;
		goto ACCEPT;
	}
	else if (ui.hard->isChecked())        //是困难模式?
	{
		iMode = 3;
		goto ACCEPT;
	}
	else         //那就是自定义模式
	{
		iMode = 0;
		iMinSpace = ui.hSliderMinSpace->value();        //读取最小空格数
		iMaxSpace = ui.hSliderMaxSpace->value();        //读取最大空格数
		bUnique = ui.uniqueSign->isChecked();        //读取是否要唯一解
		if (iMinSpace > iMaxSpace)        //如果最小空格数大于最大空格数,提示玩家
		{
			QMessageBox::information(NULL, "\346\217\220\347\244\272", "", QMessageBox::Yes, QMessageBox::Yes);        //此处中文编码太长,略去
			return;
		}
	}

ACCEPT:
	int(*temp)[81] = new int[10000][81];		
	result = new int*[iGenerateNumber];
	for (i = 0; i < iGenerateNumber; i++)
		result[i] = new int[81];
	if (iMode == 0)
		c.generate(iGenerateNumber, iMinSpace, iMaxSpace, bUnique, temp);    //调用自定义模式的generate接口
	else
		c.generate(iGenerateNumber, iMode, temp);        //调用其他模式的generate接口
		
	
	for (i = 0; i < iGenerateNumber; i++)
	{
		for (j = 0; j < 81; j++)
			result[i][j] = temp[i][j];
	}
	delete[] temp;
	emit generateSuccessfully();    //发射信号
	this->accept();
}

之后进入主界面。

数独格子中的值每发生一次改变,都会调用 refreshAboutSudokuBox 函数更新状态,refreshAboutSudokuBox 函数调用 testValuechange 函数为每个数字标色

鼠标放在数独格子上并右键单击,每个格子是包装过的 QLineEdit 控件,重写了contextMenuEvent 函数,右键单击,原本是弹出右键菜单,现在是发出自定义的 getTips 信号同时传出该格子的行索引和列索引,进而调用 responseGetTips 函数响应“提示”请求,responseGetTips 中调用计算模块的 solve 接口,若返回 false,则提示之前某个格子填错了,

若返回 true,则将发出信号的格子填上。重写 contextMenuEvent 函数代码如下:

void myQLineEdit::contextMenuEvent(QContextMenuEvent *event)
{
	int rowId, colId;
	if (this->isEnabled() && this->text().isEmpty())
	{
		colId = (geometry().left() - LEFT_MARGIN) / BOX_SIDE_LENGTH;
		rowId = (geometry().top() - TOP_MARGIN) / BOX_SIDE_LENGTH;
		emit getTips(rowId, colId);
	}
}

内置的定时器每秒发送一个信号,调用 refreshLCDCurTime 函数,计时显示器加 1。考虑到玩一盘数独玩 24 小时的情况很少见,为了表示对玩家坚持不懈解数独的尊重,无论玩家最后耗时多久,我们都将该次游戏时间记为 23:59:59。

点击“上一关”,调用 responsePreGame 函数更新至上一关。点击“下一关”,调用responseNextGame 函数更新至下一关。当前关卡序号每次发生变化,都会调用refreshPreAndNextButton,更新“上一关”和“下一关”两个按钮的状态。

编辑框内容每次发生变化,都调用 refreshJump 更新“跳转”按钮状态。点击“跳转”按钮,调用responseJump 函数更新至指定关卡。

点击“暂停游戏”,调用 responsePause 函数,冻结时间,提示功能和完成按钮。点击“继续游戏”,调用 responseContinue 函数,解冻前述控件及功能。“暂停游戏”和“继续游戏”两个按钮不能同时有效,每次“暂停游戏”的状态发生变化,都会调用 refreshContinueButton 更新“继续游戏”按钮状态。

点击“再玩一组”,则调用 responsePlayAgain 函数,打开输入页面,重新开始。点击“帮助”按钮,调用 responseGetHelp 函数,显示帮助文档。点击“退出”,退出游戏。

点击“完成”,调用 responseFinish 函数,responseFinish 函数调用 testAnswer() 函数检查对错,若不对,则提示玩家错误。

若正确,则提示玩家正确,调用 refreshLCDMinTime 更新最佳纪录,

再检查是否是最后一关,若是,提示玩家点击“再玩一局”,重新游戏,若不是,则直接更新至下一关。

键盘事件响应由 keyPressEvent 函数实现,支持 (光标上移),(光标下移),tab(光标默认后移),ctrl(光标左移),alt(光标右移),F1(上一关快捷键),F2(下一关快捷键),F3(聚焦手动选关编辑框快捷键),F4(跳转快捷键),F6(完成快捷键),F7(再玩一局快捷键),F8(暂停快捷键),F9(继续快捷键),F10(寻求帮助快捷键)等。
最后,附上最为核心的主模块的信号槽连接代码:

bool flag = false;
flag = QObject::connect(generateDialog, SIGNAL(generateSuccessfully()), this, SLOT(receiveQues()));        //题目生成成功->接收参数
assert(flag);   
flag = QObject::connect(ui._quit, SIGNAL(clicked()), qApp, SLOT(quit()));    //点击“退出”->退出应用
assert(flag);
flag = QObject::connect(ui.finish, SIGNAL(clicked()), this, SLOT(responseFinish()));    //点击“完成”->调用responseFinish函数
assert(flag);
flag = QObject::connect(timer, SIGNAL(timeout()), this, SLOT(refreshLCDCurTime()));    //计时显示器
assert(flag);
flag = QObject::connect(ui.curGameNumberContent, SIGNAL(textChanged(QString)), this, SLOT(refreshPreAndNextButton()));    //更新“上一关”“下一关”按钮状态
assert(flag);
flag = QObject::connect(ui.preGame, SIGNAL(clicked()), this, SLOT(responsePreGame()));    //点击“上一关”->调用responsePreGame函数
assert(flag);
flag = QObject::connect(ui.nextGame, SIGNAL(clicked()), this, SLOT(responseNextGame()));         //点击“下一关”->调用responseNextGame函数
assert(flag);
flag = QObject::connect(ui.chooseGameContent, SIGNAL(textChanged(QString)), this, SLOT(refreshJump()));        //更新跳转按钮状态
assert(flag);
flag = QObject::connect(ui.jump, SIGNAL(clicked()), this, SLOT(responseJump()));        //点击“跳转”->调用responseJump函数
assert(flag);
flag = QObject::connect(ui.playAgain, SIGNAL(clicked()), this, SLOT(responsePlayAgain()));        //点击“再玩一局”->调用responsePlayAgain函数
assert(flag);
flag = QObject::connect(ui.pauseButton, SIGNAL(clicked()), this, SLOT(responsePause()));         //点击“暂停游戏”->调用responsePause函数
assert(flag);
flag = QObject::connect(ui.continueButton, SIGNAL(clicked()), this, SLOT(responseContinue()));        //点击“继续游戏”->调用responseContinue函数
assert(flag);
flag = QObject::connect(ui.getHelp, SIGNAL(clicked()), this, SLOT(responseGetHelp()));         //点击“获取帮助”->调用responseGetHelp函数
assert(flag);

for (i = 0; i < NUMBER_OF_ROWS; i++)
{
    for (j = 0; j < NUMBER_OF_COLUMNS; j++)
    {
        flag = QObject::connect(ui.sudokuBox[i][j], SIGNAL(textChanged(QString)), this, SLOT(refreshAboutSudokuBox()));        //更新数独格子颜色
        assert(flag);
        flag = QObject::connect(ui.sudokuBox[i][j], SIGNAL(getTips(int, int)), this, SLOT(responseGetTips(int, int)));        //右键单击->调用responseGetTips
        assert(flag);
    }
}

通过增量修改的方式,改进程序,发布一个真正的软件

我们收到反馈的问题及相应的改进如下:

1.发布之后得到一些用户的反馈说保存记录的功能做的不彻底,只能在一次游戏中保持,关了之后就没有了。得到反馈之后花了一些时间加入了将记录存至文件后重新读取的功能。
2.还有反馈说输入页面没有提示用户输入数独数目的范围,不太方便,对此我们在输入页面提示框中增加“(1-10000)”提示用户。
3.数独游戏页面输入0是非法的,我们之前的程序虽然检查的时候会认定其非法,但并不会将其标红,对此,我们在实施检查时增加对填0情况标红。

结对过程

结对图片

由于上一次作业就是因为没接触过 GUI 因此没做,因此这次拿到题目后一脸懵逼。二人稍加讨论后认为前期并不适合结对编程,于是决定兵分两路。由于个人项目我的效率比较高,因此决定我这边研究如何生成数独,方科栋同学则调研 GUI 相关工具,各自按接口开发的差不多了再对接。

由于第一周接着国庆假期,各自都有安排,因此在时间上也比较适合各自钻研,事实也证明这样的效率是比较高的。

在大致有了架子之后,两个人在放假后的一周通过结对编程完成了后续的对接、修改,单元测试和代码复审等工作。

结对编程的体会

关于结对编程

结对编程的优点:

  1. 两人专注于一份代码,提高了代码质量。
  2. 开发过程中二人的思维处于同一层面上,交流效率较高。

结对编程的缺点:

  1. 对于处于探索阶段的项目效率极低,二人一起查同一份资料十分浪费时间。
  2. 对于思维发散度不匹配的二人不太友好,可能经常出现思维脱节的状况。
  3. 对于时间安排需要极高的契合度。

关于结对对象

结对对象方科栋同学的优点:

  1. 善于学习新知识,主动承担了 GUI 的调研及大部分开发工作。
  2. 具有较好的审美能力(比我好[捂脸])。
  3. 谋定而后动,有较好的规划能力。

结对对象方科栋同学的缺点:

  1. C++ 理解不够深入,算法掌握不够熟练。
posted @ 2017-10-15 07:54  braveTester  阅读(469)  评论(1编辑  收藏  举报