next up previous
Next: About this document ... Up: Search Methods Previous: Local Search Methods

Subsections

Hybrid Search Methods

Weak-commitment Search

Introduction

Weak-commitment Search (WCS) can be seen as one of the simplest form of nogood learning. It was proposed by Yokoo, and the main reference is:

Makoto Yokoo, Weak-commitment Search for Solving Constraint Satisfaction Problems, in AAAI'94, pg. 313-318.
WCS starts by giving tentative assignments to the variables of the problem. Labelling of the variables are then performed, guided by a heuristic that considers the tentative values. If a dead-end is reached, where no value can be assigned to a variable without violating some constraint, then the current search is abandoned, and a new search restarted from scratch with an extra `nogood' constraint. This `nogood' constraint remembers the previously assigned values to the already labelled variables in the just abandoned search. This combination of values lead to a dead-end, and will not be tried again in the new search. This is in contrast to a conventional backtracking search, where the search would not be entirely abandoned, but will try assigning new alternative values to one (generally the last assigned) variable. The `weak-commitment' refers to this feature of the search technique, wherein it is not `strongly committed' to the current branch in the search-space as in conventional backtracking search. The aim is that the search would not be `stuck' exhaustively exploring a particular region of a search-space that might not lead to a solution. The search is instead guided by the `nogood' constraints, which are added (`learned') after each step.

The min-conflict heuristic of Minton et al. is the heuristic used to label variables. In this heuristics, a `probe' is performed when assigning a value to a variable, in which all the values in the remaining domain of the variable (i.e. values which causes no constraint violations with existing assigned variables) are considered. The value chosen is the value that causes the minimum conflict (constraint violation) with the tentative values of the still unlabelled variables.

Using the WCS

The code for the facilities described below is available in the file wcs.ecl in the doc/examples directory of your ECLiPSe installation.
:- compile(wcs).
The WCS is invoked by calling the wcs/2 predicate:
wcs(+Vars, ++Initial)

where Vars is a list of variables that are to be labelled by the search, and Initial is a list of the initial tentative values that are assigned to the variables. Before calling the procedure, the user must already have set up the initial constraints so that the search can proceed. During the search process, additional nogood constraints would be added to direct the search.

Two example usage of the search are given: 1) a search for potentially all the solutions to the N-Queens problem, and 2) a search for the first solution to the 3SAT problems, when given the constraints on the variables in the form of Prolog facts.

The 3SAT example is simpler in that it involves only nogood constraints: the initial constraints on the variables are simply translated into nogood constraints before wcs/2 is called. In the N-Queens example, additional constraints on the placement of the queens have to be specified before wcs/2 is called.

The constraints that are specified for the WCS have to apply to both the tentative and actual values of the variables. Tentative values are implemented in this predicate using the repair library, and thus the constraints have to be made known to the repair library. This is done using the r_prop annotation provided by the library. With this annotation, the repair library would apply the constraint to the tentative values as well as to the normal values.

Implementation of WCS

The WCS implementation presented here is a simple and straight-forward implementation in ECLiPSe of the basic algorithm presented by Yokoo. The finite domains and the repair libraries were used. The finite domain library was used to allow for the probing step, where all valid values for a variable are tried. The repair library was used to allow for tentative values to be associated with variables, as specified in the algorithm.

The repair library is used in the following way:

The predicate also has to implement the probing and restart steps of the WCS which replace the usual tree search strategy. The probing step tries out all the possible valid values for a variable, and picks the value which leads to the least number of conflicts (constraint violations) with the tentative values in the unlabelled variables. This is done with minimize/2 from the finite domains library:

label(Var) :-
       minimize((
             indomain(Var),
             conflict_constraints(Constraints),
             length(Constraints, L) ), L).

The above tries out the available values of Var, collect the constraints violations on the tentative variables using conflict_constraints from the repair library, and counts the number of such constraints using length/2. The value with the minimum number of constraint violation is selected as the binding to Var by this procedure.

The search restart itself is quite easy to implement in ECLiPSe, as the just described labelling procedure, label/1, does not leave behind a choice-point. Thus, when a dead-end is reached in labelling values, a simple failure will cause the procedure to fail back to the beginning, i.e. before any variable is labelled. The restart is then implemented by specifically creating a choice-point at the start of the search, in the do_search/2 predicate:

do_search(Vars, _) :-
        try_one_step(Vars, Vars),
        % remember solution as a nogood so it would not be tried again
        remember_nogood(Vars).
do_search(Vars, N) :-
        % hit dead-end and failed, try again from start after recording nogoods
        add_nogood(Vars),  % put in most recent nogood
        getval(nlabels, NL),
        printf("Restart %w - labelled %w%n",[N,NL]),
        N1 is N + 1,
        do_search(Vars, N1).

try_one_step/2 tries out one search, with the first argument containing the variables remaining to be labelled (initially all the variables), and the second argument being all the variables. This would fail if the labelling hits a dead-end and fails. In this case, the second clause of do_search/2 will be tried, in which a new search is started. The only difference is that a new nogood constraint will be remembered. Note that if try_one_step succeeds, then a solution will have been generated. To allow for the search of more solutions, this solution is remembered as a nogood in the first clause of do_search/2.

The main difficulty with implementing restart is to remember the values of labelled variables so that it can be added as a nogood. The addition of the nogood must be done after the failure and backtracking from the dead-end, so that it will not be removed by the backtracking. The problem is that the backtracking process will also remove the bindings on the labelled variables. Thus, some means is required to remember the nogood values from the point just before the failure, which can then be retrieved after the failure to produce a new nogood constraint. Not only do the values themselves have to be remembered, but which variable a particular value is associated with has also to be remembered. This is done using the non-logical variable feature of ECLiPSe, which allows copies of terms to be stored across backtracking. A non-logical variable is declared by a variable/1 declaration:

:- local variable(varbindings).

which associates the name varbindings with a non-logical value. The value of this variable can then be set via setval/2 and accessed via getval/2 built-ins. In order to remember which variable is associated with which value, all the variables being labelled, which is organised as a list, are copied using setval/22.1:

remember_nogood(Vars) :-
        copy_term(Vars, NVars, _),
        setval(varbindings,NVars).

To remember the current labellings when a dead-end is reached, so that a new nogood constraint can be added for the restarted search, remember_nogood/1 is called before the actual failure is allow to occur:

label_next(Cons, Left0, Vars) :-
        pick_var(Cons, Left0, Var, Left1),
        incval(nlabels),
        ( label(Var) ->
            try_one_step(Left1, Vars)
        ;
            remember_nogood(Vars),
            fail
        ).

The routine first picks an unlabelled variable to label next, and if it is successful, the routine recursively tries to label the remaining unlabelled variables. If not, label(Var) fails, and the else case of the if-then-else is called to remember the nogoods before failing.

As already described, a new nogood constraint is added by the add_nogood/1 predicate, as shown below:

add_nogood(NewConfig) :-
        getval(varbindings, Partial),
        (foreach(P, Partial), foreach(V,NewConfig),
         fromto(NoGoods,NG0, NG1, []), fromto(NGVars,NGV0,NGV1,[]) do
            (nonvar(P) ->
                V tent_set P,
                NG0 = [P|NG1],
                NGV0 = [V|NGV1]
            ;   NG0 = NG1,   % keep old tentative value
                NGV0 = NGV1
            )
        ),
        NoGoods ~= NGVars r_prop. % no good

If a variable had been labelled in the previous search, the labelled value becomes the tentative value. Otherwise, the variable retains the original tentative value.

The nogood constraints are implemented via the built-in sound difference operator, ~=/2. For example,

[A,B,C] ~= [1,2,3]

states that the variables A, B and C cannot take on the values of 1, 2 and 3 respectively at the same time. The operator will fail when [A,B,C] becomes ground and take on the values [1,2,3]. If any of the variables take on a value different from what is specified, ~=/2 will (eventually) succeed. The operator thus acts passively, waiting for the variables to be instantiated and then check if they are taking on the `nogood' values, and does not propagate or deduce any further information.

The algorithm described by Yokoo does not specify how the next variable is selected for labelling. In this routine, it is done by the pick_var/4 predicate:

pick_var(Cons, Left0, Var, Left) :-
        term_variables(Cons, Vars0),
        deleteffc(Var0, Vars0, Vars1),
        (is_validvar(Var0, Left0, Left) ->
            Var = Var0 ; pick_var1(Vars1, Left0, Var, Left)
        ).

The next variable to be labelled is chosen from the set of variables whose tentative values are causing conflict. The repair library maintains the (repair) constraints which are causing conflict, and any variable which are causing conflict will occur in these constraints. The set of conflicting repair constraints is passed to pick_var/4 in the first argument: Cons. term_variables is used to obtain all the variables that occur in these constraints. The fd predicate deleteffc is then used to select a variable (picking the one with the smallest domain and most constraints), and then this variable is checked to make sure that it is valid variable to be labelled, i.e. that it is one of the variables to be labelled. The reason for this check is that it is expected that the WCS routine will be used as part of a larger program, and the program may use the repair library itself, and thus Cons may contain constraints unrelated to the WCS labelling.

Improving Implementation of Nogoods

As already stated, the built-in ~=/2 used for nogood constraints is passive. More powerful propagation can be added to the nogood constraint if the constraint is defined by the user. To try this out, a somewhat more powerful constraint was tried out. This constraint does forward checking, in that when only one variable specified in a nogood remains unlabelled, and the labelled variables are labelled to the values specified by the nogood, the constraint that this last variable cannot take on its nogood value can be propagated. This increase the efficiency of the search in many cases, although at the price of a slightly more complex implementation of the nogood constraint.

The nogood constraint is implemented as shown:

nogood([X|Xs], [V|Vs]) :-
        ( X==V ->   nogood(Xs, Vs)
        ; var(X) -> nogood(Xs, Vs, X, V)
        ;           true
        ).

    nogood([], [], X1, V1) :- X1 ## V1.
    nogood([X|Xs], [V|Vs], X1, V1) :-
        ( X==V ->   nogood(Xs, Vs, X1, V1)
        ; var(X) -> suspend(nogood([X1,X|Xs], [V1,V|Vs]), 3, X-X1->inst)
        ;           true
        ).
The nogood-constraint is set up by
nogood(NGVars, NoGoods) r_prop.
The implementation checks whether NGVars matches NoGoods and causes failure if this is the case. If a non-matching variable-value pair is encountered, the constraint disappears. If a variable is encountered, nogood/4 continues checking, and if the variable turns out to be the only one, the corresponding values gets removed from its domain. If a second variable is encountered, the constraint re-suspends until at least one of them gets instantiated.

Obviously, this is still a relatively naive implementation of the nogood-technique. As the number of nogoods grows, implementing them via indivudual constraints will become more and more infeasible, and optimisation techniques like merging and indexing of the nogoods will be needed.


next up previous
Next: About this document ... Up: Search Methods Previous: Local Search Methods

1999-08-07