1.项目地址
2.PSP
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 60 | 120 |
Estimate | 估计这个任务需要多长时间 | 10 | 10 |
Analysis | 需求分析(包括学习新技术) | 300 | 300 |
Design Spec | 生成设计文档 | 60 | 120 |
Design Review | 设计复审 | / | / |
Coding Standard | 代码规范(为目前的开发制定合适的规范) | 60 | 60 |
Design | 具体设计 | 180 | 180 |
Coding | 具体编码 | 300 | 420 |
Code Review | 代码复审 | 120 | 120 |
Test | 测试(自我测试,修改代码,提交修改) | 120 | 240 |
Test Report | 测试报告 | 60 | 60 |
Postmortem&Process Improvement Plan | 事后总结,并提出改进计划 | 180 | 120 |
总计 | 1450 | 1750 |
3.解题思路
根据题目进行需求分析,可以知道程序的主要功能有两点:
- 生成不重复数独终局(1<n<1000000),要求终局第一个数字固定,并按照格式输出至可执行文件同目录的文件。
- 求解数独(1<n<1000),题目以文本格式输入,输出要求与上一点相同,性能要求为60s。
除此之外,还有源代码管理、代码质量、单元测试等功能之外的需求。
最初的解题思路是,可以利用深搜来生成不重复的终局,这样第一个数字的条件也容易满足。但是由于有着性能要求,深搜的递归调用会严重的拖慢运行速度,所以深搜是肯定不行的。数独是有着明显规律的游戏,那么,是不是存在生成数独的规律?
抱着这样的目的去查询了一些资料发现,数独终局是有着很大的相似性的。例如,可以对终局进行数字替换、旋转、行列交换
等操作,生成相似的终局。所以,现在的思路变为对原始终局进行变换,生成新的相似终局。
那么,106级别的终局,需要多少个原始终局才行呢?对数字进行替换实际上是对1-9
进行全排列:假设原始数字按递增顺序排列,则可以将原始数字与全排列后的数字进行映射,以此进行替换。由于第一个数字要求不变,则数字替换可以带来
A
8
8
=
40320
A_8^8=40320
A88=40320种有效的终局。
对于行变换,由于数独有宫的要求,所以并不能随意的将行进行排列,只能在宫中(3行内)进行排列。再剔除第一行不允许改变,可以得到 2 × 6 × 6 = 72 2\times6\times6=72 2×6×6=72种可行终局。
令人开心的是,这两种变换之间并不矛盾。无论是先进行行变换还是数字替换,都不会产生重复终局,因此可以将这两种方式进行组合,可以得到 40320 × 72 = 2903040 40320\times72=2903040 40320×72=2903040种终局。所以,只需一个初始终局,进行行交换与数字替换即可满足需求,实现过程即为完成这两个矩阵变换。
至于求解数独,还是采用深搜+回溯的方式,对每个节点进行行、列、宫的判断,以尽量剪枝。由于题目不要求发现所有可行解,所以当发现一个解时即停止搜索。
4.设计实现过程
设计过程其实是一个取舍的过程。由于需求强调了性能要求,所以这里选择舍弃面向对象的涉及,而采用单纯函数与指针的方法,力求减少调用开销,提高运行速度。但是这也并不意味着全部牺牲了代码的可读性,在编写过程中基本参照了了Google C++的代码风格,并且简化单个函数,增加可读性。
4.1 生成数独终局
-
终局的数据结构
本着从简、从速的原则,终局简单的利用二维数组char[9][9]
保存,选择char
是由于数独的合法范围小,已经足够。但实际上由于过程中不涉及以值的形式传递矩阵,所以这个优化并不明显。
实际上,把矩阵这样暴露出来是很危险的,因为并没有办法保证矩阵中的数字都可以被合法的修改。一个更好的办法是把数独矩阵封装在一个类中,只给外部提供合法的改变途径,保证数据的安全性。这样的实现在后续GUI数独程序的设计中得到实现,而本程序为了减少开销,选择直接暴露。 -
数字的全排列
std类库中提供了全排列的函数std::next_permutation(char *_First, char *_Last)
,每次调用对传入的数组按照从小至大的顺序调整一次。这样的调用方式已经非常方便,所以不再对其进行封装。在调用前,应该初始化char indexList[9]={1, 2, 3, 4, 5, 6, 7, 8, 9}
,再进行重复调用。
但实际上选择初始化char indexList[9]={4, 1, 2, 3, 5, 6, 7, 8, 9}
,并在调用时从数组下标1开始(保证4的位置不被调整),这样只要初始矩阵的第一个数字为1,就可确保新矩阵的第一个数字符合要求。 -
行的交换
行的交换分为两类。第一类为1-3
行,只允许交换2、3行。第二类为4-6,7-9
行,可以随意变换顺序。这种不同给实际编码会带来麻烦,因为逻辑的不一致需要额外的代码来实现。
但是分析后可以发现,只对第二类进行变换产生的36种情况与数字变换进行组合,就已经看可以满足需求。所以可以不对第一类进行变换,使得代码更加简洁。
函数接口设计
//对数独行组中的line1与line2
// group_addr:行组首地址,应为矩阵第1,3,6行首地址,实际仅应为第3,6行首地址
// line1:被交换的行号,限制为0-2
// line2:同上
void exchange2Line(char *group_addr, int line1, int line2);
//依据type对数独行组进行排列
// group_addr:行组首地址,应为矩阵第1,3,6行首地址,实际仅应为第3,6行首地址
// type:应为1-5,分别表示不同的排列方式:(021,102,120,201,210)
void switchLineInGroup(char *group_addr, int type);
//生成num个数独终局
void generateFinalMatrix(int num);
函数调用关系
4.2 求解数独
- 对填数进行判断
按照数独的要求进行行、列、宫的判断即可。若有重复则返回false
,否则为true
。 - 深搜与回溯
深搜以左上至右下的顺序进行递归调用,层数最多为81。
每次DFS只负责完成当前位置的填数,之后递归调用,完成后面位置的填数。这也就意味着,当前位置的深搜完成代表之后的填数全部完成了。比如,若第40层的递归返回true
,则代表40-81层的填数已经全部正确的完成了。此时返回上一次再次进行判断。
递归流程图
函数接口设计
//对矩阵pos位置填入key进行合法性判断
// matrix:矩阵头指针
// pos:左上至右下的数字位置,应为0-80
// key:填入值,应为1-9
bool checkSinglePos(char matrix[9][9], int pos, char key);
//递归求解
bool findAnsByDFS(char matrix[9][9], int pos);
//对同目录下file_name的文件进行求解,输出至./sudoku.txt
int solveWholeProb(const char *file_name);
5. 代码说明
依照上文中的设计,给出程序中的关键代码。包括求解中的深搜与回溯,生成终局的数字变换与行交换。
//solve.cpp
//递归求解
bool findAnsByDFS(char matrix[9][9], int pos) {
if (pos > 81) return true; //完成求解
//当前位置是否需要填数
if (matrix[pos / 9][pos % 9] != 0) {
if (findAnsByDFS(matrix, pos + 1)) return true;
}
else {
for (int i = 1; i <= 9; i++){
if (checkSinglePos(matrix, pos, i) == true){ //填数
matrix[pos / 9][pos % 9] = i;
if (findAnsByDFS(matrix, pos + 1)) return true; //对下一位置求解
matrix[pos / 9][pos % 9] = 0;
}
}
}
return false;
}
//generate.cpp
//生成num个数独终局
void generateFinalMatrix(int num) {
//origin matrix
char seedMatrix[9][9] = {
{ 1, 2, 3, 4, 5, 6, 7, 8, 9 },
{ 4, 5, 6, 7, 8, 9, 1, 2, 3 },
{ 7, 8, 9, 1, 2, 3, 4, 5, 6 },
{ 2, 1, 4, 3, 6, 5, 8, 9, 7 },
{ 3, 6, 5, 8, 9, 7, 2, 1, 4 },
{ 8, 9, 7, 2, 1, 4, 3, 6, 5 },
{ 5, 3, 1, 6, 4, 2, 9, 7, 8 },
{ 6, 4, 2, 9, 7, 8, 5, 3, 1 },
{ 9, 7, 8, 5, 3, 1, 6, 4, 2 } };
//进行全排列的数组,第一个位置保持不变
char indexList[] = { 4,1,2,3,5,6,7,8,9 };
char output_temp[OUTPUT_TEMPLATE_SIZE];
initOutputTemplate(output_temp);
FILE *pf;
fopen_s(&pf, "sudoku.txt", "w");
int count = 0;
while (next_permutation(indexList + 1, indexList + 9)) { //全排列
//change sequence of rows
for (int i = 0; i <= 5; i++) {
if (i != 0) switchLineInGroup(seedMatrix[3], i); //4-6行互换
for (int j = 0; j <= 5; j++) {
if (j != 0) switchLineInGroup(seedMatrix[6], j); //7-9行互换
//change numbers
for (int i = 0; i < 9; i++)
for (int j = 0; j < 9; j++) {
int pos = (i * 9 + j) * 2;
output_temp[pos] = indexList[seedMatrix[i][j] - 1] + '0';
}
count++;
//输出格式调整
if (count == num) {
output_temp[OUTPUT_TEMPLATE_SIZE - 3] = '\0';
fputs(output_temp, pf);
goto end;
}
else
fputs(output_temp, pf);
}
}
}
end:
fclose(pf);
}
6.程序性能分析及改进
以生成106的终局进行性能分析,可以得到如下的报告
可以发现生成终局的函数调用占据了大部分的时间。对函数内部进行分析可以发现,实际上最消耗时间的部分是文件输出。
然而,这已经是经过改进的输出方案了:在输出前提前准备好模板,只需将终局填入模板即可一次性的将矩阵输出,而不是一行一行的进行。与之前的方案相比,这样的方式可以提高50%左右的效率,百万级的终局输出在3s内可以完成。
7.单元测试
单元测试主要对上文设计的接口进行,包含了正例与错例,保证了接口在参数正确的情况下可以返回正确的结果。但是并不保证输入参数在范围以外的情况可以正确处理,因为假设函数调用者应该负责保证参数传递的正确性。
如图可以看出,测试包含了生成终局中的所有函数,求解数独中的深搜部分。