Week 4: Notes

Some of this week's topics are discussed in the Bratko text (8.5.3 Last call optimizations and accumulators, 9.5 Graphs, 11.2 Depth-first search and iterative deepening).

accumulators

Here's a predicate that adds numbers in a list:

% sum(L, N): N is the sum of numbers in L
sum([], 0).
sum([I | L], N) :- sum(L, N1), N #= N1 + I.

Given a list such as [3, 4, 5], this computes the sum 3 + (4 + (5 + 0)) = 12. The partial sums are computed from right to left.

Here's the same predicate, rewritten to use an accumulator.

sum(L, S) :- sum(L, 0, S).  % accumulator is initially 0

% helper predicate: second argument is an accumulator
sum([], S, S).
sum([I | L], A, R) :- A1 #= A + I, sum(L, A1, R).

This computes the sum ((0 + 3) + 4) + 5 = 12. Notice that with an accumulator the partial sums are computed in a different order, i.e. from left to right. Of course, the result will be the same in either case, because addition is associative: (a + b) + c = a + (b + c).

Here's a predicate that reverses a list, naively:

reverse([], []).
reverse([X | L], N) :- reverse(L, M), append(M, [X], N).

This will run in time O(N2) on a list with N elements. We can dramatically improve its efficiency using an accumulator, which lets us combine elements in the opposite direction:

reverse(L, R) :- reverse(L, [], R).  % accumulator is initially []

% helper predicate: second argument is an accumulator
reverse([], R, R).
reverse([X | L], A, R) :- reverse(L, [X | A], R).

There's only one problem: both of these implementations of reverse (either without or with an accumulator) will fail to terminate when their first argument is an unbound variable:

?- reverse(L, [a, b, c]).
L = [c, b, a] ;
ERROR: Out of global stack
?- 

As usual, the same_length trick helps:

reverse(L, R) :- same_length(L, R), reverse(L, [], R).
% helper predicate is same as before

Now queries will terminate in both directions.

higher-order predicates

In the lecture we also introduced higher-order predicates. (These are similar to higher-order functions, which we will see when we study Haskell in a few weeks.)

Higher-order predicates are not discussed in the Bratko text, so let's review them here.

The maplist predicate applies a predicate P to elements of one or more lists, and succeeds if P always succeeds. It is a higher-order predicate because its argument P is itself a predicate. Let's look at a few examples.

maplist(P, L)

When maplist has two arguments, it applies a predicate P to every element of a single list. Suppose that we have a predicate odd:

odd(I) :- I mod 2 #= 1.

Now we can use maplist to test whether all integers in a list are odd:

?- maplist(odd, [3, 5, 7]).
true.

?- maplist(odd, [3, 4, 7]).
false.

Consider the following predicate, which states that up and down are directions:

dir(up).
dir(down).

We can use maplist to test whether all elements of a list are directions. But we can also run maplist backwards to generate lists of directions:

?- maplist(dir, [up, down]).
true.
?- length(L, 2), maplist(dir, L).
L = [up, up] ;
L = [up, down] ;
L = [down, up] ;
L = [down, down].

maplist(P, L1, L2)

When maplist has three arguments, it applies a predicate P to corresponding pairs of elements from two lists. For example, maplist(P, [1, 2, 3], [4, 5, 6]) will invoke P(1, 4), P(2, 5) and P(3, 6). maplist will succeed if all of these invocations succeed.

You can use this to compare list elements, for example:

less(I, J) :- I #< J.

?- maplist(less, [1, 2, 3], [4, 5, 6]).
true.

The preceding query succeeds because 1 < 4, 2 < 5, and 3 < 6.

Alternatively, you can pass maplist a predicate representing a function that you want to apply to every element of a list:

double(I, J) :- J #= I * 2.

?- maplist(double, [1, 2, 3], [2, 4, 6]).
true.
?- maplist(double, [1, 2, 3], L).
L = [2, 4, 6].

If the function works in both directions then maplist will too:

?- maplist(length, [[10, 11], [12, 13, 14]], L).
L = [2, 3].

?- maplist(length, L, [2, 3]).
L = [[_4144, _4150], [_4162, _4168, _4174]].

maplist(P, L1, L2, L3)

When maplist has four arguments, it applies a predicate P to triples of arguments from three lists. You can use this form if you have a three-argument predicate representing a function of two arguments:

add(A, B, C) :- C #= A + B.

?- maplist(add, [1, 2, 3], [10, 11, 12], L).
L = [11, 13, 15].

partially applied predicates

The first argument to maplist can be an atom naming a predicate, as in the examples above. Or it can be a structure containing a partially applied predicate, i.e. predicate name plus one or more arguments. maplist will append any additional arguments before applying the predicate. This is a powerful and useful capability.

For example, we can add 5 to every element of a list like this:

?- maplist(add(5), [1, 2, 3], L).
L = [6, 7, 8].

foldl

Another useful built-in higher-order function is foldl, which can combine elements of a list using an arbitrary predicate. Let's see foldl in action:

add(I, J, K) :- I + J #= K.

?- foldl(add, [3, 4, 5], 0, S).
S = 12

The code above adds the members of the list [3, 4, 5], starting with the value 0. In other words, it computes ((0 + 3) + 4) + 5. This is called a left fold because it combines values from the left, just like the accumulators that we wrote in a section above.

In general, foldl takes four arguments: a predicate of three arguments, a list, a starting value for the accumulator and a final value for the accumulator. If we write

foldl(P, [a, b, c], V0, V)

then foldl will make a series of calls to P, with this effect:

P(a, V0, V1)

P(b, V1, V2)

P(c, V2, V)

Here are some more examples:

% Convert a list of digits (e.g. [4, 5, 6]) to an integer (e.g. 456).
combine(I, J, K) :- K #= I + 10 * J.
to_int(L, I) :- foldl(combine, L, 0, I).

% An efficient implementation of reverse, using foldl.
prepend(X, L, [X | L]).
rev(L, M) :- same_length(L, M), foldl(prepend, L, [], M).

call

maplist and foldl are actually built from a more primitive predicate call. call is itself a higher-order predicate, and invokes the predicate it is given, appending additional arguments:

?- call(add, 2, 3, X).
X = 5.

?- call(length, [2, 4], N).
N = 2.

?- call(length, L, 2).
L = [_9364, _9370].

As with maplist, the first argument to call can be a partially applied predicate:

?- call(add(2), 3, N).
N = 5.

implementing maplist and foldl

We can easily implement maplist and foldl using call. Here are 2- and 3-argument versions: of maplist:

maplist(_, []).
maplist(P, [X | L]) :- call(P, X), maplist(P, L).

maplist(_, [], []).
maplist(P, [X | L], [Y | M]) :- call(P, X, Y), maplist(P, L, M).

Here is foldl:

foldl(_, [], V0, V0).

foldl(P, [X | L], V0, VR) :- call(P, X, V0, V1), foldl(P, L, V1, VR).

matrices as nested lists

We can represent a vector using a list of floating-point numbers, and a matrix using a list of row vectors. maplist is especially helpful for implementing vector and matrix operations. Note that if we use clpr constraints, then all our predicates will work in any direction. Some examples:

% helper predicates
add(I, J, K) :- { K = I + J }.
mul(I, J, K) :- { K = I * J }.

% multiply (scalar S) * (vector V) = (vector V1)
mul_sv(S, V, V1) :- maplist(mul(S), V, V1).

% multiply (scalar S) * (matrix M) = (matrix M1)
mul_sm(S, M, M1) :- maplist(mul_sv(S), M, M1).

% add (vector V1) + (vector V2) = (vector V)
add_vv(V1, V2, V) :- maplist(add, V1, V2, V).

% dot(V, W, X): X is the dot product of vectors V and W
dot(V, W, X) :- maplist(mul, V, W, Z), foldl(add, Z, 0, X).

Here is a predicate to transpose a matrix, a slightly trickier operation:

% helper predicates
elem(X, [X]).
prepend(X, L, [X | L]).

% base case: transpose 1 x N -> N x 1
trans([R], L) :- maplist(elem, R, L).

% recursive case
trans([R | M], N) :- maplist(prepend, R, N1, N), trans(M, N1).

representing graphs

There are various possible ways to represent a graph in Prolog. Consider this undirected graph:

%3

We could represent the graph using a series of facts:

edge(a, c). edge(a, d). edge(a, f).
edge(b, e).
edge(c, b).
edge(d, c). edge(d, e).
edge(f, b).

This is sometimes called a temporal representation, because a query such as edge(a, V) will return a series of solutions over time.

Alternatively, we could use an adjacency list representation:

G = [a -> [c, d, f], b-> [e], c -> [b], d -> [c, d], e -> [], f -> [b]]

This is sometimes called a spatial representation, since the graph is represented using a data structure.

We can implement graph search algorithms on either sort of graph. For now, we will discuss how to search through a graph in temporal representation. That's because search algorithms on graphs in this representation will easily generalize to searching state spaces, such as searching for a solution to a Rubik's cube. We will see various examples of this soon.

depth-first search

Here is a predicate dfs0(Start, Goal, Path) that searches for a path from Start to Goal in a graph in temporal representation. If it finds a path, it returns it in Path.

dfs0(Start, Start, [Start]).

dfs0(Start, Goal, [Start | Path]) :-
    edge(Start, V), dfs0(V, Goal, Path).

For example:

?- dfs0(a, e, Path).
Path = [a, c, b, e]

avoiding cycles

Our undirected graph above was acyclic. Let's now consider a graph with a cycle:

%3

Our predicate dfs0 may fail to terminate when searching for a path in this graph:

?- dfs0(a, e, Path)

(hangs)

That is because it may walk around a cycle forever. To remedy this, we must keep a visited set and avoid visiting any vertex twice. Let's enhance our depth-first search predicate to do that:

% recursive helper predicate

dfs(Start, Start, _, [Start]).  % base case: start = goal

dfs(Start, Goal, Visited, [Start | Path]) :-
    edge(Start, V),             % find a next vertex
    maplist(dif(V), Visited),   % make sure not already visited
    dfs(V, Goal, [V | Visited], Path).   % recurse

% top-level predicate

dfs(Start, Goal, Path) :- dfs(Start, Goal, [Start], Path).

Now we can find a path from a to e:

?- dfs(a, e, Path).
Path = [a, c, b, e]

iterative deepening

A major disadvantage of a depth-first search is that it is not guaranteed to find a shortest path. For instance, in the previous section the dfs predicate found the path [a, c, b, e], but [a, d, e] is a shorter path from a to e. When we are searching in an infinite state space, depth-first search is not even complete, i.e. is not guaranteed to find a solution at all, even if one exists. That's because it may recurse infinitely in the wrong direction.

Another useful search algorithm is iterative deepening, which performs a series of depth-first searches, each of which has a maximum depth. The first depth-first search has depth limit 1, the second has limit 2, and so on. Iterative deepening will always find a shortest path to a goal, and is complete even in an infinite state space.

We can easily implement iterative deepening in Prolog using a simple trick, as follows:

iter_deep(Start, Goal, Path) :- length(Path, _), dfs(Start, Goal, Path).

Howe does this work? The goal length(Path, _) will generate uninstantiated paths of increasing lengths:

?- length(Path, _).
Path = [] ;
Path = [_3610] ;
Path = [_3610, _3616] ;
Path = [_3610, _3616, _3622] ;
Path = [_3610, _3616, _3622, _3628]
...

So iter_deep will make a series of calls to dfs, each of which looks for a path of a particular length.

Iterative deepening is sometimes a useful technique. However, it considers all possible paths of length (N - 1) before any path of length N. In some state spaces, this may be exponentially inefficient. For example, suppose that our state space has the structure of a grid, with (0, 0) as the start state, and that we are looking for the state (30, 30). At each step we may move up, down, left, or right. The shortest path from (0, 0) to (30, 30) has length 60. Before we find that, we will consider all paths of length 59. But there are an enormous number of those. If iter_deep calls dfs0, then it will consider 459 paths of length 59, an absurdly large number. If it calls dfs, which avoids cycles, then it will not consider paths that visit the same state twice, but there will still be an enormous number of such paths of length 59.

To avoid this inefficiency, we must implement a breadth-first search that uses a visited set. We will see how to do that in Prolog next week.