繁体   English   中英

优化的CLP(FD)求解器,可解决数字拼图难题

[英]Optimized CLP(FD) solver for number board puzzle

考虑来自https://puzzling.stackexchange.com/questions/20238/explore-the-square-with-100-hops的问题:

给定一个10x10正方形的网格,您的任务是恰好访问每个正方形一次。 在每一步中,您都可以

  • 水平或垂直跳过2个正方形,或
  • 对角跳1平方

换句话说(更接近我的下方实作),将10x10的网格标记为1到100之间的数字,以使坐标(X, Y)上的每个正方形等于1或等于(X, Y-3)(X, Y+3)(X-3, Y)(X+3, Y)(X-2, Y-2)(X-2, Y+2)(X+2, Y-2)(X+2, Y+2)

这看起来像一个简单的约束编程问题,Z3可以在30秒内通过一个简单的声明性规范解决它: https : //twitter.com/johnregehr/status/1070674916603822081

我在SWI-Prolog中使用CLP(FD)的实现扩展得不太好。 实际上,除非预先指定了几乎两行,否则它甚至无法解决问题的5x5实例:

?- number_puzzle_(_Square, Vars), Vars = [1,24,14,2,25, 16,21,5,8,20 |_], time(once(labeling([], Vars))).
% 10,063,059 inferences, 1.420 CPU in 1.420 seconds (100% CPU, 7087044 Lips)
_Square = square(row(1, 24, 14, 2, 25), row(16, 21, 5, 8, 20), row(13, 10, 18, 23, 11), row(4, 7, 15, 3, 6), row(17, 22, 12, 9, 19)),
Vars = [1, 24, 14, 2, 25, 16, 21, 5, 8|...].

?- number_puzzle_(_Square, Vars), Vars = [1,24,14,2,25, 16,21,5,8,_ |_], time(once(labeling([], Vars))).
% 170,179,147 inferences, 24.152 CPU in 24.153 seconds (100% CPU, 7046177 Lips)
_Square = square(row(1, 24, 14, 2, 25), row(16, 21, 5, 8, 20), row(13, 10, 18, 23, 11), row(4, 7, 15, 3, 6), row(17, 22, 12, 9, 19)),
Vars = [1, 24, 14, 2, 25, 16, 21, 5, 8|...].

?- number_puzzle_(_Square, Vars), Vars = [1,24,14,2,25, 16,21,5,_,_ |_], time(once(labeling([], Vars))).
% 385,799,962 inferences, 54.939 CPU in 54.940 seconds (100% CPU, 7022377 Lips)
_Square = square(row(1, 24, 14, 2, 25), row(16, 21, 5, 8, 20), row(13, 10, 18, 23, 11), row(4, 7, 15, 3, 6), row(17, 22, 12, 9, 19)),
Vars = [1, 24, 14, 2, 25, 16, 21, 5, 8|...].

(这是在具有SWI-Prolog 6.0.0的旧机器上。在具有SWI-Prolog 7.2.3的较新机器上,它的运行速度大约是它的两倍,但这还不足以克服明显的指数复杂性。)

此处使用的部分解决方案来自https://www.nurkiewicz.com/2018/09/brute-forcing-seemingly-simple-number.html

所以,我的问题是: 如何加快以下CLP(FD)程序的速度?

还要特别感谢的另一个问题: 是否有一个特定的标记参数可以显着加快搜索速度,如果可以,那么我如何做出有根据的猜测呢?

:- use_module(library(clpfd)).

% width of the square board
n(5).


% set up a term square(row(...), ..., row(...))
square(Square, N) :-
    length(Rows, N),
    maplist(row(N), Rows),
    Square =.. [square | Rows].

row(N, Row) :-
    functor(Row, row, N).


% Entry is the entry at 1-based coordinates (X, Y) on the board. Fails if X
% or Y is an invalid coordinate.
square_coords_entry(Square, (X, Y), Entry) :-
    n(N),
    0 < Y, Y =< N,
    arg(Y, Square, Row),
    0 < X, X =< N,
    arg(X, Row, Entry).


% Constraint is a CLP(FD) constraint term relating variable Var and the
% previous variable at coordinates (X, Y). X and Y may be arithmetic
% expressions. If X or Y is an invalid coordinate, this predicate succeeds
% with a trivially false Constraint.
square_var_coords_constraint(Square, Var, (X, Y), Constraint) :-
    XValue is X,
    YValue is Y,
    (   square_coords_entry(Square, (XValue, YValue), PrevVar)
    ->  Constraint = (Var #= PrevVar + 1)
    ;   Constraint = (0 #= 1) ).


% Compute and post constraints for variable Var at coordinates (X, Y) on the
% board. The computed constraint expresses that Var is 1, or it is one more
% than a variable located three steps in one of the cardinal directions or
% two steps along a diagonal.
constrain_entry(Var, Square, X, Y) :-
    square_var_coords_constraint(Square, Var, (X - 3, Y), C1),
    square_var_coords_constraint(Square, Var, (X + 3, Y), C2),
    square_var_coords_constraint(Square, Var, (X, Y - 3), C3),
    square_var_coords_constraint(Square, Var, (X, Y + 3), C4),
    square_var_coords_constraint(Square, Var, (X - 2, Y - 2), C5),
    square_var_coords_constraint(Square, Var, (X + 2, Y - 2), C6),
    square_var_coords_constraint(Square, Var, (X - 2, Y + 2), C7),
    square_var_coords_constraint(Square, Var, (X + 2, Y + 2), C8),
    Var #= 1 #\/ C1 #\/ C2 #\/ C3 #\/ C4 #\/ C5 #\/ C6 #\/ C7 #\/ C8.


% Compute and post constraints for the entire board.
constrain_square(Square) :-
    n(N),
    findall(I, between(1, N, I), RowIndices),
    maplist(constrain_row(Square), RowIndices).

constrain_row(Square, Y) :-
    arg(Y, Square, Row),
    Row =.. [row | Entries],
    constrain_entries(Entries, Square, 1, Y).

constrain_entries([], _Square, _X, _Y).
constrain_entries([E|Es], Square, X, Y) :-
    constrain_entry(E, Square, X, Y),
    X1 is X + 1,
    constrain_entries(Es, Square, X1, Y).


% The core relation: Square is a puzzle board, Vars a list of all the
% entries on the board in row-major order.
number_puzzle_(Square, Vars) :-
    n(N),
    square(Square, N),
    constrain_square(Square),
    term_variables(Square, Vars),
    Limit is N * N,
    Vars ins 1..Limit,
    all_different(Vars).

首先:

这里发生了什么?

要查看正在发生的事情,以下是PostScript定义,这些定义使我们可以可视化搜索:

/n 5 def

340 n div dup scale
-0.9 0.1 translate % leave room for line strokes

/Palatino-Roman 0.8 selectfont

/coords { n exch sub translate } bind def

/num { 3 1 roll gsave coords 0.5 0.2 translate
    5 string cvs dup stringwidth pop -2 div 0 moveto show
    grestore } bind def

/clr { gsave coords 1 setgray 0 0 1 1 4 copy rectfill
     0 setgray 0.02 setlinewidth rectstroke grestore} bind def

1 1 n { 1 1 n { 1 index clr } for pop } for

这些定义为您提供了两个过程:

  • clr清除方
  • num以在正方形上显示数字。

例如,如果将这些定义保存到tour.ps ,然后使用以下命令调用PostScript解释器Ghostscript

gs -r72 -g350x350 tour.ps

然后输入以下说明:

1 2 3 num
1 2 clr
2 3 4 num

你得到:

PostScript示例说明

PostScript是一种出色的编程语言,可用于可视化搜索过程,我还建议您查看以获取更多信息。

我们可以轻松地修改您的程序,以发出合适的PostScript指令,让我们直接观察搜索。 我重点介绍了相关补充:

constrain_entries([], _Square, _X, _Y).
constrain_entries([E|Es], Square, X, Y) :-
    freeze(E, postscript(X, Y, E)),
    constrain_entry(E, Square, X, Y),
    X1 #= X + 1,
    constrain_entries(Es, Square, X1, Y).

postscript(X, Y, N) :- format("~w ~w ~w num\n", [X,Y,N]).
postscript(X, Y, _) :- format("~w ~w clr\n", [X,Y]), false.

我还自由地将(is)/2更改为(#=)/2以使程序更通用。

假设将PostScript定义保存在tour.ps ,将Prolog程序保存在tour.pl ,则以下SWI-Prolog和Ghostscript调用说明了这种情况:

swipl -g "number_puzzle_(_, Vs), label(Vs)" tour.pl | gs -g350x350 -r72 tour.ps -dNOPROMPT

例如,我们在突出显示的位置看到很多回溯:

脱粒图

但是,基本问题已经完全存在于其他地方:

抛出原因

突出显示的方块都不是有效的移动!

由此可见,您的当前公式并没有(至少还不是很早)让求解器识别出何时无法完成对解决方案的部分分配! 这是个坏消息 ,因为无法识别不一致的作业通常会导致无法接受的性能。 例如,为了纠正1→3过渡(这种过渡永远不会发生,但在这种情况下已经是首选选择之一),求解器在枚举后必须回溯大约8个正方形。一个非常粗略的估计-25 8 = 152587890625部分解决方案,然后仅从电路板的第二个位置重新开始。

在约束性文献中,这种回溯称为“ 抖动” 这意味着由于相同原因而反复失败。

这怎么可能? 您的模型似乎是正确的,可用于检测解决方案。 那很好! 但是,良好的约束条件公式不仅可以识别解决方案,而且可以快速检测 无法完成的部分分配。 这就是使求解程序能够有效地简化搜索的原因,并且在这一重要方面,您当前的公式还很欠缺。 造成这种情况的原因之一与正在使用的已约束化约束中的约束传播有关。 特别是,请考虑以下查询:

?- (X + 1 #= 3) #<==> B, X #\= 2.

直观地,我们期望B = 0 但事实并非如此! 相反,我们得到:

X in inf..1\/3..sup,
X+1#=_3840,
_3840#=3#B,
B in 0..1.

因此,求解器不会非常强烈地传播化等式。 也许应该吧! 只有Prolog从业人员的充分反馈将告诉您是否应该更改约束求解器的该区域,可能会牺牲一点速度来实现更强的传播。 此反馈的高度相关性是我建议在有机会时(即,每次对整数进行推理时)都使用CLP(FD)约束的原因之一。

对于这种特殊情况,我可以告诉您,从这种意义上说,使求解器更强大不会带来太大的改变。 从本质上讲,您最终得到的是仍然存在核心问题的电路板版本,其中有许多转换(在下面的其中一些突出显示)在任何解决方案中都不可能发生:

还具有域一致性

解决核心问题

我们应该在其核心消除回溯的原因。 要修剪搜索,我们必须早些识别不一致的(部分)分配。

直观地,我们正在搜索关联的游览 ,并且希望在明显无法按预期方式继续游览时回溯。

为了完成我们想要的,我们至少有两个选择:

  1. 更改分配策略以考虑连通性
  2. 以更紧密地考虑连通性的方式对问题进行建模。

选项1:分配策略

CLP(FD)约束的主要吸引力在于,它们使我们可以任务描述与搜索分离。 使用CLP(FD)约束时,我们通常通过label/1labeling/2进行搜索。 但是,我们可以随意使用任意方式为变量赋值。 如果我们按照您所做的那样遵循将“约束发布”部分放到称为核心关系的谓词中的良好做法,这将非常容易。

例如,这是一个自定义分配策略,可确保游览始终保持连接状态:

allocation(Vs) :-
        length(Vs, N),
        numlist(1, N, Ns),
        maplist(member_(Vs), Ns).

member_(Es, E) :- member(E, Es).

通过这种策略,我们可以从头开始获得5×5实例的解决方案:

?- number_puzzle_(Square, Vars), time(allocation(Vars)).
% 5,030,522 inferences, 0.907 CPU in 0.913 seconds (99% CPU, 5549133 Lips)
Square = square(row(1, 8, 5, 2, 11), ...),
Vars = [1, 8, 5, 2, 11, 16, 21, 24, 15|...]

此策略有多种修改值得尝试。 例如,当允许使用多个正方形时,我们可以尝试通过考虑正方形的剩余域元素数来做出更明智的选择。 我将尝试改进作为挑战。

从标准标记策略来看,在这种情况下, min标记选项实际上与该策略非常相似,实际上,它还为5×5情况找到了解决方案:

?- number_puzzle_(Square, Vars), time(labeling([min], Vars)).
% 22,461,798 inferences, 4.142 CPU in 4.174 seconds (99% CPU, 5422765 Lips)
Square = square(row(1, 8, 5, 2, 11), ...),
Vars = [1, 8, 5, 2, 11, 16, 21, 24, 15|...] .

但是,即使是拟合分配策略也无法完全补偿弱约束传播。 对于10×10的实例,在使用min选项进行一些搜索之后,木板看起来像这样:

使用标签选项<code> min </ code>进行脱粒

请注意,我们还必须调整PostScript代码中的n值,以按预期将其可视化。

理想情况下,我们应制定以这样的方式,我们从强大的传播中受益的任务,然后还要使用一个很好的分配策略。

选项2:重塑

良好的CLP配方应尽可能强地传播(在可接受的时间内)。 因此,我们应该努力使用约束,这些约束使求解器可以更轻松地推理任务的最重要要求。 在这种具体情况下,这意味着我们应该尝试为目前表示为修正约束的析取关系找到更合适的公式,如上所述,该约束不允许太多的传播。 从理论上讲,约束求解器可以自动识别这种模式。 但是,这对于许多用例来说是不切实际的,因此我们有时必须通过手动尝试几种有希望的配方进行试验。 尽管如此,在这种情况下:在有来自应用程序程序员的充分反馈的情况下,这种情况更有可能得到改进和解决!

现在,我使用CLP(FD)约束circuit/1来表明我们正在寻找特定图形中的哈密​​顿电路 该图表示为整数变量列表,其中每个元素表示其后继元素在列表中的位置。

例如,一个包含3个元素的列表正好允许2个哈密顿回路:

?- Vs = [_,_,_], circuit(Vs), label(Vs).
Vs = [2, 3, 1] ;
Vs = [3, 1, 2].

我使用circuit/1来描述也是封闭式游览的解决方案。 这意味着,如果我们找到了这样的解决方案,那么我们可以通过从找到的游览中的最后一个广场开始的有效移动,从头开始重新开始:

n_tour(N, Vs) :-
        L #= N*N,
        length(Vs, L),
        successors(Vs, N, 1),
        circuit(Vs).

successors([], _, _).
successors([V|Vs], N, K0) :-
        findall(Num, n_k_next(N, K0, Num), [Next|Nexts]),
        foldl(num_to_dom, Nexts, Next, Dom),
        V in Dom,
        K1 #= K0 + 1,
        successors(Vs, N, K1).

num_to_dom(N, D0, D0\/N).

n_x_y_k(N, X, Y, K) :- [X,Y] ins 1..N, K #= N*(Y-1) + X.

n_k_next(N, K, Next) :-
        n_x_y_k(N, X0, Y0, K),
        (   [DX,DY] ins -2 \/ 2
        ;   [DX,DY] ins -3 \/ 0 \/ 3,
            abs(DX) + abs(DY) #= 3
        ),
        [X,Y] ins 1..N,
        X #= X0 + DX,
        Y #= Y0 + DY,
        n_x_y_k(N, X, Y, Next),
        label([DX,DY]).

请注意,现在如何将可接受的后继者表示为领域元素 ,从而减少了约束数量并完全消除了对版本化的需求。 最重要的是,现在将自动考虑预期的连接性,并在搜索过程中的所有时间点都将其强制执行。 谓词n_x_y_k/4(X,Y)坐标与列表索引相关联。 您可以通过更改n_k_next/3轻松地使该程序适应其他任务(例如,骑士之旅)。 我将归纳归结为公开旅行是一个挑战。

这是使我们以更易读的形式打印解决方案的其他定义:

:- set_prolog_flag(double_quotes, chars).

print_tour(Vs) :-
        length(Vs, L),
        L #= N*N, N #> 0,
        length(Ts, N),
        tour_enumeration(Vs, N, Es),
        phrase(format_string(Ts, 0, 4), Fs),
        maplist(format(Fs), Es).

format_(Fs, Args, Xs0, Xs) :- format(chars(Xs0,Xs), Fs, Args).

format_string([], _, _) --> "\n".
format_string([_|Rest], N0, I) -->
        { N #= N0 + I },
        "~t~w~", call(format_("~w|", [N])),
        format_string(Rest, N, I).

tour_enumeration(Vs, N, Es) :-
        length(Es, N),
        maplist(same_length(Es), Es),
        append(Es, Ls),
        foldl(vs_enumeration(Vs, Ls), Vs, 1-1, _).

vs_enumeration(Vs, Ls, _, V0-E0, V-E) :-
        E #= E0 + 1,
        nth1(V0, Ls, E0),
        nth1(V0, Vs, V).

在具有强传播性的公式中,预定义的ff搜索策略通常是一个很好的策略。 实际上,它可以让我们在商用机器上几秒钟内解决整个任务,即原始的10×10实例:

?- n_tour(10, Vs),
   time(labeling([ff], Vs)),
   print_tour(Vs).
% 5,642,756 inferences, 0.988 CPU in 0.996 seconds (99% CPU, 5710827 Lips)
   1  96  15   2  97  14  80  98  13  79
  93  29  68  94  30  27  34  31  26  35
  16   3 100  17   4  99  12   9  81  45
  69  95  92  28  67  32  25  36  33  78
  84  18   5  83  19  10  82  46  11   8
  91  23  70  63  24  37  66  49  38  44
  72  88  20  73   6  47  76   7  41  77
  85  62  53  86  61  64  39  58  65  50
  90  22  71  89  21  74  42  48  75  43
  54  87  60  55  52  59  56  51  40  57
Vs = [4, 5, 21, 22, 8, 3, 29, 26, 6|...]

为了获得最佳性能,我建议您也将其与其他Prolog系统一起尝试。 商业级CLP(FD)系统的效率通常是购买Prolog系统的重要原因。

还要注意,这绝不是该任务唯一有希望的Prolog或CLP(FD)公式,我将其他公式视为挑战。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM