针对数字拼图难题的优化CLP(FD)求解器

时间:2018-12-07 14:57:30

标签: prolog clpfd

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

  

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

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

换句话说(与下面的实现更接近),用10到10的网格标记从1到100的数字,以使坐标(X, Y)处的每个正方形等于1或等于处的“先前”正方形的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)。 / p>

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

在30秒内解决它。

我在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).

1 个答案:

答案 0 :(得分:4)

首先:

这是怎么回事?

要查看正在发生的情况,以下是 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 sample instructions

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

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

Thrashing illustration

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

Thrashing reason

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

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

在约束文献中,这种回溯称为颠簸。这表示由于相同原因反复失败

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

?- (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从业者只有足够的 feedback 会告诉您是否应该更改约束求解器的这一区域,可能会为提高传播速度而付出一些速度。该反馈的高度相关性是我建议在有机会时(即每次您对 integers 进行推理时)都使用CLP(FD)约束的原因之一。

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

Thrashing also with domain consistency

解决核心问题

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

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

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

  1. 更改分配策略以考虑连接性
  2. 以更加重视连通性的方式对问题进行建模。

选择1:分配策略

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

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

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选项进行搜索后,木板看起来像这样:

Thrashing with labeling option <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)公式,而我将其他公式视为挑战。