目录
GitHub项目地址
PSP表格
题目要求
-
数独的概念:
下图为数独题目的示意图,数独要求任意某一行或者某一列都不存在重复的数字。图中的粗线将数独题目划分为9个区域,这些区域称为宫。每个宫内也不允许存在重复的数字。
-
生成终局:
1、在命令行中使用参数-c和数字N(1<=N<=1000000)生成N个数独终局。
2、生成的数独终局保存在一个txt中,每次生成的txt覆盖上次生成的txt文件。生成的数独终局,每行每个数字间用一个空格分隔,行末无空格。终局与终局间空一行。 -
求解数独:
1、在命令行中使用参数-s和文件名来求解数独,要求从该文件中读取题目并求解,输出结果到与此程序相同目录下的sudoku.txt中。
2、数独题目个数N满足1<=N<=1000000,格式同数独终局。
解题思路描述
- 生成数独终局
查阅网上资料后发现生成数独终局的方法大概有如下几种:
1、回溯法。随机生成数字填入当前网格中,检测是否符合要求,如果符合则继续生成数字填到下一个网格,否则回溯到上一步,不断随机生成数字直到所有网格被填满。
2、模板法。事先准备好一个符合要求的数独终局模板,模板中的元素为字母a~i,然后随机生成一串包含1-9的数字序列,将字母一一对应,替换为数字,获得数独终局。
3、数列法。生成一个数列,然后将此数列分别向右移动0、3、6、1、4、7、2、5、8位得到终局第一到第九行。可以将第1~3行对应的移动位数改变一下顺序,如改为0、6、3,就可以得到一个新终局。此外,4到6,7到9行也可以更改。于是一个数列可以生成6^3=216个终局,数字1到9可以生成9!= 362880个数列。
显然,方法一生成的数独终局随机性非常高,但是效率非常低,如果要生成大量的数独终局,将花费大量的时间和计算机资源;而方法二虽然效率高,但是如果只使用一个模板的话,最多能生成的不同的数独终局数仅为9!=362880,而本项目中规定数独终局的第一行第一列为定值,使一个模板能生成的不同的数独终局数为8!=40320,显然是远远无法达到题目要求的数量上限(N<=1000000)的。此外,在生成数列的过程中,容易产生相同的数列,导致实际生成的数独终局更少;方法三效率非常高,而且可以保证生成的终局中不存在重复的终局,缺点是生成的终局结构非常相似。
本次实验采用了方法三。由于实验要求终局的第一位为固定值,所以在生成数列时先固定第一位的数字。实验要求的最大终局数为100万,而8个数字的全排列数为8!约为4万,也就是说一个数列最多只需生成100/4=25个终局。本次实验中每个数列生成30个终局。生成终局的思路如下:
1、设定一个初始数列为当前数列。
2、由当前数列生成一个全排列,当前数列设置为全排列,由当前数列生成30个数独终局。
3、检查生成的终局数是否达到要求,如果没达到,返回第二步,否则退出。
- 求解数独
使用递归方法。递归函数为 void place_num(int sudo[][9], int pos, int num)。其中sudo为当前的数独题。pos 为当前将要放置的位置(0~80),num为将要放置的数字。递归的过程如下:
1、如果 pos>80,跳到第三步。
2、如果 0<=pos<=80 将 sudo[pos/9][pos%9] 设置为 num。将pos置为下一个空白位置对应的位置号。尝试将数字1~9填入位置pos中,检测是否符合数独的要求。如果符合,递归调用此函数,调用方式为 place_num(int sudo[][9], int pos, int num),其中sudo和pos的值均为修改后的值。
3、输出当前数独sudo。
设计和实现过程
生成终局流程图如下:
- 生成终局用到的一些主要函数:
//记录移动的方式。每行记录生成一个数独时需要进行的操作。
//例如,第一行 0,3,6,1,4,7,2,5,8 表示,数独第一行由数列se向右移动0位得到;数独第二行由se向右移动3位得到……
//此数组共有30行,代表通过一个数列可以获得30个数独。
int move_way[30][9] = {
{ 0,3,6,1,4,7,2,5,8 },
{ 0,3,6,1,7,4,2,5,8 },
{ 0,3,6,4,1,7,2,5,8 },
{ 0,3,6,4,7,1,2,5,8 },
{ 0,3,6,7,1,4,2,5,8 },
{ 0,3,6,1,4,7,2,8,5 },
{ 0,3,6,1,4,7,5,2,8 },
{ 0,3,6,1,4,7,5,8,2 },
{ 0,3,6,1,4,7,8,2,5 },
{ 0,3,6,1,4,7,8,5,2 },
{ 0,3,6,1,7,4,2,8,5 },
{ 0,3,6,4,1,7,5,2,8 },
{ 0,3,6,4,7,1,5,8,2 },
{ 0,3,6,7,4,1,8,2,5 },
{ 0,3,6,7,1,4,8,5,2 },
{ 0,6,3,1,4,7,2,5,8 },
{ 0,6,3,1,7,4,2,5,8 },
{ 0,6,3,4,1,7,2,5,8 },
{ 0,6,3,4,7,1,2,5,8 },
{ 0,6,3,7,1,4,2,5,8 },
{ 0,6,3,1,4,7,2,8,5 },
{ 0,6,3,1,4,7,5,2,8 },
{ 0,6,3,1,4,7,5,8,2 },
{ 0,6,3,1,4,7,8,2,5 },
{ 0,6,3,1,4,7,8,5,2 },
{ 0,6,3,1,7,4,2,8,5 },
{ 0,6,3,4,1,7,5,2,8 },
{ 0,6,3,4,7,1,5,8,2 },
{ 0,6,3,7,4,1,8,2,5 },
{ 0,6,3,7,1,4,8,5,2 },
};
//将数列se向右移动n位,将移动后的结果转化为符合要求的格式并存入result
void move_se(char* se, char* result, int n)
//生成N个数独到文件file_name中。如果generate_way = 0则生成终局,为1生成数独题
int generate_sudoku(int N, char* file_name, int generate_way)
求解数独流程图:
- 求解数独用到的一些主要函数:
//递归,将num放置到空位置pos(0~80)上,直到所有的空位置被填满,填好的数独存在result中
void place_num(int sudo[][9], int pos, int num, int result[][9])
//判断在sudo[x][y]上放置数字num是否符合规则,是则返回1,否则返回0
int is_suit(int sudo[][9], int x, int y, int num)
代码改进及性能测试
本次实验只要求测试生成终局的性能。
- 原始代码的CPU使用率及函数占用的CPU函数分析:
本次测试的参数为-c 10000
可以看出,用于输出数独终局至文件的函数print_sudoku占用了很大一部分的CPU时间。因而接下来将对此函数进行改进。
原始代码使用fprintf进行输出,尝试着修改为使用fputs进行输出。修改后使用参数-c 100000对两版代码进行测试,统计了运行时间,结果如下:
可以看出,使用fputs输出会使程序性能得到较大幅度的提高。因而重新修改了代码。
但是经过与他人的程序性能进行对比,发现我的程序性能大概只有其他人的百分之一。经过研究及测试后发现,这是由于我的代码中频繁开闭文件导致的。于是将原来的输出函数print_sudoku整合到其他函数中去(因为在此函数中,需要打开并关闭文件,之前是生成一个终局就调用一次此函数)。除此之外,原程序每次输出只一个数字。
于是将程序修改为保持文件始终打开,每生成一个终局就将终局修改为一长串符合格式的字符串,然后将字符串输出到文件中。修改后的程序生成1000000个终局大约需要5~6s,但是这样修改的缺陷是模块化程度降低。
尽管此时的程序性能已经较高,但是与其他同学的(生成100万终局需2~3s)相比还是有着较大的差距。于是开始思考是否还有能够再进行优化的地方。经过一段时间的研究及代码测试,发现有许多时间是花费在转化格式的过程中了。在之前,我的做法是,通过移动数列生成终局的一行后,先将其转化为符合要求的格式(每个数字之间有空格,行末有换行符),再将其拼接到用于表示整个数独终局的字符串中去。修改之后的做法是,在移动数列的过程中就把数列转化成相应格式,直接写入数独终局字符串,于是便省去了许多转化过程。修改后的代码生成100万终局大约需要1.5到2秒,性能大大提高。
接下来是对改进后的代码的测试:
CPU占用率测试:
参数为-c 1000000
可以看到,cpu占用率大约在25~30之间。多次测试,运行时间在1.5到2s之间。耗时最多的函数为generate_suodku(),详细代码在“关键代码说明”部分。
单元测试
测试的函数如下:
//将数列se向右移动n位,将移动后的结果转化为符合要求的格式并存入result
void move_se(char* se, char* result, int n)
{
for (int i = 0; i < n; i++)
{
result[i * 2] = se[9 - n + i];
result[i * 2 + 1] = ' ';
}
for (int i = n; i < 9; i++)
{
result[i * 2] = se[i - n];
result[i * 2 + 1] = ' ';
}
result[17] = '\n';
}
单元测试的代码如下:
#include "stdafx.h"
#include "CppUnitTest.h"
#include "测试函数.h"
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
namespace UnitTest1
{
TEST_CLASS(UnitTest1)
{
public:
TEST_METHOD(Test_Move_se)
{
//将se向右移动三位并转化为符合要求的格式
char se[9] = { '2','1','3','4','5','6','7','8','9' };
//预期的结果
char expect_string[18] = { '7',' ','8',' ','9',' ','2',' ','1',' ','3',' ','4',' ','5',' ','6','\n' };
//运行的结果
char result_string[18] = { 0 };
move_se(se, result_string, 3);
for (int i = 0; i <= 18; i++)
Assert::AreEqual(result_string[i], expect_string[i]);
}
};
}
测试结果截图如下:
关键代码说明
- 生成终局
//生成N个数独到文件file_name中。如果generate_way = 0则生成终局,为1生成数独题
int generate_sudoku(int N, char* file_name, int generate_way)
{
FILE *fp;
errno_t open_error = fopen_s(&fp, file_name, "w");//打开成功返回非零,失败返回0
char se[9] = { '2','1','3','4','5','6','7','8','9' };//学号后两位为5、5,(5+5)%9+1=2,所以数列首位为2
int sudoku_sum = 0;//已生成的数独终局数
while (1)
{
next_permutation(&se[1], &se[1] + 8);//对se的第二位到第九位进行全排列变换,得到一个新数列
for (int i = 0; i < 30; i++)//对每个数列,生成30个数独
{
char sudoku_string[18 * 9 + 1] = { 0 };//一个数独的字符串形式
for (int j = 0; j < 9; j++)
{
//按照移动表来移动数列se,移动后的结果拼接在sudoku_string后面
move_se(se, &sudoku_string[strlen(sudoku_string)], move_way[i][j]);
}
sudoku_string[18 * 9] = '\n';
if (generate_way == 1)
change_into_problem(sudoku_string);
sudoku_sum++;
if (sudoku_sum == N)//已生成足够数目的数独
{
sudoku_string[18 * 9 - 1] = '\0';
sudoku_string[18 * 9] = '\0';
fputs(sudoku_string, fp);//输出数独字符串到文件
fclose(fp);
return N;
}
fputs(sudoku_string, fp);//输出数独字符串到文件
}
}
}
- 求解数独
//递归,将num放置到空位置pos(0~80)上,直到所有的空位置被填满,填好的数独存在result中
void place_num(int sudo[][9], int pos, int num, int result[][9])
{
int copy[9][9] = { 0 };
copy_sudo(sudo, copy);
if (pos >= 0)//当前位置合法,将此位置置为num
copy[pos / 9][pos % 9] = num;
//找到下一个对应的数字为0的位置
do
{
pos++;
if (pos > 80)//当前数独已是终局
{
copy_sudo(copy, result);
return;
}
} while (copy[pos / 9][pos % 9] != 0);
//尝试将此位置的下一位置置为n,n的范围是1~9
for (int n = 1; n <= 9; n++)
{
if (s_is_suit(copy, (pos / 9) , (pos % 9) , n) == 1)//如果当前位置置为n合适,则递归设置下一个为0的位置
{
place_num(copy, pos, n, result);
}
}
}
附加题
要求:为数独游戏做一个GUI界面。
设计和实现过程:
创建MFC程序来实现此要求。由于已经有生成数独题的函数了,只需将生成的题目展现在界面上即可。实现过程比较简单,拖动文本框和按钮控件到界面上,然后为相应的按钮响应函数添加代码即可。最终的界面如下:
界面上有五个按钮。
其中“生成数独题按钮”可以在网格中生成一个数独题。点击后界面变成这样:
然后用户可以在空白处填写数字。填写过程中可以自由修改填写的内容,也可以点击“清除输入”按钮清除输入的数字。随意填写几个数字,点击提交:可以看到显示数独填写错误。
点击清除输入:恢复到一开始的状态。
点击显示答案:可以看到答案显示在网格中。
再点击一次提交。可以看到此时网格中的数独是正确的。
点击退出按钮,退出游戏。