shuduku

代码链接

GitHub仓库:https://github.com/gcrth/sudoku
图形界面的附加题同样在这个库的GUIBIN下,只有一个文件。

代码工程说明

运行说明

在根目录下有BIN文件夹,其中有soduku.exe即为主工程的可执行文件。
在根目录下有GUIBIN文件夹,其中的python文件为图形界面可执行文件。
图形界面为了方便测试可以注释倒数第二行的next函数生成只有一个空的题目。
请注意python调用了tkinter以及numpy库,python版本为3.6。

代码组织结构

主目录下的soduku文件夹放的是主工程文件,UnitTestForsoduku放的是单元测试文件。

PSP表格估计时间

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

解题思路

概要

这个项目有两个主要功能。第一个是生成数独终局;第二个是求解数独。这部分说明思路,具体算法查看后面的核心代码部分

生成终局

拿到题目的第一反应是利用随机加上搜索来进行生成。但是这种方式难以判重,速度也比较慢。经过搜索,我找到了一种确定性的生成算法。
这种算法是基于下面这种终局的基础上交换得到的。
1 2 3 4 5 6 7 8 9 1 1 2 3 4 5 6 7 8 9 2 7 8 9 1 2 3 4 5 6 3 4 5 6 7 8 9 1 2 3 4 9 1 2 3 4 5 6 7 8 5 6 7 8 9 1 2 3 4 5 6 3 4 5 6 7 8 9 1 2 7 8 9 1 2 3 4 5 6 7 8 5 6 7 8 9 1 2 3 4 9 2 3 4 5 6 7 8 9 1 \begin{array}{c|lllllllll} { }&{1}&{2}&{3}&{4}&{5}&{6}&{7}&{8}&{9}\\ \hline {1}&{1}&{2}&{3}&{4}&{5}&{6}&{7}&{8}&{9}\\ {2}&{7}&{8}&{9}&{1}&{2}&{3}&{4}&{5}&{6}\\ {3}&{4}&{5}&{6}&{7}&{8}&{9}&{1}&{2}&{3}\\ {4}&{9}&{1}&{2}&{3}&{4}&{5}&{6}&{7}&{8}\\ {5}&{6}&{7}&{8}&{9}&{1}&{2}&{3}&{4}&{5}\\ {6}&{3}&{4}&{5}&{6}&{7}&{8}&{9}&{1}&{2}\\ {7}&{8}&{9}&{1}&{2}&{3}&{4}&{5}&{6}&{7}\\ {8}&{5}&{6}&{7}&{8}&{9}&{1}&{2}&{3}&{4}\\ {9}&{2}&{3}&{4}&{5}&{6}&{7}&{8}&{9}&{1}\\ \end{array} 123456789117496385222851749633396285174441739628555284173966639528417774163952888527416399963852741
不难发现第二行到第九行是在第一行的基础上右移3,6,1,4,7,2,5,8位得到的。交换第一行的任意两个数,后续行依次改变,终局依旧有效。我们交换2,3行/4,5,6行之间交换/7,8,9行之间交换/将4,5,6行与7,8,9行整体交换都可以得到不一样的终局。

求解数独

求解数独并没有太多的技巧可言,就是基本的回溯法搜索。为了可以比较快地得到结果,我们需要尽可能地剪枝。由于没有更多的评估函数,我们无法使用A*等算法进行加速。

设计

各模块设计

设计与许多元素有关,其中最重要的是选择是面向对象还是面向过程,以及用什么语言进行实现。需求中多次提到了性能的要求,所以我最后选择使用C++,设计中部分采用了面向对象,以求在不影响性能的前提下,尽可能地易于理解。
工程的主体由四部分构成。分别是主控模块命名为soduku,生成模块generate,求解模块solve,以及将IO部分进行一定封装的IO模块。各个模块的主要类以及相互之间的关系参见下面的包图。

在这里插入图片描述

在soduku主模块中,我主要处理了命令行参数,并根据命令行参数的具体内容来调用generate以及solve这两个真正的处理模块。在处理命令行参数以及文件操作时,无可避免会可能出现异常,所以我使用了try catch机制来处理异常,并在可能出现异常的地方加入检测以及抛出异常的代码。
在需求中有严格的性能要求,所以我采用了C语言中的文件函数,而没有采用c++中的输出流。为了适应c++中try catch机制,我对这些文件函数做了一些封装,使得对象在离开try的部分之后会自动调用析构函数关闭文件。在此基础上,我加上了使用文件必要的函数作为接口将其封装为OutFile以及InFile这两个类,为其他模块提供输入输出的支持。
生成模块中有生成器一个类,主模块会在使用生成功能时实例化一个匿名的生成器,并将生成的数量,输出对象,以及左上角元素的值传递给生成器的构造函数。构造构造函数会在初始化相应字段之后,调用生成的主函数run。主函数会生成终局,并调用类内对于输出对象的适配函数output进行输出。
求解模块的构建思路相似,构造函数会收到一个输入对象以及一个输出对象。构造构造函数会在初始化相应字段之后,调用生成的主函数run。在run中,它会用输入适配函数接收一个求解的局,用求解函数求解,然后用输出适配函数进行输出。

测试设计

在这个项目中,最重要的是三个模块,主控模块,生成模块,以及求解模块。我分三部分进行测试。
在生成测试中,我实例化一个生成器,随后读入生成的文件,测试其是否是合法的输出。具体来说会有一个小样例方便调试和一个大样例确认边界情况。
在求解测试中,我在生成的终局基础上挖空,生成题目,然后实例化求解器进行求解。随后读入输出文件,测试其是否是合格的输出。样例设置同上。
主控模块会接收一系列合法以及不合法的参数,测试其是否能够正确应对。样例主要关注异常情况。异常情况包括多参数,功能参数不存在,参数格式出错,输入文件不存在(打不开)。

在这里插入图片描述

性能改进

性能改进大概用时100分钟。

用例说明

我们选用比较大例子进行测试。生成我们会生成1e6个终局,求解我们会求解1e5个题目。我们测试版本均为release版。

生成

原性能

在这里插入图片描述
在这里插入图片描述

改进

不难看出大部分时间用在了输出上,我们尝试利用内存缓冲,然后一次性输出,以减少对文件的频繁写入,提高性能。缓冲区大小根据生成数量一次确定。
改进前代码

bool generator::output(bool withExtralEndl )
{
	for (int i = 0; i < 9; i++)
	{
		for (int j = 0; j < 9; j++)
		{
			lineBuf[2 * j] = tableBuf[i][j] + '0';
		}
		if (writeFile.puts(lineBuf) == EOF)throw runtime_error("写入文件失败");
		if (writeFile.puts("\n") == EOF)throw runtime_error("写入文件失败");		
	}
	if (withExtralEndl)
	{
		if (writeFile.puts("\n") == EOF)throw runtime_error("写入文件失败");
	}
	return true;
}

改进后代码

bool generator::output(bool withExtralEndl )
{
	for (int i = 0; i < 9; i++)
	{
		for (int j = 0; j < 9; j++)
		{
			lineBuf[2 * j] = tableBuf[i][j] + '0';
		}
		outbuf += lineBuf;
		outbuf += '\n';
	}
	if (withExtralEndl)
	{
		outbuf += '\n';
	}
	return true;
}

bool generator::flush()
{
	if (writeFile.puts(outbuf.c_str()) == EOF)throw runtime_error("写入文件失败");
	return 0;
}

在主函数里要加上flush函数调用

改进后的性能

在这里插入图片描述
效果明显。

求解

原性能

在这里插入图片描述
在这里插入图片描述

改进

IO部分按照之前的改进办法进行改进。由于缓冲区大小无法确定,我们只能按照最大的情况进行考虑。同时改进递归部分的代码,减少递归深度,减少压栈出栈的时间。
原递归代码

bool solver::test(char i)
{
	if (i == 81)return true;
	if (tableBuf[i / 9][i % 9] != 0)return test(i+1);
	char list[10];
	searchAvalibleNumber(i / 9, i % 9, list);
	queue<char> avalibleNumber;
	for (int i = 0; i < 9; i++)
	{
		if (list[i + 1] == 1)avalibleNumber.push(i + 1);
	}
	while (avalibleNumber.empty() != true)
	{
		tableBuf[i / 9][i % 9] = avalibleNumber.front();
		avalibleNumber.pop();
		if (test(i + 1) == true)return true;
	}
	tableBuf[i / 9][i % 9] = 0;
	return false;
}

改进后代码

bool solver::test(char i)
{
	if (i == 81)return true;
	if (tableBuf[i / 9][i % 9] != 0)
	{
		int j;
		for (j = i + 1; j < 81; j++)
		{
			if (tableBuf[j / 9][j % 9] == 0)break;
		}
		return test(j);
	}
	char list[10];
	searchAvalibleNumber(i / 9, i % 9, list);
	queue<char> avalibleNumber;
	for (int i = 0; i < 9; i++)
	{
		if (list[i + 1] == 1)avalibleNumber.push(i + 1);
	}
	while (avalibleNumber.empty() != true)
	{
		tableBuf[i / 9][i % 9] = avalibleNumber.front();
		avalibleNumber.pop();
		if (test(i + 1) == true)return true;
	}
	tableBuf[i / 9][i % 9] = 0;
	return false;
}

改进后性能

在这里插入图片描述

核心代码

生成

我们根据之前找到的规律得到以下算法。

1. 选取一个包含1到9这九个整数的全排列作为数独终局的第一行。
2. 选取一组0到8的合法排序。根据这个序列在第一行上向左移相应位生成第一行到第九行。合法定义见下。
3. 根据上面的两组序列即可唯一确定一个合法的终局

想要生成一个合法的终局,第一步的排序没有任何要求。
由于需求中规定了左上角的元素的值,第一行的排序有 8 ! = 40320 8!=40320 8!=40320种组合。
第二步的要求如下。

  • 第一个数为0,否则终局会重复。
  • 第二三个数为1与2的全排序。
  • 后面的六个数分为两组。一组为3,4,5;另一组为6,7,8。
  • 上述的两组书可以在组内任意交换,两组的位置也可以任意交换。

根据上面的要求,我们可以生成 2 × 2 × 3 ! × 3 ! = 144 2 \times 2 \times 3! \times3!=144 2×2×3!×3!=144种组合。
所以根据这个算法我们可以生成 40320 × 144 = 5806080 40320\times144=5806080 40320×144=5806080种终局超过1e6,可以满足要求。核心算法如下。

int numOfRow;
numOfRow = numToGen / 144;
for (int i = 0; i < numOfRow; i++)
{
    for (int j = 0; j < 144; j++)
    {
        for (int k = 0; k < 9; k++)
        {
            for (int l = 0; l < 9; l++)
            {
                tableBuf[k][l] = theFirstLine[(l + changelist[j][k]) % 9];
            }
        }
        output(true);
    }
    next_permutation(theFirstLine + 1, theFirstLine + 8);
}
numOfRow = numToGen % 144;
for (int i = 0; i < numOfRow; i++)
{
    for (int k = 0; k < 9; k++)
    {
        for (int l = 0; l < 9; l++)
        {
            tableBuf[k][l] = theFirstLine[(l + changelist[i][k]) % 9];
        }
    }
    if (i == numOfRow - 1)
        output(false);
    else output(true);
}

我将第二类144个9位的序列记录在了changelist中,希望可以用空间交换一些时间。

求解

算法的基本框架如下。按照行优先的顺序递归遍历整个棋盘。如果元素值不是零(元素给定),就跳过这个元素。遇到一个空我们会先寻找目前可行的值,将其中一个值填入,继续向下递归试探。若是没有值可以填写就返回之前的位置,尝试另一个值。递归深度达到81层(0为第一层)时,棋盘上的值就形成了一个合法的终局。核心代码如下。

bool solver::test(char i)
{
	if (i == 81)return true;
	if (tableBuf[i / 9][i % 9] != 0)return test(i + 1);
	char list[10];
	searchAvalibleNumber(i / 9, i % 9, list);
	queue<char> avalibleNumber;
	for (int i = 0; i < 9; i++)
	{
		if (list[i + 1] == 1)avalibleNumber.push(i + 1);
	}
	while (avalibleNumber.empty() != true)
	{
		tableBuf[i / 9][i % 9] = avalibleNumber.front();
		avalibleNumber.pop();
		if (test(i + 1) == true)return true;
	}
	tableBuf[i / 9][i % 9] = 0;
	return false;
}

我们通过test(0)即可调用上面的函数。

实现过程细节

我还依靠vs自带的代码分析工具进行了代码质量的分析,并做了更正。我犯的错主要是有一些变量未在一开始做初始化。
在这里插入图片描述

PSP表格实际时间

PSP2.1Personal Software Process Stages预估耗时(min)实际耗时(min)
Planning计划3030
· Estimate估计这个任务需要多少时间1010
Development开发10001130
· Analysis需求分析(包括学习新技术)100300
· Design Spec生成设计文档100120
· Design Review设计复审(和同事审核设计文档)5030
· Coding Standard代码规范(为目前的开发制定合适的规范)5030
· Design具体设计100100
· Coding具体编码300200
· Code Review代码复审10050
· Test测试(自我测试,修改代码,提交修改)200300
Reporting报告200240
· Test Report测试报告5060
· Size Measurement计算工作量5060
· Postmortem & Process Improvement Plan事后总结,并提出过程改进计划100120
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值