24 de mayo de 2018 Laboratorio de Algoritmos y Estructuras II CI2692
Proyecto 1 Problema del Caballo
Integrantes: Jaua, Nicolás 15-10732 Lerones, Marcos 15-10778
Introducción: En este proyecto analizamos el problema del caballo o “knight’s tour”, donde dado un tablero de tamaño n*n se busca un recorrido que pase por cada casilla del tablero una sola vez, usando movimientos de un caballo de ajedrez. Con este trabajo conseguimos dos modos de encontrar soluciones, a través de la fuerza bruta, y ayudados por la regla Warnsdorff, y usando el método de dividir y conquistar o “divide and conquer”. También creamos un algoritmo que permite al usuario buscar su propio recorrido. Como correr el programa: Para correr este programa debemos correrlo desde la consola. Al abrirlo te pedirá los números uno, dos o tres para escoger qué modalidad se quiere usar. Al escoger 1 se entra en el modo manual, donde se le pedirá al usuario el tamaño que quiere para el tablero y las coordenada en las que quiere comenzar. Después de esto se cargará el tablero con el tamaño especificado y con las coordenadas dadas ocupadas por una ‘k’, que representa la posición del caballo y el resto del tablero ocupado por ‘o’ o por números del 0 al 7. Tanto las ‘o’ como los números son casillas vacías. Los números representan casillas accesibles desde la casilla actual, y al ingresar alguno de los números visibles en el tablero el caballo se moverá a esa casilla, dejando una x en las casillas ya visitadas. Después de un movimiento se puede deshacer la jugada ingresando ‘-1’ a la consola. Al rellenar el tablero, se mostrará lleno de x, excepto por la última posición del caballo, donde estará una k y culminará el programa. Si se llega a una posición sin salida en la que el tablero no está resuelto, el programa también culminará. Al escoger la opción 2 se entra en el modo backtracking, donde se le pedirá al usuario el tamaño del tablero y devolverá un tablero relleno con números del 0 al n 2-1, donde estos describen el orden, siendo el 0 la casilla inicial, el 1 la siguiente y así consecutivamente. Después de imprimir el tablero, el programa culmina. Si para el n dado no existen recorridos posibles, el programa devolverá “No existe un recorrido posible para el n dado”. La opción 3 ofrecerá respuestas de la misma forma que la opción 2. Como se modeló el tablero: Creamos un tablero en la consola usando ‘|’ para separar cada casilla. En el modo manual el tablero tiene al inicio 'o’ en las casillas vacías y 'k’ en la casilla ocupada por el caballo. Al moverse las casillas por las que ya se pasó van a tener una x. En las casillas que pueden ser accedidas desde donde está el caballo se mostrarán números del 0 al 7, que al ser ingresados en la consola harán que el caballo se mueva a esa posición. El tablero se imprimirá en cada turno. En el modo Brute y Divide and Conquer el tablero se imprime una sola vez, de haber un recorrido posible. Mostrará números del 0 al n 2-1 siendo el 0 la posición inicial del caballo, y para cada i, i+1 va a ser la posición siguiente. n 2-1 va a ser la posición final del caballo, y si desde el n2-1 se puede llegar al 0, el recorrido es cerrado. Como se almacenó el recorrido: Para la modalidad manual, se almacena el recorrido con las listas Hx y Hy que te dicen las posiciones por las que ha pasado el caballo en orden. Se hizo así para facilitar la implementación de la función de Undo o Deshacer. Además se tiene un arreglo que representa el tablero, y tiene 1 si ya fue visitado ó 0 sino.
Por otro lado, en las otras dos modalidades se usa un arreglo cuadrado nxn inicializado en -1 (número que indica que una casilla no ha sido visitada) y a medida que se va resolviendo el tablero se enumeran las casillas por donde pasa el caballo de 0 a n 2-1. Esto se hizo así para facilitar la unión de varios tableros en el D&C.
Como se implementaron las 3 modalidades: -Manual: Creamos una función manual que recibe 7 argumentos: ● n: El tamaño del tablero ● Kx,Ky: La posición actual del caballo ● V: El tablero lógico con las posiciones visitadas ● ct: La cantidad de movimientos realizados ● Hx,Hy: Historial de los movimientos realizados por el jugador Primero dibujamos el tablero en cada turno con dibujarTabMan, luego vemos si ya terminó el camino, ya sea porque recorrió todas las casillas (ct==n2-1) o porque ya no tiene más movimientos (función overMan). Luego se pide el siguiente movimiento, si desea deshacer, se eliminan los últimos elementos de Hx y Hy y se vuelve a llamar a manual desde la posición anterior. Si no desea deshacer, se ve si su movimiento es válido (función valid) y si lo es se llama a manual desde esa posición. Si el movimiento es inválido, se vuelve a llamar a manual desde Kx,Ky. -Fuerza Bruta: Creamos la función “brute” que recibe un alto y un ancho para el tablero, la posición del caballo, un tablero con el orden de las posiciones visitadas y ‘ct’, un contador del número de casillas visitadas. Al iniciar se ingresa el tablero lleno de ‘-1’ excepto en la posición (0,0), se ingresa el contador en 1, y Kx, Ky, que son las coordenadas del caballo, en 0 cada una. Dentro de la función lo primero que se hace es revisar si el contador alcanzó el valor de n 2, de modo que dibuja el tablero y devuelve 1, finalizando el programa. En caso de que esto no se cumpla, revisa con la función over si el caballo llegó a una posición donde no tiene más movimientos, de ser así cambia la casilla a -1, de manera de que se cuente como vacía, y devuelve 0 para que pase a la siguiente opción posible, en vista de que a través de ella no se llega al resultado. Luego de esto el programa usa una heurística para ir hacia los caminos más probables primero. Se crea un arreglo “moves” que guarda los numeros del 0 al 7, que al ser llamados se usarán como índice de las listas vert y horiz, que indican cómo moverse desde la casilla actual. También se crea un arreglo “wandsorf” de largo 8 cuyas casillas son 10, de manera que siempre sea mayor al número de casillas a las que se puede acceder, que es máximo 8. A continuación hay un ciclo donde se revisa cuántos movimientos hay desde cada una de las casillas accesibles desde donde está el caballo. Esto será almacenado en “wandsorf”, y en el caso de que una casilla no sea válida, tendrá el valor inicial que se le había dado a toda la lista, que es 10.
Después del ciclo se usa la función quicksort randomizado hecha en el laboratorio, de manera que ordena moves en base a lo guardado en “wandsorf”. De esta manera en moves quedarán los movimientos posibles en orden de conveniencia. Finalmente se hace un ciclo donde i se mueve en la lista moves, y se le asigna a ‘x’ y ‘y’ los valores que resultan de vert y horiz en i. Se revisa si moverse a x,y sería válido y de ser así se le asigna el contador a esta casilla y se llama recursivamente a la función brute, que si devuelve un 1 significa que llega a completar el tablero y si devuelve 0, entonces se pondrá la casilla donde se probó como vacía y se volverá al comienzo del ciclo. Si el ciclo llega a terminar sin resultado esto será porque ninguna opción desde esa casilla permite terminar el recorrido, por lo que se le asignará -1 a la casilla donde se probó y se devolverá 0. De haber un recorrido se llegará a este y se dibujará el tablero y se retornará 1 hasta salir de la función; y de no haberlo, se retornará 0 hasta salir de la función, donde que por devolver 0, el programa imprimirá que no hay recorrido posible. -Divide and Conquer: Antes de hacer la función del Divide and Conquer se calcularon los casos base con la función “NotSoBrute”, que funciona como la usada en la Fuerza Bruta, pero de manera que forme recorridos estructurado, es decir que siempre que el caballo pueda acceder a una de las esquinas, entre a esta, y que si está en una de las casillas vecina a una esquina vaya a la casilla que está a dos posiciones de esa esquina. También revisa que los recorridos resultantes son cerrados, es decir que desde la última casilla del recorrido se podría acceder a la primera a través de un movimiento de caballo. Se usó esta función para calcular todos los casos base: 6*6, 8*8, 5*6, 6*7, 7*8, 8*9, 9*10, 10*11, 6*8, 8*10, y 5*5, 7*7 y 9*9, pero estos tres últimos con dos casos diferentes, uno en el que el recorrido no pasa por una esquina, pero por el resto de las casillas que pasa forma un recorrido cerrado y estructurado, y otro caso donde resuelve el tablero completo pero el recorrido no es cerrado, equivalente a un resultado de 5*5, 7*7 o 9*9 con la función brute. Estos casos son guardados en un archivo .txt que después será leído durante la función de Divide and Conquer. La función en si solo pide el largo y ancho del tablero, y una variable booleana que almacena si esta es la primera vez que se llama a la función, para saber cómo devolver los tableros con largo y ancho impar. La función va a devolver un tablero con el recorrido de la misma forma en que lo devuelve brute. Luego la función presenta a través de ifs los diferentes casos que se pueden dar en la búsqueda de un recorrido de un tablero n*n. Si el tablero es uno de los casos base, se carga el resultado del archivo “base_case.text”. Si no, para resolver un tablero m*n, hay varios casos: ●
m==n ^ m%4==0: En este caso, se calcula recursivamente el resultado del tablero (n/2)*(n/2) y se crean 4 tableros iguales con esta solución. Como los 4 son cerrados y estructurados, podemos unirlos para crear un tablero n*n cerrado y estructurado, y esto lo hacemos mediante la función unir_simple. unir_simple recibe 4 tableros con caminos estructurados y cerrados y los une de la siguiente forma.
●
m==n ^ m%2==0 ^ m%2!=0: En este caso se calcula recursivamente un camino en un tablero (n/2)x(n/2) que no pasa por la esquina inferior derecha, y que es cerrado y estructurado. Se crean otros 3 tableros rotando 90, 180 y 270 grados el anteriormente calculado. Luego se unen con unir complex que lo hace de la siguiente forma.
●
m==n ^ m%2==1: En este tenemos dos casos, m//2 es par o m//2 es impar. Si es par, se crea recursivamente un camino cerrado y estructurado para (m//2)x(m//2), uno para (m//2)x(m//2 + 1), uno para (m//2 + 1)x(m//2) rotando el anterior, y uno para (m//2 +1)x(m//2 +1). Si se desea que sea cerrado, estructurado y sin una esquina, se unen con unir_simple, sino, se usa la función unir impar que une 4 tableros cerrados y estructurados en el que uno no tiene una esquina, para crear un camino abierto. unir_impar funciona de la siguiente forma:
●
●
Si m//2 es impar se sigue el mismo procedimiento, con la única diferencia de que ahora el tablero que no tendrá una esquina es el (m//2)x(m//2) en lugar del (m//2 +1)x(m//2 +1) n==m+1: Este caso se usa para completar tableros n*n, donde n es impar. A su vez se divide en 4 casos: si m es divisible entre 4, si n es divisible entre 4, si m es par y no es divisible entre 4 y si n es par y no es divisible entre 4. En todos los casos se creará una variable l=m//2 si m es par o l=n//2 si n es par. En el caso donde m es divisible entre 4 se dividirán los lados del tablero. m quedará como l+l y n como l+(l+1), y esto resultará en dos tableros l*l y dos tableros l*(l+1) que serán calculados recursivamente y unidos con unir_simple. Si n es divisible entre 4 se resultarán dos tableros (l-1)*l y dos tableros l*l, que también serán calculados recursivamente. Estos funcionan ya que l es par, y siempre que un tablero tenga uno de sus lados pares y sean mayores que 4 va a haber una solución cerrada que abarque todas las casillas. En caso de que l sea impar, es decir, m o n sean pares pero no divisibles entre 4, no sé podrá dividir de igual manera que en los casos anteriores, ya que estos los tableros cuadrados l*l con l impar no tienen una solución cerrada. Por esto, para m par y no divisible entre 4, se divide la altura del tablero (m) en l+1 y l-1, y el ancho del tablero (n) en l y l+1. Esto resulta en cuatro tableros de diferente tamaño: (l+1)*l,(l+1)*(l+1), (l-1)*l y (l-1)*l+1; en lo que todos tienen al menos un lado para y serán unidos usando la función unir_simple. De igual manera, para n par pero no divisibles entre 4 se divide la altura en l y l-1 y el ancho en l+1 y l-1, resultando en cuatro subtableros: l*(l+1), l*(l-1), (l-1)*(l+1) y (l-1)*(l-1), que también serán unidos por unir simple. En los casos como l*(l-1) o (l+1)*l se calculan tableros (l-1)*l y l*(l+1), que luego se rotarán, debido a que divide and conquer solo calculate tableros donde m es menor o igual que n, es decir, la altura menor o igual que el ancho. n==m+2 ^ n%2==0: Este caso se usa únicamente para calcular los tableros (l-1)*(l+1) usados en el caso donde n==m+1. En vista de que ambos son pares simplemente se calcularán recursivamente cuatro tableros (m//2)*(n//2), que serán unidos con unir_simple.
Complejidad: -BackTracking: La complejidad de nuestra implementación para el back-tracking es de orden O(2 (n^2)). Esta cota fue calculada viendo la cantidad de posibles estados que puede tener un tablero nxn, teniendo un 1 si la casilla ya fue visitada o 0 sino. Nuestro problema nunca puede alcanzar estos 2(n^2) estados, pero aún así nos sirve como cota superior para el tiempo nuestro algoritmo. En el mejor caso nuestra implementación corre en tiempo lineal con respecto a la cantidad de casillas O(n 2). También añadimos a nuestra implementación del backtracking una heurística (la regla de Warnsdorff) que consiste en visitar la siguiente casilla que tenga menos vecinos no visitados, lo cual tiene un costo extra en operaciones para aplicarla, pero nos ayuda a encontrar una solución más rápido, ya que debe probar menos casos. -NotSoBrute: Tiene la misma complejidad que el anterior y usa los mismo principios, pero tiene otras consideraciones ya que debe producir una solución estructurada y cerrada para poder ser usada como caso base en la recursión del D&C.
-D&C: Nuestro algoritmo D&C tarda T(n) = 4T(n/4) + O(n2), porque se divide en 4 el tablero y el proceso de unir los 4 toma O(n 2) operaciones, entonces, por tercer caso del Teorema Maestro, el algoritmo tarda O(n 2). Nótese que los casos base fueron precalculados con otro programa y almacenados en el archivo “base_case.txt”, por lo que responder un caso base cuesta O(1), ya que solo hay que leer del archivo de texto. Conclusiones: -Logros: Con este proyecto logramos los objetivos de calcular recorridos de caballos de ajedrez en un tablero a través de 3 métodos diferentes. Creamos un modo manual que permite al usuario buscar el recorrido por si mismo. Creamos un modo que usa backtracking para conseguir un recorrido, que resuelve hasta tableros de tamaño 31*31. Y creamos un modo que a través del uso de tableros más pequeños, los une permitiendo conseguir recorridos en tableros de muy gran tamaño. Los tres modos fueron realizados exitosamente. -Comparación de los algoritmos: El algoritmo de fuerza bruta permite calcular recorridos abiertos que tengan hasta 31 de tamaño, limitado por la cola de recursión de Python. Mientras tanto, el algoritmo de divide and conquer logra hacer tableros, estructurados y cerrados para tamaños pares y abiertos para impares para tamaños arbitrariamente grandes en tiempo O(n^2). -Aprendizaje: Realizando este proyecto nuestra experiencia con funciones recursivas y con el método divide and conquer, ya que tuvimos que realizar funciones complejas de este tipo. Fue necesario investigar profundamente sobre el tema, y a través de análisis profundo, ensayo y error, e investigar fuimos consiguiendo lo necesario para crear las funciones y darnos cuenta de lo que nos faltaba. Con este proyecto nos enfrentamos a múltiples llamadas de error, que analizamos y exploramos el código para conseguir los errores. -Recomendaciones: Recomendamos utilizar la regla de Warnsdorff para acelerar el algoritmo de fuerza bruta. Además, se deben tomar como casos base los tableros nxn para 5<=n<=9, nx(n+1) para 5<=n<=10 y nx(n+2) para n=6 y n=8. Fuentes consultadas: https://www.sciencedirect.com/science/article/pii/S0166218X04003488 “An efficient algorithm for the Knight’s your problem” de Ian Parberry