[软件工程基础]个人项目 数独

项目地址

https://github.com/Leext/SudokuSolver

解题思路

程序的需求有两个:

  1. 生成给定数量的数独终局
  2. 求解给定的数独

对于第一个需求,我直接想到的就是,随机生成一些初始局,再求解不就是数独终局了吗。并且这种方法可以生成任意对初始局有限制的数独。所以核心的问题就转化为求解。

关于求解的思路很容易想到,就是回溯搜索+优化剪枝。

如果是从左往右、从上到下依次搜索每个格子,这样会使需要求解的数量变得比较大。数独每个格子的数字是被行、列、格所限制的,人在玩的时候,总是会根据其他数字的限制先填一个格子。由此可以得到一个剪枝的思路,每当要决定搜索哪个格子的时候,选择可行解最少的。直觉上来说,搜索时每一步的可行解都是最少的,每一次尝试之后,试填的格子周围的可行解数量也都会减少,使得后面的可行解数量也变小。这样就加快了搜索的速度。

对于规定生成的终局左上角为特定数字,由于是求解生成,所以只要初始局满足要求,那生成的所有终局都是满足要求的。

实现过程

实现

代码的整个 设计如下:

SudokuBoard 类:封装了数独棋盘,方法包括:棋盘构建,寻找可行解,计算可行解数量,寻找最小可行解格子

SudokuSolver 类:求解器类,方法包括:验证棋盘,深度优先搜索,生成棋盘,文件读取,求解数独

核心的算法是搜索:

  1. 计算所有格子的可行解数量
  2. 如果没有可求解的,则回溯;如果获得一个解,则保存起来,达到一定数量后退出
  3. 寻找可行解最少的格子作为待解格子
  4. 获取该格子的所有可行解
  5. 对于格子的每一个可行解,设置棋盘为该可行解
  6. 递归搜索(回到1)

对于题目中要求的左上角数字,

单元测试

测试代码如下:

void test()
{
    SudokuBoard board = SudokuBoard(std::string("012345678000000000000000000000000000000000000000000000000000000000000000000000000"));
    assert((1 << 8) == (board.getFeasible(0, 0) >> 1));
    assert(1 == board.countFeasible(0, 0));
    board = SudokuBoard(std::string("012345678900000000000000000000000000000000000000000000000000000000000000000000000"));
    assert(0 == board.countFeasible(0, 0));
    board = SudokuBoard(std::string("012300000400000000507000000000000000000000000600000000000000000600000000000000000"));
    assert(2 == board.countFeasible(0, 0));
    auto p = board.findFewest();
    assert(0 == p.first && 0 == p.second);
    board = SudokuBoard(std::string("000000010400000000020000000000050407008000300001090000300400200050100000000806000"));
    SudokuSolver solver;
    SudokuBoard *b = solver.solve(board);
    assert(solver.check(*b));

    board = SudokuBoard(std::string("000000010400000000020000000000050604008000300001090000300400200050100000000807000"));
    b = solver.solve(board);
    assert(solver.check(*b));

    board = SudokuBoard(std::string("000000012003600000000007000410020000000500300700000600280000040000300500000000000"));
    b = solver.solve(board);
    assert(solver.check(*b));

    board = SudokuBoard(std::string("000000012008030000000000040120500000000004700060000000507000300000620000000100000"));
    b = solver.solve(board);
    assert(solver.check(*b));

    board = SudokuBoard(std::string("000000012040050000000009000070600400000100000000000050000087500601000300200000000"));
    b = solver.solve(board);
    assert(solver.check(*b));

    board = SudokuBoard(std::string("000000012050400000000000030700600400001000000000080000920000800000510700000003000"));
    b = solver.solve(board);
    assert(solver.check(*b));

    board = SudokuBoard(std::string("000000013000030080070000200000206000030000900000010000600500204000400700100000000"));
    b = solver.solve(board);
    assert(b == NULL);

    solver.generate(board);
    solver.generateN(3, board);
    copeSolve("a.txt");
    copeGenerate("10");
}

测试包括几个构造好的样例来测试可行解有关的函数,然后测试代码是否能正确解题,最后是测试处理命令行时调用函数的正确性。

代码也都全部覆盖(未覆盖的是对于文件读入的异常提示,因为这次作业不会出现这种情况,就没有测试)

性能改进

在初步完成代码以后,我进行了性能分析。以下是生成100万个数独终局时,程序所用的时间分布。由于我的算法生成和求解是等价的,所以生成时的性能可以体现求解的性能。

从中可以发现getBanArray这个函数耗费的时间比较多。

bool *SudokuBoard::getBanArray(int x, int y)
{
    bool *banArray = new bool[10];
    for (int i = 0; i < 10; i++)
        banArray[i] = false;
    for (int i = 0; i < 9; i++)
        banArray[_board[i][y]] = true;
    for (int j = 0; j < 9; j++)
        banArray[_board[x][j]] = true;
    int start_x = x / 3 * 3;
    int start_y = y / 3 * 3;
    for (int i = start_x; i < start_x + 3; i++)
        for (int j = start_y; j < start_y + 3; j++)
            banArray[_board[i][j]] = true;
    return banArray;
}

std::vector<int>& SudokuBoard::getSolveVector(int x, int y)
{
    bool *banArray = getBanArray(x, y);
    std::vector<int>* rtn = new std::vector<int>;
    for (int i = 1; i < 10; i++)
        if (!banArray[i])
            rtn->push_back(i);
    delete banArray;
    return *rtn;
}

这个函数是我用来获取某个格子的可行解情况的,它使用布尔数组来储存。在另一个函数中还要利用这个布尔数组生成可行解的vector。这个过程非常繁琐。由于我的算法需要大量调用这个函数,所以非常耗时。我改进了这个过程,使用一个int来表示可行解。用int的低位来表示某个数字是否可行。

改进之后这个过程的代码:

int SudokuBoard::getFeasible(int x, int y)
{
    int bit = 0;
    const int complete = 0x3fe;
    for (int i = 0; i < 9; i++)
        bit |= 1 << _board[i][y];
    for (int j = 0; j < 9; j++)
        bit |= 1 << _board[x][j];
    int start_x = x / 3 * 3;
    int start_y = y / 3 * 3;
    for (int i = start_x; i < start_x + 3; i++)
        for (int j = start_y; j < start_y + 3; j++)
            bit |= 1 << _board[i][j];
    return bit^complete;
}
int SudokuBoard::countFeasible(int x, int y)
{
    // _board[x][y] must be 0
    int bit = getFeasible(x, y) >> 1;
    int count = 0;
    while (bit)
    {
        bit &= (bit - 1);
        count++;
    }
    return count;
}

改进之后的性能分析:

可以看到花费的时间从21.6s减少到了7.6秒,性能提升了60%以上。

进一步可以看到fprintf,即写文件的函数,占用了五分之一的时间。因此我尝试另外开一个线程来完成写入文件,但是不知道是不是我自己实现的问题,这个改进并没有加快速度。

代码说明

关键的代码是搜索求解的函数:

bool SudokuSolver::dfs(SudokuBoard& board)
{
    std::pair<int, int>& target = board.findFewest();
    if (target.first == -1) // end
    {
        _solveCount++;
        solutions->push_back(board.toString());
        return _solveCount >= _solveLimit;
    }
    if (target.second == -1) // no solution
        return false;
    int feasible = board.getFeasible(target.first, target.second);
    for (int i = 1; i <= 10; i++)
    {
        if ((feasible >> i) & 1)
        {
            board.set(target, i);
            if (dfs(board))
                return true;
        }
    }
    board.set(target, 0);
    return false;
}

首先获得可行解最少的格子,findFewest 的结果可以作为是否找到解和解不存在的标识。继续搜索则获取该格子的可行解。for循环遍历每个可行解。for循环结束后,把当前格子置为0。

PSP

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

感想

这一次作业我一开始是当成一个OO作业来做的,因为和上学期的OO课作业差不多。但是这一次我开始审视自己的编码过程。

首先对于项目花费时间的估计,我就和实际的有很大偏差。能想到的原因有几个。

一个是我对于C++不太熟悉,因为之前一直写的Java,没有指针的困扰,写着也比较方便。这次写C++踩了一些坑,查了很多资料。

还有就是我在还没有仔细想好组织的时候就开始写了,边写代码边思考架构比较耗时间,因为经常会陷入到C++实现的细节里面去,同时思考不同层次的问题会比较消耗认知资源。。。

最后就是自己的时间管理还是有些问题,有时不太专注。

一个小项目还是能发现自己很多问题的,这大概就是自己选这课的目的吧,走出舒适区暴露自己的问题。

posted on 2017-09-26 17:29 Damocles 阅读(...) 评论(...) 编辑 收藏

导航