Nombre Password [ Regístrate ]

Backtracking

 

* Introducción
* La vuelta del caballo
* El problema de las ocho reinas
* El problema de la mochila (selección óptima)
* Problemas propuestos

 

Introducción

Los algoritmos de vuelta atrás se utilizan para encontrar soluciones a un problema. No siguen unas reglas para la búsqueda de la solución, simplemente una búsqueda sistemática, que más o menos viene a significar que hay que probar todo lo posible hasta encontrar la solución o encontrar que no existe solución al problema. Para conseguir este propósito, se separa la búsqueda en varias búsquedas parciales o subtareas. Asimismo, estas subtareas suelen incluir más subtareas, por lo que el tratamiento general de estos algoritmos es de naturaleza recursiva.

¿Por qué se llaman algoritmos de vuelta atrás?. Porque en el caso de no encontrar una solución en una subtarea se retrocede a la subtarea original y se prueba otra cosa distinta (una nueva subtarea distinta a las probadas anteriormente).

Puesto que a veces nos interesa conocer múltiples soluciones de un problema, estos algoritmos se pueden modificar fácilmente para obtener una única solución (si existe) o todas las soluciones posibles (si existe más de una) al problema dado.

Estos algoritmos se asemejan al recorrido en profundidad dentro de un grafo (ver sección de grafos, estructuras de datos, y recorrido de grafos, algoritmos), siendo cada subtarea un nodo del grafo. El caso es que el grafo no está definido de forma explícita (como lista o matriz de adyacencia), sino de forma implícita, es decir, que se irá creando según avance el recorrido. A menudo dicho grafo es un árbol, o no contiene ciclos, es decir, al buscar una solución es, en general, imposible llegar a una misma solución x partiendo de dos subtareas distintas a y b; o de la subtarea a es imposible llegar a la subtaréa b y viceversa.
Gráficamente se puede ver así:

 

A menudo ocurre que el árbol o grafo que se genera es tan grande que encontrar una solución o encontrar la mejor solución entre varias posibles es computacionalmente muy costoso. En estos casos suelen aplicarse una serie de restricciones, de tal forma que se puedan podar algunas de las ramas, es decir, no recorrer ciertas subtareas. Esto es posible si llegado a un punto se puede demostrar que la solución que se obtendrá a partir de ese punto no será mejor que la mejor solución obtenida hasta el momento. Si se hace correctamente, la poda no impide encontrar la mejor solución.

A veces, es imposible demostrar que al hacer una poda no se esté ocultando una buena solución. Sin embargo, el problema quizás no pida la mejor solución, sino una que sea razonablemente buena y cuyo coste computacional sea bastante reducido. Esa es una buena razón para aumentar las restricciones a la hora de recorrer un nodo. Tal vez se pierda la mejor solución, pero se encontrará una aceptable en un tiempo reducido.

Los algoritmos de vuelta atrás tienen un esquema genérico, según se busque una o todas las soluciones, y puede adaptarse fácilmente según las necesidades de cada problema. A continuación se exponen estos esquemas, extraídos de Wirth (ver Bibliografía). Los bloques se agrupan con begin y end, equivalentes a los corchetes de C, además están tabulados.

- esquema para una solución:

procedimiento ensayar (paso : TipoPaso)
  repetir
  |  
seleccionar_candidato
  |  if aceptable then
  |  begin
  |    anotar_candidato
  |    if solucion_incompleta then
  |    begin
  |      
ensayar(paso_siguiente)
  |      if no acertado then borrar_candidato
  |    end
  |    else begin
  |     
anotar_solucion
  |      acertado <- cierto;
  |  end
  hasta que
(acertado = cierto) o (candidatos_agotados)
fin procedimiento

 

- esquema para todas las soluciones:

procedimiento ensayar (paso : TipoPaso)
  para cada candidato hacer
  |  seleccionar candidato
  |  if aceptable then
  |  begin
  |    anotar_candidato
  |    if solucion_incompleta then
  |      
ensayar(paso_siguiente)    
  |    else
 
 |      almacenar_solucion
  |    borrar_candidato
  |  end
  hasta que
candidatos_agotados
fin procedimiento

 

Por último, se exponen una serie de problemas típicos que se pueden resolver fácilmente con las técnicas de vuelta atrás. El primero que se expone es muy conocido. Se trata de la vuelta del caballo. Muchos problemas de los pasatiempos de los periódicos pueden resolverse con la ayuda de un ordenador y en esta web se muestran algunos de ellos.

 

La vuelta del caballo

Se dispone de un tablero rectangular, por ejemplo el tablero de ajedrez, y de un caballo, que se mueve según las reglas de este juego. El objetivo es encontrar una manera de recorrer todo el tablero partiendo de una casilla determinada, de tal forma que el caballo pase una sola vez por cada casilla. Una variante es obligar al caballo a volver a la posición de partida en el último movimiento.
Por último se estudiará como encontrar todas las soluciones posibles partiendo de una misma casilla.

Para resolver el problema hay que realizar todos los movimientos posibles hasta que ya no se pueda avanzar, en cuyo caso hay que dar marcha atrás, o bien hasta que se cubra el tablero. Además, es necesario determinar la organización de los datos para implementar el algoritmo.

- ¿Cómo se mueve un caballo?. Para aquellos que no sepan jugar al ajedrez se muestra un gráfico con los ocho movimientos que puede realizar. Estos movimientos serán los ocho candidatos.

Con las coordenadas en las que se encuentre el caballo y las ocho coordenadas relativas se determina el siguiente movimiento. Las coordenas relativas se guardan en dos arrays:
ejex = [2, 1, -1, -2, -2, -1,   1,  2]
ejey = [1, 2,  2,  1,  -1, -2, -2, -1]

El tablero, del tamaño que sea, se representará mediante un array bidimensional de números enteros. A continuación se muestra un gráfico con un tablero de tamaño 5x5 con todo el recorrido partiendo de la esquina superior izquierda.

 

Cuando se encuentra una solución, una variable que se pasa por referencia es puesta a 1 (cierto). Puede hacerse una variable de alcance global y simplificar un poco el código, pero esto no siempre es recomendable.

Para codificar el programa, es necesario considerar algunos aspectos más, entre otras cosas no salirse de los límites del tablero y no pisar una casilla ya cubierta (selección del candidato). Se determina que hay solución cuando ya no hay más casillas que recorrer.

A continuación se expone un código completo en C, que recubre un tablero cuadrado de lado N partiendo de la posición (0,0).

#include <stdio.h>

#define N 5
#define ncuad N*N

void mover(int tablero[][N], int i, int pos_x, int pos_y, int *q);

const int ejex[8] = { -1,-2,-2,-1, 1, 2, 2, 1 },
          ejey[8] = { -2,-1, 1, 2, 2, 1,-1,-2 };

int main(void)
{
  int tablero[N][N]; /* tablero del caballo. */
  int i,j,q;

   /* inicializa el tablero a cero */
  for (i = 0; i < N; i++)
   for (j = 0; j < N; j++)
    tablero[i][j] = 0;

   /* pone el primer movimiento */
  tablero[0][0] = 1;
  mover(tablero,2,0,0,&q);

  if (q) { /* hay solucion: la muestra. */
    for (i = 0; i < N; i++) {
      for (j = 0; j < N; j++)
        printf("%3d ", tablero[i][j]);
      putchar('\n');
    }
  }
  else
    printf("\nNo existe solucion\n");

  return 0;
}

void mover(int tablero[][N],int i, int pos_x, int pos_y, int *q)
{
  int k, u, v;

  k = 0;
  *q = 0;
  do {
    u = pos_x + ejex[k]; v = pos_y + ejey[k]; /* seleccionar candidato */
    if (u >= 0 && u < N && v >= 0 && v < N) { /* esta dentro de los limites? */
      if (tablero[u][v] == 0) {  /* es valido? */
        tablero[u][v] = i;  /* anota el candidato */
        if (i < ncuad) {  /* llega al final del recorrido? */
          mover(tablero,i+1,u,v,q);
          if (!*q) tablero[u][v] = 0; /* borra el candidato */
        }
        else *q = 1; /* hay solucion */
      }
    }
    k++;
  } while (!*q && k < 8);
}
   

Cambiando el valor de N puede obtenerse una solución para un tablero cuadrado de tamaño N.

A continuación, se muestra una adaptación del procedimiento que muestra todas las soluciones. Si se ejecuta para N = 5 se encuentra que hay 304 soluciones partiendo de la esquina superior izquierda.
Cuando se encuentra una solución se llama a un procedimiento (no se ha codificado aquí) que imprime todo el tablero.

void mover(int tablero[][N],int i, int pos_x, int pos_y)
{
  int k, u, v;

  for (k = 0; k < 8; k++) {
    u = pos_x + ejex[k]; v = pos_y + ejey[k];
    if (u >= 0 && u < N && v >= 0 && v < N) { /* esta dentro de los limites */
      if (tablero[u][v] == 0) {
        tablero[u][v] = i;  
        if (i < ncuad)   
          mover(tablero,i+1,u,v);
        else imprimir_solucion(tablero);
        tablero[u][v] = 0;  
      }
    }
  }
}

 

El problema de las ocho reinas

Continuamos con problemas relacionados con el ajedrez. El problema que ahora se plantea es claramente, como se verá, de vuelta atrás. Se recomienda intentar resolverlo a mano.

Se trata de colocar ocho reinas sobre un tablero de ajedrez, de tal forma que ninguna amenace (pueda comerse) a otra. Para los que no sepan ajedrez deben saber que una reina amenaza a otra pieza que esté en la misma columna, fila o cualquiera de las cuatro diagonales.

La dificultad que plantea este problema es la representación de los datos. Se puede utilizar un array bidimensional de tamaño 8x8, pero las operaciones para encontrar una reina que amenace a otra son algo engorrosas y hay un truco para evitarlas. La solución aquí expuesta vuelve a ser tomada de Wirth (ver Bibliografía).

Es lógico que cada reina debe ir en una fila distinta. Por tanto, en un array se guarda la posición de cada reina en la columna que se encuentre. Ejemplo: si en la tercera fila hay una reina situada en la quinta columna, entonces la tercera posición del array guardará un 5. A este array se le llamará col.
Hace falta otro array que determine si hay puesta una reina en la fila j-ésima. A este array se le llamará fila.
Por último se utilizan dos arrays más para determinar las diagonales libres, y se llamarán diagb y diagc.

Para poner una reina se utiliza esta instrucción:
col[i] = j ; fila[j] = diagb[i+j] = diagc[7+i-j] = FALSE;

Para quitar una reina esta otra:
fila[j] = diagb[i+j] = diagc[7+i-j] = TRUE;

Se considera válida la posición para este caso:
if (fila[j] && diagb[i+j] && diagc[7+i-j]) entonces proceder ...

A continuación se expone el código completo en C. Se han utilizado tipos enumerados para representar los valores booleanos.

#include <stdio.h>

enum bool {FALSE, TRUE};
typedef enum bool boolean;

void ensayar(int i, boolean *q, int col[], boolean fila[], boolean diagb[], boolean diagc[]);

int main(void)
{
  int i;
  boolean q;

  int col[8];
  boolean fila[8],diagb[15], diagc[15];

  for (i = 0; i < 8; i++) fila[i] = TRUE;
  for (i = 0; i < 15; i++) diagb[i] = diagc[i] = TRUE;

  ensayar(0,&q,col,fila,diagb,diagc);
  if (q) {
    printf("\nSolucion:");
    for (i = 0; i < 8; i++) printf(" %d", col[i]);
  } else printf("\nNo hay solucion");

  return 0;
}

void ensayar(int i, boolean *q, int col[], boolean fila[], boolean diagb[], boolean diagc[])
{
  int j;

  j = 0;
  *q = FALSE;
  do {
    if (fila[j] && diagb[i+j] && diagc[7+i-j]) {
      col[i] = j; fila[j] = diagb[i+j] = diagc[7+i-j] = FALSE;
      if (i < 7) { /* encuentra solucion? */
        ensayar(i+1,q,col,fila,diagb,diagc);
        if (!*q)
          fila[j] = diagb[i+j] = diagc[7+i-j] = TRUE;
      } else *q = TRUE; /* encuentra la solucion */
    }
    j++;
  } while (!*q && j < 8);
}

Por último, se deja al lector que implemente un procedimiento que encuentre todas las soluciones. Si se desea complicar más entonces se puede pedir que encuentre todas las soluciones distintas, es decir, aquellas que no sean rotaciones o inversiones de otras soluciones.

Ahora que se conoce el método general, puede hacerse extensible a múltiples piezas simultáneamente.

 

El Problema de la mochila (selección óptima)

Con anterioridad se ha estudiado la posibilidad de encontrar una única solución a un problema y la posibilidad de encontrarlas todas. Pues bien, ahora se trata de encontrar la mejor solución, la solución óptima, de entre todas las soluciones.

Partiendo del esquema que genera todas las soluciones expuesto anteriormente se puede obtener la mejor solución (la solución óptima, seleccionada entre todas las soluciones) si se modifica la instrucción almacenar_solucion por esta otra:
si f(solucion) > f(optimo) entonces optimo <- solucion
siendo f(s) función positiva, optimo es la mejor solucion encontrada hasta el momento, y solucion es una solucion que se está probando.

El problema de la mochila consiste en llenar una mochila con una serie de objetos que tienen una serie de pesos con un valor asociado. Es decir, se dispone de n tipos de objetos y que no hay un número limitado de cada tipo de objeto (si fuera limitado no cambia mucho el problema). Cada tipo i de objeto tiene un peso wi positivo y un valor vi positivo asociados. La mochila tiene una capacidad de peso igual a W. Se trata de llenar la mochila de tal manera que se maximice el valor de los objetos incluidos pero respetando al mismo tiempo la restricción de capacidad. Notar que no es obligatorio que una solución óptima llegue al límite de capacidad de la mochila.

Ejemplo: se supondrá:
n = 4
W = 8
w() = 2, 3, 4, 5
v() = 3, 5, 6, 10
Es decir, hay 4 tipos de objetos y la mochila tiene una capacidad de 8. Los pesos varían entre 2 y 5, y los valores relacionados varían entre 3 y 10.
Una solución no óptima de valor 12 se obtiene introduciendo cuatro objetos de peso 2, o 2 de peso 4. Otra solución no óptima de valor 13 se obtiene introduciendo 2 objetos de peso 3 y 1 objeto de peso 2. ¿Cuál es la solución óptima?.

A continuación se muestra una solución al problema, variante del esquema para obtener todas las soluciones.

void mochila(int i, int r, int solucion, int *optimo)
{
  int k;

  for (k = i; k < n; k++) {
    if (peso[k] <= r) {
      mochila(k, r - peso[k], solucion + valor[k], optimo);
      if (solucion + valor[k] > *optimo) *optimo = solucion+valor[k];
    }
  }
}

Dicho procedimiento puede ser ejecutado de esta manera, siendo n, W, peso y valor variables globales para simplificar el programa:

n = 4,
W = 8,
peso[] = {2,3,4,5},
valor[] = {3,5,6,10},
optimo = 0;
...
mochila(0, W, 0, &optimo);

 

Observar que la solución óptima se obtiene independientemente de la forma en que se ordenen los objetos.


 

Problemas propuestos

- Caminos. OIE 97.
  Enunciado - Solución
- Tom y Jerry (solamente el apartado 3). OIE 97.
  Enunciado
- Buque. OIE 98.
  Enunciado
- Sellos (El correo del zar). OIE 99.
  Enunciado
- El salto del caballo. OIE 2001.
  Enunciado
- Islas en el mar. IOI 92.
  Enunciado (en inglés: Islands in the sea)
- Números primos. IOI 94.
  Enunciado (en inglés: The Primes) - Solución

- Muchos otros problemas requieren vuelta atrás, en general se trata de algoritmos auxiliares para resolver una pequeña tarea.

-
También los algoritmos de vuelta atrás permiten la resolución del problema del laberinto, puesto que la vuelta atrás no es más que el recorrido sobre un grafo implícito finito o infinito. De todas formas, los problemas de laberintos se resuelven mucho mejor mediante exploración en anchura (ver grafos).

 


© (2001-2008) ALGORITMIA.NET - Política de privacidad