结对项目-数独程序扩展

项目地址

https://github.com/si1entic/Sudoku-2.git


需求分析

  • 命令行
    合法参数有六种: -c 、 -s 、 -n -m 、 -n -r 、 -n -u 、 -n -r -u (支持多参数的顺序任意)
    -c 1~1000000 -n 1~10000 -m 1~3 -r 20~55

  • GUI程序
    难度选择、计时、提示、最佳记录


开发过程

  • 看教科书和其它资料中关于Information Hiding, Interface Design, Loose Coupling的章节,说明你们在结对编程中是如何利用这些方法对接口进行设计的。(5')
      在OO课程中,我们学习面向对象程序的需求分析和设计原则时,提到过5个经典的设计原则检查(SOLID)。其中的SRP(Single Responsibility Principle)要求每个模块都只有一个明确的职责。因为模块职责多,就意味着逻辑难以封闭,容易受到外部因素变化而变化,导致该模块不稳定。在本项目中,我们把涉及到计算数据的生成和求解数独功能提出来,形成一个独立的模块。其他的控制输入、数据可视化等功能也形成各自的模块,再通过接口把它们联系起来,这样各模块间就做到了松耦合(即修改一个模块时不需要更改其他的模块),同时也实现了不同模块间的信息隐藏(即每个模块只访问自己感兴趣的数据来实现自己负责的功能)。
      例如本项目中的Core模块,功能就是生成和求解数独,因此提供generate和solve两个接口函数供外部调用。当需求发生变化时(生成符合要求的数独),我们遵从OCP原则,不修改已有实现(close),而是通过扩展来增加新功能(open)。所以我们重载了generate函数,通过接收不同参数,来满足不同的需求。

  • 计算模块接口的设计与实现过程。设计包括代码如何组织,比如会有几个类,几个函数,他们之间关系如何,关键函数是否需要画出流程图?说明你的算法的关键(不必列出源代码),以及独到之处。(7')
      Core模块主要可分为三部分,一是随机生成终盘,二是按要求挖空,三是求解数独题目。因此主要分为三个类,其中FinalMaker类的make函数采用每行随机填数的方法生成一个终盘(为了可玩性牺牲了绝对不重复性,虽然理论上可能生成等效数独但概率极低),PuzzleSovlver类的求解函数采用效率极高的DLX算法,而Core类通过调用这两类的函数来实现随机生成终盘、求解数独、保证唯一解挖空功能。最后一个功能应该是最难实现的,这里我们采取的办法是:先生成终盘,再随机挖空,然后求解看有没有多解,有则重新挖。流程图如下:

    此外,影响数独难度的因素较多,评定起来也比较复杂,比赛一般采取人工求解评定难度,而软件中普遍认可的是SE值(求解该数独用到最难解法难度)。但这对于我们的程序来说显然有些难了,所以采用比较简单的以挖空数来划分:Easy 20~30空 Normal 31~45空 Hard 46~55空。

  • 画出UML图显示计算模块部分各个实体之间的关系(画一个图即可)。(2’)

  • 计算模块接口部分的性能改进。记录在改进计算模块性能上所花费的时间,描述你改进的思路,并展示一张性能分析图(由VS 2015/2017的性能分析工具自动生成),并展示你程序中消耗最大的函数。(3')
      主要分析最复杂的生成唯一解数独的功能。生成数量少时还好,个数大于1000就明显慢到无法接受的地步。分析发现findSolutions()函数耗时极长,于是针对它做了修改,在发现有第二个解时就抛出一个int,在最外面通过try catch来接收这个抛出,从而跳出了多重的递归,大大减少了判断是否有唯一解的时间。下面是在-n 10000 -r 40~50 -u参数下的性能分析图:

    消耗最大的函数时Input类的handle函数,负责调用其他函数实现功能。而各功能函数中,耗时较多的是生成随机数独的make()和检查唯一解的checkUnique()。但需要说明的是,当挖空数在50以上时,程序耗时会大大加长,原因在于挖较多空时需要大量地调用checkUnique函数,导致消耗激增,暂时没有更好的解决方法。

  • 看Design by Contract, Code Contract的内容:
    http://en.wikipedia.org/wiki/Design_by_contract
    http://msdn.microsoft.com/en-us/devlabs/dd491992.aspx
    描述这些做法的优缺点, 说明你是如何把它们融入结对作业中的。(5')
    契约式设计(OO老师非常推崇的模式,让我们写了不少)
    设计人员为软件组件定义了形式化、精确和可验证的接口规范。使用者和被调用者地位平等,双方必须彼此履行义务,才可以行驶权利。
    这样做的优点是提倡程序员对程序设计进行规范化操作,有了语言级别的前置后置条件和不变式的明确定义,程序的结构变得更加便于阅读和交流;各部件职责独立且仅为自己的功能负责,产生错误时定位较容易;保证了调用与被调用双方代码的质量,提高了软件工程的效率和质量。
    缺点是对于程序语言有一定的要求,需要断言机制。
    我们在Core模块接口的设计中运用了DbC,规定了generate()、solve()传入参数的前置条件(例如0<mode<4),后置条件(result为合法的数独),不变式(None)。

  • 计算模块部分单元测试展示。展示出项目部分单元测试代码,并说明测试的函数,构造测试数据的思路。并将单元测试得到的测试覆盖率截图,发表在博客中。要求总体覆盖率到90%以上,否则单元测试部分视作无效。(6')
      Core单元的功能为生成和求解数独,对于生成的测试主要分三个方面:一是生成的题目是否合法(比如某行是否会出现两个"1"之类的),二是挖空数是否在规定范围之内。可通过下面的函数检测:

    bool checkValid(int final[9][9], int row, int col, int& blanks)
    {
    	int value = final[row][col];
    	if (value == 0)
    	{
    		blanks++;
    		return true;
    	}
    	for (int i = row / 3 * 3; i < row / 3 * 3 + 3; i++) // 检测该块是否已有该数字
    		for (int j = col / 3 * 3; j < col / 3 * 3 + 3; j++)
    			if (final[i][j] == value)
    				if (!(i == row&&j == col))
    					return false;
    	for (int i = 0; i < 9; i++) // 检测该行该列是否已有该数字
    		if ((i != col&&final[row][i] == value) || (final[i][col] == value&&i != row))
    			return false;
    	return true;
    }
    

三是判断是否有唯一解,这里直接调用PuzzleSolve::checkUnique()进行检查。
测试代码:
```
[TestMethod]
void TestGenerate1()
{
srand((unsigned)time(NULL));
Core c;
const int number = 100;
for (int mode = 1; mode <= 3; mode++) // 遍历三个难度
{
int result[number][81];
c.generate(number, mode, result);
int game[9][9];
int blanks;
for (int i = 0; i < number; i++) // 遍历生成的题目
{
memcpy(game, result[i], sizeof(game));
blanks = 0;
for (int j = 0; j < 81; j++)
Assert::IsTrue(checkValid(game, j / 9, j % 9, blanks), L"合法性出错");
switch (mode)
{
case 1:
Assert::IsTrue(blanks >= 20 && blanks <= 30, L"难度1挖空范围出错");
break;
case 2:
Assert::IsTrue(blanks >= 31 && blanks <= 45, L"难度2挖空范围出错");
break;
case 3:
Assert::IsTrue(blanks >= 46 && blanks <= 55, L"难度3挖空范围出错");
break;
default:
break;
}
}
}
};

[TestMethod]
void TestGenerate2()
{
	Core c;
	const int number = 100, lower = 20, upper = 30;
	int result[number][81];
	c.generate(number, lower, upper, false, result);
	int game[9][9];
	int blanks;
	for (int i = 0; i < number; i++)    // 遍历生成的题目
	{
		memcpy(game, result[i], sizeof(game));
		blanks = 0;
		for (int j = 0; j < 81; j++)
			Assert::IsTrue(checkValid(game, j / 9, j % 9, blanks), L"合法性出错");
		Assert::IsTrue(blanks >= lower && blanks <= upper, L"挖空范围出错");
	}
};

[TestMethod]
void TestGenerate3()
{
	Core c;
	PuzzleSovlver ps;
	const int number = 100, lower = 40, upper = 55;
	int result[number][81];
	c.generate(number, lower, upper, true, result);
	int game[9][9];
	int blanks;
	for (int i = 0; i < number; i++)    // 遍历生成的题目
	{
		memcpy(game, result[i], sizeof(game));
		blanks = 0;
		for (int j = 0; j < 81; j++)
			Assert::IsTrue(checkValid(game, j / 9, j % 9, blanks), L"合法性出错");
		Assert::IsTrue(blanks >= lower && blanks <= upper, L"挖空范围出错");
		Assert::IsTrue(ps.checkUnique(game), L"唯一性出错");
	}
};

[TestMethod]
void TestSolve()
{
	Core c;
	int puzzle[1][81];
	int final[9][9];
	int blanks = 0;

	c.generate(1, 1, puzzle);
	Assert::IsTrue(c.solve(puzzle[0], puzzle[0]), L"求解失败");
	memcpy(final, puzzle, sizeof(final));
	for (int j = 0; j < 81; j++)
		Assert::IsTrue(checkValid(final, j / 9, j % 9, blanks), L"合法性出错");

	c.generate(1, 2, puzzle);
	Assert::IsTrue(c.solve(puzzle[0], puzzle[0]), L"求解失败");
	memcpy(final, puzzle, sizeof(final));
	for (int j = 0; j < 81; j++)
		Assert::IsTrue(checkValid(final, j / 9, j % 9, blanks), L"合法性出错");

	c.generate(1, 3, puzzle);
	Assert::IsTrue(c.solve(puzzle[0], puzzle[0]), L"求解失败");
	memcpy(final, puzzle, sizeof(final));
	for (int j = 0; j < 81; j++)
		Assert::IsTrue(checkValid(final, j / 9, j % 9, blanks), L"合法性出错");

	c.generate(1, 20, 55, false, puzzle);
	Assert::IsTrue(c.solve(puzzle[0], puzzle[0]), L"求解失败");
	memcpy(final, puzzle, sizeof(final));
	for (int j = 0; j < 81; j++)
		Assert::IsTrue(checkValid(final, j / 9, j % 9, blanks), L"合法性出错");

	c.generate(1, 50, 55, true, puzzle);
	Assert::IsTrue(c.solve(puzzle[0], puzzle[0]), L"求解失败");
	memcpy(final, puzzle, sizeof(final));
	for (int j = 0; j < 81; j++)
		Assert::IsTrue(checkValid(final, j / 9, j % 9, blanks), L"合法性出错");

	puzzle[0][0] = puzzle[0][1] = 1;
    Assert::IsFalse(c.solve(puzzle[0], puzzle[0]), L"解出非法数独");
};
```

单元测试覆盖率:

  • 计算模块部分异常处理说明。在博客中详细介绍每种异常的设计目标。每种异常都要选择一个单元测试样例发布在博客中,并指明错误对应的场景。(5')
    针对generate和solve接口的参数,异常可分为以下四类。
  1. NumberException:-n/-c参数的number范围出错
    [TestMethod]
    void TestNumberException()
    {
        Core c;
        int result[1][81];
        try
        {
            c.generate(-1, 1, result);  // 	number传入-1        
            Assert::Fail(L"number范围出错");
        }
        catch (NumberException& e)
        {
            cout << e.what() << endl;
        }
        try
        {
            c.generate(INT_MAX, 20, 30, true, result); // 	number传入最大int值
            Assert::Fail(L"number范围出错");
        }
        catch (NumberException& e)
        {
            cout << e.what() << endl;
        }
    };
    
  2. ModeException :-m参数的mode范围出错
    [TestMethod]
    void TestModeException()
    {
        Core c;
        int result[1][81];
        try
        {
            c.generate(1, 0, result); // 	mode传入0
            Assert::Fail(L"mode范围出错");
        }
        catch (ModeException& e)
        {
            cout << e.what() << endl;
        }
        try
        {
            c.generate(1, 4, result); // 	mode传入4
            Assert::Fail(L"mode范围出错");
        }
        catch (ModeException& e)
        {
            cout << e.what() << endl;
        }
    };
    
  3. RangeException :-r参数的range范围出错
    [TestMethod]
    void TestRangeException()
    {
        Core c;
        int result[1][81];
        try
        {
            c.generate(1, -1, 20, false, result); // 	lower传入-1
            Assert::Fail(L"range范围出错");
        }
        catch (RangeException& e)
        {
            cout << e.what() << endl;
        }
        try
        {
            c.generate(1, 50, 40, false, result); // 传入的lower比upper大
            Assert::Fail(L"range范围出错");
        }
        catch (RangeException& e)
        {
            cout << e.what() << endl;
        }
        try
        {
            c.generate(1, 20, 56, false, result); // upper传入56
            Assert::Fail(L"range范围出错");
        }
        catch (RangeException& e)
        {
            cout << e.what() << endl;
        }
    };
    
  4. ValidException :传入非法数独报错
    [TestMethod]
    void TestValidException()
    {
        Core c;
        int result[1][81];
        c.generate(1, 3, result);
        result[0][0] = result[0][1] = 1;
        try
        {
            c.solve(result[0], result[0]); // 传入非法数独
            Assert::Fail(L"解出非法数独");
        }
        catch (ValidException& e)
        {
            cout << e.what() << endl;
        };
    };
    
  • 界面模块的详细设计过程。在博客中详细介绍界面模块是如何设计的,并写一些必要的代码说明解释实现过程。(5')

    • Sudoku.cpp/h 界面部分
      界面分为菜单栏、主界面,最佳纪录界面和说明界面四部分。
      菜单栏中有New和Help两个界面,New中提供选择难度以及最佳纪录查看功能,Help对应于说明界面。通过QAction实现动作的,并用connect进行绑定
      例:
    Sudoku.cpp
    
    private:
    QAction *easyOpenAction;
    QMenu *menuNew;
    void easyOpen();
    …………
    
    menuNew = menuBar()->addMenu(tr("&New"));
    menuNew->addAction(easyOpenAction);
    easyOpenAction = new QAction(tr("Easy"), this);
    connect(easyOpenAction, &QAction::triggered, this, &QtGuiApplication2::easyOpen);
    
    

    主界面划分为上下两个部分。上部分是一些当前状态及重置按钮的显示,下部分为游戏主界面,其中又分九个小格,通过对Margin参数的设置,实现小九宫格之间的空隙。

    QGridLayout *mainLayout;    // 主界面
    QGridLayout *topLayout;    // 上部分
    QGridLayout *midLayout;    // 游戏部分
    QGridLayout *midLayoutIn[3][3];    // 小九宫格
    …………
    
    for (int i = 0; i < 3; i++)     // 将小九宫格加入游戏部分
    {
    	for (int j = 0; j < 3; j++) 
    	{
    		midLayoutIn[i][j] = new QGridLayout();
    		midLayoutIn[i][j]->setMargin(2);        // 空隙
    		midLayout->addLayout(midLayoutIn[i][j], i, j, 0);
    	}
    }
    for (int i = 0; i < 81; i++)     // 向小九宫格中加入小格子
    {
    	midLayoutIn[i / 9 / 3][i % 9 / 3]->addWidget(sudo[i], i / 9, i % 9, 0);
    	connect(sudo[i], SIGNAL(tip_clicked()), this, SLOT(tipClick()));
    	connect(sudo[i], SIGNAL(textChanged(const QString& )), this, SLOT(sudoTableEdit()));    
    	// 如果检测到参数改变,则调用相应方法,方法中会对填入的数进行一个简单判断,并检查该数独是否完全正确
    }
    

    最佳纪录界面中为最佳纪录的展示以及重置功能,实现基本同上,使用 recordLayout->show();弹出新窗口
    说明界面中则用一个标签对程序进行简单介绍

    • MineEditLine.cpp/h 重写的单行输入框控件
      对于提示功能,由于需要具体确定格子位置,最终选择了在格子上右键,会弹出一个菜单栏,其中有tip选项,点击tip获得该格子的提示的方式。为此,我通过MineEdlitLine继承了QEditLine类,重写了其中的contextMenuEvent方法,并在选中tip时放出一个tip_click()的信号,主窗口通过接收到这个信号,来执行相关操作。
    MineEditLine.cpp
    
    void MineLineEdit::contextMenuEvent(QContextMenuEvent *event)
    {
    //清除原有菜单
    pop_menu->clear();
    if (this->isReadOnly()) {    // 如果不可填,就不弹出菜单
    	return;
    }
    pop_menu->addAction(tipAction);
    pop_menu->exec(QCursor::pos());
    event->accept();
    }
    
    …………
    connect(tipAction, &QAction::triggered, this, &MineLineEdit::tip);
    
    …………
    emit tip_clicked();
    
    

    ps: 由于是文本框模式,需要限制输入,具体实现大致如下
    QRegExp rx("[1-9]");
    sudo[i]->setMaxLength(1);
    sudo[i]->setValidator(new QRegExpValidator(rx, sudo[i]));

  • 界面模块与计算模块的对接。详细地描述UI模块的设计与两个模块的对接,并在博客中截图实现的功能。(4')
    由于对Core模块接口作了明确的规定,所以在双方都遵守这一契约进行编码的情况下,对接起来没有什么难度,花时间最多的反而是在VS上安装Qt以及一些配置问题。

    • 计算模块实例
    Sudoku.cpp
    private:    // Core中对应的模块
        Core sudoku;
    
    • UI模块设计与对接
      UI中在数独生成、提示生成的部分使用到了计算模块。
    • 数独生成
      点击start/restart按钮后,通过 sudoku.generate(1, model, result); 调用计算模块中的数独生成,再通过一一将数以对应方式呈现到界面上,实现初始游戏界面的生成。
    • 提示生成
      在检查到tip_click()信号后,调用相关方法,通过数独求解方法生成tip,并以蓝色显示在对应位置上。如果当前数独不合法或不可解,则弹出对应提示。
    void Sudoku::tipClick() 
    {
    	MineLineEdit *mle = qobject_cast<MineLineEdit*>(sender());
    	int i = mle->accessibleName().toInt();
    	int solution[81];
    	bool f = false;
    	try {
    		f = sudoku.solve(result[0], solution);
    	}
    	catch (const std::exception&) {
    		QMessageBox::information(this, tr("tip"), tr("Already Wrong"));
    		return;
    	}
    	if (f) 
    	{
    		mle->setText(QString::number(solution[i]));
    		result[0][i] = solution[i];
    		sudoTable[i / 9][i % 9] = solution[i];
    		mle->setStyleSheet("color: blue;");
    	}
    	else 
    		QMessageBox::information(this, tr("tip"), tr("Already Wrong"));
    }
    

    功能截图见下面附加题部分。

  • 描述结对的过程,提供非摆拍的两人在讨论的结对照片。(1')
    由于行动得比较慢,当时剩的人已经不多了,于是选了和我同班的女生 😃
    考虑到上次个人项目我的算法稍快一些,于是决定我做Core她写GUI。
    当然这只是大致分工,很多细节还是一起讨论商量出来的共同成果。

  • 看教科书和其它参考书,网站中关于结对编程的章节,例如:
    http://www.cnblogs.com/xinz/archive/2011/08/07/2130332.html
    说明结对编程的优点和缺点。结对的每一个人的优点和缺点在哪里 (要列出至少三个优点和一个缺点)。(5')
    结对编程
    优点:交流更加方便,能更加高效地解决开发问题;可以互相学习,快速提升编程水平(尤其是水平较低一方的);互相监督能提高工作效率,还可以提高代码质量,减少一些过失性bug。
    缺点:有些情况下成员各持己见,加大内耗;老手对于新手可能耐心不够,产生团队矛盾,影响工作气氛。

    优点:代码注释多,擅长交流,有耐心
    缺点:有点拖延症233

  • PSP表格(0.5'+0.5')

    PSP2.1Personal Software Process Stages预估耗时(分钟)实际耗时(分钟)
    Planning计划105
    · Estimate· 估计这个任务需要多少时间105
    Development开发13601780
    · Analysis· 需求分析 (包括学习新技术)120180
    · Design Spec· 生成设计文档 3010
    · Design Review· 设计复审 (和同事审核设计文档) 105
    · Coding Standard· 代码规范 (为目前的开发制定合适的规范) 105
    · Design· 具体设计60120
    · Coding· 具体编码600800
    · Code Review· 代码复审3060
    · Test· 测试(自我测试,修改代码,提交修改)500600
    Reporting报告70100
    · Test Report· 测试报告 3060
    · Size Measurement· 计算工作量1010
    · Postmortem & Process Improvement Plan· 事后总结, 并提出过程改进计划3030
    Total总计14301885

附加题

  1. 模块的松耦合测试
    合作小组:15061132 15061151
    遇到的问题

    • 他们的Core模块+我们的GUI模块:
      在向solve传入非法数独后,我们的模块希望catch到一个我们定义的异常类型,而他们的模块里显然没有,故编译错误。
      改进办法:由于我们的自定义异常继承自标准库的exception类,故可以改为 catch (const std::exception&)
    • 他们的Core模块+我们的Test模块:
      这部分处理比较麻烦,因为当时为了方便,我们的测试模块里调用了Core模块的非接口函数来进行检测生成的数独是否有唯一解,但他们的模块里显然没有开这个接口。
      改进办法:在测试模块里重新实现检测函数。
      其次,由于我们对于mode的划分不同,因此我们对-m参数的测试不能应用在他们的Core上。
      最后我们有两个测试对方无法通过:一个是generate(100,40,55,true,result)程序抛出异常,另一个是向solve传入错误的数独时没有抛出异常。经确认,第一点的确是对方的代码出了bug,第二点是由于他们对于传入错误数独的处理方式是返回false而不抛异常。
    • 我们的Core模块+他们的模块
      根据对方反馈,他们的GUI和TEST都能正常对接我们的Core模块,所以没有发现问题。具体内容请移步他们的博客查看。😃
  2. 软件发布
    加入了使用说明,并添加了应用图标,软件截图如下:

    根据用户反馈,做出了以下改进:
    1.玩家A觉得游戏界面太丑。因此我们对界面的设计、配色等进行了一些修改,比初版好了不少。
    2.玩家B希望在某格填入不符规则的数字时给予提醒。故我们将不合法数字设为红色,并将提示的数字设为蓝色。
    3.玩家C认为提示应该有次数限制。但我们考虑到某些玩家可能在有限的提示里解不出题目,所以仍不做限制。
    4.玩家D的评价是界面还是太丑。我们非常感谢这条建议 然后选择了无视,并表示等招募到美工再给大爷您做美化吼不吼啊。(面带微笑)

posted @ 2017-10-14 16:58  silentic  阅读(303)  评论(1编辑  收藏  举报