北京理工大学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. 解题思路
本题要求实现实现一个能生成数独终局并独立求解数独问题的程序。
主要需求如下:
- 能够生成数独终局
- 能够独立求解数独
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
由于数独每一列必须取到a到i中的所有值,故f(k)是从A到B的双射函数 。
同时,由于每个3\times3的 子网格也包含a到i中的所有值,即在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
取满足上述条件的一个从A到B的双射如下表:
| x | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| f(x) | 0 | 3 | 6 | 1 | 4 | 7 | 2 | 5 | 8 |
对a到i的每种个取值,对数独的第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,则还有数独元素未确定,改变当前数独元素的值,更新数独的约束条件,继续回溯求解。
