Advertisement

北京理工大学2019软件工程个人项目-数独-All_in_one

阅读量:

北京理工大学2019软件工程个人项目-数独

  • 0. GitHub项目地址

  • 1. PSP 表格

  • 2. 解题思路

    • 2.1 生成数独终局

      • 2.1.1 生成数独的通法
      • 2.2.2 数独的扩充
    • 2.2 求解数独

  • 3. 软件设计

    • 3.1 用例图
    • 3.2 类图
    • 3.3 顺序图
  • 4. 软件实现

    • 4.1 实现Sudoku类
    • 4.2 实现SudokuGenerator类
    • 4.3 实现SudokuSolver类
    • 4.4 实现main.cpp
  • 5. 代码测试

    • 5.1 测试SudokuGenerator类

      • 5.1.1 用例设计
    • 5.2 测试SudokuSolver类

  • 6. 性能改进

    • 6.1 SudokuGenerator类
    • 6.2 SudokuSolver类
  • 7. 关键代码说明

    • 7.1 SudokuGenerate::GenerateSudoku函数
    • 7.2 SodukuSolver::SolveSudoku函数

0. GitHub项目地址

本项目对应的GitHub代码仓库点此进入

1. PSP 表格

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

2. 解题思路

本题要求实现实现一个能生成数独终局并独立求解数独问题的程序。
主要需求如下:

  1. 能够生成数独终局
  2. 能够独立求解数独

2.1 生成数独终局

根据项目要求,需要生成10^6个不重复数独。由于需求并未要求数独的难度,故可以考虑使用数独的特性来寻找生成简单而不同的数独的通法。

2.1.1 生成数独的通法

不妨设数独的第一行为:a,b,c,d,e,f,g,h,i
A= \left\{x|1 \leq x \leq 9 \; and \; x \in N \right\}
B= \left\{x|0 \leq x \leq 8 \; and \; x \in N \right\}
我们可以按照如下方式构建一个合法的数独:

令数独的k行为第一行向右移动 f(k)列。故:f(1) = 0
由于数独每一列必须取到ai中的所有值,故f(k)是从AB双射函数
同时,由于每个3\times3的 子网格也包含ai中的所有值,即在3\times3的小方格中不能出现重复数字,要求:
f(2) \geq f(1) + 3, f(3) \geq f(2) + 3, f(3) \leq f(1) + 6
f(5) \geq f(4) + 3, f(6) \geq f(5) + 3, f(6) \leq f(4) + 6
f(8) \geq f(7) + 3, f(9) \geq f(8) + 3, f(9) \leq f(7) + 6

取满足上述条件的一个从AB的双射如下表:

x 1 2 3 4 5 6 7 8 9
f(x) 0 3 6 1 4 7 2 5 8

ai的每种个取值,对数独的第k行,将数独的第一行向右循环平移f(k)列即可得到一个合法的数独。
如当取值如下表:

变量 a b c d e f g h i
取值 1 2 3 4 5 6 7 8 9

时,所得的数独如下图。

1 2 3 4 5 6 7 8 9
7 8 9 1 2 3 4 5 6
4 5 6 7 8 9 1 2 3
9 1 2 3 4 5 6 7 8
6 7 8 9 1 2 3 4 5
3 4 5 6 7 8 9 1 2
8 9 1 2 3 4 5 6 7
5 6 7 8 9 1 2 3 4
2 3 4 5 6 7 8 9 1

据此,可以实现得到数独8!=40320(数独的首位由学号确定,无法更改)个,我们称之为基本数独

2.2.2 数独的扩充

仅凭借上述方法构造的数独数目无法满足题目要求,因此我们对数独进行变换来扩充数独数目。

注意到,对于任何数独,都可以任意交换它的1\sim 3行,4\sim 6行,7\sim9行,1\sim 3列,4\sim 6列或7\sim9列而使得交换后的数独仍然是一个合法的数独。

对本项目而言,由于左上角的数字已经确定,所以无法交换第一行和第一列。同时,由于基本数独是通过取遍第一行的所有合法的列的排列产生的,交换数独中的列会导致和其他的基本数独重复。因此,仅能通过:

  • 交换2,3行
  • 任意交换4~6行
  • 任意交换7~9行

来产生新的数独。

据此可以使数独的数目变为原先的:2\times3!\times3!=72倍,共计72\times40320=2903040\approx2.9\times10^6个数独。显然,我们已经找到了超过10^6个数独,足以满足需求。

2.2 求解数独

对数独的求解较为简单,使用回溯法搜索所有可能结果即可。

3. 软件设计

在软件设计阶段,我们将设计第二章所述算法的程序结构。使用面向对象的方法对其进行分析和建模。分析过程如下。

3.1 用例图

分析需求中的使用者,仅有用户一个。更具需求描述,做出用例图如下。
用例图

3.2 类图

需求描述中涉及到的类与对象包括:

  • 数独
  • 数独求解器
  • 数独生成器

使用类图描述他们之间的关系,如下。
在这里插入图片描述

3.3 顺序图

对生成数独终局的过程使用顺序图描述如下。
生成数独
对求解数独的过程使用顺序图描述如下。
在这里插入图片描述

4. 软件实现

在软件实现阶段,我们将实现第三章所述程序设计。

4.1 实现Sudoku类

根据前述软件设计,在Sudoku类中,有如下成员:

  • 变量

    • int[][] data 保存当前数独的局面。
  • 方法

    • Sudoku(const int[][] = NULL) 构造函数,初始化数独
    • Print():void 将数据输出至控制台
    • PrintToFile(const String& fileName): void 向特定文件输出数独
    • operator=(const Soduku&):Soduku 重载赋值运算符
    • Sudoku(const Soduku&):Soduku 重载拷贝构造函数
    • ~Sudoku() 重载析构函数

Sudoku类的实现已签入github。

4.2 实现SudokuGenerator类

根据前述软件设计,在SudokuGenerator类中,有如下成员:

  • 变量

    • String outputFile 指示生成的数独应当输出到哪个文件
    • int sodukuNum 指示生成的数独个数
    • Sudoku* Sudokus 存放生成的数独
  • 方法

    • SudokuGenerator(String outputFile, int sodukuNum) 构造函数

    • GenerateSudoku():void 生成数独

    • ValidateInput(const String[] parameters) :bool 验证输入是否有效

    • GenerateBaseSoduku(int num):void 生成基础数独,基础数独的定义见开发日志3

    • ExpandSoduku():void

Sudoku类的实现已签入github。

4.3 实现SudokuSolver类

根据前述软件设计,在SudokuSolver类中,有如下成员:

  • 变量

    • String outputFile 指示将数独的解输出到那个文件中去。
    • String inputFile 指示从那个文件中读取待解数独。
  • 方法

    • SudokuSolver(String inputFile,String outputFile) 构造函数
    • SolveSudokus():void 求解inputFile中的数独,并将结果输出到outputFile中。
    • ValidateInput(const String[] parameters) :void 验证输入是否合法。对于非法输入,抛出异常。

SudokuSolver类所对应代码已经签入github。

4.4 实现main.cpp

main.cpp 文件实现了类图设计中User
的功能。它接受命令行传来的参数,在进行参数正确性检验后,调用SudokuGenerator和SudokuSolver类的实例求解指定任务。

main.cpp 的实现已经签入github。

5. 代码测试

为验证软件产品的正确性,针对所有用户调用的类——即SudokuGenerator和SudokuSolver类进行测试。

5.1 测试SudokuGenerator类

使用Visual Studio 2019新建本地测试项目,在其中对SudokuGenerator类进行测试。

5.1.1 用例设计

SudokuGenerator的公有函数仅有构造函数和GenerateSudoku,因此测试这个函数即可。
该函数的行为仅受SudokuGenerator构造函数创建的文件指针和生成数独数量影响,而文件指针在主次运行中固定,因此可以将输入文件名固定,测试用例间的不同仅有
输出值。
测试用例应当包含各种可能的值:合法的和不合法的,同时应当覆盖各种数量级。据此,设计测试用例如下

Id 输入 输出
1 SudokuGenerator("./Sudoku.txt", “-10”).GenerateSudoku 抛出异常
2 SudokuGenerator("./Sudoku.txt", “-1”).GenerateSudoku 抛出异常
3 SudokuGenerator("./Sudoku.txt", “0”).GenerateSudoku 抛出异常
4 SudokuGenerator("./Sudoku.txt", “1”).GenerateSudoku 一组合法数独
5 SudokuGenerator("./Sudoku.txt", “10”).GenerateSudoku 10组合法数独
6 SudokuGenerator("./Sudoku.txt", “100”).GenerateSudoku 100组合法数独
7 SudokuGenerator("./Sudoku.txt", “1000”).GenerateSudoku 1000组合法数独
8 SudokuGenerator("./Sudoku.txt", “10000”).GenerateSudoku 10000组合法数独
9 SudokuGenerator("./Sudoku.txt", “100000”).GenerateSudoku 100000组合法数独
10 SudokuGenerator("./Sudoku.txt", “1000000”).GenerateSudoku 1000000组合法数独

对应的测试方法如下。

复制代码
    		TEST_METHOD(testGenerateSudoku)
    		{
    			std::string outFile = "./Sudoku.txt";
    			std::string num[10] = { "-10","-1", "0", "1", "10", "100",
    									"1000","10000", "100000", "1000000" };
    			for (int i = 0; i < 10; i++)
    			{
    				try{
    					SudokuGenerator g(outFile.c_str(), num[i]);
    					g.GenerateSudoku();
    				}
    				catch(std::invalid_argument e)
    				{
    					if (i >= 3)
    						Assert::Fail();
    				}
    				catch(std::exception e)
    				{
    					Assert::Fail();
    				}
    				Assert::IsTrue(this->SudokuLegal(outFile));
    			}

测试结果如图:
在这里插入图片描述

为了验证测试的有效性,我们还进行了代码覆盖率检测,检测结果如下。
在这里插入图片描述
测试的覆盖率达82.69%,说明我们实际的测试用例是有效合理的。

5.2 测试SudokuSolver类

与SudokuGenerator类同理,我们调用的这个类的公有函数仅有构造函数和SolveSudoku. 该函数的行为受输入文件和输出文件的影响。由于输出文件是固定的,我们仅需改变输入文件即可。据此,设计测试用例如下。

Id 输入 输出
1 SudokuGenerator("", “./Sudoku.txt”).GenerateSudoku 抛出异常
2 SudokuGenerator("./", “./Sudoku.txt”).GenerateSudoku 抛出异常
3 SudokuGenerator(“emptyFile.txt”, “./Sudoku.txt”).GenerateSudoku 抛出异常
4 SudokuGenerator("./Sudoku1.txt", “./Sudoku.txt”).GenerateSudoku 一个数独的解
5 SudokuGenerator("./Sudoku10.txt").GenerateSudoku 10个数独的解
6 SudokuGenerator("./Sudoku100.txt").GenerateSudoku 100个数独的解
7 SudokuGenerator("./Sudoku1000.txt").GenerateSudoku 1000个数独的解
8 SudokuGenerator("./Sudoku10000.txt").GenerateSudoku 10000个数独的解
9 SudokuGenerator("./Sudoku100000.txt").GenerateSudoku 100000个数独的解
10 SudokuGenerator("./Sudoku1000000.txt").GenerateSudoku 1000000个数独的解

其中,emptyFile.txt为一个空文件,"./Sudoku*.txt"为包含*个数独题目的文件。

对应的测试方法如下。

复制代码
    		TEST_METHOD(testSolveSudoku)
    		{
    			std::string inFile = "./Sudoku.txt";
    			std::string outFile[10] = {"", ".", "emptyFile","./Sudoku1.txt", "./Sudoku10.txt",
    										"./Sudoku100.txt","./Sudoku1000.txt","./Sudoku10000.txt",
    										"./Sudoku100000.txt", "./Sudoku1000000.txt"};
    			for(int times = 0; times < 4; times++)
    				for (int i = 0; i < 10; i++)
    				{
    					try {
    						SudokuSolver g(inFile.c_str(), outFile[i]);
    						g.SolveSudoku();
    					}
    					catch (std::invalid_argument e)
    					{
    						if (i >= 3)
    							Assert::Fail();
    					}
    					catch (std::exception e)
    					{
    						Assert::Fail();
    					}
    					Assert::IsTrue(this->SolutionLegal(inFile, outFile[i]));
    				}
    
    		}

运行上述测试,结果如下图。
在这里插入图片描述
为了验证测试的有效性,我们还进行了代码覆盖率检测,检测结果如下。
在这里插入图片描述
测试的覆盖率达86.54%,说明我们实际的测试用例是有效合理的。

6. 性能改进

6.1 SudokuGenerator类

主程序如下图。
在这里插入图片描述
运行VS2019 性能分析工具,结果如下图。
在这里插入图片描述
在这里插入图片描述
可见fclose函数是所有函数中占用时间最长的,其调用次数也远远超过其他热点函数。由于这是一个系统函数,我们无法进行优化,因此我们试图减少它的调用次数。分析程序可知,由于模块间及模块内部传递文件信息时使用的是文件名,因此对每一个基础数独,我们都需要打开、关闭一次文件指针。因此,我们改用传递文件指针。这样就可以避免频繁地打开关闭文件。

据此对Sudoku类和SudokuGenerator类进行重构。由于用户仅可见SudokuGenerator类,我们仅保留SudokuGenerator的构造函数使用字符串传递文件名,其余的接口全部改用文件指针传递文件信息。

重构后,再次运行性能分析工具,结果如下。
在这里插入图片描述
可见运行时间大大降低,生成100万组数据用时降低91.88%。查看调用树如下。
在这里插入图片描述
发现用数最多的函数为Sudoku类的构造函数中的new运算。分析后发现,这里并无动态分配内存的必要,因此改用静态分配内存。同时,并不需要为每一个输出的数独一个Sudoku对象,仅需为基本数独产生即可。进行修改,再次运行性能分析工具。结果如下。
在这里插入图片描述
可见此时new运算已经不再是热点,生成100万组数据用时4.581s,且主要花费在磁盘IO上。本次改进是的性能再次提升33.906%,相比最初版本运行时间降低94.631%。

6.2 SudokuSolver类

使用VS性能探查器测试代码的性能。结果如下图。
在这里插入图片描述
由图可知,把backtracing函数是程序中消耗最大的热点函数。查看该函数的执行情况,如下图。在这里插入图片描述
可知出函数递归调用外,nextVal函数调用最多,fprintf函数用时最多。updateConstraint函数调用次数也较多。考虑到nextVal函数中循环不复杂,将循环改写为顺序语句,并将其指定为内联函数。另外,updateConstraint函数不含循环,将其指定为内联函数。
经过改进后,效率略有提高。
在这里插入图片描述

7. 关键代码说明

本项目的代码的关键在于SudokuGenerate::GenerateSudoku函数,和SudokuGenerate::GenerateSudoku函数。我们将对这两个函数及他们产生的调用树作出说明。

7.1 SudokuGenerate::GenerateSudoku函数

GenerateSudoku函数用于生成的sudokuNum成员变量指示的数目的数独。函数实现如下:

复制代码
    void SudokuGenerator::GenerateSudoku()
    {
    	const int basicSudokuNumber = this->sudokuNum / 72 + 1;
    	this->GenerateBaseSudoku(basicSudokuNumber);
    	this->PrintSudoku();
    }

该函数将首先根据sudokuNum成员变量的值生成数独。由于每一个基本数独可以产生72个互不相同的数独,故最多仅需产生sudokuNum / 72 + 1个基本数独即可。基本数独的定义见开发博客02.之后,它调用PrintSudoku函数根据生成的基本数独打印需求中要求的数独。对调用的两个函数分别说明如下。

GenerateBaseSudoku函数用来产生基本数独,并将它们保存在Sudoku *sudoku成员变量中。该函数实现如下。

复制代码
      int n = 0;

    	do
    	{
    		sudoku[n].SetRow(this->firstRow, 0);
    		sudoku[n].ConstructFromFirstRow();
    		n++;
    	} while (n < num && std::next_permutation(this->firstRow + 1, this->firstRow + 9));

该函数使用STL库中的函数next_permutation来产生1~9的全排列,并将它们作为函数的第一行。由于题目要求数独第一个元素为学号,因此全排列仅限于在第一行的后8个元素中进行。

该函数调用的Sudoku::SetRow用于设定数独的指定行,Sudoku::ConstructFromFirstRow函数用于从数独的第一行按照开发博客02中所述的算法构建完整的基本数独。它们的实现如下。

复制代码
    void Sudoku::SetRow(const int row[9], int row_num)

    {
    	memcpy(this->data[row_num], row, sizeof(int) * 9);
    }
    
    void Sudoku::ConstructFromFirstRow()
    {
    	int f[10] = { 0,3,6,1,4,7,2,5,8 };
    	for(int i = 1; i < 9; i++)
    	{
    		for(int j = 0; j < 9; j++)
    		{
    			this->data[i][j] = data[0][(j - f[i] + 9) % 9];
    		}
    	}
    }

PrintSudoku函数根据成员变量sudokuNum将制定数目的数独输出到文件。

复制代码
    void SudokuGenerator::PrintSudoku() const

    {
    	const int basicSudokuNumber = this->sudokuNum / 72 + 1;
    	const int restSudokuNumber = this->sudokuNum % 72;
    	if (restSudokuNumber == 0)
    	{
    		for (int i = 0; i < basicSudokuNumber - 2; i++)
    		{
    			this->sudoku[i].PrintExpandedSudoku(this->outputFile, -1);
    		}
    		this->sudoku[basicSudokuNumber - 2].PrintExpandedSudoku(this->outputFile, 71);
    		fprintf(this->outputFile, "\n\n");
    		this->sudoku[basicSudokuNumber - 1].PrintExpandedSudoku(this->outputFile, 1);
    	}
    	else
    	{
    		for (int i = 0; i < basicSudokuNumber - 1; i++)
    		{
    			this->sudoku[i].PrintExpandedSudoku(this->outputFile, -1);
    		}
    		this->sudoku[basicSudokuNumber - 1].PrintExpandedSudoku(this->outputFile, restSudokuNumber);
    	}
    }

该函数使用Sudoku::PrintExpandedSudoku输出所有数独。该函数的原型为void Sudoku::PrintExpandedSudoku(FILE* fp, int maxSudokuPrinted) const,它将以调用它的数独为基本数独,按照开发博客03所述的数独扩充方法产生的maxSudokuPrinted个数独输出到文件指针fp指向的文件中。实现如下。

复制代码
    void Sudoku::PrintExpandedSudoku(FILE* fp, int maxSudokuPrinted) const

    {
    	char row[9][19];
    	memset(row, 0, sizeof(row));
    	for (int i = 0; i < 9; i++)
    	{
    		for (int j = 0; j < 9; j++)
    		{
    			row[i][j<<1] = '0' + this->data[i][j];
    			row[i][(j << 1) + 1] = ' ';
    		}
    		row[i][17] = 0;
    	}
    	int outputOrder[9];
    	memset(outputOrder, 0, sizeof(outputOrder));
    
    	int firstBlock[2][3] = { {0,1,2},{0,2,1} };
    	int secondBlock[6][3] = { {3,4,5},{3,5,4},{4,3,5},{4,5,3},{5,3,4},{5,4,3} };
    	int thirdBlock[6][3] = { {6,7,8},{6,8,7},{7,6,8},{7,8,6},{8,6,7},{8,7,6} };
    	for (int firstBlockOrder = 0; firstBlockOrder < 2; firstBlockOrder++)
    	{
    		for (int secondBlockOrder = 0; secondBlockOrder < 6; secondBlockOrder++)
    		{
    			for (int thirdBlockOrder = 0; thirdBlockOrder < 6; thirdBlockOrder++)
    			{
    				if (maxSudokuPrinted == 0)
    					return;
    				else
    					maxSudokuPrinted--;
    				fprintf(fp, "%s\n", row[firstBlock[firstBlockOrder][0]]);
    				fprintf(fp, "%s\n", row[firstBlock[firstBlockOrder][1]]);
    				fprintf(fp, "%s\n", row[firstBlock[firstBlockOrder][2]]);
    				
    				fprintf(fp, "%s\n", row[secondBlock[secondBlockOrder][0]]);
    				fprintf(fp, "%s\n", row[secondBlock[secondBlockOrder][1]]);
    				fprintf(fp, "%s\n", row[secondBlock[secondBlockOrder][2]]);
    				
    				fprintf(fp, "%s\n", row[thirdBlock[thirdBlockOrder][0]]);
    				fprintf(fp, "%s\n", row[thirdBlock[thirdBlockOrder][1]]);
    				fprintf(fp, "%s", row[thirdBlock[thirdBlockOrder][2]]);
    
    				if (maxSudokuPrinted != 0)
    				{
    					fprintf(fp, "\n\n");
    				}
    				else
    				{
    					return;
    				}
    			}
    		}
    	}
    }

7.2 SodukuSolver::SolveSudoku函数

该函数从成员变量inputFile指示的文件中读取数独题目,并将其解输出到outputFile指示的文件中。它的实现如下。

复制代码
    void SudokuSolver::SolveSudoku()
    {
    	bool firstSolution = true;
    	bool lastSolution = false;
    	while (true)
    	{
    		char cdata[9][20];
    		memset(cdata, 0, sizeof(cdata));
    		memset(this->isEmpty, 0, sizeof(isEmpty));
    		memset(this->sudoku, 0, sizeof(this->sudoku));
    		memset(this->constraint, 0, sizeof(this->constraint));
    		for(int i = 0; i < 9; i++)
    		{
    			const size_t res = fread_s(cdata[i], (size_t)20, sizeof(char), (size_t)18, this->inputFile);
    			if (res == 0)
    			{
    				lastSolution = true;
    				break;
    			}
    		}
    		if (lastSolution)
    			break;
    		if (!firstSolution)
    			fprintf(this->outputFile, "\n\n");
    		else
    			firstSolution = false;
    		found = false;
    		
    		for (int i = 0; i < 9; i++)
    			for (int j = 0; j < 9; j++)
    			{
    				sudoku[i][j] = cdata[i][j<<1] - '0' - 1;
    				isEmpty[i][j] = (sudoku[i][j] == -1);
    				if (!isEmpty[i][j]) updateConstraint(i, j, 1);
    			}
    		this->backtracing(0, 0);
    		fread_s(cdata, 20, sizeof(char), 1, this->inputFile);
    	}
    }

它不断地冲文件中读取数独题,每读一题变求解一次并将解输出。其核心为backtracing函数。该函数使用回溯法求解数独,并在找到一组解时调用Sudoku::PrintToFile函数将解输出到文件。
backtracing函数实现如下。

复制代码
    void SudokuSolver::backtracing(int x, int y)
    {
    	if (found)
    		return;
    	if (y == 9)
    	{
    		x++;
    		y = 0;
    	}
    	while (x <= 8 && !isEmpty[x][y])
    	{
    		y++;
    		if (y == 9)
    		{
    			x++;
    			y = 0;
    		}
    	}
    	if (x > 8)
    	{
    		found = true;
    		for (int i = 0; i < 9; i++)
    			for (int j = 0; j < 9; j++)
    				sudoku[i][j]++;
    		Sudoku(sudoku).PrintToFile(this->outputFile);
    		return;
    	}
    	while (~(sudoku[x][y] = nextVal(x, y, sudoku[x][y] + 1)))
    	{
    		updateConstraint(x, y, true);
    		backtracing(x, y + 1);
    		updateConstraint(x, y, false);
    	}
    }

found变量初值为false,指示是否已经找到解。每确定一个值,则y自增1,自增到9时进位,y归零,x自增1。若x大于8,这说明已经确定全部数独中的值,数独求解完成。由于数独在输入时为了求解方便将所有数值减1,此时应当将数独加1。之后将数独输出到文件。若x未大于8,则还有数独元素未确定,改变当前数独元素的值,更新数独的约束条件,继续回溯求解。

全部评论 (0)

还没有任何评论哟~