Resolviendo el problema de las 3 jarras usando automaton y clpz
El problema de las 3 jarras (Three jugs) consiste en 3 jarras de agua, de 3L, 5L y 8L. Las dos primeras están vacías y la de 8L está llena. El objetivo es que, echando el agua de unas jarras a otras, consigamos tener en alguna jarra 4L exactos. Este problema lo vamos a resolver en Prolog intentando usar una técnica más avanzada de la librería clpz, automaton. Los usos básicos de clpz ya los vimos en otro post.
.¿Qué nos ofrece automaton?
Los predicados automaton (automaton/3 y automaton/8) se usan en problemas de scheduling para obtener el orden de las tareas a realizar si estas pueden describirse como un autómata finito determinista (AFD, en inglés las siglas son DFA). Es decir, si describimos el problema como un AFD, podremos pasárselo a clpz para que encuentre la forma más corta de resolver realizar las tareas.
Veamos un ejemplo muy sencillo. Nosotros podemos definir un AFD para analizar la expresión regular "0*1*0*". Es decir, N ceros, seguido de N unos, seguido de N ceros. El diagrama de estados sería el siguiente:
Diagrama sacado del libro Constraint Solving and Planning with Picat
Nosotros empezamos en el estado S1. Si leemos un cero, seguimos en S1. Si leemos un uno, pasamos a S2. En S2 si recibimos un uno seguimos en S2. Si recibimos un cero pasamos a S3. Si recibimos un 1 es un error. Si recibimos un cero, seguimos en S3 hasta acabar.
¿Cómo pasamos esto a automaton? En primer lugar, vamos a usar el predicado automaton/3 que es más sencillo. El otro contiene más opciones y se puede usar en automátas más complejos. El predicado tiene 3 argumentos: lista de entradas, estados especiales (entradas y salidas; source y sink) y finalmente un listado de arcos entre estados tomando una acción. Podemos usarlo para comprobar si una lista cumple el autómata.
?- Vs = [0,0,1,1,1,0], automaton(Vs, [source(s1), sink(s1), sink(s2), sink(s3)], [arc(s1,0,s1), arc(s1,1,s2),arc(s2,1,s2),arc(s2,0,s3),arc(s3,0,s3)]).
Pero como estamos en Prolog, también podemos preguntar qué listas de longitud N se pueden construir. Para ello necesitamos llamar a label/1, ya que estamos en clpz. Un ejemplo con N=6
?- length(Vs, 6), automaton(Vs, [source(s1), sink(s1), sink(s2), sink(s3)], [arc(s1,0,s1), arc(s1,1,s2),arc(s2,1,s2),arc(s2,0,s3),arc(s3,0,s3)]), label(Vs).
El problema de las 3 jarras
El principal problema de resolver problemas mediante su traslación a DFA, es que muchas veces se requieren muchos pasos para transformar los datos a un DFA correcto.
En este caso lo que voy a hacer es buscar una representación programática del estado. Como son 3 jarras, usaré un guión para separar entre los litros que contiene cada jarra en este momento. Es decir, el término 1-2-5 representará al estado en que la primera jarra tiene 1L, la segunda tiene 2L y la tercera tiene 5L. Con esta representación el estado inicial será 0-0-8 y solo hay uno.
Sacar los estados finales ya es más complicado. Para ello primero voy a generar todos los posibles estados que se pueden hacer con 3 jarras y 8L. De esos filtraré en los que alguna jarra contiene 4L exactos.
states(States) :-
[A,B,C] ins 0..8,
A+B+C #= 8,
findall(A-B-C, label([A,B,C]), States).
end_states(States, EndStates) :-
findall(sink(S), (
member(S, States),
S = A-B-C,
(A = 4; B = 4; C = 4)
), EndStates).
Ahora toca la parte maś compleja que es definir los arcos. Evidentemente no los vamos a escribir a mano, sino que vamos a hacer reglas. Como tenemos ya todos los estados de 8L, vamos a ir filtrando los arcos que van entre dos estados posibles diferentes con una acción. Las acciones las vamos a numerar del 1 al 6, ya que automaton requiere que estas acciones sean números enteros.
De un estado se puede pasar a otro a través de un arco haciendo una acción si tiene sentido el movimiento de litros que hacemos (llenamos la jarra hasta el tope o hasta que nos quedamos sin agua en el origen). Usamos aritmética de clpz.
arcs(States, Arcs) :-
findall(arc(S0, Action, S1),(
member(S0, States),
member(S1, States),
S0 \= S1,
arc(S0, Action, S1)
), Arcs).
% Arcs
% Pour from A to B
arc(A-B-C, 1, X-Y-Z) :-
Dif #= min(A, 5-B),
X #= A - Dif,
Y #= B + Dif,
Z = C.
% Pour from A to C
arc(A-B-C, 2, X-Y-Z) :-
Dif #= min(A, 8-C),
X #= A - Dif,
Y = B,
Z #= C + Dif.
% Pour from B to C
arc(A-B-C, 3, X-Y-Z) :-
Dif #= min(B, 8-C),
X = A,
Y #= B - Dif,
Z #= C + Dif.
% Pour from B to A
arc(A-B-C, 4, X-Y-Z) :-
Dif #= min(B, 3-A),
X #= A + Dif,
Y #= B - Dif,
Z = C.
% Pour from C to A
arc(A-B-C, 5, X-Y-Z) :-
Dif #= min(C, 3-A),
X #= A + Dif,
Y = B,
Z #= C - Dif.
% Pour from C to B
arc(A-B-C, 6, X-Y-Z) :-
Dif #= min(C, 5-B),
X = A,
Y #= B + Dif,
Z #= C - Dif.
¡Ya tenemos todas las piezas! Solo hace falta juntarlas para resolver el problema.
:- use_module(library(lists)).
:- use_module(library(clpz)).
solve(Vs) :-
states(States),
end_states(States, EndStates),
arcs(States, Arcs),
length(Vs, _),
automaton(Vs, [source(0-0-8)|EndStates], Arcs),
label(Vs).
% ?- solve(Vs).
% Vs = [6,4,2,4,6,4]
%; Vs = [5,1,5,1,3,1,5]
%; Vs = [6,4,2,4,6,4,2]
%; Vs = [5,1,5,1,3,1,5,1]
%; Vs = [5,1,6,4,2,4,6,4]
%; Vs = [5,2,6,4,2,4,6,4]
%; ...
La solución más corta parece ser [6,4,2,4,6,4]. Con esto ya tendríamos el problema resuelto, pero es cierto que es complicado de interpretar. Vamos a añadir un predicado para explicar la solución.
print(Xs) :- print(Xs, 0-0-8).
print([], _).
print([X|Xs], S) :-
arc(S, X, S1),
S = A-B-C,
S1 = D-E-F,
format("Going from (~d,~d,~d) to (~d, ~d, ~d)\n", [A,B,C,D,E,F]),
print(Xs, S1).
Con esto ya tenemos resuelto el problema de las 3 jarras.