软工作业 - 数独

软工作业 - 数独

PSP 2.1 Personal Software Process Stages 预估耗时(小时) 实际耗时(小时)
Planning 计划
· Estimate · 估计这个任务需要多少时间 24.25 33.5
Development 开发
· Analysis · 需求分析 1 1
· Design Spec · 生成设计文档 1 0.5
· Design Review · 设计复审 0.25 0.0
· Coding Standard · 代码规范 2 1
· Design · 具体设计 2 4
· Coding · 具体编码 8 16
· Code Review · 代码复审 3 3
· Test · 测试 5 6
Reporting 报告
· Test Report · 测试报告 1 1
· Size Measurement · 计算工作量 0 0
· Postmortem & Process Improvement Plan · 事后总结,提出过程改进计划 1 1
合计 24.25 33.5

1. 思路描述

开始之前,我先明确了数独的术语

如下图,黄色掩板下即为大行(Band), 蓝色掩板下即为大列(Stack),绿色掩板下为一个宫(Block):

legend.png

1.1 终局生成算法

进而查找了数独的生成算法

  • 回溯法
  • 矩阵交换 (随机性是否足够? 不够:生日悖论 200k时 碰撞概率超50%)

网上介绍的大多是回溯法,即一个格子一个格子的试,违背规则就撤回前一步。我认为效率很低

在Google之后发现一个Github上的数独程序,做法是拿一个合法的数独终盘做矩阵变换。我以此为启发,加上一些思考设计了矩阵交换法

其基本思想是,对一个合法终盘做如下变换,不会破坏其合法性:

  • 对同一大行(Band) 内的任意2行做交换
  • 对同一大列(Stack) 内的任意2列做交换
  • 对任意2个大行(Band) 做交换
  • 对任意2个大列(Stack) 做交换
  • 对任意2种数值 ,交换它们所有的元素

那么,我们要做的就是拿着1个可行解当作范本,构造行变换、列变换的映射向量,然后加上一个数值映射即可。终盘的生成完全依赖于映射向量,而映射向量的随即顺序直接用shuffle()洗牌方法即可。

如此一来,我们就不需要再不断地回溯来找可行解了。

如下图,就是行变换、列变换的映射向量示例

mapping.png

1.2 数独求解算法

求解算法使用回溯法求解。在输入时记录已知值的位置,且维护3个列表来记录每行、列、block已存在的值。对每个空格,逐个尝试可能的取值.

显然,这样的求解算法比起生成所用的置换法来说慢了很多,但似乎这是能得到的最便于实现的方法。

1.3 GUI界面

GUI界面我使用Qt 5图形库,其界面大致长这样:

image.png

点击“下一题”将会调用终局生成的算法,生成1个新终局。取出后对每个block随机选取2~7个元素挖掉。要求中有"总挖掉数介于30~60之间",所以随机出一个挖掉方案之后要检查一下,不行就重新挖

点击“检查解答”将会检查用户的解答。为了应付一题多解的情况,我们不以挖空前的相对照,而是直接根据数独的要求(行列宫填入1~9)来判断。

点击“查看答案”将会把生成的终局显示出来。

2. 具体设计

具体实现过程中,我设计了2个类,分别是:

  • LevelGenerator: 用于生成数独终局
  • PuzzleSolver: 用于读入、求解题目

而在命令行程序中,main函数的职能有:

  • 识别参数
  • 调用LevelGenerator或是PuzzleSolver做生成或求解
  • 把得到的结果取出,写入文件

参数识别我直接使用了开源库cxxopts(当然没忘了附带它的版权声明)[https://github.com/jarro2783/cxxopts],它能负责从参数列表中识别开关、读出参数值。

2.1 类的设计

LevelGenerator类可以生成数独终局,接受参数指明要生成的数目,将结果格式化成字符串存入缓冲区out_buffer中。它具有的方法:

  • mapping():从原始终局映射到新的终局
  • fix_first():固定首位
  • testAndSet():查重
  • generate_level():生成终局
  • print_to_buf():格式化输出到缓冲区

PuzzleSolver类用于做数独求解,指明谜题文件后调用solve()方法可以将结果格式化成字符串存入缓冲区out_buffer中。它具有的方法:

  • blockIndex():从行列坐标,求属于第几宫(block)
  • markExistance():标记这一数值已存在
  • findNextValue():枚举出可能填入的值
  • load_puzzle():从文件读取谜题
  • solve():求解
  • print_to_buf():格式化输出到缓冲区

2.2 单元测试

我设计了10个单元测试项目,对LevelGeneratorPuzzleSolver这两个模块的方法进行了测试。单元测试的方法主要是:实例化一个对象-->设定其属性-->调用方法-->对返回值或对象属性做Assert

但由于我使用的Microsoft Visual Studio 2017,其分支覆盖功能仅提供给企业版使用,社区版不提供此功能,也没有替代的插件。因此没有分支覆盖率这一指标。

单元测试

2.3 GUI设计

GUI界面我开启了一个新的工程,名为QSudoku。使用Qt 5图形库构建。

在可视化设计工具中添加几个固定的按钮,之后使用代码动态生成QLineEdit控件来当数独的格子。

之后将每个控件应该做的响应都写好代码即可

image.png

3. 性能分析

下图为生成1,000,000个数独终局的性能分析。

测试平台:

  • CPU: Intel i7-6700k
  • RAM: DDR4-2133MHz 16GB
  • OS: Windows 10 1709 x64

可以看出2.213s完成这一速度还是很不错的

性能分析

打开主要方法查看具体的时间消耗:

1. testAndSet()

看出耗时占比最大,耗费了36.9%的时间。这一函数是检查生成的终局是否重复而设计的。因为要求中着重强调了“不重复”,为了稳妥起见,则将每次生成的终局存入STL提供的unordered_map,每次生成都会从其中查询,若已存在则剔除这一重复项。

事实上,生成1,000,000个终局时大致会出现3~4个重复,若这样的碰撞率是可以容忍的,则这一testAndSet大可不必,但为保险还是留下了。而STL标准库的unordered_map已经是高度优化的,继续优化的空间不大。

2. print_to_buf()

很快注意到第二个耗时函数, 这是格式化输出到字符串缓冲区的函数。最初使用的是C++的流,在更换为sprintf做格式化输出后,其时间占比下降了约2%

性能分析: generate_level()

下图为耗时最长的函数:

耗时最大的函数:testAndSet

下图为对数独求解的性能分析:

可以看出递归的调用solve方法耗费的时间最长。

性能分析:数独求解

4. 代码说明

以下代码即是LevelGenerator终局生成器的关键代码。它主要的动作是对行、列、数值映射向量做洗牌,加上首位固定为7(我的学号末2位是51),判重。若不重复则格式化输出到缓冲字符串中。

void LevelGenerator::generate_level(int quantity)
{

    for (int i = 0; i < quantity; ++i)
    {
        // 循环,直到生成了不重复的终局
        for (;;)
        {
            shuffle(band_pattern.begin(), band_pattern.end(), generator);
            shuffle(stack_pattern.begin(), stack_pattern.end(), generator);
            shuffle(row1_pattern.begin(), row1_pattern.end(), generator);
            shuffle(row2_pattern.begin(), row2_pattern.end(), generator);
            shuffle(row3_pattern.begin(), row3_pattern.end(), generator);
            shuffle(col1_pattern.begin(), col1_pattern.end(), generator);
            shuffle(col2_pattern.begin(), col2_pattern.end(), generator);
            shuffle(col3_pattern.begin(), col3_pattern.end(), generator);
            shuffle(val_pattern.begin(), val_pattern.end(), generator);
            fix_first(7);

            // 若是新的终局,停止循环
            if (testAndSet()) { break; }
            // 否则计数(供分析使用)
            duplicate_times_count++;
        }
        // 映射
        mapping(original, gen1);
        // 写入缓存
        print_to_buf(gen1);
        out_buffer += '\n';
    }
}

下面是使用PuzzleSolver求解数独的调用者。
首先实例化对象,提供谜题文件的路径。
不断循环地load_puzzle()来读入谜题,直到文件尾
调用solve进行求解。
最后,再将对象中缓冲区的文本写入文件。

// solve
void solve(string file_path)
{
    PuzzleSolver pz(file_path);
    while (pz.load_puzzle()) { pz.solve(0, 0); }
    if (!pz.out_buffer.empty())
        pz.out_buffer.erase(pz.out_buffer.size() - 2); // strip last 2 LF

    // write to file
    ofstream file;
    file.open(FILE_OUT, ios::out);
    file << pz.out_buffer;
    file.close();
}

下面是solve()函数代码:
其主要动作是跳过已知值,递归的枚举未知值


int PuzzleSolver::solve(int row, int col)
{
    if (col == 9) { row++; col = 0; }
    // skip fixed
    while (row <= 8 && isFixed[row][col])
    {
        col++;
        if (col == 9) { row++; col = 0; }
    }
    // on completion
    if (row > 8)
    {
        print_to_buf(puzzle);
        out_buffer += '\n';
        return 1;
    }

    while (puzzle[row][col] = findNextValue(row, col, puzzle[row][col] + 1))
    {
        markExistance(row, col, puzzle[row][col], 1);
        if (solve(row, col + 1))
        {
            return 1;
        }
        markExistance(row, col, puzzle[row][col], 0);
    }
    return 0;
}

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容