15

我希望为我的数独求解器优化我的回溯算法。


它现在的作用:

递归求解器函数采用具有各种给定值的数独游戏。

我将遍历拼图中的所有空槽,寻找可能性最小的槽,并获取值列表。

从值列表中,我将通过将列表中的一个值放入槽中来循环遍历它,并递归求解它,直到填满整个网格。


对于一些谜题,这个实现仍然需要非常长的时间,我希望进一步优化它。有谁知道我如何能够进一步优化这个?


如果您有兴趣,这是我的 Java 代码。

public int[][] Solve(int[][] slots) {
    // recursive solve v2 : optimization revision

    int[] least = new int[3];
    least[2] = Integer.MAX_VALUE;
    PuzzleGenerator value_generator = new PuzzleGenerator();
    LinkedList<Integer> least_values = null;

    // 1: find a slot with the least possible solutions
    // 2: recursively solve.

    // 1 - scour through all slots.
    int i = 0;
    int j = 0;
    while (i < 9) {
        j = 0;
        while (j < 9) {
            if (slots[i][j] == 0) {
                int[] grid_posi = { i, j };
                LinkedList<Integer> possible_values = value_generator
                        .possibleValuesInGrid(grid_posi, slots);
                if ((possible_values.size() < least[2])
                        && (possible_values.size() != 0)) {
                    least[0] = i;
                    least[1] = j;
                    least[2] = possible_values.size();
                    least_values = possible_values;
                }
            }
            j++;
        }
        i++;
    }

    // 2 - work on the slot
    if (least_values != null) {
        for (int x : least_values) {
            int[][] tempslot = new int[9][9];
            ArrayDeepCopy(slots, tempslot);
            tempslot[least[0]][least[1]] = x;

            /*ConsoleInterface printer = new gameplay.ConsoleInterface();
            printer.printGrid(tempslot);*/

            int[][] possible_sltn = Solve(tempslot);
            if (noEmptySlots(possible_sltn)) {
                System.out.println("Solved");
                return possible_sltn;
            }
        }
    }
    if (this.noEmptySlots(slots)) {
        System.out.println("Solved");
        return slots;
    }
    slots[0][0] = 0;
    return slots;
}
4

9 回答 9

10

我有一个任务要做:构建 Java 中最快的数独求解器。我最终以 0.3 毫秒的时间赢得了比赛。

我没有使用舞蹈链接算法,也没有与它进行比较,但是一些参赛者肯定尝试过,但我最接近的竞争对手花了大约 15 毫秒。

我只是使用了递归回溯算法,用 4 个“规则”对其进行了扩充(这使得几乎每个谜题都不需要回溯)并保留一个位字段作为每个位置的合法值列表。

我写了一篇关于它的博客文章:http: //byteauthor.com/2010/08/sudoku-solver/

并在此处发布代码:https ://github.com/stonkie/SudokuSolverV1

于 2010-08-09T22:07:14.030 回答
7

我最近用 Python 编写了一个可以解决数独难题的程序。它基本上是一种强制搜索空间的回溯算法。我已在此线程中发布了有关实际算法的更多详细信息。

然而,在这里我想更多地关注优化过程。更准确地说,我探索了不同的方法来最小化求解时间和迭代次数。这更多是关于可以进行的算法改进,而不是编程改进。

考虑到这一点,回溯蛮力算法中没有多少可以优化的东西(很高兴在这里被证明是错误的)。可以进行的两个真正的改进是:第一,选择下一个空白单元格的方法,第二,选择下一个可能数字的方法。这两个选择可以决定是沿着一条死胡同的搜索路径还是沿着一条以解决方案结束的搜索路径。

接下来,我坐下来尝试为上述两种选择想出不同的方法。这就是我想出的。

可以通过以下方式选择下一个空白单元格:

  • A - 从左到右,从上到下的第一个单元格
  • B - 从右到左,从下到上的第一个单元格
  • C - 一个随机选择的单元格
  • D - 离网格中心最近的单元格
  • E - 当前可用选项最少的单元格(此处的选项表示从 1 到 9 的数字)
  • F - 当前拥有最多选择的单元格
  • G - 具有最少空白相关单元格的单元格(相关单元格来自同一行、同一列或同一 3x3 象限)
  • H - 具有最多空白相关单元格的单元格
  • I - 最接近所有填充单元格的单元格(从单元格中心点到单元格中心点测量)
  • J - 距离所有填充单元格最远的单元格
  • K - 相关空白单元格具有最少可用选项的单元格
  • L - 相关空白单元格具有最多可用选项的单元格

可以通过以下方式选择下一个数字:

  • 0 - 最低位
  • 1 - 最高位
  • 2 - 随机选择的数字
  • 3 - 启发式地,全面使用最少的数字
  • 4 - 试探性地,全线使用最多的数字
  • 5 - 将导致相关空白单元格具有最少可用选项的数字
  • 6 - 将导致相关空白单元格具有最多可用选择的数字
  • 7 - 相关空白单元格中最不常见的可用选择的数字
  • 8 - 相关空白单元格中最常见的可用数字
  • 9 - 最不常见的数字
  • a - 最常见的可用选择的数字

所以我把上面的方法都编到了程序中。前面的数字和字母可以作为参数传递给程序,程序会使用相应的优化方法。更重要的是,因为有时两个或更多单元格可以具有相同的分数,所以可以选择提供第二个排序参数。例如,参数“EC”意味着从所有可用选项最少的单元格中选择一个随机单元格。

第一个函数将分配乘以 1000 的权重,第二个函数将添加乘以 1 的新权重。因此,例如,如果来自第一个函数的三个单元格具有相同的权重,例如 3000、3000 3000,那么第二个函数将添加其自己的重量。例如 3111、3256、3025。排序总是会选择最低的权重。如果需要相反,则使用 -1000 和 -1 调用权重函数,但排序仍然选择最低的权重。

在继续之前,值得一提的是,程序将始终选择一个空白单元格(而不是填充单元格),并且始终选择一个在单元格当前数独限制范围内的数字(否则这样做太不合理了)。

有了上述内容,然后我决定使用所有可能的参数组合运行程序,看看会发生什么,哪些表现最好 - 基本上是蛮力蛮力:) 有 12 种单元格选择方法和 11 种数字选择方法所以理论上有17424种组合可以尝试,但是我去掉了一些不必要的(比如“AA”、“BB”等,还排除了随机方法,因为它们都非常低效),所以组合的数量最后是 12,100。每次运行都是在同一个数独谜题上完成的,这很简单:

0,3,0,0,9,0,6,1,0
6,0,8,5,0,3,4,9,7
0,9,0,6,7,0,0,0,3
0,5,0,8,0,4,0,0,1
1,6,0,3,0,0,9,8,2
0,0,2,9,6,0,3,0,0
0,8,0,1,3,0,2,0,6
3,0,5,0,4,6,0,7,9
0,4,6,0,8,0,1,0,0

...搜索空间为 36,691,771,392。这只是给定谜题的每个空白单元格的选择数量的简单乘积。这是夸大其词,因为一旦一个单元格被填满,这会减少其他单元格的选择数量,但这是我能想到的最快和最简单的分数。

我编写了一个简短的脚本(当然是在 Python 中),它可以自动化整个测试过程——它为每组参数运行求解器,记录完成时间并将所有内容转储到一个文件中。此外,我决定每次运行 20 次,因为我从 time.time() 函数中获得了 0 次单次运行。而且,如果任何组合的完成时间超过 10 秒,脚本将停止并移动到下一个。

该脚本在 13:04:31 小时内完成,在配备 Intel Core i7-4712MQ 2.30GHz 的笔记本电脑上完成,使用的内核不超过 8 个内核,平均 CPU 负载约为 12%。12,100 个组合中的 8,652 个在 10 秒内完成。

获胜者是:(*针对单次运行时间/迭代调整回来的数字)

1)最快1.55毫秒:“A0”和“A1”有84次迭代和46次回溯迭代和“B0”、“B01”、“B1”、“B10”、“BA01”、“BA1”、“BD01” , "BD1" 和 "BD10" 有 65 次迭代和 27 次回溯迭代 最快的方法是 A、B 和 D 等最简单的方法。另一种方法直到排名第 308 位才会出现,即 "E0"。

2) 最少的 38 次迭代和 0 次回溯迭代:令人惊讶的是,许多方法都能实现这一目标,最快的是“B17”、“B6”、“B7”、“BA16”、“BA60”、“BA7”、“BD17”和“BD70”,时间为 2.3 ms,最慢的是“IK91”、“JK91”、“KI91”、“KJ91”、“KJ9a”、“IK9a”、“JK9a”和“KI9a”,时间约为 107小姐。同样令人惊讶的是,方法 F 在这里有几个不错的位置,例如 7 ms 的 "FB6" (???)

总体而言,A、B、D、E、G 和 K 的表现似乎明显优于 C、F、H 和 L,而 I 和 J 介于两者之间。此外,数字的选择似乎并不重要。

最后,让我们看看这些获胜方法如何处理世界上最难的数独难题,正如本文所声称的那样http://www.telegraph.co.uk/news/science/science-news/9359579/Worlds-hardest-sudoku-can -you-crack-it.html * 请记住,算法并不是普遍快速的,也许某些算法在某些数独谜题上做得更好,但在其他谜题上则不然……谜题是:

8,0,0,0,0,0,0,0,0
0,0,3,6,0,0,0,0,0
0,7,0,0,9,0,2,0,0
0,5,0,0,0,7,0,0,0
0,0,0,0,4,5,7,0,0
0,0,0,1,0,0,0,3,0
0,0,1,0,0,0,0,6,8
0,0,8,5,0,0,0,1,0
0,9,0,0,0,0,4,0,0

...搜索空间为 95,865,912,019,648,512 x 10^20。

获胜者是“A0”,在 1092 毫秒内完成了 49,559 次迭代和 49,498 次回溯迭代。其他大多数都做得不好。“A0”、“A1”、“B0”、“B01”、“B1”、“B10”、“BA01”、“BA1”、“BD01”、“BD1”和“BD10”在大约 2500 ms 和 91k 内完成迭代,剩下的 30+ 秒,400k+ 次迭代。

但这还不够,所以我也对最难的数独的所有参数集进行了全面测试。这次做单次跑不是20次,也是2.5秒的截止时间。脚本在 8 点 23 分 30 分完成。12,100 种组合中有 149 种在 2.5 秒内完成。两个类别的获胜者分别是“E36”、“E37”、“EA36”和“EA37”,时间为 109 ms,迭代次数为 362 次,回溯迭代次数为 301 次。此外,前 38 个位置以开头的“E”为主。

总体而言,E 在图表中名列前茅,毫无疑问,仅通过查看摘要电子表格即可。A、B、I和J有几个排名,但没什么,其余的甚至没有超过2.5秒。

总之,我认为可以肯定地说,如果数独谜题很简单,那么就用最简单的算法强力破解它,但如果数独谜题很难,那么花在选择方法的开销上是值得的。

希望这可以帮助 :)

于 2018-01-13T10:51:14.290 回答
4

很长一段时间我写了一个数独求解器(几年前,但我保留了我写的所有代码)。它还没有被推广到解决比通常的数独“更大”的大小,但它非常快。

它在 103 毫秒内解决了以下问题(在 Core 2 Duo 1.86 Ghz 上)并且实际上还没有经过优化:

        {0,0,0,0,7,0,9,4,0},
        {0,7,0,0,9,0,0,0,5},
        {3,0,0,0,0,5,0,7,0},
        {0,8,7,4,0,0,1,0,0},
        {4,6,3,0,0,0,0,0,0},
        {0,0,0,0,0,7,0,8,0},
        {8,0,0,7,0,0,0,0,0},
        {7,0,0,0,0,0,0,2,8},
        {0,5,0,2,6,8,0,0,0},            

你的速度有多快,在哪个板上慢?你确定你不是不断地重新访问不应该重新访问的路径吗?

这是算法的核心:

private static void solveRec( final IPlatform p ) {
    if (p.fullBoardSolved()) {
        solved = p;
        return;
    }
    boolean newWayTaken = false;
    for (int i = 0; i < 9 && !newWayTaken; i++) {
        for (int j = 0; j < 9 && !newWayTaken; j++) {
            if (p.getByteAt(i, j) == 0) {
                newWayTaken = true;
                final Set<Byte> s = p.avail(i / 3, j /3);
                for (Iterator<Byte> it = s.iterator(); it.hasNext();) {
                    final Byte b = it.next();
                    if (!p.columnContains(j, b) && !p.lineContains(i, b)) {
                        final IPlatform ptemp = duplicateChangeOne(p, b, i, j);
                        solveRec(ptemp);
                        if (solved != null) {
                            return;
                        }
                    }
                }
            }
        }
    }
}

还有 IPlatform 抽象(请注意,它是很多年前写的,在我知道在 Java 中在接口名称之前添加“I”并不是很流行之前):

public interface IPlatform {

    byte getByteAt(int i, int j);

    boolean lineContains(int line, int value);

    boolean columnContains(int column, int value);

    Set<Byte> avail(int i, int j);

    boolean fullBoardSolved();

}
于 2010-02-01T23:27:34.640 回答
3

我认为一个很大的优化不仅是保持棋盘的状态,而且如果它包含每个数字 1-9,那么对于每一行/列/正方形。现在要检查一个位置是否可以有一个数字,您只需检查该位置所在的行/列/正方形是否不包含该数字(这只是 3 个数组查找)。

此外,还必须为每个递归调用创建一个新数组,从而造成很大的速度损失。不要这样做,而是在递归调用之前对数组进行更改,然后在递归调用之后撤消它。基本上添加了 Solve 将在运行时更改槽的不变量,但当它返回时,它将保持调用函数时的状态。

此外,每次解决返回时,您都必须检查板是否已解决。如果solve没有找到解决方案它应该只返回null,如果它找到一个解决方案它应该返回它。这样,您可以快速测试您的递归调用是否找到了解决方案。

在选项最少的方框中放置一个数字真的有帮助吗?没有它,代码会简单得多(您不必将内容保存在链接列表等中)

这是我的伪代码:

for(square on the board)
      for(possible value)
           if(this square can hold this value){
                place value on the board
                update that this row/col/square now contains this value

                recursive call
                if recursive call succeeded return the value from that call

                update that this row/col/square does not contain this value
                undo placing value on board
           }
if (no empty squares)
    return solved

这是我的代码(我没有测试过):

public int[][] solve(int[][] board, boolean[][] row, boolean[][] col, boolean[][] square){
    boolean noEmpty = true;
    for(int i = 0; i < 9;i++){
        for(int j = 0; j < 9;j++){
            if(board[i][j] == 0){
                noEmpty = false;
                for(int v = 1; v <= 9; v++){
                    int sq = (i/3)*3+(j/3);
                    if(row[i][v-1] == false && col[j][v-1] == false && square[sq][v-1] == false){
                        board[i][j] = v;
                        row[i][v-1] = true;
                        col[j][v-1] = true;
                        square[sq][v-1] = true;
                        int[][] ans = solve(board,row,col,square);
                        if(ans != null)
                            return ans;
                        square[sq][v-1] = false;
                        col[j][v-1] = false;
                        row[i][v-1] = false;
                        board[i][j] = 9;
                    }
                }
            }
        }
    }
    if(noEmpty){
        int[][] ans = new int[9][9];
        for(int i = 0; i < 9;i++)
            for(int j = 0; j < 9;j++)
                ans[i][j] = board[i][j];
        return ans;
    }else{
        return null;
    }       
}
于 2009-10-07T16:28:46.057 回答
3

不久前,我在 Ruby 中实现了 Donald Knuth 的 Dancing Links 和他的数独算法 X(一种效率不高的语言)。对于我检查的几个示例,在我的 1.5 GHz 笔记本电脑上花费了几毫秒。

您可以查看维基百科 Dancing Links 的工作原理,并自行将其改编为数独。或者您可以查看“A Sudoku Solver in Java implementation Knuth's Dancing Links Algorithm”

PS:算法X是回溯算法。

于 2009-10-07T11:53:51.947 回答
2

在每个非确定性步骤之前进行一些约束传播。

在实践中,这意味着您有一些规则可以检测强制值并插入它们,并且只有当这不再取得进展时,您才通过可能的值进行回溯搜索。

大多数人类数独谜题的设计使它们根本不需要回溯。

于 2009-10-05T10:45:43.813 回答
1

找到具有最少可能解决方案的插槽非常昂贵,对于传统的数独谜题可能不值得开销。

一个更简单的优化是跟踪每个数字有多少被使用,当你“尝试”将一个数字放在一个插槽中时,从使用最少的那个开始(编辑:确保包括那些拼图被播种)。这将使您的算法更有可能从一条成功的路径开始,而不是一条失败的路径。

另外,请查看Imsasu 建议的人工智能:一种现代方法。这是一本很棒的书,详细介绍了递归回溯。

PS 我很好奇您的“第 1 步”优化所带来的性能提升(如果有的话)。你有图吗?

于 2010-01-21T18:45:06.443 回答
1

我对数独回溯算法的优化结果如下。您可以从http://yikes.com/~bear/suds.c下载代码。这纯粹基于鸽子洞原理,我发现它通常比基于规则的求解更快。

使用该线程上另一篇文章中的值,我在 core2 duo @2.2 ghz 上得到 7ms 或在 core i5 上得到 3ms 的结果。这与海报的 100 毫秒结果相比,尽管可能以不同的方式测量。在http://yikes.com/~bear/suds2.c中添加了时间。

这是我 10 年前写的,如果我重新解决这个问题,肯定会以不同的方式进行优化。

$ ./a.out 000070940070090005300005070087400100463000000000007080800700000700000028050268000
[----------------------- Input  Data ------------------------]

*,*,*   *,7,*   9,4,*   
*,7,*   *,9,*   *,*,5   
3,*,*   *,*,5   *,7,*   

*,8,7   4,*,*   1,*,*   
4,6,3   *,*,*   *,*,*   
*,*,*   *,*,7   *,8,*   

8,*,*   7,*,*   *,*,*   
7,*,*   *,*,*   *,2,8   
*,5,*   2,6,8   *,*,*   

[------------------ Solution 01 -------------------]

2,1,5   8,7,6   9,4,3   
6,7,8   3,9,4   2,1,5   
3,4,9   1,2,5   8,7,6   

5,8,7   4,3,2   1,6,9   
4,6,3   9,8,1   7,5,2   
1,9,2   6,5,7   3,8,4   

8,2,6   7,4,3   5,9,1   
7,3,4   5,1,9   6,2,8   
9,5,1   2,6,8   4,3,7   

Time: 0.003s Cyles: 8619081 
于 2014-07-02T00:21:47.100 回答
0

您可能应该使用分析器来查看哪个语句花费的时间最多,然后考虑如何优化它。

在不使用分析器的情况下,我的建议是您每次都从头开始创建一个新的 PuzzleGenerator,并将插槽作为参数传递给 possibleValuesInGrid 方法。我认为这意味着 PuzzleGenerator 每次都从头开始重新计算每个位置和每个插槽配置的所有内容;相反,如果它记住以前的结果并逐步改变,它可能会更有效。

于 2009-10-05T05:23:18.643 回答