软件工程基础-个人项目-数独游戏
------------------------------------------------------------------------------------------------------------------
实现一个能够生成数独终局并且求解数独问题的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-9的数字。使1-9每个数字在每一行、每一列和每一宫中都只出现一次。形如下图:
- 生成数独终局的思路:
老实说,刚看到题目时,我是很晕的。当时唯一想到的方法就是每一行都随机生成一个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的排列组合;共 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种
- 求解数独的思路:
采用回溯的方法求解数独问题。
- 用一个二维数组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 (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来托管代码,学会了撰写博客,学会了性能分析和单元测试怎么实现,收获很多。通过这次的个人项目我发现自己还有很多不足的地方,比如面向对象的概念不强,变量和函数的命名也还有待提升,模块化,以及高内聚低耦合的设计方式也没怎么注意。