Bactracking y dividir y conquistar

Anuncio
Bactracking y dividir y conquistar
Problemas, Algoritmos y Programación
1.
Backtracking
1.1.
Introducción
Introducción básica (para el que no recuerda la idea de backtracking):
http://es.wikipedia.org/wiki/Vuelta_Atr %C3 %A1s
La idea de resolver un problema con backtracking es mejorar la fuerza bruta.
Fuerza bruta: Generar todas las soluciones válidas y quedarse con la mejor (o la útil, en un problema de decisión).
Generalmente podemos generar las soluciones en pasos, tomando decisiones locales. Por ejemplo, si las soluciones
son todas las permutaciones de cierto conjunto de elementos, en cada paso podríamos preguntarnos qué número de los
que faltan poner a continuación. De esta manera, podemos ver la generación como un árbol, dónde los nodos son los
lugares donde nos preguntamos cosas y cada hijo es una de las posibles respuestas. En las hojas del árbol se encuentran
las soluciones generadas.
Es notable lo simple que es implementar esta generación mediante funciones recursivas, ya que la parte de probar
muchas decisiones sale naturalmente y no necesitamos pensar en como recordar las cosas. En la vista de árbol, vemos
que el árbol teórico de generación se acopla perfectamente a un árbol de recursión de una función implementada.
La idea del backtracking es que hay familias de soluciones que podemos saber de antemano que no van a funcionar,
y que además son reconocibles habiendo generado sólo los primeros pasos de la misma. Siguiendo con el ejemplo de
las permutaciones, quizás poniendo los primeros 3 elementos ya sabemos que ninguna permutación. Esto, en la vista
de árbol, quiere decir que al llegar a un nodo de profundidad 3, sabemos que todas las hojas/soluciones del subárbol
que es descendiente de ese nodo no va a servir. Lo que podemos hacer entonces es podar ese subárbol, es decir, no
continuar generando ninguna permutación que empiece con esos 3 elementos y volver atrás (hacer backtracking) para
seguir probando por otro lado.
Si pensamos nuevamente en la implementación recursiva, las podas se implementan de forma muy simple: NO
llamando a la función recursivamente a partir del nodo que se quiere podar.
En general podemos dividir las podas en 2 familias:
(1) Podas de validez, o a priori. Son podas que las podemos establecer de antemano dependiendo sólo del problema.
Por ejemplo, si sólo nos interesan permutaciones que alternen elementos par/impar, podríamos podar cada vez que los
últimos dos elementos generados tengan la misma paridad. Este tipo de podas podemos hacerlas siempre.
(2) Podas de optimización. En los problemas de optimización, muchas veces el valor de una solución se puede ir
calculando a medida que se genera (por ejemplo, si están generando todos los ciclos hamiltonianos de un grafo, para
resolver el problema del viajante de comercio (TSP) se puede ir calculando su costo total como la suma de los ejes ya
atravesados). Esto permite hacer ciertas podas cuando podemos saber de antemano que todas las soluciones a patir
de cierto estado/nodo del árbol van a ser peores que una solución que ya examinamos, por lo cual podemos podarlas.
Esta clasicación no es exhaustiva ni tampoco exacta. Ciertas podas podría parecer que pertenecen a ambas
familias. La idea de las familias es simplemente tener un indicio de por dónde podemos encontrar podas, no clasicarlas estrictamente. Notar que estamos enfocados en utilizar la técnica para resolver problemas, no en analizarla
teóricamente.
Notar que para las podas de tipo (1), no importa en que orden recorramos los hijos de cada nodo, siempre se va a
podar lo mismo (porque la poda no depende de que soluciones fueron recorridas previamente). En cambio, las podas
de tipo (2) aumentan su eciencia notablemente si el algoritmo encuentra soluciones buenas rápidamente. En este caso
puede convenir utilizar heurísticas para priorizar explorar ciertas ramas antes que otras.
En casos muy extremos podríamos llegar no sólo a priorizar los hijos de un nodo y recorrerlos en ese orden, sino
incluso a alterar el DFS y posponer nodos poco promisorios que quizás podamos podar mas adelante. Esta idea implica
1
2do
Problemas, Algoritmos y Programación
cuatrimestre de 2011
mucha mas labor de implementación, pero para problemas grandes y necesarios puede valer la pena. En el tamaño de
problema que atacamos en esta materia, seguramente no tengamos que hacerlo, pero vale la pena tenerlo en cuenta
para el futuro.
Muchos problemas de decisión se pueden resolver con backtracking con cierta eciencia. En principio podría pensarse
que las podas de tipo (2) no aplican a problemas de decisión, pero, contrariamente, aplican fuertemente. Si estoy
buscando si existe una combinación de cosas que cumpla cierta propiedad (por ejemplo, una permutación de los nodos
de un grafo sin pesos que sea un camino hamiltoniano), una vez que encuentro una, ya se que la respuesta es sí, con
lo cual podo todo el resto (podemos pensar que para todos los nodos vale que en su subárbol no puede haber una
solución mejor que sí).
1.2.
Ejemplos
En los ejemplos van unos links para tratar de resolver el problema con un juez online similar a los que utilizaremos
para los TPs.
Sudoku
Enunciado clásico:
http://uva.onlinejudge.org/index.php?
option=com_onlinejudge&Itemid=8&category=11&page=show_problem&problem=930
Variante dicil:
http://uva.onlinejudge.org/index.php?
option=com_onlinejudge&Itemid=8&category=20&page=show_problem&problem=1834
Variante de reglas heurísticas de llenado:
http://acm.uva.es/archive/nuevoportal/data/problem.php?p=3351
Explicación del uso de backtracking para Sudoku (lo que hubiéramos hecho en clase):
http://es.wikipedia.org/wiki/Sudoku_backtracking
Problema de las 8 reinas
Enunciado clásico, explicación e historia: http://es.wikipedia.org/wiki/Problema_de_las_ocho_reinas
Problema similar al enunciado clásico: http://uva.onlinejudge.org/external/1/167.html
1.3.
Backtracking vs Goloso
Es interesante pensar en la relación entre algoritmos de backtracking (o fuerza bruta pero generando las soluciones
paso a paso) y golosos. Mirando el árbol, podríamos pensar que un algoritmo goloso es el que simplemente elige una de
las opciones para bajar a un hijo, podando siempre todas las otras ramas, mientras que el backtracking potencialmente
recorre las otras.
Esta mirada sirve para tener un panorama mas genérico de lo que es una solución, pero también para pensar en
algoritmos híbridos, que eligen golosamente, pero no siempre, aunque casi.
Otra cosa adicional para pensar es que todo algoritmo goloso podríamos pensarlo como un algoritmo de backtracking
con una heurística de priorización de hijos muy buena, pero con algunas sutilezas.
Pensemos en el siguiente problema como ejemplo: http://www.borderschess.org/KnightTour.htm.
Aquí tenemos la presentación de una solución golosa a este problema (no es importante para lo que sigue entenderla
en detalle): http://www.borderschess.org/KTsimple.htm
Pensemos ahora en dos implementaciones:
(1) Algoritmo goloso utilizando esa idea.
(2) Algoritimo de backtracking que prioriza el movimiento planteado por la estrategia golosa y que corta apenas
encuentra un camino.
Debería ser claro que ambos algoritmos terminan haciendo básicamente lo mismo (si aceptamos que el goloso
funciona) y, por lo tanto, tomando el mismo tiempo. Lo interesante aquí es ver la diferencia:
Para (1) la demostración de que la complejidad es lineal en la cantidad de casillas del tablero es trivial, pero la
2
2do
Problemas, Algoritmos y Programación
cuatrimestre de 2011
demostración de correctitud requiere de un argumento complejo.
Para (2) es al revés, la correctitud es trivial, porque en caso de que la heurística falle, el algoritmo probará todos
los caminos posibles, pero el análisis directo de complejdiad nos daría algo exponencial. Sin embargo, aplicando
mismo
el
argumento complejo que antes usábamos a la hora de demostrar correctitud, podemos demostrar una cota de
complejidad lineal para este algoritmo también.
Tanto en (1) cómo en (2) aparece en la explicación de la solución, el algoritmo de decisión golosa. En (1) es
prácticamente todo el algoritmo, mientras que en (2) es sólo la heurística que prioriza que hijo visitar primero al
recorrer el árbol.
La diferencia está entonces en como repartir la dicultad entre las distintas secciones de la solución. A partir de este
ejemplo extremo también podemos pensar en la posibilidad de reparticiones parciales, es decir, poner parte de la idea
importante en la demostración de correctitud y parte en la complejidad. También nos permite hacer relaciones como
pensar en un algoritmo goloso como en el límite mas extremo posible del renamiento de las podas y las heurísticas
utilizadas en un backtracking.
2.
Dividir y conquistar
La téncnica de dividir y conquistar ya ha sido vista extensamente en Algoritmos II. Para un recordatorio, ver aquí:
http://en.wikipedia.org/wiki/Divide_and_conquer_algorithm
http://es.wikipedia.org/wiki/Teorema_maestro
2.1.
Ejemplos clásicos
1. Merge-sort: http://es.wikipedia.org/wiki/Merge_sort
2. Quick-sort: http://en.wikipedia.org/wiki/Quick_sort
3. Búsqeda binaria: http://es.wikipedia.org/wiki/Busqueda_binaria (ver también binary_search, lower_bound y
upper_bound en algorithm de la STL de C++).
4. Búsqueda ternaria: http://en.wikipedia.org/wiki/Ternary_search
Lo que queremos recalcar de esta técnica es su obvia conexión con las implementaciones recursivas. Lo más importante es olvidarse de como se resuelve el caso recursivo al implementar el combinar. Se debe conar en que la
devolución recursiva es correcta, para a partir de ahí pensar en como combinarla.
Una técnica a tener muy en cuenta es la generalización: Hacer una función con un resultado mas general que el
buscado puede resultar más simple. Esto implica siempre un equilibrio, una función general implica mas información
obtenida recursviamente, pero tenemos que tener en cuenta que debemos ser capaces, al mismo tiempo, de generar
esa información adicional en el combinar.
2.2.
Encuentro en la mitad
Además de las búsquedas binaria y ternaria, la aplicación que veremos de dividir y conquistar es una técnica mixta
que se llama encuentro en la mitad. Veamos, como ejemplo, la siguiente versión del teorema de la mochila:
Dado un conjunto de N enteros, calcular cuantos resultados distintos se pueden obtener como suma de sus elementos.
Un algoritmo de backtracking (o fuerza bruta, ya que no realizaremos podas), podría ser generar todos los subconjuntos. En este caso el árbol tendría como altura máxima
N
y sería binario (en cada caso tengo 2 opciones posibles,
contar o no contar el elemento). Una cosa notable es que sí realizamos la suma al llegar a la hoja obtenemos una
complejidad de
O(n2n )
mientras que si vamos haciendo la suma parcial en cada estado, resulta
O(2n ).
Ahora, si aplicamos el concepto de dividir y conqusitar aquí, podemos partir el conjunto en dos conjuntos de
N/2 elementos y obtener todas las sumas posibles de cada uno de ellos mediante cualquier método (por ejemplo, el
anterior) y luego combinarlas usando
O(X × Y )
donde
X
Y la
O(22n/2 + X × Y ) =
es la cantidad de sumas distintas de un conjunto e
cantidad del otro. En peor caso, cuando todos los subconjuntos suman distinto, esto tardaría
O(22n/2 +2n/2 ×2n/2 ) = O(2n ) que es la misma complejidad que traíamos de antes. Ahora, dependiendo de que valores
3
2do
Problemas, Algoritmos y Programación
cuatrimestre de 2011
puedan tomar los enteros del conjunto, lo más probable es que haya muchas colisiones entre las posibles sumas, lo que
reduce notablemente el valor de
X
e
Y
del peor caso mencionado, reduciendo el término dominante de la complejidad.
Obviamente este approach se puede utilizar recursivamente, llegando a una implementación clásica de una algoritmo
de dividir y conquistar.
Esta técnica se llama encuentro en la mitad (meet in the middle) porque hacemos solo la mitad del camino
utilizando fuerza bruta/backtracking. Como la complejidad de hacer fuerza bruta o bactracking es usualmente exponencial, hacer la mitad del camino resulta en una complejidad que es la raiz cuadrada de hacer todo el camino. Si
tenemos suerte y los resultados que obtenemos de cada mitad no son muchos, la parte de combinarlos puede ser incluso
mas rápida que el procesamiento recursivo de las mitades. En el ejemplo anterior, si los valores que pueden tomar los
elementos del conjunto son bastante chicos respecto de
2N ,
entonces la cantidad de sumas posibles (que está acotada
por la suma de todos los elementos) va a ser chica relativamente.
Ojo! Este problema que usamos como ejemplo aquí se puede resolver mejor con programación dinámica, pero eso
es para otra clase.
4
Descargar