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