Nondeterministic Programs

Chapter: Nondeterministic Programs

The term Nondeterministic algorithm refers to a class of algorithms in which computational progress may correctly follow several different paths. Since we are satisfied that the algorithm computes the solution to a particular problem even if only one such path leads to termination with a solution, the implementation of nondeterministic algorithms requires a strategy for exploring the multiple paths that may emanate from some choice point. The simplest such strategy is called backtracking, and involves having control return to earlier choice points when reaching a dead end.

Sequential nondeterministic programs (i.e. programs executing on a sequential machine that implement nondeterministic algorithms) have a distinctive continuation structure, since they must be able to run both forwards and backwards (forwards when successfully extending a partial solution, and backwards when failing to do so). Consequently, such programs written using CPS must pass continuations, a success continuation, k, to be used in the former case, and a failure continuation, q, to be used in the latter.

A General Backtracking Program

Starting from some initial value, a backtrack program incrementally constructs a solution by extending the current partial solution with some legal choice. If the partial solution becomes a complete solution it is returned. Otherwise, upon reaching a dead end, the program backtracks to the previous choice point, discards the choice it made there, and continues as though that choice were illegal in the first place. It may happen that every possible choice is examined and rejected, in which case the problem has no solution.

In Scheme we can write a general backtracker, parameterized by the specifics of the problem being solved:

(define solve
  (lambda (*initial* *complete?* *extensions* *first* 
                     *rest* *empty?* *extend* *legal?* *top-k* *do* *undo*)
These nine parameters define the problem space as follows:
(*initial*) returns an initial partial solution (psol)
(*complete?*psol) true if partial solution psol is a complete solution
(*extensions* psol) creates the set of all possible choices to extend psol
(*first* choices) selects next choice
(*rest* choices) returns remaining choices
(*empty?* choices) true if all choices are exhausted
(*extend* psol choice) extends partial solution by given choice
(*legal?* psol) true if psol is a legal partial solution
(*top-k* sol q) top-level success continuation
(*do* psol) performs any required state change when recording new choice (e.g. for graphics)
(*undo* psol) retracts any required state change when backtracking

Here is the code for solve. Look specifically at how the two continuations (k and q) are used.

(define solve
  (lambda (*initial* *complete?* *extensions* *first* 
                     *rest* *empty?* *extend* *legal?* *top-k* *do* *undo*)
    (let try ([psol (*initial*)]
              [choices (*extensions* (*initial*))]
              [k *top-k*]
              [q (lambda () 'failed)])
      (if (*complete?* psol) (k psol q)
          (if (*empty?* choices) (q)
              (let* ([new-psol (*extend* psol (*first* choices))]
                     [new-k (lambda (new-psol new-q)
                              (*do* new-psol)
                              (try new-psol (*extensions* new-psol) k new-q))]
                     [new-q (lambda () 
                              (*undo* new-psol)
                              (try psol (*rest* choices) k q))])
                (if (*legal?* new-psol)
                    (new-k new-psol new-q)
                    (new-q))))))))

The success continuation k is used as in CPS to carry the computation forward and return the answer. The failure continuation q is invoked to backtrack when the choice list becomes empty. This is why a new q is constructed in the call (try new-psol ...), in which we have extended the current partial solution and are moving forward to extend the next with a recursive call. The new failure continuation backtracks with a call to try with the current partial solution, starting from the next choice.

The files knight.ss, eightqueens.ss and goodseq.ss contain example programs that require solve.ss. The first 2 are graphical and also require several graphics modules (built out of the OOP material from Lab 8, in fact).

Now consider the good sequences problem. A good sequence is one that contains no adjacent repeated subsequence, for example (3 2 1 3 2) but not (3 2 1 2 1 3) (because of the 2 1 2 1). The top level continuation prints the solution and invokes the failure continuation, so the that running (goodseq n) prints all good sequences of length n. Try it with different values of n, such as 4 8 and 12.


Exercise 10

The program for solve is correct formally, but much more complicated than it needs to be for the good sequences problem. It can be re-written without any continuations in such a way that it becomes deeply-recursive in the backward direction only.

  1. Copy the good sequences solution to your file.
  2. Paste in the code for solve from solve.ss and remove the require expression. Get rid of *do* and *undo* since they aren't used here. Run goodseq to be sure it still works.
  3. Modify the code for solve so that it uses no continuations and uses deep-recursion only when backtracking. Change the name of *top-k* to *print-it*, and change its definition so that it only accepts 1 argument, which it prints.
  4. It is possible to look at the original code for solve and see immediately that success continuations can be eliminated without requiring the substitution of deep recursion. What are the tell-tale signs?




This last problem is optional. You are encouraged to do it, but don't hand it in.


Exercise 11

Write solve-cc, a version of solve that uses Scheme continuations in place of CPS. Use traverse-cc as a model.


rms@cs.oberlin.edu