簡體   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