优化回溯算法求解数独
Posted
技术标签:
【中文标题】优化回溯算法求解数独【英文标题】:Optimizing the backtracking algorithm solving Sudoku 【发布时间】:2010-12-03 20:26:12 【问题描述】:我希望为我的数独求解器优化我的回溯算法。
现在做了什么:
递归求解器函数采用具有各种给定值的数独游戏。
我将遍历拼图中的所有空槽,寻找可能性最小的槽,并获取值列表。
从值列表中,我将通过将列表中的一个值放入槽中来循环遍历它,并递归求解,直到填满整个网格。
对于一些谜题,这个实现仍然需要非常长的时间,我希望进一步优化它。有谁知道我可以如何进一步优化它?
如果您有兴趣,这是我的 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;
【问题讨论】:
另见:***.com/questions/1518335 作为风格的评论,为什么要使用'i = 0;而 (i 只是出于兴趣,“难以置信的长”有多长?程序运行其他谜题的速度有多快? 【参考方案1】:我有一个任务要做:构建 Java 中最快的数独求解器。我最终以 0.3 毫秒的时间赢得了比赛。
我没有使用舞蹈链接算法,也没有与它进行比较,但肯定有一些参赛者尝试过,但我最接近的竞争对手花了大约 15 毫秒。
我只是使用递归回溯算法,用 4 个“规则”对其进行扩充(这使得几乎每个谜题都不需要回溯),并保留一个位字段作为每个位置的合法值列表。
我写了一篇关于它的博客文章:http://byteauthor.com/2010/08/sudoku-solver/
并在此处发布代码:https://github.com/stonkie/SudokuSolverV1
【讨论】:
你的第四条规则非常简单和合乎逻辑,让我想起了记忆技术。整洁的!也许还有更多这样的规则来防止不必要的回溯。 有!我在这里写了一些更新规则:byteauthor.com/2010/08/sudoku-solver-update我不敢相信那是 8 年前! 嘿,我的链接失效了,请问您可以检查一下吗? 我必须重建博客(在我非常稀缺的空闲时间)。同时,我从旧博客中放了一个 web.archive.org 链接。【参考方案2】:我最近用 Python 编写了一个可以解决数独难题的程序。它基本上是一种强制搜索空间的回溯算法。我已经发布了更多关于实际算法的细节in this thread。
但是,在这里我想更多地关注优化过程。更准确地说,我探索了不同的方法来最小化求解时间和迭代次数。这更多是关于可以进行的算法改进,而不是编程改进。
考虑到这一点,回溯蛮力算法中没有多少可以优化的东西(很高兴在这里被证明是错误的)。可以进行的两个真正的改进是:第一,选择下一个空白单元格的方法,第二,选择下一个可能数字的方法。这两个选择可以决定是沿着一条死胡同的搜索路径还是沿着一条以解决方案结束的搜索路径。
接下来,我坐下来尝试为上述两种选择想出不同的方法。这是我想出的。
可以通过以下方式选择下一个空白单元格:
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 种方法对于数字选择,理论上有 17,424 种组合可以尝试,但我删除了一些不必要的组合(例如“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 个内核中的 2 个,平均 CPU 负载约为 12%。 12,100 种组合中的 8,652 种在 10 秒内完成。
获胜者是:(*针对单次运行时间/迭代调整回来的数字)
1) 最快 1.55 毫秒: 84 次迭代和 46 次回溯迭代的“A0”和“A1” 和“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秒。
总之,我认为可以肯定地说,如果数独谜题很简单,那么就用最简单的算法强力破解它,但如果数独谜题很难,那么花费选择的开销是值得的方法。
希望这会有所帮助:)
【讨论】:
【参考方案3】:很长一段时间我都在写一个数独求解器(几年前,但我保留了我写的所有代码)。它还没有被推广到解决比通常的数独“更大”的大小,但它非常快。
它在 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();
【讨论】:
【参考方案4】:不久前,我在 Ruby(一种效率不高的语言)中实现了 Donald Knuth 的 Dancing Links 和他的数独算法 X。对于我检查的几个示例,在我的 1.5 GHz 笔记本电脑上花费了几毫秒。
您可以查看*** Dancing Links 的工作原理,并自行将其改编为数独。或者你看看"A Sudoku Solver in Java implementing Knuth’s Dancing Links Algorithm"。
PS:算法 X 是一种回溯算法。
【讨论】:
【参考方案5】:我认为一个很大的优化不仅是保持棋盘的状态,而且如果它包含数字 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;
【讨论】:
【参考方案6】:在每个不确定的步骤之前进行一些约束传播。
实际上,这意味着您有一些规则可以检测强制值并插入它们,并且只有当这不再取得进展时,您才诉诸回溯搜索可能的值。
大多数人类数独游戏的设计目的是让它们根本不需要回溯。
【讨论】:
在人工智能中 - 一种现代方法 (aima.cs.berkeley.edu),Contraint Satisfaction Problems 一章向您展示了一些有效的回溯技术。【参考方案7】:找到具有最少可能解决方案的插槽非常昂贵,对于传统的数独游戏来说可能不值得开销。
一个更简单的优化是跟踪每个数字有多少已被使用,当您“尝试”将一个数字放入插槽时,从使用最少的那个开始(编辑:确保包括拼图种子的那些)。这将使您的算法更有可能走上成功的道路,而不是失败的道路。
另外,请按照 Imsasu 的建议查看 Artificial Intelligence: A Modern Approach。这是一本很棒的书,详细介绍了递归回溯。
附:我很好奇您的“第 1 步”优化所带来的性能提升(如果有的话)。有图吗?
【讨论】:
【参考方案8】:我对数独回溯算法的优化结果如下。您可以从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
【讨论】:
我的需要大约 10 分钟才能找到解决方案。我每次都使用回溯(dfs)并填充所有81个单元格,检查它是否有效。我似乎没有在代码中得到你的“鸽子洞原理”。能否请您详细说明。谢谢。 @Fawad:大多数情况下,代码会尽可能快地查看数独,试图找到一个未知数(即鸽子洞)。加速是通过快速执行该操作(即基于位的算术),然后使用内存密集方式将数独存储到堆栈(最小化内存复制操作)。祝您优化成功!【参考方案9】:您可能应该使用分析器来查看哪个语句花费的时间最多,然后考虑如何优化它。
如果不使用分析器,我的建议是您每次都从头开始创建一个新的 PuzzleGenerator,并将槽作为参数传递给 possibleValuesInGrid 方法。我认为这意味着 PuzzleGenerator 每次都从头开始重新计算每个位置和每个插槽配置的所有内容;相反,如果它记住以前的结果并逐步改变,它可能会更有效。
【讨论】:
它因拼图而异。缓慢的是选择正确的插槽开始。现在我用的是可能性最小的槽,从左右、上到下的横向有一些改进,但还是不理想。 我猜这是可能的ValuesInGrid 方法很昂贵:它探测与传入位置相同的行和列上的16个单元格中的每一个:并且如果程序可能会更快这只是一个查找。 possibleValuesInGrid 方法以恒定的时间(几乎)运行,确实是值的暴力递归尝试使得它运行得非常长。感谢您的输入 :) 是的,它是恒定的,我只是猜测它可能快 16 倍。以上是关于优化回溯算法求解数独的主要内容,如果未能解决你的问题,请参考以下文章