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

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

------------------------------------------------------------------------------------------------------------------
实现一个能够生成数独终局并且求解数独问题的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.1Personal Software Process Stage预估耗时(小时)实际耗时(小时)
Planning计划9060
Estimate估计这个任务需要多少时间48355580
Development开发25003000
Analysis需求分析(包括学习新技术)360300
Design Spec生成设计文档9075
Design Review设计复审(和同事审核设计文档)2535
Coding Standard代码规范(为目前的开发制定合适的规范)2515
Design具体设计180200
Coding具体编码12001500
Code Review代码复审9060
Test测试(自我测试、修改代码、提交修改)150200
Reporting报告4545
Test Report测试报告3030
Size Measurement计算工作量2025
Postmortem & Process Improvement Plan事后总结并提出过程改进计划3035
合计48355580

三、解题思路

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

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

934871562
871562934
562934871
348715629
715629348
629348715
293487156
156293487
487156293

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

  • 第1行:除去左上角固定的数字,剩下的八个数字进行全排列,一共有8!种;
  • 2, 3行的平移序列:3, 6的排列组合;共 A 2 2 A_2^2 A22
  • 4,5,6的平移序列:2,5,8的排列组合;共 A 3 2 A_3^2 A32
  • 7,8,9的平移序列:1,4,7的排列组合;共 A 3 2 A_3^2 A32
    一共 8 ! × A 2 2 × A 3 2 × A 3 2 = 2903040 > 1000000 种 8!×A_2^2×A_3^2×A_3^2=2903040>1000000种 8!×A22×A32×A32=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个数独大约需要一个 ( 1000000 − 1 ) × ( 2 + 9 × 2 × 8 + 17 ) + ( 9 × 2 × 8 + 17 ) = 162999998 (1000000-1)×(2+9×2×8+17)+(9×2×8+17)=162999998 (10000001)×(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来托管代码,学会了撰写博客,学会了性能分析和单元测试怎么实现,收获很多。通过这次的个人项目我发现自己还有很多不足的地方,比如面向对象的概念不强,变量和函数的命名也还有待提升,模块化,以及高内聚低耦合的设计方式也没怎么注意。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值