Apuntes DAM
Volver al inicio

Backtracking (vuelta atrás)

AlgoritmosTécnicas de diseñoNivel avanzadoTambién: vuelta atrás

Construye la solución paso a paso, probando cada opción y deshaciendo la última decisión en cuanto un camino no lleva a ninguna parte: las N reinas, sudokus, laberintos y combinaciones.

Visualízalo paso a paso

Cambia los datos, dale a reproducir y sigue cada paso en el dibujo, en la línea de Java que se ejecuta y en sus variables.

Las N reinas

Elige el tamaño del tablero y mira cómo el backtracking prueba, poda y vuelve atrás hasta la primera solución.

  • reina colocada

Paso 1

Fila 0, columna 0: nadie la ataca. Se coloca la reina y se pasa a la fila 1.

1static boolean colocar(int fila) {
2    if (fila == n) return true;
3    for (int c = 0; c < n; c++) {
4        if (atacada(fila, c)) continue;
5        reina[fila] = c;  // fila = 0, c = 0, reinas = 1
6        if (colocar(fila + 1)) return true;
7        reina[fila] = -1;
8    }
9    return false;
10}

Variables

fila
0
c
0
reinas
1
vueltas atrás
0

Atajos con el foco dentro del visualizador: ← → paso a paso, Espacio reproducir o pausar, Inicio/Fin ir al principio o al final.

La idea

Muchos problemas se resuelven tomando decisiones una detrás de otra: dónde pongo la reina de la primera fila, dónde la de la segunda… El backtracking explora ese árbol de decisiones en profundidad: toma una opción, sigue con la siguiente decisión y, si llega a un punto en el que ninguna opción vale, vuelve atrás, deshace la última decisión y prueba la siguiente opción.

Lo que lo hace práctico es la poda: comprobar en cada paso si la solución parcial ya incumple alguna regla. Si dos reinas se atacan en las primeras filas, no tiene sentido colocar las demás: se descarta de golpe toda esa rama del árbol, que puede tener millones de combinaciones.

Casi siempre se programa con recursividad y con el mismo esquema: si la solución está completa, se guarda; si no, para cada opción válida, se aplica, se hace la llamada recursiva y se deshace. Ese «deshacer» es lo que le da el nombre.

Cuándo usarlo

  • Hay que encontrar una o todas las soluciones que cumplen unas reglas: puzzles, sudokus, las N reinas, horarios con restricciones, colocar piezas.
  • Hay que generar todas las combinaciones, permutaciones o subconjuntos con alguna condición.
  • Las reglas permiten descartar soluciones parciales pronto (cuanto antes se poda, más rápido va).

Cuándo no

  • Si hay una forma directa o una estrategia voraz que siempre acierta: el backtracking es exponencial en el peor caso.
  • Si los mismos subproblemas se repiten muchas veces: ahí gana la programación dinámica, que los calcula una vez.
  • Si el espacio de búsqueda es enorme y no hay forma de podar: no terminará en un tiempo razonable.

Paso a paso

  1. Estado parcial. Una estructura con las decisiones tomadas hasta ahora (por ejemplo, la columna de cada reina ya colocada) y lo que haga falta para comprobar las reglas deprisa.
  2. Caso base. Si la solución está completa, se guarda (o se cuenta) y se vuelve.
  3. Probar cada opción. Para cada opción del siguiente paso, se comprueba si es válida con lo decidido hasta ahora (la poda) y, si lo es, se aplica.
  4. Bajar y volver. Se hace la llamada recursiva para el siguiente paso y, al volver, se deshace la opción para dejar el estado como estaba y probar la siguiente.

El código

El esquema general

Todos los backtracking tienen esta forma; lo que cambia es qué es el estado, qué opciones hay y cuándo una es válida.

Java
1void backtracking(Estado parcial) {
2    if (esSolucion(parcial)) {         // caso base: la solución está completa
3        guardar(parcial);              // (una copia: parcial va a seguir cambiando)
4        return;
5    }
6    for (Opcion op : opciones(parcial)) {
7        if (esValida(parcial, op)) {   // poda: no seguir por caminos imposibles
8            aplicar(parcial, op);
9            backtracking(parcial);
10            deshacer(parcial, op);     // volver atrás para probar la siguiente opción
11        }
12    }
13}

Las N reinas

Colocar N reinas en un tablero de N×N sin que se ataquen. Se coloca una reina por fila; tres arrays de booleanos dicen al momento si una columna o una diagonal ya está ocupada, y eso es la poda.

Java
1public class Main {
2    static int n, soluciones, llamadas;
3    static int[] columnaDeFila;              // columnaDeFila[f] = columna de la reina de la fila f
4    static boolean[] col, diag1, diag2;      // columnas y diagonales ya ocupadas
5    static String primera;
6
7    static void colocar(int fila) {
8        llamadas++;
9        if (fila == n) {                     // todas colocadas: una solución más
10            soluciones++;
11            if (primera == null) primera = tablero();
12            return;
13        }
14        for (int c = 0; c < n; c++) {
15            // poda: la casilla está atacada por una reina de una fila anterior
16            if (col[c] || diag1[fila + c] || diag2[fila - c + n - 1]) continue;
17            columnaDeFila[fila] = c;
18            col[c] = diag1[fila + c] = diag2[fila - c + n - 1] = true;     // aplicar
19            colocar(fila + 1);
20            col[c] = diag1[fila + c] = diag2[fila - c + n - 1] = false;    // deshacer
21        }
22    }
23
24    static String tablero() {
25        StringBuilder sb = new StringBuilder();
26        for (int f = 0; f < n; f++) {
27            for (int c = 0; c < n; c++) sb.append(c > 0 ? " " : "").append(columnaDeFila[f] == c ? "Q" : ".");
28            sb.append('\n');
29        }
30        return sb.toString();
31    }
32
33    public static void main(String[] args) {
34        n = 6;
35        columnaDeFila = new int[n];
36        col = new boolean[n];
37        diag1 = new boolean[2 * n - 1];      // fila + columna es igual en cada diagonal «/»
38        diag2 = new boolean[2 * n - 1];      // fila - columna es igual en cada diagonal «\»
39        colocar(0);
40        System.out.println(n + " reinas: " + soluciones + " soluciones (" + llamadas + " llamadas)");
41        System.out.print(primera);
42    }
43}

Salida al ejecutarlo (la misma en los 5 lenguajes)

6 reinas: 4 soluciones (153 llamadas)
. Q . . . .
. . . Q . .
. . . . . Q
Q . . . . .
. . Q . . .
. . . . Q .

Traza: 4 reinas hasta la primera solución

PasoFilaColumnaQué pasa
100coloca la reina
210, 1atacadas (columna y diagonal)
312coloca la reina
420, 1, 2, 3todas atacadas: vuelta atrás, se quita la reina de la fila 1
513coloca la reina
620 / 10 atacada; coloca en la 1
730, 1, 2, 3todas atacadas: vuelta atrás hasta la fila 0
801quita la reina de la columna 0 y la coloca en la 1
9130, 1 y 2 atacadas; coloca en la 3
1020coloca la reina
11320 y 1 atacadas; coloca en la 2: ¡solución [1, 3, 0, 2]!

Con la poda, el programa solo explora 17 llamadas para resolver las 4 reinas por completo (las dos soluciones), de los 256 tableros posibles con una reina por fila.

Complejidad

NSolucionesLlamadas con podaTableros sin poda (Nᴺ)
4217256
6415346.656
8922.05716.777.216
1072435.53910.000.000.000

El backtracking es exponencial en el peor caso, pero la poda cambia los números de forma espectacular: para 10 reinas, 35.539 llamadas frente a diez mil millones de tableros. Cuanto antes se detecta que un camino no sirve, mejor.

0102030405015101520tamaño de la entrada (n)operacionesO(n!)O(2ⁿ)O(n²)O(n log n)O(1)O(log n)O(n)
  • Mejor caso: O(n)
  • Caso medio: O(n!)
  • Peor caso: O(n!)

Las N reinas exploran como mucho n! tableros; la poda deja muchísimos menos. Las curvas grises son las demás clases, para comparar.

En la práctica

  • Los programas que resuelven sudokus, crucigramas o puzzles, y los generadores de horarios, combinan backtracking con heurísticas (probar primero la casilla con menos opciones).
  • El motor de expresiones regulares de Java (java.util.regex) usa backtracking: patrones como (a+)+b pueden tardar muchísimo con ciertas entradas (catastrophic backtracking).
  • Los resolvedores de restricciones (constraint solvers) y el lenguaje Prolog se basan en él.
  • La búsqueda en profundidad de un camino en un laberinto o en un juego es un backtracking sobre el grafo de casillas.

Errores típicos

  • Olvidar deshacer el cambio al volver: el estado queda sucio y las siguientes opciones se prueban con decisiones que ya no deberían estar.
  • Guardar como solución la referencia al array del estado en vez de una copia: cuando el algoritmo sigue, la «solución» guardada cambia con él.
  • Comprobar las reglas solo al final (sin poda): el programa es correcto pero explora todas las combinaciones y no termina con tamaños medianos.
  • Poner mal el caso base (fila == n - 1 en vez de fila == n) y perder la última decisión o salirse del array.
  • Usar una variable global para la suma o el contador sin restaurarla al volver; pasarla como parámetro evita el problema.

Ejercicios

Cada ejercicio se corrige solo con sus pruebas (algunas ocultas). Escribe tu solución en el editor y pulsa Ejecutar o Comprobar; la solución explicada está debajo, por si te atascas.

1. Subconjuntos que suman un objetivo

Dados un objetivo y una lista de números positivos, escribe todos los subconjuntos cuya suma es exactamente el objetivo, en el orden en que los encuentra un backtracking que recorre los números en el orden de la entrada y, para cada uno, prueba primero a incluirlo y después a no incluirlo. Cuando la suma llega al objetivo se escribe la solución y no se siguen añadiendo números; cuando se pasa, se poda. El main ya lee la entrada y escribe el total: completa buscar.

  • Línea 1: el objetivo. Línea 2: los números, separados por espacios (enteros mayores que 0).
  • Cada solución: a + b + c = objetivo, con los números en el orden de la entrada (un número repetido en la entrada cuenta como dos números distintos). Al final: Total: N subconjuntos (1 subconjunto) o Ningún subconjunto suma X.
  • Si la entrada no son enteros positivos: Entrada no válida.
☕JavaSubconjuntos que suman un objetivoMedio

Ejemplo

Entrada (lo que se escribe por teclado)
15
3 5 7 8 2 10
Salida esperada
3 + 5 + 7 = 15
3 + 2 + 10 = 15
5 + 8 + 2 = 15
5 + 10 = 15
7 + 8 = 15
Total: 5 subconjuntos
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    static int[] numeros;
5    static int objetivo, encontrados;
6    static List<Integer> elegidos = new ArrayList<>();
7
8    /** Decide sobre el número i: primero lo incluye y después no. «suma» es la de los elegidos. */
9    static void buscar(int i, int suma) {
10        if (suma == objetivo) {                       // solución: se escribe y no se sigue añadiendo
11            encontrados++;
12            System.out.println(String.join(" + ", elegidos.stream().map(String::valueOf).toList()) + " = " + objetivo);
13            return;
14        }
15        if (i == numeros.length || suma > objetivo) return;   // sin números o pasada: poda
16        elegidos.add(numeros[i]);                     // opción 1: incluirlo
17        buscar(i + 1, suma + numeros[i]);
18        elegidos.remove(elegidos.size() - 1);         // deshacer
19        buscar(i + 1, suma);                          // opción 2: no incluirlo
20    }
21
22    public static void main(String[] args) {
23        Scanner sc = new Scanner(System.in);
24        try {
25            objetivo = Integer.parseInt(sc.nextLine().trim());
26            numeros = Arrays.stream(sc.nextLine().trim().split("\\s+")).mapToInt(Integer::parseInt).toArray();
27        } catch (Exception e) {
28            System.out.println("Entrada no válida");
29            return;
30        }
31        if (objetivo <= 0 || Arrays.stream(numeros).anyMatch(x -> x <= 0)) {
32            System.out.println("Entrada no válida");
33            return;
34        }
35        buscar(0, 0);
36        if (encontrados == 0) System.out.println("Ningún subconjunto suma " + objetivo);
37        else System.out.println("Total: " + encontrados + (encontrados == 1 ? " subconjunto" : " subconjuntos"));
38    }
39}

Cada número tiene dos opciones (dentro o fuera), así que el árbol completo tiene 2ⁿ hojas. La poda por suma corta todas las ramas que ya se han pasado, que en la práctica son la mayoría.

El orden de las soluciones sale de probar primero «incluir»: por eso las primeras soluciones empiezan por los primeros números de la entrada.

2. Resolver un sudoku

Resuelve un sudoku de 9×9 con backtracking: recorre las casillas de izquierda a derecha y de arriba abajo, y en cada casilla vacía prueba los dígitos del 1 al 9 que no se repiten en su fila, su columna ni su caja de 3×3. Busca hasta dos soluciones para saber si es única. La lectura, la comprobación de las pistas y la salida ya están escritas: completa valido y resolver.

  • Entrada: 9 líneas de 9 caracteres, cada uno un dígito o un punto para las casillas vacías.
  • Salida: el tablero resuelto con los números separados por espacios, | entre cajas y una línea ------+-------+------ cada tres filas. Si hay más de una solución, antes: Tiene más de una solución; esta es la primera:.
  • Sin solución si no la tiene y Tablero no válido si una línea está mal o dos pistas chocan.
☕JavaResolver un sudokuDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
53..7....
6..195...
.98....6.
8...6...3
4..8.3..1
7...2...6
.6....28.
...419..5
....8..79
Salida esperada
5 3 4 | 6 7 8 | 9 1 2
6 7 2 | 1 9 5 | 3 4 8
1 9 8 | 3 4 2 | 5 6 7
------+-------+------
8 5 9 | 7 6 1 | 4 2 3
4 2 6 | 8 5 3 | 7 9 1
7 1 3 | 9 2 4 | 8 5 6
------+-------+------
9 6 1 | 5 3 7 | 2 8 4
2 8 7 | 4 1 9 | 6 3 5
3 4 5 | 2 8 6 | 1 7 9
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    static int[][] t = new int[9][9];           // 0 = casilla vacía
5    static int[][] primera;
6    static int soluciones;
7
8    static void escribir(int[][] g) {
9        for (int f = 0; f < 9; f++) {
10            if (f == 3 || f == 6) System.out.println("------+-------+------");
11            StringBuilder sb = new StringBuilder();
12            for (int c = 0; c < 9; c++) {
13                if (c == 3 || c == 6) sb.append("| ");
14                sb.append(g[f][c]).append(c < 8 ? " " : "");
15            }
16            System.out.println(sb);
17        }
18    }
19
20    /** ¿Se puede poner d en (f, c) sin repetir en su fila, su columna ni su caja de 3x3? */
21    static boolean valido(int f, int c, int d) {
22        for (int i = 0; i < 9; i++) {
23            if (t[f][i] == d || t[i][c] == d) return false;
24            if (t[f / 3 * 3 + i / 3][c / 3 * 3 + i % 3] == d) return false;
25        }
26        return true;
27    }
28
29    /** Rellena las casillas vacías de izquierda a derecha y de arriba abajo; para en la segunda solución. */
30    static void resolver(int pos) {
31        if (soluciones >= 2) return;
32        if (pos == 81) {
33            soluciones++;
34            if (primera == null) primera = Arrays.stream(t).map(int[]::clone).toArray(int[][]::new);
35            return;
36        }
37        int f = pos / 9, c = pos % 9;
38        if (t[f][c] != 0) {
39            resolver(pos + 1);
40            return;
41        }
42        for (int d = 1; d <= 9; d++) {
43            if (!valido(f, c, d)) continue;
44            t[f][c] = d;
45            resolver(pos + 1);
46            t[f][c] = 0;                          // deshacer
47        }
48    }
49
50    public static void main(String[] args) {
51        Scanner sc = new Scanner(System.in);
52        for (int f = 0; f < 9; f++) {
53            String linea = sc.hasNextLine() ? sc.nextLine().trim() : "";
54            if (!linea.matches("[0-9.]{9}")) {
55                System.out.println("Tablero no válido");
56                return;
57            }
58            for (int c = 0; c < 9; c++) t[f][c] = linea.charAt(c) == '.' ? 0 : linea.charAt(c) - '0';
59        }
60        // las pistas no pueden chocar entre sí
61        for (int f = 0; f < 9; f++) {
62            for (int c = 0; c < 9; c++) {
63                int d = t[f][c];
64                if (d == 0) continue;
65                t[f][c] = 0;
66                boolean ok = valido(f, c, d);
67                t[f][c] = d;
68                if (!ok) {
69                    System.out.println("Tablero no válido");
70                    return;
71                }
72            }
73        }
74        resolver(0);
75        if (soluciones == 0) {
76            System.out.println("Sin solución");
77            return;
78        }
79        if (soluciones > 1) System.out.println("Tiene más de una solución; esta es la primera:");
80        escribir(primera);
81    }
82}

Es el esquema de siempre: el estado es el tablero, la decisión es el dígito de la siguiente casilla vacía y la poda es valido. Deshacer es volver a poner un 0.

Buscar una segunda solución cuesta poco más y responde a una pregunta importante: un sudoku bien hecho tiene solución única. Los resolvedores rápidos eligen además la casilla con menos candidatos, lo que reduce muchísimo el árbol.

Test

Test: Backtracking (vuelta atrás)

0/5 respondidas · 0 aciertos

Elige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.

  1. 1.¿Qué es la «poda» en un backtracking?

  2. 2.¿Por qué hay que deshacer la decisión después de la llamada recursiva?

  3. 3.Guardas cada solución con soluciones.add(estado) donde estado es el array que va modificando el algoritmo. ¿Qué pasará?

  4. 4.¿Qué técnica suele ser mejor si los mismos subproblemas se repiten muchas veces?

  5. 5.En las N reinas, ¿qué tienen en común las casillas de una diagonal «/»?

Relacionado