Github项目链接
PSP2.1 | Personal Software Process Stage | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 60 | 30 |
·Estimate | ·估计这个任务需要多少时间 | 10 | 10 |
Development | 开发 | 1200 | 1200 |
·Analysis | ·需求分析(包括学习新技术) | 300 | 300 |
·Design Spec | ·生成设计文档 | 60 | 60 |
·Design Review | ·设计复审 | 30 | 30 |
·Code Standard | ·代码规范(为当前的开发制定合适的规范) | 30 | 30 |
·Design | ·具体设计 | 200 | 250 |
·Coding | ·具体编码 | 400 | 450 |
·Code Review | ·代码复审 | 30 | 30 |
·Test | ·测试(自我测试、修改代码、提交修改) | 600 | 700 |
Reporting | 报告 | 120 | 60 |
·Test Report | ·测试报告 | 120 | 180 |
·Size Measurement | ·计算工作量 | 20 | 30 |
·Postmortem & Process Improvement Plan | ·事后总结,并提出改进计划 | 60 | 60 |
合计 | 2040 | 2220 |
一、解题思路
1.1 实现生成数独终局
实现生成数独有两个方案。
第一种方案是暴力枚举,这种方法理论上能够生成所有种类的数独终局,但是时间复杂度太高,在性能上表现很差。
第二种方案是随机生成 1 ~ 9 的某种全排列顺序,然后通过某种变换生成最终的数独。这种方案十分快速,生成一个数独终局的时间复杂度基本上是 O(1) 的,只不过是常数很大。但这种方案有个缺点,就是它能够生成的数独终局的数量很少,只有 9!个,而第一种方案有6,670,903,752,021,072,936,960(约为6.67×10的21次方)种组合。但是在此项目中,要求1000个内基本无重复是完全足够了。
所以我们采用第二种方案。
1.2 实现数独求解
数独求解只能暴力dfs搜索,我目前没有找到理论上时间复杂度更优的算法。
二、设计实现
2.1 generate 函数
generate 函数生成给定的数量的数独终局到指定文件中。
2.2 game 类
game 类包含一个solution方法,该方法功能为求解数独。
game 类还包含两个private成员函数 game::dfs()(实现深度优先搜索)和 game::get_index()(具体功能在后面)。
2.3 processinput 函数
项目需求中要求通过cmd指令sudoku.exe -c 1000
来生成一定数量的数独终局。
以及sudoku.exe -s absolute_path_of_puzzlefile
来求解指定文件中的数独终局。
processinput功能为:处理输入指令,并调用相应的函数。
2.5 单元测试设计
通过设计测试用例,使得代码覆盖率达到92.5%。
三、性能分析
3.1. 解数独功能的性能分析
我们使用一个较强的用例
0 0 0 0 0 5 0 2 0
1 0 0 9 0 0 0 0 0
4 0 0 0 0 0 0 0 0
0 0 8 0 0 2 0 0 0
0 0 0 0 0 0 0 0 1
0 0 0 0 0 0 9 0 4
0 0 5 0 0 0 3 0 0
0 0 0 1 0 0 0 0 7
0 0 2 4 0 8 0 0 0
我们先运行一下,看一下程序运行时间。
34.263秒
非常的差劲。
我们再回过头看一下solve_puzzle的代码,可以发现,设作标志的变量太多,整个循环计算太繁琐。看一下调用的jdg_rep函数(用来判断数独上某个空是否可以填某个数字)。
int jdg_rep(int row, int line, int putnum, int sudoku[9][9])
{
for (int i = 0; i < 9; i++)
{
if (i == row) continue;
if (sudoku[i][line] == putnum)
return false;
}
for (int i = 0; i < 9; i++)
{
if (i == line) continue;
if (sudoku[row][i] == putnum)
return false;
}
for (int i = row - row % 3; i < row - row % 3 + 3; i++)
{
for (int j = line - line % 3; j < line - line % 3 + 3; j++)
{
if (i == row && j == line) continue;
if (sudoku[i][j] == putnum) return false;
}
}
return true;
}
可以发现每个操作都是必须的,jdg_rep函数没法继续优化。而且整个DFS因为之前已经做过剪枝(未展示代码),所以算法上也无法进行优化。能够做的只有简化DFS的代码实现的逻辑(减少过多的状态变量以及状态数组或更改为递归实现。)。
简化代码,并使用位运算
如果我们把int变量的最低9位用作表示1~9九个数字,那么很多操作都会简化。
具体表示方法为:
32位的 int 变量从最低位到最高位,若前九位任意一位为1,则这个int变量就代表哪个数字,如果有一位为1,则其余位全为0。具体思想类似one-hot编码方式。
优化方法:
如果判定数独的某个空可不可以填入某个数字,需要查看和该空同一行、同一列、同一 3 x 3矩阵内有没有相同的数字。则可以将同行、同列、同框内的所有数字按位取反,将亟待判断的位置的值记作 x,置为 0…0111111111(2e9 - 1),然后将 x 与所有已经按位取反的数字进行按位与操作,最后 x 的低 9 位哪里为1,则 x 可以取值为几。如:若 x = 0…0000000001,则该位置只能填1;若 x = 0…111111111,则该位置能填1~9任何一个数。参考代码
优化后的时间
2.614秒
速度快了很多。
3.2 进行生成数独部分的优化
我们生成10000个数独,命令:
./sudoku.exe -c 10000
启动性能探查器 - CPU使用率
分析结果:
双击process_input函数
不出所料,使用CPU最多的还是生成数独的函数。
查看函数内部CPU使用情况。
可以看到占用CPU最多的是文件的写入,占用了90.7%。
所以生成数独部分代码即便得到优化,效果也不会太明显。
所以通过减少调用fprintf函数来达到优化时间的目的。
优化前代码:
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (num == 0 && i == 8 && j == 8)
fprintf(file, "%d", sudoku[i][j]);
else
fprintf(file, "%d%c", sudoku[i][j], j == 8 ? '\n' : ' ');
}
}
优化前生成用时为
10000个用时2.235秒,1000000用时21.984秒。
优化后代码:
for (int i = 0; i < 9; i++) {
if (i == 8)
fprintf(file, "%d %d %d %d %d %d %d %d", sudoku[i][0], sudoku[i][1], sudoku[i][2], sudoku[i][3], sudoku[i][4], sudoku[i][5], sudoku[i][6], sudoku[i][7]);
else
fprintf(file, "%d %d %d %d %d %d %d %d\n", sudoku[i][0], sudoku[i][1], sudoku[i][2], sudoku[i][3], sudoku[i][4], sudoku[i][5], sudoku[i][6], sudoku[i][7]);
}
优化后用时
四、代码说明
4.1 generate 函数
核心代码:
//洗牌算法随机生成左上角 3 x 3小矩阵
for (int i = 0; i < 20; i++)
{
int a = rand() % 8 + 1;
int b = rand() % 8 + 1;
int tmp = sudoku[a / 3][a % 3];
sudoku[a / 3][a % 3] = sudoku[b / 3][b % 3];
sudoku[b / 3][b % 3] = tmp;
}
for (int a = 0; a < 3; a++) {
for (int b = 0; b < 3; b++) {
//枚举每个 3 x 3 小矩阵
if (a == 0 && b == 0) continue;
//通过将左上角的小矩阵进行行列变换生成其余8个矩阵
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
int row = i + a * 3;
int line = j + b * 3;
sudoku[row][line] = sudoku[(row + b) % 3][(line + a) % 3];
}
}
}
}
代码思路:
首先通过洗牌算法,随机生成一个 3x3 的矩阵,即为数独最左上角的小矩阵。然后通过行列变换生成其余8个 3x3 的小矩阵。变换方式为:将行数和列数每次加1模3。变换次数为该矩阵离左上角小矩阵的水平和竖直距离。
4.2 game class
处理矩阵:
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
//读文件
int end = fscanf_s(readfile, "%d%c", &number_temp, &temp, (unsigned int)(sizeof(int) + sizeof(char)));
//如果是空位
if (number_temp == 0) vec.push_back(pair<int, int>(i, j));
//如果是数字
else sudoku[i][j] = 1 << (number_temp - 1);
if (end == EOF) flagtobreak = 1;
}
}
深搜:
int game::dfs(int now)
{
int row = vec[now].first;
int line = vec[now].second;
//查询可以填入的数字
int f = (1 << 9) - 1;
for (int i = 0; i < 9; i++) {
f &= (~sudoku[row][i]) & ((~sudoku[i][line]) & (~sudoku[row / 3 * 3 + i / 3][line / 3 * 3 + i % 3]));
}
//枚举可以填入的数字
while (f)
{
//取出数字
sudoku[row][line] = f & (-f);
//清除该数字
f &= ~sudoku[row][line];
//递归
if (now < vec.size() - 1 && !dfs(now + 1)) continue;
else return true;
}
//没有可以填入的数字
sudoku[row][line] = 0;
return false;
}
转换为整形
int game::get_index(int num)
{
int ans = 0;
while (num) {
ans++;
num >>= 1;
}
return ans;
}
代码详细思路:
(1)表示方法: one-hot编码。例如,数字1表示为
00000000 00000000 00000000 00000001
数字5表示为
00000000 00000000 00000000 00010000
(2)查询某空位能填入什么数字的方法:
首先将变量 f,通过操作 f = (1 << 9) - 1
置为
00000000 00000000 00000001 11111111
让后让 f 和与所查询空位同行、同列、同 3x3 小矩阵中的其余数字的按位取反后的值进行按位与操作。最后 f 中剩下的值为1的位即为可以填入的数字。
例:
若同行、同列、同矩阵中有数字5,表示为:
00000000 00000000 00000000 00010000
按位取反后表示为:
11111111 11111111 11111111 11101111
在与 f 进行按位与操作后,f 的值变为:
00000000 00000000 00000001 11101111
从右往左第5位变为0,则此位置不能填5。
若同行、列、矩阵中有 1 ~ 9 所有数字,则 f 值变为 0,即此空位无可填入的数字。
五、项目总结
通过此次项目,我熟悉了个人项目的流程,理解了软件工程的很多概念。深刻理解了一个好的设计、规范的代码风格以及规范的文件组织对于整个软件生命周期的影响。