数独 - 软件工程实践 2017 第二次作业


Github 地址

https://github.com/ladit/Sudoku


解题思路

数独生成

我算法方面差一些,数独以前也是没有玩过的。了解了数独的规则后,想到的方法也只是暴力计算——也就是随机法——循环生成一行随机数字,依次填入数独棋盘第一到第九行,每行每个位置填入时,检查当前是否符合数独规则。

花费一些时间实现这个算法并 De 掉层出不穷的 Bug (太久没写 C++ 了)后,测试过程中发现理想很丰满,现实很骨感——仅仅第二行就一直无法插入,即使 循环生成一行随机数字 对应的方法调用了上千次。的确,这样效率太低,因为第二行很容易就和第一行冲突了。

这意味着算法需要改进,冥思苦想后没太大结果,在网上阅读了一些生成数独的算法的文章:

其中第二种的矩阵转换法和第三种的“随机法”虽然效率都较高,但在其文章和评论中都提到不够随机,有规律可循,因此我决定参考第一篇文章中说的深度优先搜索和回溯法。

这种方法先随机把九个数字填入数独,然后递归寻找整个数独棋盘 可填写数字 数量最少的格子,最后生成整个棋盘,遇到 可填写数字 数量为0的格子时,向前回溯。

后来看到网上讨论(如下),Dancing Links (DLX)应该是目前求解数独较快的算法,对于题目中的1000000个数独,可能这种算法比较好实现。但当时已经基本完成了深度优先搜索和回溯法,所以这种方法有待之后改进再实现。

文件输出

文件输出无非使用 C/C++ 标准的 freadfwritefstream 来实现,但这两种方式的使用有值得探讨的地方。

在网上找了一些文件输出效率对比的文章(以下列出),似乎它们都一致认为 C 标准的函数效率更高,但这些文章都较为老旧,不知道是否都使用 Visual Studio,有使用 Visual Studio 的版本又较低,新的 Visual Studio 使用 MSBuild 作为编译器,据说 C++ 标准的函数效率有所改进,同时重要的是,为了统一代码标准,应当先使用 C++ 标准的 fstream

然而看到同学们的博客中有的使用了 C 标准的函数很大地提高了效率,这我就陷入纠结:是应该遵守统一标准,还是追求效率?或者其实两者本来就被允许通用?

使用规范

在题目中提到,

值得一提的是,测试数据中有可能出现错误,比如出现 sudoku.exe -c abc 这样的命令,你的程序需要自行处理错误情况,并给出合适的错误提示信息。

因此需要对程序限制使用规范,以增强其健壮性。这个程序我设置了两种使用方式:

  • 直接运行程序,输入需要生成的数独数量;
  • sudoku.exe -c N (0 < N <= 1000000, N 为需要生成的数独数量,整数)(也可以是 -C);

这样的话,由于有输入,需要对输入检测是否合法,例如是否整数,是否 0 < N <= 1000000。


设计实现

我的设计实现经历了一些比较大的变化。开始的时候为了快速实现算法,把一切东西都写在 main 和其他附属方法中,完成基本的输出一个数独的功能后,我开始对文件结构、类、函数整理。

文件结构

/root
  /BIN                   // 可执行文件目录
    sudoku.exe           // 可执行文件
  /src                   // 源文件目录
    stdafx.h             //  Visual Studio 自带头文件
    stdafx.cpp           //  Visual Studio 自带头文件
    targetver.h          //  Visual Studio 自带头文件
    sudokuMaker.h        // 数独生成类声明
    sudokuMaker.cpp      // 数独生成类实现
    fileController.h     // 文件控制类声明
    fileController.cpp   // 文件控制类实现
    usageValidator.h     // 使用规范类声明
    usageValidator.cpp   // 使用规范类实现
    Sudoku.cpp           // 主程序入口
  README.md              // 说明

类与方法结构

共有三个类,分别为 sudokuMakerfileControllerusageValidator,其中sudokuMakerusageValidator 在主函数 main 中实例化,用来实现数独生成和输入验证,fileController 的实例是 sudokuMaker 的私有成员,用来生成完数独后输出到文件。

class sudokuMaker {
  public:
    bool generateAndPrint(const int &sudokuQuantity);  // 循环生成数独并且打印到文件

  private:
    int blank;  // 数独棋盘待填格数,新的待填数独棋盘模板初始化后有 72 格待填
    int hint;  // 数独棋盘提示格数
    int fullBoard[9][9];  // 完整数独棋盘
    fileController fc;  // 文件控制器,用来输出到文件
    void newTemplate();  // 产生新的待填数独棋盘模板
    bool dfs();  // 深度优先搜索填写数独棋盘
    int validNumbersQuantity(const int &row, const int &col, int (&mark)[10]);
        // 某一格可填写数字数量计算
};
class fileController {
  public:
    bool open(const string &outputPath);  // 打开文件以输出
    void write(int (&fullBoard)[9][9]);  // 写入
    void close();  // 关闭文件

  private:
    ofstream outFile;
};
class usageValidator {
  public:
    int check(int argc, char **argv);  // 检查输入

  private:
    int checkStringAndConvertToInteger(const string &str); // 检查字符串并转换成整数
};

代码说明

一些主要方法的说明:

输入要生成的数独数量,生成完整数独棋盘并输出到文件

bool sudokuMaker::generateAndPrint(const int &sudokuQuantity) {
  if (fc.open(".\\sudoku.txt")) {
    for (int i = 1; i <= sudokuQuantity; i++) {
      while (true) {
        newTemplate();  // 随机生成模板,除了第一行第一个,把其他八个数字随机填入数独棋盘
        if (dfs()) {  // 深度优先搜索
          fc.write(fullBoard);  // 若成功则输出到文件
          break;
        }
        // 失败则重新循环
      }
    }
    fc.close();
    return true;
  }
  return false;
}

深度优先搜索

bool sudokuMaker::dfs() {
  if (blank == 0) {
    return true;  // 填写完毕,无空格
  }

  int minQuantity = 10;  // 当前可填写数字最少的数量
  int mini = 0, minj = 0;  // 当前 可填写数字数量最少 的格子位置

  int mark[10] = {0};  // 标记已填写和未填写的数字

  // 寻找可填写数字数量最少的格子
  for (int i = 0; i < 9; i++) {
    for (int j = 0; j < 9; j++) {
      if (fullBoard[i][j] != 0) {
        continue;
      }

      int count = validNumbersQuantity(i, j, mark);  // 计算可填写数字数量
      if (count == 0) {
        return false;
      }
      if (count < minQuantity) {
        minQuantity = count;
        mini = i;
        minj = j;
      }
    }
  }

  // 优先填写 可填写数字数量最少 的格子
  validNumbersQuantity(mini, minj, mark);
  for (int i = 1; i <= 9; i++) {
    if (mark[i] == 0) {
      fullBoard[mini][minj] = i;
      blank--;
      dfs();
      if (blank == 0) {
        return true;
      }
      fullBoard[mini][minj] =  0; // 回溯
      blank++;
    }
  }
  return true;
}

计算可填写数字数量

int sudokuMaker::validNumbersQuantity(const int &row, const int &col, int (&mark)[10]) {
  for (int i = 0; i < 10; i++) {
    mark[i] = 0;
  }
  for (int i = 0; i < 9; i++) {  // 某一格所在的行和列已填写数字记录
    mark[fullBoard[i][col]] = 1;
    mark[fullBoard[row][i]] = 1;
  }
  int baseRow = row / 3 * 3;
  int baseCol = col / 3 * 3;
  for (int i = 0; i < 3; i++) {  // 某一格所在的九宫格已填写数字记录
    for (int j = 0; j < 3; j++) {
      mark[fullBoard[baseRow + i][baseCol + j]] = 1;
    }
  }
  int count = 0;
  for (int i = 1; i <= 9; i++) {  // 可填写数字计算
    if (mark[i] == 0) {
      count++;
    }
  }
  return count;
}

检查字符串是否整数形式并转换成整数

int usageValidator::checkStringAndConvertToInteger(const string &str) {
  size_t sizeOfString = str.size();
  for (size_t i = 0; i < sizeOfString; i++) {
    int temp = int(str[i]);
    if (temp >= 48 && temp <= 57) {
      continue;
    }
    else {
      return 0;  // 字符串中某个字符不是数字则返回 0,即失败
    }
  }
  int convertedInteger = stoi(str);  // 字符串转换为整数
  if (convertedInteger <= 0 || convertedInteger > 1000000) {
    return 0;  // 不在 0 到 1000000 则返回失败
  }
  return convertedInteger;
}

测试运行

运行测试


改进记录与性能分析

改进记录

  • 使用形如 const int &row 这样的参数代替 int row 这样的参数,提升效率,防止修改;
  • 使用定长数组代替 vector;
  • 文件打开和关闭操作只执行一次;
  • 文件写入方式改进,从每一个数字都使用文件流输出变成把一个数独棋盘转换成一个字符串后一起输出;
  • 其他改进……;

性能分析

文件写入改进前:

文件写入改进前

文件写入改进后:

文件写入改进后

CPU 占用

可以看到,在改进前 fileController::write() 消耗很大,改进后 sudokuMaker::validNumbersQuantity() 是最大消耗的函数。如果想要进一步改进的话,应该需要使用上面说的 DLX 算法。

代码覆盖率

助教在群里推荐的 C++ Coverage Validator,我没办法配置成功,这款工具的教程网上少、旧。所以我去找了一下其他替代的工具。

OpenCppCoverage(使用教程)是一款好用方便的 C++ 代码覆盖率检测工具,可以独立在命令行运行也可以作为 Visual Studio 13/15/17 的插件。

我使用 OpenCppCoverage 测试代码覆盖率的结果如下:

代码覆盖率

单元测试

待填写。


PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 40 35
· Estimate · 估计这个任务需要多少时间 40 35
Development 开发 915 1055
· Analysis · 需求分析 (包括学习新技术) 300 360
· Design Spec · 生成设计文档 10 15
· Design Review · 设计复审 (和同事审核设计文档) 5 5
· Coding Standard · 代码规范 (为目前的开发制定合适的规范) 5 5
· Design · 具体设计 25 30
· Coding · 具体编码 240 250
· Code Review · 代码复审 30 40
· Test · 测试(自我测试,修改代码,提交修改) 300 350
Reporting 报告 240 295
· Test Report · 测试报告 40 40
· Size Measurement · 计算工作量 20 15
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 180 240
All 合计 1195 1385

后话

感觉这次的作业还是挺赶的,之前本来以为应该工作量不大,先写其他项目去了。然而较久没有写 C++ 给我带来许多麻烦,有一些忘记了,有一些不知道怎么用/实现。另外 Visual Studio 和其他工具的安装配置、性能测试了解和实施其实都需要挺多时间。所以本来想实现一下 GUI 的界面,看起来也只能是后话的后话了。


本文发布于 ladit.me/posts/use-dfs-and-backtracking-algorithm-to-generate-sudoku


comments powered by Disqus