[软件工程基础]个人项目 数独
GitHub 项目地址
PSP 表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
- Estimate | - 估计这个任务需要多少时间 | 10 | 10 |
Development | 开发 | ||
- Analysis | - 需求分析(包括学习新技术) | 180 | 240 |
- Design Spec | - 生成设计文档 | 20 | 30 |
- Design Review | - 设计复审(和同事审核设计文档) | 0 | 0 |
- Coding Standard | - 代码规范(为目前的开发制定合适的规范) | 0 | 0 |
- Design | - 具体设计 | 30 | 60 |
- Coding | - 具体编码 | 200 | 230 |
- Code Review | - 代码复审 | 60 | 120 |
- Test | - 测试(自我测试,修改代码,提交修改) | 60 | 300 |
Reporting | 报告 | ||
- Test Report | - 测试报告 | 20 | 40 |
- Size Measurement | - 计算工作量 | 20 | 20 |
- Postmortem & Process Improvement Plan | - 是否总结,并提出过程改进计划 | 30 | 20 |
Total | 总计 | 630 | 1070 |
解题思路
找到一个终局之后,满足题目要求的变化总共有 \(8! = 40320\),这样为了生成超过 \(1000000\) 个不重复的数独,只需要找到最开始的 \(25\) 组基础解即可。
求解数独准备使用业界有名的 DLX 算法,由于之前不会,所以上网找了几篇博客(博客1,博客2)。看了看大致懂了意思,但是并没有理解精髓,于是通过 Google 找到了 Knuth 在 arXiv 上的原始论文。
实现过程
最开始没有实际操作过 VS,通过问学长,查资料学了下 VS 的基本用法,然后开始正式的编码工作。
由于前期并没有看懂论文,因此打算先写个朴素的回溯把数独生成写出来。最开始的设计是用 Sudoku9
保存数独,Sudoku9NaiveSolver
实现回溯求解找出 \(25\) 个基准解,NaiveSudoku9Generator
实现具体的数独生成功能。
为了保证正确性,在 Sudoku9
通过总计 \(3 \times 9 = 27\) 个整数通过位运算记录每行、每列、每小块中已使用的数扫描 \(81\) 个格子验证正确性的函数 isValid
。
Sudoku9NaiveSolver
采用同样的状态表示方法加速求解速度,采用最基本的 DFS 方法实现了函数 solve
。在确认找到的一组解合法后,又对 solve
进行改进,使得下回调用 solve
会从上次找到的解开始找新解,而非从头开始。
NaiveSudoku9Generator
会在构造时创建一个大小为 \(25 \times 8!\) 的 vector<int>
存储程序生成的每组解的编号 \(i\),通过 random_shuffle
实现一定的随机性。通过 \(\lfloor i / 8! \rfloor\) 可以知道对应的基础解是什么,然后通过 \(i \bmod 8!\) 依据 Cantor Expansion 找到对应的排列,调整一下使得左上角固定为 \((0 + 6) \bmod 9 + 1 = 7\)。
该部分的单元测试首先调用每个生成数独的 isValid
函数确定合法,再通过将一个数独打成长度为 \(81\) 的字符串,然后没 \(17\) 位一组变成 \(6\) 个 long long
,排序之后通过 unique
来判断是否有重复进行的。
改进过程
看完了论文之后写了一个通用的 DLXSolver
解决精确覆盖问题,其中采用了每次选列中 \(1\) 最少的进行覆盖的启发式策略。然后通过一个中间层 Sudoku9DLXSolver
将数独问题转化为精确覆盖问题后调用 DLXSolver
的 solve
函数求解。该部分的单元测试通过 \(17\) hints 唯一解数独分别调用 Sudoku9DLXSolver
和 Sudoku9NaiveSolver
判断二者解是否一样进行。
将该部分改完之后,跑源自于这里的四万多个 \(17\) hints 唯一解数独时间从无法估计变成了 \(17\) 秒。通过效能分析工具发现由于在 DLXSolver
的频繁创建、销毁中总是会不断的 new
或 delete
一些中间变量,效率很低,因此内部用 vector
实现了一个中间变量的内存池,使得只要 new
和 delete
一遍,效率大大提高,时间从 \(17\) 秒变到了 \(5\) 秒。求解部分优化暂时到此为止。
生成 \(1000000\) 个数独的时间最初是需要 \(16\) 秒,经过简单的使用 fputc
直接输出速度从 \(16\) 秒变到 \(5.5\) 秒。通过将一个矩阵输出到一个 buffer
中再一次调用 fputs
使速度进一步缩减到 \(3\) 秒。使用 fwrite
替代 fputs
之后效率进一步提升到 \(2.2\) 秒。
下图是生成 \(1000000\) 个数独的效能分析报告,其中最花时间的是 IO。
下图是求解 \(49151\) 个 \(17\) hints 唯一解数独的效能分析报告,其中最花时间的是 DLX 求解。
关键代码展示
DLX 求解过程
该部分代码几乎原样照抄 Knuth 论文中的伪代码,未作太大改动。论文链接见前文。
void DLXSolver::uncoverColumn(DLXNode *colHead)
{
DLXNode *row, *col;
//printf("uncover col %d\n", colHead->rid);
for (row = colHead->U; row != colHead; row = row->U)
{
for (col = row->L; col != row; col = col->L)
{
col->colHead->size += 1;
col->U->D = col;
col->D->U = col;
}
}
colHead->R->L = colHead;
colHead->L->R = colHead;
}
void DLXSolver::dfs()
{
int size = ~0u >> 1;
DLXNode *colHead = head, *row, *nxt, *col;
if (head->R == head)
{
flag = true;
res = choose;
sort(res.begin(), res.end());
return;
}
for(colHead = nxt = head->R, size = colHead->size;colHead != head;colHead = colHead->R)
{
if (colHead->size < size)
{
nxt = colHead;
size = colHead->size;
}
//printf("col %d, size: %d\n", colHead->rid, colHead->size);
}
//printf("choose col %d to cover, size: %d\n", nxt->rid, nxt->size);
row = colHead = nxt;
coverColumn(colHead);
for (row = colHead->D; row != colHead && !flag; row = row->D)
{
for (col = row->R; col != row; col = col->R)
coverColumn(col->colHead);
choose.push_back(row->rid);
dfs();
choose.pop_back();
for (col = row->L; col != row; col = col->L)
uncoverColumn(col->colHead);
}
uncoverColumn(colHead);
}
内存池
只要 DLXSolver
的实例还在,pool
中存储的 DLXNode
就一直有效,而由于数独转化的精确覆盖问题规模相近,因此在频繁求解时效率会有很大提升。
inline DLXNode *DLXSolver::allocateNode()
{
if (pid >= pool.size())
pool.push_back(new DLXNode());
return pool[pid++];
}
输出缓存
这里展示的是求解部分的输出缓存,生成部分类似。采用一次调用 fwrite
写入 \(162\) 个字节,减少了 IO 次数,对效率有很大提升。
fout = fopen("sudoku.txt", "w");
while (1)
{
for (i = 0; i < 9; i += 1)
{
for (j = 0; j < 9; j += 1)
{
if (scanf("%d", &puzzle.data[i][j]) == EOF)
break;
}
if (j < 9)
break;
}
if (i < 9)
break;
if (cnt)
fputc('\n', fout);
solver.set(puzzle);
if (solver.solve())
{
ans = solver.solution();
for (i = 0; i < 9; i += 1)
{
for (j = 0; j < 9; j += 1)
{
buffer[(i * 9 + j) * 2] = ans.data[i][j] + '0';
buffer[(i * 9 + j) * 2 + 1] = " \n"[j + 1 == 9 ? 1 : 0];
}
}
fwrite(buffer, 1, 162, fout);
}
else
fprintf(fout, "No Solution!\n");
cnt += 1;
}
fclose(fout)
康托展开
将一个标识转换成一个排列。具体解析见前文链接。
void NaiveSudoku9Generator::cantorExpand(int id, int perm[], int n)
{
int i, j;
int factor[10] = {1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880};
bool use[10] = {};
//printf("id: %d\n", id);
for (i = 0; i < n; i += 1)
{
//printf("i: %d, n: %d, id: %d\n", i, n, id);
for (j = 0; j < n; j += 1)
{
if (use[j])
continue;
if (id < factor[n - i - 1])
{
use[j] = true;
perm[i] = j + 1;
break;
}
id -= factor[n - i - 1];
}
//printf("%d%c", perm[i], " \n"[i + 1 == n?1:0]);
}
}
测试
单元测试
求解部分覆盖率测试
输出覆盖率测试
感想
本次作业从难度上并不是很难,各个部分逻辑结构很清晰,并且逻辑链条较短,主要部分花在学习 VS、DLX 以及思考如何优化上,在代码编写上倒是行云流水没有什么卡壳的地方。但即时这样,该花的时间也是要花的,最终用了近 \(18\) 个小时完成本次作业。
本来以为这个小项目会很快搞定,但是后来发现其实并不是这么回事。第一是对于任务的实际完成时间没有很好的估计,另外一个是总是有能做的事情,导致时间投入越来越多。以后还需要多多锻炼准确预判的能力。
自己对于测试的具体要求还是不慎清楚,虽然自己做了一定程度的测试,但是有些并没有写成单元测试的形式,而是自己随便测测就完了。个人项目可能还好,但是到了大型项目中这种方法可能就不 work 了。需要一些大型项目的经历具体说明测试的重要性。