软件工程基础-个人项目-数独游戏

------------------------------------------------------------------------------------------------------------------------------------------------------------
实现一个能够生成数独终局并且求解数独问题的Windows控制台程序。
程序要求:
(1) 程序能够生成不重复的数独终局至一个文本文件;
(2) 读取文件内的数独问题,求解并将结果输出到一个文本文件;

生成终局:在命令行中使用-c参数加数字N(1≤N≤1000000)控制生成数独终局的数量,例如sudoku.exe -c 20

求解数独:在命令行中使用-s参数加文件名的形式求解数独,并将结果输出至文件,例如sudoku.exe -s absolute_path_of_puzzle
------------------------------------------------------------------------------------------------------------------------------------------------------------

一、GitHub项目地址

这里附上GitHub项目地址
https://github.com/Xiemixue/sudoku

二、PSP表格

PSP2.1 Personal Software Process Stage 预估耗时(小时) 实际耗时(小时)
Planning 计划 90 60
Estimate 估计这个任务需要多少时间 4835 5580
Development 开发 2500 3000
Analysis 需求分析(包括学习新技术) 360 300
Design Spec 生成设计文档 90 75
Design Review 设计复审(和同事审核设计文档) 25 35
Coding Standard 代码规范(为目前的开发制定合适的规范) 25 15
Design 具体设计 180 200
Coding 具体编码 1200 1500
Code Review 代码复审 90 60
Test 测试(自我测试、修改代码、提交修改) 150 200
Reporting 报告 45 45
Test Report 测试报告 30 30
Size Measurement 计算工作量 20 25
Postmortem & Process Improvement Plan 事后总结并提出过程改进计划 30 35
合计 4835 5580

三、解题思路

  1. 先上网查阅数独游戏的规则:数独盘面是个"九宫格",每一宫又分为九个小格。在这八十一格中给出一定的已知数字,在其他的空格上填入1-9的数字。使1-9每个数字在每一行、每一列和每一宫中都只出现一次。形如下图:
    软件工程基础-个人项目-数独游戏
  2. 生成数独终局的思路:
    老实说,刚看到题目时,我是很晕的。当时唯一想到的方法就是每一行都随机生成一个1-9的全排列,然后通过回溯法来判断终局是否满足数独条件。但是题中最多需要生成的数独终局可能达到1000000,显然从时间性能角度来看,这种方法并不理想。于是,我又企图从数独终局中发现一些规律,结合自己画的一些数独终局,以及从网上查阅的一些资料,我发现一个数独终局可以由第一行的特定平移序列构成,且交换前三行的任意两行,中间三行的任意两行或最后三行的任意两行,都会生成一个新的终局。例如:
9 3 4 8 7 1 5 6 2
5 6 2 9 3 4 8 7 1
8 7 1 5 6 2 9 3 4
6 2 9 3 4 8 7 1 5
7 1 5 6 2 9 3 4 8
3 4 8 7 1 5 6 2 9
2 9 3 4 8 7 1 5 6
1 5 6 2 9 3 4 8 7
4 8 7 1 5 6 2 9 3

交换第2和3行,第4和6行后

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

针对此题,考虑以下的终局生成方式:

  • 第1行:除去左上角固定的数字,剩下的八个数字进行全排列,一共有8!种;
  • 2, 3行的平移序列:3, 6的排列组合;共A22A_2^2
  • 4,5,6的平移序列:2,5,8的排列组合;共A32A_3^2
  • 7,8,9的平移序列:1,4,7的排列组合;共A32A_3^2
    一共8!×A22×A32×A32=2903040>10000008!×A_2^2×A_3^2×A_3^2=2903040>1000000种
  1. 求解数独的思路:
    采用回溯的方法求解数独问题。
  • 用一个二维数组sudoku[][]记录读入数独盘面;
  • 用一个二维数组location[][]记录空白单元格所在位置;
  • 用一个一维数组blank[]数组记录每一行的空白单元格个数;
  • 用一个二维数组visit[][]记录空白单元格对数字的访问情况;
  • 初始化:按照从左至右,从上到下的顺序依次遍历整个盘面,把盘面数字分布情况相应的记录在sudoku、location和blank数组中。
  • 依次遍历空的单元格节点,填入一个数字,检查是否满足条件。若满足,则标记该空白单元格对填入的数字为已访问,并继续扩展下一个节点;否则修改当前填入的数字直至满足条件,若当前节点已无合适的数字可填,则清除该空白单元格对所有数字的访问标记,修改相应位置的sudoku[i][j]为0,并返回修改上个节点;
  • 重复上述过程,直至所有的空白单元都已填充;
  • 输出求解后的数独到文件sudoku.txt;

四、设计实现过程

4.1 代码组织

五个函数:

  • 生成数独终局函数:void GenerateSudokuEndings(int N) ;
  • 求解数独函数:void SudokuSolver(int su[][9]) ;
  • 检查函数:int Check(int x, int row, int col) ; // 返回0表示不满足条件;返回1表示满足条件
  • 平移生成数独函数: void ProduceOneSudokuByTranslation(int seq1, int seq2, int seq3); //通过平移生成一个数独终局
  • 打印数独函数:void PrintOneSudoku(int is_first_sudoku) ; // 输出一个数独终局到sudoku.txt文件

函数关系:
软件工程基础-个人项目-数独游戏

4.2 流程图

求解数独函数 SudokuSolver流程图:
软件工程基础-个人项目-数独游戏

4.3 求解样本的生成

求解数独的测试用例是通过编写另一个程序来生成的。打开之前某次已生成的sudoku.txt文件,通过在已生成的终局上利用随机函数来进行随机挖空,从而形成测试样例。
代码如下:

#include <iostream>
#include <fstream>
#include <string.h>
#include <stdlib.h>
#include <random>
using namespace std;
//生成需求解的数独盘面
int main(int ardc, char*argv[])
{
	fstream file;
	ofstream mout;
	int a[9][9], i, j, k, s = 0, z, num = 0;
	default_random_engine e;
	uniform_int_distribution<unsigned> u(0, 8); //随机数分布对象 
	uniform_int_distribution<unsigned> u0(30, 60);
	mout.open("sudoku_puzzle.txt");
	file.open(argv[2]);
	string line;
	while (getline(file, line) && num < 1000000)
	{
		if (line.length()>0)
		{
			for (int j = 0; j < 9; j++)
			{
				a[s][j] = line[j * 2] - '0';
			}
			s += 1;
		}
		if (s == 9)
		{
			num += 1;
			s = 0;
			e.seed(num);
			z = u0(e);
			for (k = 0; k < z; k++)
			{
				i = u(e);
				j = u(e);
				a[i][j] = 0;

			}
			if (num != 1) mout << endl << endl;
			for (i = 0; i < 9; i++)
			{
				for (j = 0; j < 9; j++)
				{
					if (j < 8) mout << a[i][j] << ' ';
					else mout << a[i][j];
				}
				if (i < 8) mout << endl;
			}
		}
	}
	mout.close();
	file.close();
}

五、程序性能分析及改进

5.1 性能分析图

写完第一版的程序跑了跑,生成1000个数独终局时,需要耗费0.695秒,而生成1000000个数独终局时就比较慢了,需要478.318秒。显然程序还有很大的改进空间。
软件工程基础-个人项目-数独游戏
软件工程基础-个人项目-数独游戏
于是我利用VS2017自带的性能分析工具进行了分析,其分析解果如下图:
软件工程基础-个人项目-数独游戏软件工程基础-个人项目-数独游戏
软件工程基础-个人项目-数独游戏
软件工程基础-个人项目-数独游戏
软件工程基础-个人项目-数独游戏
软件工程基础-个人项目-数独游戏
软件工程基础-个人项目-数独游戏

5.2 改进思路

  • 1、从性能分析图可以看出,PrintSudoku函数耗去了大部分时间,主要是因为operator<<操作占用了很多时间,可以考虑优化数独输出到文件的过程。
    查阅网上资料后发现,可以把数据先存在某个缓存字符串中,之后再一次性输出到文件里,同时还可把空格、空行也预先存入字符串中,这样可以省去判断换行的时间。经计算1000000个数独大约需要一个(10000001)×(2+9×2×8+17)+(9×2×8+17)=162999998(1000000-1)×(2+9×2×8+17)+(9×2×8+17)=162999998长的字符串(包括空格和空行)。而用string str;cout << str.max_size();查阅后发现string类的最长长度为2147483647,可见这种方法应该可行。(后来发现用string类进行字符串拼接时一样很耗时间,所以最后采用了char output_buffer[165000000],而不是string output_buffer)
  • 2、另外,自己在实现数独第一行的全排列时,直接是暴力实现,用了8个for循环,不仅代码看着不美观,而且效率也比较低,这方面也需要优化。
    C++的 < algorithm >头文件中包含有一个全排列函数next_permutation,其原型为bool next_permutation(iterator start,iterator end)。next_permutation(num, num+n)可实现对数组num中的前n个元素进行全排列。另外,需要注意的是next_permutation()给出按照字典序排列的下一个值较大的组合,所以在使用前需要对欲排列数组按升序排序,否则只能找出该序列之后的全排列数。

经程序改进后,性能有了很大提升,生成1000000个数独终局只需要差不多3秒左右。
软件工程基础-个人项目-数独游戏

5.3单元测试设计

  • 控制台命令的判别:
    程序的第一步便是进行命令对错的识别,所以我在单元测试时,主要集中在了命令行的测试,包括对正确和错误的命令进行测试。代码如下:
#include "stdafx.h"
#include "CppUnitTest.h"
#include "../sudoku/sudoku.h"

using namespace Microsoft::VisualStudio::CppUnitTestFramework;

namespace UnitTest1
{		
	TEST_CLASS(UnitTest1)
	{
	public:
		
		TEST_METHOD(order_request1) //测试正确的命令
		{
			using namespace std;
			int argc = 3;
			char* argv[3];
			argv[1] = "-c";
			argv[2] = "100000";
			int realvalue = IdentifyOrder(argc, argv);
			int expectvalue = 100000;
			Assert::AreEqual(expectvalue, realvalue);
		}
		TEST_METHOD(order_request2) //测试命令参数个数错误的命令
		{
			using namespace std;
			int argc = 4;
			char* argv[4];
			argv[1] = "-c";
			argv[2] = "100000";
			argv[3] = "asd";
			int realvalue = IdentifyOrder(argc, argv);
			int expectvalue = 0;
			Assert::AreEqual(expectvalue, realvalue);
		}
		TEST_METHOD(order_request3) //测试生成数独个数超过范围的错误命令
		{
			using namespace std;
			int argc = 3;
			char* argv[3];
			argv[1] = "-c";
			argv[2] = "1000000000";
			int realvalue = IdentifyOrder(argc, argv);
			int expectvalue = 0;
			Assert::AreEqual(expectvalue, realvalue);
		}
		TEST_METHOD(order_request4) //测试包含非法字符的错误命令
		{
			using namespace std;
			int argc = 3;
			char* argv[3];
			argv[1] = "-c";
			argv[2] = "100we$#%0";
			int realvalue = IdentifyOrder(argc, argv);
			int expectvalue = 0;
			Assert::AreEqual(expectvalue, realvalue);
		}
		TEST_METHOD(order_request5) //测试文件路径无效的错误命令
		{
			using namespace std;
			int argc = 3;
			char* argv[3];
			argv[1] = "-s";
			argv[2] = "D:\sudoku_puzzle.txt";
			int realvalue = IdentifyOrder(argc, argv);
			int expectvalue = 0;
			Assert::AreEqual(expectvalue, realvalue);
		}
		
		TEST_METHOD(check) //检查生成和求解的数独是否满足条件
		{
			char file[100] = "E:\sudoku.txt";
			int realvalue = CheckResult(file);
			int expectvalue = 1;
			Assert::AreEqual(expectvalue, realvalue);

		}
	};
}
  • 生成单元:
    为了辅助生成单元的数独检查:在头文件中编写了一个CheckResult函数来检查sudoku.txt文本中的数独是否都正确,返回1为正确,返回0为错误;在单元测试时,便可以直接调用该函数。
    代码如下:
 int CheckResult(char *file); //检查某个数独是否正确


int CheckResult(char *file)
{
	ifstream ifile;
	ifile.open(file);
	string line;
	char x;
	char su[9][10];
	int i = 0, j, ii, jj;
	while (getline(ifile, line))
	{
		if (int len = line.length() > 0)
		{
			for (j = 0; j < 9; j++)
			{
				x = line[j * 2];
				su[i][j] = x;
			}
			su[i][9] = '\0';
			i += 1;
			if (i == 9)
			{
				int v[10];
				for (ii = 0; ii < 9; ii++) //检查每行有无重复数字
				{
					memset(v, 0, sizeof(v));
					for (jj = 0; jj < 9; jj++)
					{
						if (v[su[ii][jj] - '0'] == 1) return 0;
						v[su[ii][jj] - '0'] = 1;
					}
				}
				for (jj = 0; jj < 9; jj++) //检查每列有无重复数字
				{
					memset(v, 0, sizeof(v));
					for (ii = 0; ii < 9; ii++)
					{
						if (v[su[ii][jj] - '0'] == 1) return 0;
						v[su[ii][jj] - '0'] = 1;
					}
				}
				int iii, jjj;
				for (ii = 0; ii < 7; ii += 3) //检查每宫有无重复数字
				{
					for (jj = 0; jj < 7; jj += 3)
					{
						memset(v, 0, sizeof(v));
						for (iii = ii; iii < ii + 3; iii++)
						{
							for (jjj = jj; jjj < jj + 3; jjj++)
							{
								if (v[su[iii][jjj] - '0'] == 1) return 0;
								v[su[iii][jjj] - '0'] = 1;
							}
						}
					}
				}		
				i = 0;		
			}
		}
	}
	ifile.close();
	return 1;
}
  • 单元测试结果:
    软件工程基础-个人项目-数独游戏

六、代码说明

  • 求解某个数独的关键代码:
    整体采用回溯法,遇到无法继续扩展的空白单元格,则返回上个空白单元格修改填入的数字,重复此过程直至队列为空。
    全局变量int num记录总的空白单元格数量;
    局部变量int flag标记当前空白单元格是否还有数字可填入,1表示还有,0表示没有;
    int Check(int x, int row int col);
    参数:将填入的数字x,所在行号row,所在列号col;
    返回值:检查在当前的数独盘面中是否能填入该数字。返回1为可以,0为不可以;
//求解某个数独的代码
void SudokuSolver(char su[][10])
{
	int m, x, flag = 0, s = 0; //flag=0表示没有合适的数字可填
	while (s < sum)
	{
		flag = 0;
		for (x = 1; x < 10; x++)
		{
			if (visit[s][x] != 0) continue; //该数字已经存在或者已被访问了
			if (Check(x, blank[s][0], blank[s][1]) == 1)
			{
				sudoku[blank[s][0]][blank[s][1]] = x + '0';
				visit[s][x] = 2; //标记为已访问
				flag = 1;
				s += 1;//继续扩展下一个空白单元格
				break;
			}
		}
		if (flag == 0) //当前节点没有合适的数字可填,返回上一个结点,进行修改
		{
			for (m = 1; m < 10; m++) //清除当前已访问过的数字(即标记为2的数字),因为标记为1的数字是数独中原先已存在的,为了检索效率,不清除为1的标记)
			{
				if (visit[s][m] == 2) visit[s][m] = 0;
			}
			sudoku[blank[s][0]][blank[s][1]] = '0';
			s -= 1; //返回到上一个空白单元格
			if (s < 0) s = 0;
		}
	}
}
  • 生成数独的关键代码:
    由于next_permutation函数是按照字典序大小来排列的,所以第一行的初始排列应该是最小的812345679(第一个数字固定)。
    各行的平移步长组合如下:
    全局变量int row2to3_translation_sequence[2][2] = { { 3,6 },{ 6,3 } };
    全局变量int row4to6_translation_sequence[6][3] = { { 2,5,8 },{ 2,8,5 },{ 5,2,8 },{ 5,8,2 },{ 8,2,5 },{ 8,5,2 } };
    全局变量int row7to9_translation_sequence[6][3] = { { 1,4,7 },{ 1,7,4 },{ 4,1,7 },{ 4,7,1 },{ 7,1,4 },{ 7,4,1 } };
    void ProduceOneSudokuByTranslation(int seq1, int seq2, int seq3, int sudoku_order);
    参数:seq1对应row2to3_translation_sequence的第一维下标;
    参数:seq2对应row4to6_translation_sequence的第一维下标;
    参数:seq3对应row7to9_translation_sequence的第一维下标;
    参数:sudoku_order指第几个数独,主要是了控制换行符;
void GenerateSudokuEndings(int N)
{
	int i, j, k, count = 0;
	sudoku[0][0] = '8';//按题目要求将学号后两位的运算结果作为左上角填入的数字:(6+1)%9+1 =8
	sudoku[0][1] = '1';
	sudoku[0][2] = '2';
	sudoku[0][3] = '3';
	sudoku[0][4] = '4';
	sudoku[0][5] = '5';
	sudoku[0][6] = '6';
	sudoku[0][7] = '7';
	sudoku[0][8] = '9';
	sudoku[0][9] = '\0';
	for (i = 0; i < 2; i++) //第2,3行的组合排列序号
	{
		for (j = 0; j < 6; j++) //第4,5,6行的组合排列序号
		{
			for (k = 0; k < 6; k++) //第7,8,9行的组合排列序号
			{
				do
				{
					count += 1;
					ProduceOneSudokuByTranslation(i, j, k, count);
					if (count >= N) return;
				} while (next_permutation(&(sudoku[0][1]), &(sudoku[0][9])));
			}
		}
	}
	//cout << "生成数独个数:" << count << endl;
}

七、个人项目开发总结

从前期的查阅资料准备,到后面的编程实现,既是对我编程能力的考验,也是对我意志力的一种锻炼。第一次跑程序时,跑了差不多4个小时,几乎没有耐心去等它跑出结果来。还有后面的修改,几乎每改一次,又会引入新的未知错误,调试起来好麻烦,但是好在最后坚持了下来。连续几天的挑灯夜战,我发现软件开发真的不是一件容易的事儿,这次还没怎么涉及到需求分析,仅仅是编程实现就已经很耗时了,更不用谈完整的开发流程了。
这次个人项目软件开发,虽然难度不小,期间遇到了各种各样的问题,但是也让我学会了使用GitHub来托管代码,学会了撰写博客,学会了性能分析和单元测试怎么实现,收获很多。通过这次的个人项目我发现自己还有很多不足的地方,比如面向对象的概念不强,变量和函数的命名也还有待提升,模块化,以及高内聚低耦合的设计方式也没怎么注意。

相关文章: