El poder del SAT
Generalidades
SAT es el ejemplo canónico de problema NP-completo. Consiste en determinar, dada una fórmula booleana en forma de conjunción de disyunciones (donde cada disyunción se llama claúsula), si existe una asignación de valores a sus variables que hagan verdadera la fórmula (esto se denomina “satisfacer” la fórmula y de ahí viene el nombre SATisfiability asignado al problema). Por ejemplo,
,
ya que la asignación hace verdadera a la expresión. Mientras que
,
ya que no existe forma de asignarle valores de verdad a y
sin hacer falsa a la fórmula.
Un algoritmo simple para resolverlo es probar distintas combinaciones de valores para las variables hasta encontrar alguna que haga verdadera a la fórmula o agotar las posibles combinaciones de valores. En el peor caso (cuando no se pueda encontrar una asignación), el algoritmo examinará posibles asignaciones (siendo
el número de variables). Por lo tanto, este algoritmo tiene una complejidad temporal
.
Pero, aunque no se cree posible encontrar un algoritmo de complejidad sub-exponencial para este problema, si se conocen algoritmos que mejoran significativamente al algoritmo anteriormente descrito. A continuación estudiaremos uno de los más simples, conocido como DPLL.
DPLL
El algoritmo DPLL, llamado así por las iniciales de sus creadores, mejora al algoritmo anteriormente descrito mediante el uso de dos técnicas:
- Backtracking: la asignación de variables para satisfacer la fórmula se construye variable por variable, volviéndose atrás apenas se detecta que la asignación de valor efectuada a una variable hace imposible satisfacer la fórmula.
- Unit propagation: cuando una de las cláusulas contiene una sola variable (negada o no), la conjunción obliga a que esta variable tome un valor específico. El proceso de unit propagation consiste en propagar el efecto de esta asignación de valor sobre el resto de las cláusulas.
Si bien la referencia original utilizaba una técnica adicional de eliminación de literales puros, esta no proporciona ventajas significativas en performance por lo que la omitiremos del análisis.
Implementación de DPLL en Javascript
A continuación analizaremos una implementación de este algoritmo en Javascript. La implementación enlazada toma una claúsula por línea, utilizando - como operador de negación y sin requerir operadores explícitos para la disyunción y la conjunción. Por ejemplo, la fórmula se representaría como
x1 x2 -x3
-x2 x3
-x1
Un script incluido en el archivo HTML se encarga de transformar el problema a un formato canónico, en el que las variables son reemplazadas por números enteros mayores que uno y la negación lógica se transforma en una negaciór aritmética. Las cláusulas se convierten en arrays Javascript, con lo que la fórmula anterior quedaría transformada en [[1, 2, -3], [-2, 3], [-1]].
La primera función que analizaremos es simplifyClause():
function simplifyClause(c, a) {
var nc, i;
nc = [];
for (i = 0; i < c.length; i++) {
if (c[i] in a) {
return null;
} else if (!(-c[i] in a)) {
nc.push(c[i]);
}
}
return nc;
}
Toma dos parámetros, c y a, que representan una claúsula dada como array de valores numéricos y una asignación de valores a las variables dada como un diccionario, respectivamente. El diccionario se utiliza como conjunto, empleando el número correspondiente a la variable como clave y true como valor; la clave numérica se niega para indicar la asignación de un valor falso a la variable. Por ejemplo, la asignación y
se representaría con
{1: true, -2: true}.
La función simplemente copia la claúsula c a una nueva cláusula nc, examinando las apariciones de variables a la luz de la asignación de valores que se utiliza en ese momento. Si encuentra una aparición de variable que se corresponda con una asignación incluida en a, significa que la cláusula es verdadera; por lo tanto devuelve null indicando la “desaparición” de la misma. Por el contrario, si encuentra la aparición negada en a, simplemente no la incluye en la nueva claúsula (ya que es falsa, el valor neutro de la disyunción).
Continuando con las funciones, podemos examinar la función applyAssignment():
function applyAssignment(f, a) {
var nf, i, nc;
nf = [];
for (i = 0; i < f.length; i++) {
nc = simplifyClause(f[i], a);
if (nc !== null) {
nf.push(nc);
}
}
return nf;
}
Esta función hace lo mismo que la anteriormente descrita pero sobre toda una fórmula. Como la simplificación se efectúa cláusula por cláusula, el único procesamiento no delegado a simplifyClause() es la supresión de las claúsulas que son devueltas como null (que son las que simplifyClause() determinó como tautológicas dada la asignación a).
Dejando a un lado funciones auxiliares, como cloneAssignment(), solo resta analizar la función principal: recDPLL().
function recDPLL(f, a) {
var i, na, v, ret;
f = applyAssignment(f, a);
if (f.length === 0) {
return [true, a];
}
for (i = 0; i < f.length; i++) {
if (f[i].length === 0) {
return [false, {}];
} else if (f[i].length === 1) {
na = cloneAssignment(a);
na[f[i][0]] = true;
return recDPLL(f, na);
}
}
na = cloneAssignment(a);
na[f[0][0]] = true;
ret = recDPLL(f, na);
if (ret[0]) {
return ret;
}
delete na[f[0][0]];
na[-f[0][0]] = true;
return recDPLL(f, na);
}
En primer lugar la función simplifica la fórmula f con la asignación de valores a, empleando las técnicas anteriormente descritas. Si esta simplificación conduzca a una fórmula vacía, dado que una conjunción vacía es verdadera, devuelve true y la asignación de valores que llevó a este valor de verdad.
Si no se llegó a una fórmula vacía es obvio que restan cláusulas no tautológicas dentro de la misma (todo esto tomando como “dada” la asignación a de variables). Estas pueden ser de dos tipos: cláusulas contradictorias o contingencias. Como las cláusulas restantes no pueden contener variables con valores asignados, dado el anterior paso de simplificación, una cláusula contradictoria solo puede ser vacía.
El procesamiento restante de la fórmula consiste en recorrer las cláusulas buscando cláusulas vacías o con una sola aparición de variable. Si se encuentra un cláusula vacía, estaremos en presencia de una contradicción que imposibilita aumentar a hasta convertirla en una asignación que satisfaga a la fórmula en cuestión. Por lo tanto se realiza backtracking devolviendo [false, {}].
La aparición de una cláusula con una sola variable es más interesante, ya que puede verse como “forzando” una asignación. En este caso es cuando se realiza el proceso de unit propagation, que consiste simplemente en incorporar la única asignación compatible con la cláusula unitaria dentro de a y continuar la búsqueda recursiva. No es necesario simplificar la fórmula, ya que esto se realizará apenas comience la ejecución de la nueva instancia de recDPLL().
Por último, si la fórmula no cae en ninguno de estos dos casos se realiza una exploración recursiva normal.
Utilizando SAT para resolver otros problemas
Para mostrar como SAT puede utilizarse para resolver problemas lógicos en general, veremos como aplicarlo a la resolución de un problema de lógica conocido como “el problema de Einstein” (aunque es bastante poco probable que Einstein efectivamente lo haya creado). La formulación que utilizaremos es la siguiente:
1. There are 5 houses (along the street) in 5 different colors: blue, green, red, white and yellow.
2. In each house lives a person of a different nationality: Brit, Dane, German, Norwegian and Swede.
3. These 5 owners drink a certain beverage: beer, coffee, milk, tea and water,
smoke a certain brand of cigar: Blue Master, Dunhill, Pall Mall, Prince and blend,
and keep a certain pet: cat, bird, dog, fish and horse.
4. No owners have the same pet, smoke the same brand of cigar, or drink the same beverage.Hints
1. The Brit lives in a red house.
2. The Swede keeps dogs as pets.
3. The Dane drinks tea.
4. The green house is on the left of the white house (next to it).
5. The green house owner drinks coffee.
6. The person who smokes Pall Mall rears birds.
7. The owner of the yellow house smokes Dunhill.
8. The man living in the house right in the center drinks milk.
9. The Norwegian lives in the first house.
10. The man who smokes blend lives next to the one who keeps cats.
11. The man who keeps horses lives next to the man who smokes Dunhill.
12. The owner who smokes Blue Master drinks beer.
13. The German smokes Prince.
14. The Norwegian lives next to the blue house.
15. The man who smokes blend has a neighbor who drinks water.The question is: Who keeps fish?
(Puede verse también una formulación equivalente en español.)
Si bien probablemente la técnica más simple de aplicar en este caso sea constraint-based programming, podemos aplicar SAT sin problemas. Para ello debemos decidir primero que variables utilizaremos, siendo una elección razonable numerar las casas, colores, nacionalidades, bebidas, cigarros y mascotas e introducir variables booleanas asociando las casas con los distintos atributos de la persona que vive en ellas. Eso nos da un total de variables.
La traducción de estas relaciones a cláusulas SAT puede ser algo tediosa, por lo que nos conviene emplear un script simple para este proceso. En caso de no disponer de Python, puede consultarse la salida del script en ep_sat.txt. La nomenclatura de las variables es simple: hijk, donde i indica el núemro de casa, j indica el atributo en cuestión y k indica un posible valor de este atributo. En el código Python pueden verse los detalles de como se traducen las implicaciones y restricciones a disyunciones.
Introduciendo las cláusulas SAT en la página de prueba de DPLL, podemos encontrar el siguiente resultado:
SATISFIABLE h0c4 h1c0 h2c2 h3c1 h4c3 h0n3 h0b4 h0s1 h0p0 h1n1 h1b3 h1s4 h1p4 h2n0 h2b2 h2s2 h2p1 h3n2 h3b1 h3s3 h3p3 h4n4 h4b0 h4s0 h4p2 -h0c3 -h2b0 -h2b1 -h2b3 -h2b4 -h0b2 -h1b2 -h3b2 -h4b2 -h2n1 -h2c1 -h3c3 -h0n0 -h0n1 -h0n2 -h0n4 -h1n3 -h2n3 -h3n3 -h4n3 -h2s0 -h1c1 -h1c2 -h1c3 -h1c4 -h0c0 -h2c0 -h3c0 -h4c0 -h1n0 -h2c3 -h0c1 -h0c2 -h2c4 -h3c2 -h4c2 -h3c4 -h4c1 -h4c4 -h3n0 -h4n0 -h5c3 -h3b0 -h3b3 -h3b4 -h0b1 -h1b1 -h4b1 -h3n1 -h0s0 -h0s2 -h0s3 -h0s4 -h1s1 -h2s1 -h3s1 -h4s1 -h1p0 -h1p1 -h1p2 -h1p3 -h0p4 -h2p4 -h3p4 -h4p4 -h1n4 -h1s2 -h3s0 -h4s4 -h0b0 -h0b3 -h1b4 -h4b4 -h2s4 -h3s4 -h0p1 -h0p2 -h0p3 -h2p0 -h3p0 -h4p0 -h1n2 -h4n1 -h1b0 -h4b3 -h1s0 -h1s3 -h2n2 -h2n4 -h2s3 -h3s2 -h4s2 -h4s3 -h2p2 -h2p3 -h3p1 -h4p1 -h4n2 -h3n4 -h4p3 -h3p2
Como el pez sería la mascota número 3, buscamos apariciones de p3 en una variable no negada y encontramos h3p3. Buscando la nacionalidad del habitante de la casa número 3 encontramos h3n2, por lo que podemos concluir que el alemán es quien tiene un pez como mascota (coincidiendo con la solución dada a este problema).
Espirales sobre Noruega
Hace una semana me mostraron una imagen tan espectacular que inmediatamente sospeché que era falsa:
Pero la explicación real resulta mucho más interesante:
Nota: ahora soy un nuevo miembro del extraño movimiento obsesionado en expresar algo con sentido usando solo 140 caracteres
Los orígenes del dialecto judicial y cosas varias
Una interesante perspectiva “evolutiva” sobre el origen de la complejidad del dialecto utilizado (principalmente) por los abogados:
Why is legal language so hard to understand?
(a) A lawyer always tries to use clauses that
have already been interpreted by the courts
[as you noted].(b) A court interprets a clause only when it is
brought to its attention through litigation.(c) Litigation over the meaning of a clause
occurs only when there is some disagreement
over the meaning of a clause(d) Disagreement over the meaning of a clause
is most likely to occur when the clause is
ambiguous or hard to understand.Therefore, the system produces a kind of Darwinian
pressure: the legal language most likely to survive
is that which on its face is most ambiguous or
difficult to understand.
A continuación, debido a la falta de material adicional completo con la clásica lista de links:
- Como se observa una avalancha en primera persona.
- No todos los herbívoros son inofensivos.
- Radiografías en tiempo real.
- Un interesante video amateur que combina elementos de los universos de Half-Life y Lost.
La caída de las lámparas… resuelta
En un comentario del post anterior, Kardo mostraba una solución al problema en 19 intentos:
… Siguiendo este pensamiento, la idea es probar en los pisos múltiplos de N y, en el momento que la lámpara se rompe, probar en los anteriores N-1 pisos para buscar el piso mínimo. De esta forma, si buscamos el mínimo de la función f(100/N+N-1) con 1<N<100, encontramos que ese valor es 19.
Esa es la mejor solución “inteligible” al problema entre las que vi, pero no llega a ser óptima.
El algoritmo
Para buscar la solución óptima, definamos dos funciones: y
, donde
es la cantidad de pisos que podrían ser el piso mínimo,
es la cantidad de lámparas disponibles y
es un piso determinado desde donde la próxima lámpara será arrojada. La primera función
nos indica la cantidad máxima de lanzamientos de lámparas requeridos según la estrategia óptima, mientras que
nos indica esa misma cantidad con la restricción adicional del piso desde donde se arrojará la próxima lámpara.
Si hay un candidato solo restante no hace falta seguir realizando lanzamientos, por lo que
.
Por otro lado, si solo nos queda una lámpara no podemos arriesgarnos a que se rompa quedando pisos sin explorar y tendremos que ir probando piso por piso. Por consiguiente tendremos
.
En los otros casos habrá que efectuar un cierto número de lanzamientos para determinar el piso mínimo. No podemos saber a priori cual será el piso óptimo desde donde realizar el siguiente lanzamiento, pero sabemos que será uno de los que son candidatos. Por lo tanto podemos decir que
,
ya que la estrategia óptima elegirá el piso desde donde lanzar de modo de minimizar el costo.
Pero cuando realizamos un lanzamiento desde el piso solo podemos tener dos resultados: la lámpara puede romperse o no. En caso de que se rompa examinaremos los
candidatos menores que
, mientras que en caso de que quede intacta haremos lo propio con los
candidatos menores que
. Como se realizó un lanzamiento, el costo será
.
Con esto tenemos definidas casi por completo a las funciones y
, pero quedan pendientes algunas sutilezas que suelen conducir a errores en las definiciones recursivas. En este caso particular, habrá llamadas a
, que nunca fue definida. Podemos resolver esto haciendo que
,
que es más simple que separar los casos en que es innecesario buscar.
El algoritmo implementado
La implementación, en este caso en Python, es directa a partir de las definiciones recursivas:
def cost(n, l):
if n in (0, 1):
return 0
elif l == 1:
return n
else:
return min(fixed_try_cost(t, n, l) for t in xrange(0, n))
def fixed_try_cost(t, n, l):
return 1 + max(cost(t, l-1), cost(n - t - 1, l))
Pero si intentamos ejecutar cost(100, 2), observaremos que la ejecución no termina. Esto es debido a que, al igual que en caso del cálculo recursivo de los números de Fibonacci, se recalculan los mismos valores un gran número de veces. Incluso la ejecución de cost(24, 2) demora decenas de segundos.
El algoritmo utilizando programación dinámica
Una forma común para resolver este problema es aplicar la técnica conocida como programación dinámica, consistente esencialmente en almacenar los resultados previamente calculados de modo de no requerir calcularlos nuevamente. Una forma simple de implementar esto en Python es utilizar un diccionario para almacenar el costo correspondiente a cada valor de y
:
costs = {}
def m_fixed_try_cost(t, n, l):
return 1 + max(m_cost(t, l-1), m_cost(n - t - 1, l))
def m_cost(n, l):
if (n, l) in costs:
return costs[(n, l)]
if n in (0, 1):
c = 0
elif l == 1:
c = n
else:
c = min(m_fixed_try_cost(t, n, l) for t in xrange(0, n))
costs[(n, l)] = c
return c
Se observa que las únicas diferencias (aparte del prefijo m_) son la consulta del diccionario al inicio de la función m_cost y el grabado del valor calculado antes de devolverlo.
Si se desea obtener mejor performance, puede utilizarse una tabla normal y llenarla mediante iteración en lugar de recursión. Por ejemplo, la misma función podría implementarse en C del siguiente modo:
#define MAX_N 200
#define MAX_L 200
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define MIN(a, b) ((a) < (b) ? (a) : (b))
int cost(int n, int l)
{
int costs[MAX_N][MAX_L];
int i, j, k, c;
for (i = 0; i <= n; i++)
costs[i][1] = i;
for (j = 1; j <= l; j++)
costs[0][j] = costs[1][j] = 0;
for (i = 2; i <= n; i++)
{
for (j = 2; j <= l; j++)
{
c = i;
for (k = 0; k < i; k++)
{
c = MIN(c, 1 + MAX(costs[k][j-1],
costs[i-k-1][j]));
}
costs[i][j] = c;
}
}
return costs[n][l];
}
De todos modos, todas las versiones se ejecutan en forma esencialmente instantánea, dando como resultado . Mediante una simple modificación podría devolverse la secuencia requerida en base a lo que sucede con las lámparas al ser arrojadas, obteniendo así un algoritmo para resolver el problema en la práctica.
La caída de las lámparas
We want to figure out how strong our new super-strong light bulbs
are. We know that they will be break when dropped from the top floor
of this building (the 101st). We want to know the exact minimum floor
from which a fall will cause the light bulb to break. You’ll be given
two light bulbs for this task and we want you to do it with the
minimum number of trials.
(Extraído del blog de Louis Brandy.)
Respuestas pendientes
Un problema de sumas
El problema pedía encontrar un algoritmo subcuadrático para determinar si dos elementos en un vector de enteros con una suma dada. Si confiamos en el caracter O(1) de las tablas hash podemos obtener un algoritmo lineal aun más breve que el cuadrático mostrado anteriormente (y sin recurrir al code golfing
):
def sum1_n(s, v):
d = {}
for x in v:
if s-x in d:
return True
d[x] = True
return False
Si no confiamos en eso, puede hacerse en tiempo O(n log n) ordenando la secuencia y reemplazando las búsquedas en la tabla por búsquedas binarias (como sugirió Fer) o directamente escanear el vector ordenado con dos punteros desde los extremos (no es difícil determinar las reglas de avance).
Ahora pueden probar su suerte buscando algoritmos subcuadráticos para resolver 3SUM; es solo un poquito más difícil…
Circuitos hamiltonianos
Este otro problema pedía determinar si era posible encontrar circuitos hamiltonianos en grillas cuadradas con una cantidad impar de vértices. Para ver que esto no es posible, dividamos a los vértices de la grilla en dos clases de acuerdo al siguiente esquema:

División de los nodos del grafo en dos clases, indicadas por el color utilizado para representarlos.
Si recorremos un circuito en el grafo no es difícil convencernos de que en cada paso cambiaremos de clase de vértice. Ahora, en un circuito hamiltoniano la cantidad de pasos será impar, dándonos para el último vértice una clase distinta que para el primero. Como esto es incompatible con el hecho de que se trata de un circuito, es imposible encontrar un circuito hamiltoniano en grillas con una cantidad impar de vértices (no solo cuadradas!).
Circuitos hamiltonianos
Un circuito hamiltoniano es un recorrido cerrado de un grafo que pasa por todos sus vértices una única vez. Si nos limitamos a grafos en forma de grillas cuadradas, podemos encontrar fácilmente un circuito en las grillas con una cantidad par de vértices siguiendo este esquema:
Puede encontrarse un circuito de esta misma naturaleza en una grilla cuadrada con una cantidad impar de vértices? Cambia esto si se permiten grillas rectangulares?
La respuesta a este problema (y al anterior de sumas) en el próximo post…
Números de Stirling de segunda especie
Contar la cantidad de formas en que pueden distribuirse elementos en
cajas es bastante simple, una vez que descubrimos el truco de pensarlo como asignarle un número de caja a cada elemento. Claramente esto nos da
posibles formas. Si ignoramos el “etiquetado” de las cajas, claramente esto nos deja
formas.
Este problema se hace más difícil si agregamos la restricción de que ninguna caja puede permanecer vacía y, de hecho, recuerdo haber esquivado resolver este problema en más de una ocasión (y no soy el único
). Pero en Wikipedia encontré una solución elegante y, tal vez, lo suficientemente memorable para que la recuerde en caso de encontrarme nuevamente con este problema…
Denominemos
al número de distintas formas en las que podemos distribuir elementos en
cajas sin dejar ninguna vacía y sin distinguir entre cajas (o sea el número de distintas particiones de
en
subconjuntos). Entonces podemos dividir las formas de efectuar esta distribución en dos clases: las que ponen al elemento
solo en una caja y las que no lo hacen.
Una vez situado el elemento en una caja, las distribuciones serán equivalentes a
elementos en
cajas. Por lo tanto, habrá
formas de efectuar la distribución dejando solo al elemento .
Por otro lado, si el elemento no se encuentra solo, el problema es equivalente a distribuir
elementos en
cajas sin dejar ninguna vacía y luego insertar el elemento
en alguna de ellas (distinguibles por tener distintos subconjuntos de los elementos). En consecuencia habrá
formas de efectuar la distribución sin dejar al elemento solo en una caja.
En conjunto eso nos deja la relación de recurrencia
para estas cantidades, denominadas números de Stirling de segunda especie.
Una historia interesante
Via Steve Hsu, encontré una interesante entrevista a Daniel Kahneman. Me resultó particularmente interesante el siguiente fragmento (en aquel entonces vivía en una parte de Francia ocupada por los alemanes luego de 1940):
It must have been the fall of ‘41, when there was a curfew for Jews. We were also supposed to be wearing a yellow star, and there was a curfew which I think was 6:00 PM. I was in first or second grade and I’d gone to play with a friend and I was going home and I missed the curfew, I was late. And so, I turned my sweater inside out and walked home, and as I was coming close I remember the street was deserted and there was this German soldier walking towards me. He was wearing the black uniform and I knew that was not good. That was the uniform of the SS. We were walking towards each other and as we were coming close he sort of beckoned me, and of course I went there, and he picked me up and hugged me. I remember being terrified that he would see the Star of David inside my sweater. Then he put me down and took out his wallet and showed me a picture of a boy and gave me some money. That’s a formative memory because of what it meant about the complexity of things. I remember being very fascinated at the time by this and by stories of Hitler liking flowers and kissing babies. The complexity of evil was much on my mind as a seven- or eight-year-old.
Un problema de sumas
Sea v un vector formado por n enteros y sea s un número entero. El problema consiste en determinar de la forma más eficiente posible si existen dos elementos en v, vi y vj (i != j), tales que vi + vj = s.
Es simple hacerlo en tiempo cuadrático O(n2) mediante el algoritmo “de fuerza bruta”:
def sum1_nsq(s, v):
for i in xrange(len(v)):
for j in xrange(len(v)):
if j >= i:
break
if v[i] + v[j] == s:
return True
return False
pero lo interesante es ver si puede mejorarse este tiempo…

