TL;博士
是的,基于回溯的数独求解器可以根据谜题从并行化中受益匪浅!拼图的搜索空间可以建模为树数据结构,并且回溯执行此树的深度优先搜索 (DFS),这本质上是不可并行的。但是,通过将 DFS 与其相反形式的树遍历、广度优先搜索 (BFS)相结合,可以解锁并行性。这是因为 BFS 允许同时发现多个独立的子树,然后可以并行搜索。
因为 BFS 解锁了并行性,所以使用它保证使用全局线程安全队列,所有线程都可以将发现的子树推入/弹出该队列,与 DFS 相比,这需要显着的性能开销。因此,并行化这样的求解器需要微调执行的 BFS 的数量,以便执行足以确保树的遍历被充分并行化,但又不能过多地导致线程通信的开销(推送/弹出子树队列)超过了并行化提供的加速。
不久前,我并行化了一个基于回溯的数独求解器,并实现了 4 个不同的并行求解器变体以及一个顺序(单线程)求解器。并行变体都以不同方式和不同程度地结合了 DFS 和 BFS,最快的变体平均是单线程求解器的三倍以上(见底部图表)。
此外,为了回答您的问题,在我的实现中,每个线程都会收到初始拼图的副本(当线程产生时),因此所需的内存略高于顺序求解器 - 这在并行化某些东西时并不少见。但这就是您所说的唯一“低效率”:如上所述,如果 BFS 的数量经过适当微调,则通过并行化可实现的加速大大超过线程通信的并行开销以及更高的内存占用。此外,虽然我的求解器假设了独特的解决方案,但将它们扩展为处理不正确的谜题并找到所有解决方案将很简单,并且由于求解器设计的性质,不会显着降低加速比(如果有的话)。
完整答案
数独求解器是否受益于多线程很大程度上取决于其底层算法。常见的方法,如约束传播(即基于规则的方法),其中难题被建模为约束满足问题或随机搜索,并没有真正受益,因为使用这些方法的单线程求解器已经非常快。然而,回溯在大多数情况下会受益匪浅(取决于谜题)。
您可能已经知道,数独的搜索空间可以建模为树数据结构:三级中的第一级表示第一个空单元格,第二级表示第二个空单元格,依此类推。在每个级别,节点代表给定其祖先节点值的该单元格的潜在值。因此搜索这个空间可以通过同时搜索独立的子树来并行化。单线程回溯求解器必须自己遍历整个树,一个接一个的子树,但并行求解器可以让每个线程并行搜索单独的子树。
有多种方法可以实现这一点,但它们都是基于深度优先搜索 (DFS) 和广度优先搜索 (BFS) 相结合的原理,这是树遍历的两种(相反)形式。单线程回溯求解器仅对整个搜索进行 DFS,这本质上是不可并行的。但是,通过将 BFS 添加到混合中,可以解锁并行性。这是因为 BFS 逐级遍历树(与 DFS 的逐个分支相反),从而在移动到下一个较低级别之前找到给定级别上的所有可能节点,而 DFS 获取第一个可能的节点并在移动到下一个可能的节点之前完全搜索其子树。因此,BFS 可以立即发现多个独立的子树,然后可以通过单独的线程进行搜索;DFS 没有
与多线程的情况一样,并行化代码很棘手,如果您不确切知道自己在做什么,最初的尝试通常会降低性能。在这种情况下,重要的是要意识到 BFS 比 DFS 慢得多,因此主要关注的是调整您执行的 DFS 和 BFS 的数量,以便执行足够的 BFS 以解锁发现多个子树的能力同时,还要最小化它,因此它的缓慢最终不会超过这种能力。请注意,BFS 本质上并不比 DFS 慢,只是线程需要访问发现的子树,以便它们可以搜索它们。因此,BFS 需要一个全局线程安全的数据结构(例如队列),子树可以由单独的线程推入/弹出该数据结构,与不需要线程之间任何通信的 DFS 相比,这需要显着的开销。因此,并行化这样的求解器是一个微调过程,并且希望执行足够的 BFS 来为所有线程提供足够的子树进行搜索(即在所有线程之间实现良好的负载平衡),同时最小化线程之间的通信(推/将子树弹出到队列中/从队列中弹出)。
不久前,我并行化了一个基于回溯的数独求解器,并实现了 4 个不同的并行求解器变体,并将它们与我也实现的顺序(单线程)求解器进行了基准测试。它们都是用 C++ 实现的。最佳(最快)并行变体的算法如下:
- 从拼图树的 DFS 开始,但只能达到一定的水平,或搜索深度
- 在搜索深度,执行 BFS 并将在该级别发现的所有子树推送到全局队列
- 然后线程将这些子树从队列中弹出并对其执行 DFS,一直到最后一层(最后一个空单元格)。
下图(取自我当时的报告)说明了这些步骤: 不同颜色的三角形代表不同的线程和它们遍历的子树。绿色节点表示该级别上允许的单元格值。注意在搜索深度执行的单个 BFS;在这一层发现的子树(黄色、紫色和红色)被推入全局队列,然后并行独立地遍历到树的最后一层(最后一个空单元格)。
如您所见,此实现仅执行一个级别(在搜索深度)的 BFS。这个搜索深度是可调的,优化它代表了前面提到的微调过程。搜索深度越深,执行的 BFS 就越多,因为树的宽度(给定级别上的 # 个节点)自然会增加您越往下走。有趣的是,最佳搜索深度通常处于相当浅的级别(即树中不是很深的级别);这表明即使执行少量的 BFS 也足以生成充足的子树并在所有线程之间提供良好的负载平衡。
此外,由于全局队列,可以选择任意数量的线程。将线程数设置为等于硬件线程数(即 # 逻辑核心)通常是一个好主意;选择更通常不会进一步提高性能。此外,还可以通过执行树的第一级(第一个空单元格)的 BFS 来并行化在开始时执行的初始 DFS:然后并行遍历在级别 1 发现的子树,每个线程在给定搜索深度。这就是上图中所做的。尽管如上所述,最佳搜索深度通常很浅,但这并不是绝对必要的,因此即使是单线程的,下降到搜索深度的 DFS 仍然非常快。
I thoroughly tested all solvers on 14 different Sudoku puzzles (specifically, a select set of puzzles specially designed to be difficult for a backtracking solver), and the figure below shows each solver's average time taken to solve all puzzles, for various thread counts (my laptop has four hardware threads). Parallel variant 2 isn't shown because it actually achieved significantly worse performance than the sequential solver. With parallel variant 1, the # threads was automatically determined during run-time, and depended on the puzzle (specifically, the branching factor of the first level); Hence the blue line represents its average total solving time regardless of the thread count.
所有并行求解器变体都以不同方式在不同程度上结合了 DFS 和 BFS。当使用 4 个线程时,最快的并行求解器(变体 4)平均比单线程求解器快三倍!