BUSQUEDAS
EJEMPLOS DE
APLICACIÓN DE TÉCNICAS DE BÚSQUEDA.
Descripcion del problema:
Descripción del problema:
Todos hemos jugado alguna vez a armar un rompecabezas. Quizá recuerdes aquellos rompecabezas en los que te daban un tablero con pequeños cuadros y un espacio vacio que te permitía mover los cuadros hasta armar una figura. Si no los recuerdas, la figura de abajo seguramente refrescará tu memoria.
En la figura se muestra el mismo rompecabezas 2 veces, la primera imagen es el rompecabezas armado y la segunda el rompecabezas desarmado.
En este tipo de rompecabezas, puedes mover cualquiera de los cuadros contiguos al cuadro vacio (cuadrito blanco de la figura).
Para este problema se utilizará un rompecabezas de 9 cuadritos en una figura de 3 x 3 cuadros y un cuadro vacio. Deberas escribir un programa que dada la configuración inicial del rompecabezas determine cual es el número mínimo de pasos que se tienen que realizar para armar el rompecabezas. Se considera un paso el mover una pieza cualquiera hacia el espacio vacio.
Descripcion de la entrada:
Tu programa deberá leer del teclado la configuracion inicial del rompecabezas. La configuración inicial estará dada por una secuencia de 10 números entre el 0 y el 9, donde el 0 representa al cuadro vacio y los números del 1 al 9 representan a las piezas. Por ejemplo, la secuencia de números {1 2 0 4 6 3 7 5 8 9} representa la configuración:
1 |
2 |
0 |
4 | 6 | 3 |
7 | 5 | 8 |
9 |
Tu programa deberá buscar la forma de llegar a    
1 |
2 |
3 |
4 | 5 | 6 |
7 | 8 | 9 |
0 |
Los dos cuadros a la izquierda de la última fila no pueden utilizarse por ninguna pieza.
La secuencia de números se te dara en una sola línea con cada número separado del anterior por un espacio.
Descripcion de la salida:
Tu programa deberá escribir en la pantalla un único número que indica la cantidad mínima de movimientos que se requieren para armar el rompecabezas.
Ejemplo:
Entrada | Salida |
1 2 0 4 6 3 7 5 8 9 | 5 |
Condiciones de ejecucion.
Tu programa debera ejecutarse en un tiempo maximo de 1 segundo.
Estructura de la solución: ¿Qué nos están
pidiendo? El número mínimo de
movimientos para llegar de una permutación inicial de los números {0..9} a la
permutación {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}, utilizando únicamente los
movimientos permitidos por el problema.
Es decir, acomodar los números sobre el tablero y mover el 0 ya sea
hacia arriba, abajo, izquierda o derecha según sea posible.
Modelo del espacio de búsqueda como árbol: La raíz de nuestro árbol es el tablero
inicial, en cada nodo, dependiendo de la posición del cuadro vacío {0}, se
podrá pasar a 1, 2, 3 ó 4 nuevos estados.
Por lo que nuestro árbol de búsqueda quedaría de la siguiente
forma: en la raíz, el tablero inicial,
como hijos de la raíz cada uno de los estados a los que se puede pasar por
medio de un movimiento permitido, etc.
El árbol se muestra en la figura de abajo.
1 | 2 | 0 |
4 | 6 | 3 |
7 |
5 |
8 |
9 |
1 | 0 | 2 | 1 | 2 | 3 | ||||||||||
4 | 6 | 3 | 4 | 6 | 0 | ||||||||||
7 | 5 | 8 | 7 | 5 | 8 | ||||||||||
9 | 9 |
0 | 1 | 2 | 1 | 2 | 0 | 1 | 6 | 2 | 1 | 2 | 3 | 1 | 2 | 3 | 1 | 2 | 0 | ||||||||||
4 | 6 | 3 | 4 | 6 | 3 | 4 | 0 | 3 | 4 | 0 | 6 | 4 | 6 | 8 | 4 | 6 | 3 | ||||||||||
7 | 5 | 8 | 7 | 5 | 8 | 7 | 5 | 8 | 7 | 5 | 8 | 7 | 5 | 0 | 7 | 5 | 8 | ||||||||||
8 | 9 | 9 | 9 | 9 | 9 |
Técnica de búsqueda a utilizar: Para este
problema no solo queremos llegar a la solución, sino que necesitamos saber cual
es el número mínimo de movimientos
para llegar a la solución, dado que cada nivel en el árbol de búsqueda
representa un movimiento, la solución más cercana a la raíz es la solución que
buscamos. Y cuando necesitamos la
solución más cercana a la raíz utilizamos búsqueda en amplitud.
Detalles de implementación: Una búsqueda en amplitud se implementa
utilizando una cola en la que se introducen uno a uno todos los nodos a los que
se puede llegar desde el nodo que se esta procesando. Para este caso en particular, cada estado nos puede llevar a un
máximo de 4 nuevos estados, dado que no conocemos si existe un limite teórico
para el número de movimientos que nos llevan a la solución no sabemos cual es
la profundidad a la que tendremos que descender para encontrar una solución.
Una forma de implementar nuestra solución sería
representar cada estado como un arreglo de 10 bytes, tomando el peor caso de
crecimiento, es decir 4 nuevos estados por cada estado, para una profundidad de
10 movimientos requeriríamos de un total de memoria de 410 arreglos
de 10 bytes, es decir aproximadamente 10MB, para 12 movimientos requeriríamos
de 160MB y para 13 de 640MB. Aunque
quizá muchos tableros se puedan resolver en menos de 10 movimientos (el del
ejemplo es uno de ellos), seguramente hay algunos que necesitan mas, y en esos
casos nuestra solución no resultaría posible de implementar por limitaciones de
memoria (recuerden el caso de la aguja en el pajar, necesitábamos un cuarto lo
suficientemente grande). Necesitamos
por lo tanto hacer algo para reducir la cantidad de memoria que necesitamos.
Una de las formas más comunes de reducir el espacio
de búsqueda en una búsqueda por amplitud es el almacenar los estados que ya han
sido revisados para no volverlos a revisar.
Siempre que se
aplica una poda, debe revisarse antes que esta poda no elimine alguna posible
solución. En el caso de esta poda,
podemos estar seguros de que al aplicarla no eliminamos ninguna solución por la
siguiente razón, si llegamos a un estado que ya habíamos revisado anteriormente
quiere decir que había una manera más corta de llegar a dicho estado, por lo tanto
hay una manera mas corta de llegar a todos los estados que se deriven de
él. Como estamos buscando la manera mas
corta de llegar a la solución, entonces podemos eliminar dicho estado y todo su
subárbol sin miedo a eliminar una posible solución mejor.
Si revisamos con más cuidado el árbol de la figura,
veremos que de cualquier estado siempre se deriva uno que es idéntico a su
padre, el cual surge de “regresar” el último movimiento. Este estado siempre puede ser eliminado, ya
que nos lleva a un estado previamente visitado. Tomando esto en cuenta, entonces nuestro espacio de búsqueda ira
creciendo máximo 3 nuevos estados por cada estado en vez de 4. Si de nuevo utilizamos la representación de
10 bytes para cada posible estado tenemos que para 13 movimientos necesitamos
apenas 16MB, sin embargo, aunque esta mejora nos aumenta algunos movimientos,
difícilmente podremos pasar de 15 movimientos lo cual pudiera no ser
suficiente. Necesitamos pues alguna
otra poda.
De nuevo hay que analizar el espacio de búsqueda para
encontrar formas de optimizar la búsqueda (este procedimiento es algo que tendrás que
realizar constantemente con todos los problemas, hasta que estés seguro que
tienes la mejor solución o que tu solución es suficientemente buena para las
restricciones de tiempo y memoria del problema). Con nuestra poda anterior eliminamos algunos de los nodos
repetimos, sin embargo no se eliminaron todos los nodos repetidos, únicamente
aquellos que “regresaban” el último movimiento. Para poder eliminar todos los movimientos repetidos requerimos
de tener almacenados en algún lugar todos los nodos que ya hemos revisado, de
modo que para cada nodo nuevo podamos verificar si se encuentra entre los
anteriormente revisados y de ser así eliminarlo. Como no sabemos cuales nodos vamos a revisar, necesitamos
espacio suficiente para almacenar entre los revisados todos los estados
posibles, ya que en el peor de los casos la solución podría ser el último
nodo que revisáramos, una vez que todos los estados anteriores ya han sido
revisados.
La siguiente pregunta que debemos hacernos por tanto
es ¿Cuántos estados posibles hay en nuestro problema? Dado que cada estado se representa por una permutación de 10
números, la cantidad de estados posibles es de 10! ≈ 3.6 millones, si a cada uno de estos estados lo
representamos como un arreglo de 10 bytes, entonces tenemos que necesitamos un
total de 36MB para almacenar los estados visitados, además necesitamos memoria
para la cola, como en el peor de los casos vamos a introducir cada estado en la
cola solamente una vez, entonces la cola jamás contendrá mas de 10! Elementos,
lo que nos indica que con otros 36MB tenemos suficiente para la cola, esto nos
da un total de 72MB máximo, sin importar la cantidad de movimientos necesarios. Si
bien, esto es una cantidad de memoria grande, al menos es una cantidad de
memoria realista.
Podríamos pensar que ya tenemos nuestra solución
ideal para el problema, pero todavía queda un pequeño detalle por
resolver. Todavía necesitamos saber
como vamos a buscar si un nuevo estado se encuentra entre los estados
previamente revisados. Como nuestra
representación de un estado es un arreglo de 10 bytes, tenemos varias opciones
que se enumeran a continuación:
·
Cada
que tengamos un nuevo estado simplemente lo agregamos a una lista de estados
visitados: Si utilizamos este método,
cada que queramos revisar si algún estado se encuentra en la lista tendremos
que revisar toda la lista, lo cual nos llevará un tiempo proporcional al número
de elementos de la lista, y esto tendremos que hacerlo para cada nuevo
estado. Como el número de elementos de
la lista puede ser hasta de 10! y el
número de estados que podemos revisar es también de hasta 10! esto nos da que
en el peor caso teórico tendríamos que revisar (10!)(10!) , mas de
10,000,000,000,000 veces. Obviamente
esta solución no es viable por tiempo.
·
Cada
que tengamos un nuevo estado agregarlo a una arreglo ordenado: Buscar en una
arreglo ordenado se puede llevar a cabo con una búsqueda binaria en tiempo
logarítmico esto nos reduciría el numero de búsqueda a (10!) log (10!)
aproximadamente 75,000,000, esto ya es realizable por una computadora moderna
en fracciones de segundo. Sin embargo
introducir un elemento en una arreglo ordenado nos lleva un tiempo proporcional
al tamaño del arreglo, por lo que si bien la búsqueda es rápida, la inserción
de cada nuevo elemento vuelve a ser cuadrada del orden de millones de millones.
·
Utilizar
un árbol binario de búsqueda para almacenar los estados visitados: Esta solución ya es mucho más viable, ya que
un árbol de búsqueda requiere de un tiempo logarítmico tanto para buscar un
elemento como para insertar uno nuevo.
Sin embargo, aunque sería suficiente para el caso promedio pueden
existir casos en los que el árbol de búsqueda quede degenerado y el tiempo de
acceso e inserción no sean logarítmicos.
·
Utilizar
un árbol binario de búsqueda balanceado para almacenar los estados visitados: Esta solución nos
asegura un tiempo logarítmico de acceso e inserción sin importar cual sea el
caso de prueba, sería por lo tanto la mejor de todas las soluciones y la única
que tendría esperanzas de correr tanto en espacio como en tiempo.
El análisis anterior no es muy alentador, la única
solución viable es utilizar una estructura de datos avanzada lo cual dificulta
bastante la implementación y la hace muy propensa a errores. ¡Debe existir algo mejor!
Aquí llegamos al punto más importante de la
implementación de una búsqueda, ya definimos nuestro espacio de búsqueda, ya
sabemos donde buscar y que es lo que estamos buscando, ahora tenemos que
introducir todo nuestro modelo a una computadora y nos topamos con la siguiente
cuestión ¿Cuál es la
mejor manera de representar nuestro modelo en una computadora?
Volvamos una vez más al análisis del problema, en
total tenemos 10! estados, esto es menos de 4 millones de estados. Un entero sin signo de 32 bits puede
representar enteros desde el 0 hasta mas de 4,000,000,000. Mucho mas que lo que necesitamos. Dado que cada estado es único, ¿No existirá
la manera de asignarle a cada estado un número entero? Si fuera posible, entonces bastaría con
tener un arreglo de 3.6 millones de boléanos para representar los estados
visitados, si el valor de esa permutación tiene verdadero entonces ya ha sido
visitado, si tiene falso no ha sido visitado.
La memoria total que necesitaríamos para el arreglo de visitados sería
de 3.6MB y para la cola necesitaríamos máximo 3.6 millones de enteros de 32
bits, lo que nos da un total de 18MB para la cola y el arreglo. ¡Esto suena mucho mejor! Además, el tiempo para verificar si un
estado ha sido revisado o para marcar un estado como visitado, una vez que se
tiene su representación numérica, sería constante lo cual es mucho mejor que el
tiempo logN que obteníamos con la solución del árbol
balanceado. Por lo tanto si podemos
representar cada estado como un número entero único, tendremos una solución que
es mucho mejor en memoria y en velocidad.
Tenemos ahora un nuevo problema. ¿Cómo asignar un número entero único a cada
una de las permutaciones?
Estructura de la
solución: ¿Qué nos están pidiendo?
Una función f(X) → Z+ , es decir, una función que
reciba como parámetro de entrada una permutación y nos entregue como resultado
un número entero.
Modelo del espacio
de búsqueda como árbol: En la
primera sección se propuso un árbol para crear todas las permutaciones de N números, donde cada camino
desde la raíz hasta una hoja representaba una permutación. La figura se muestra abajo.
Búsqueda a
utilizar: Una posible idea para enumerar
las permutaciones es la siguiente. Si
numeramos las hojas del árbol de izquierda a derecha, con los números del 1 al N! lo único que tenemos que hacer para obtener nuestra función es
tomar nuestra permutación de entrada e ir recorriendo las ramas del árbol de
acuerdo a los elementos, cuando lleguemos a una hoja podremos saber que número
de permutación es.
Detalles de
implementación: Antes de leer esta
sección, te recomiendo tratar de hacer tu propia implementación utilizando la
idea anterior. Cuando la tengas sigue
leyendo.
Contrario a lo
que parece a primera vista, la implementación de la idea es muy sencilla. La idea principal es la siguiente: El árbol para formar permutaciones que se
presento anteriormente tiene N! hojas para N niveles, como la
estructura se mantiene, esto quiere decir que un árbol de (N-1) niveles tendrá (N-1)! hojas. Esto es, cada uno de los hijos de la raíz es
a su vez la raíz de un subárbol que tiene (N-1)! hojas. ¿De qué nos sirve esto? Bueno, es sencillo, si el primer elemento de
la permutación de entrada es el hijo i de la raíz, entonces sabemos que en los hijos
anteriores a i tenemos en total
(i-1)(N-1)! hojas. Teniendo este dato, podemos recursivamente
hacer lo mismo con el nuevo árbol.
La implementación
en pascal es la siguiente:
Type
TPermutacion
= array[1..10] of byte; // EN PASCAL ES NECESARIO DECLARAR
//
UN TIPO DE DATOS PARA PODER PASAR
//
UN ARREGLO COMO PARÁMETRO
const
Fact : array[1..10] of integer =
(9!,8!,7!,6!,5!,4!,3!,2!,1!,0!); // EN UNA IMPLEMENTACION REAL
// DEBEN SUSTITUIRSE ESTOS NUMEROS
POR LOS VALORES REALES, PERO POR
//
LEGIBILIDAD DEL EJEMPLO SE UTILIZARON ASI.
RECUERDA QUE POR
//
CONVENCIÓN 0! = 1.
Function NumeroDePermutacion(Permutacion
: TPermutacion) : integer;
Var
i, j, k : integer;
ElementosNoUtilizados : array
[1..10] of integer; // NECESITAMOS ESTE ARREGLO PARA
//
SABER QUE ELEMENTOS HEMOS UTILIZADO
NElemNoUtilizados : integer;
Res : integer; // EN ESTA VARIABLE
LLEVAMOS EL RESULTADO.
Begin
Res:=0;
// PRIMERO HAY QUE CARGAR TODOS LOS
ELEMENTOS DE LA PERMUTACION COMO NO UTILIZADOS
for i:=1 to 10 do
ElementosNoUtilizados[i]:=X[i]; //
CARGA LOS ELEMENTOS ORIGINALES
NelemNoUtilizados:=10;
// DE INICIO HAY 10 ELEMENTOS QUE NO HAN SIDO UTILIZADOS.
//
AHORA VAMOS A BUSCAR LA PERMUTACIÓN
for
i:=1 to 10 do begin // PARA TODOS LOS
ELEMENTOS DE LA PERMUTACION
for
j:=1 to NelemNoUtilizados do // CALCULA QUE NUMERO DE HIJO ES
if
ElementosNoUtilizados[j] = Permutacion[i] then begin
Res:=Res
+ (j-1)*Fact(i); // AGREGA TODAS LAS HOJAS DE LOS
//
SUBARBOLES ANTERIORES
//
POR ULTIMO, HAY QUE ELIMINAR ESE ELEMENTO DE LOS NO UTILIZADOS
for k:=j to Pred(NelemNoUtilizados) do
ElementosNoUtilizados[k]:=ElementosNoUtilizados[k+1]; Dec(NelemNoUtilizados);
break;
end;
end;
NumeroDePermutacion:=Res; // POR
ULTIMO DEVUELVE EL RESULTADO.
End;
La rutina
anterior obtiene el número de permutación en un tiempo proporcional a N2, existen formas
de mejorar esto a N
log N utilizando estructuras de datos muy avanzadas para el manejo de los
elementos no utilizados, sin embargo para permutaciones de N=10 no hay mejora perceptible,
sin embargo para permutaciones de muchos mas elementos se tendría que utilizar
dicha técnica. Un tip importante es que si la
solución que tienes es suficiente para resolver lo que te piden, no es
necesario buscar soluciones más óptimas, y en general más complicadas.
Teniendo la función para enumerar permutaciones, la
implementación de la búsqueda en amplitud resulta sencilla, y se presenta a
continuación (queda para el alumno las tareas de implementar las funciones
faltantes para manejo de la cola)
var
EstadosVisitados
: array [0..10!] of boolean; // AQUI SE ALMACENAN INICIALMENTE LOS ESTADOS
VISITADOS.
PermutacionEntrada,
Ptemporal, SigEstado : Tpermutacion;
Movimientos
: integer;
i :
integer;
begin
PermutacionEntrada:=LeePermutacionDeEntrada(); // LEE LA PERMUTACION DE ENTRADA DEL
PROBLEMA
InsertaEnCola(PermutacionEntrada,0);
// INSERTA EN LA COLA INDICANDO CUANTOS MOVIMIENTOS SE REQUIEREN
//
PARA LLEGAR A ESE ESTADO
while Not(ColaVacia) do begin
Ptemporal:=SacaDeCola(Movimientos);
// LA FUNCION SacaDeCola DEBE DEVOLVER LA SIGUIENTE
PERMUTACION
//
EN LA COLA Y EL NUMERO DE MOVIMIENTOS QUE SE NECESITAN PARA LLEGAR
//
A DICHO ESTADO.
if Ptemporal = PermutacionSolucion then begin
// SI ES LA PERMUTACION SOLUCION YA
TERMINAMOS, EL RESULTADO FINAL ES EL NUMERO
//
DE MOVIMIENTOS QUE SE NECESITARON PARA LLEGAR
EscribeResultado(Movimientos);
exit(0); // TERMINA EL PROGRAMA
end
else begin
// SI NO SE HA ENCONTRADO LA
SOLUCION HAY QUE HACER VARIOS PASOS
//
PRIMERO MARCAR COMO VISITADO EL ESTADO
EstadosVisitados[NumeroDePermutacion(Ptemporal)]:=true;
//
DESPUES METER A LA COLA TODOS SUS HIJOS, SIEMPRE Y CUANDO ESTOS NO HAYAN SIDO
//
VISITADOS ANTERIORMENTE.
for i:=1 to 4 do begin
SigEstado:=SiguienteEstado(Ptemporal,i);
// LA FUNCION SIGUIENTE ESTADO GENERA
//
LOS NUEVOS ESTADOS MOVIENDO EL ESPACIO VACIO HACIA
//
ARRIBA, ABAJO, DERECHA O IZQUIERDA. SI
EL ESTADO
//
NO ES POSIBLE DEVUELVE COMO RESULTADO EL MISMO ESTADO
if
Not(EstadosVisitados[NumeroDePermutacion(SigEstado)]) then
InsertaEnCola(SigEstado,Movimientos
+ 1);
end;
end;
end;
end.
Como se puede ver, la implementación de la búsqueda
es sencilla, de apenas unas 10 – 15 líneas si se eliminan los comentarios. Hay un punto importante que observar en la
implementación anterior, en la cola se están insertando las permutaciones
completas, es decir, arreglos de 10 bytes, esto obliga a apartar del orden de
unos 16MB mínimo para la cola, si queremos insertar en la cola, no la
permutación sino su número, necesitamos entonces una función que a partir del
número de permutación. Esta función
es sencilla y muy parecida a la que calcula el número de la permutación. Queda para tarea del alumno realizar dicha
implementación.