🔙 ¿Qué es backtracking?
Backtracking (vuelta atrás) es una técnica algorítmica que resuelve problemas explorando todas las posibilidades de forma sistemática, pero con una ventaja crucial sobre la fuerza bruta: cuando detecta que un camino no puede llevar a una solución válida, retrocede (backtracks) y prueba la siguiente alternativa sin explorar el resto de ese camino.
Imagina que estás en un laberinto. En cada bifurcación eliges un camino. Si llegas a un callejón sin salida, vuelves a la última bifurcación y tomas otro camino. No necesitas explorar todo el callejón sin salida: en cuanto ves la pared, retrocedes. Eso es exactamente lo que hace backtracking con los problemas computacionales.
La técnica se formalizó en los años 1960, aunque sus ideas se remontan a los trabajos de Derrick Henry Lehmer sobre problemas combinatorios. Hoy es fundamental en la resolución de puzzles (Sudoku, N-Reinas), satisfacción de restricciones, compiladores, inteligencia artificial y optimización combinatoria.
🌳 Cómo funciona: el árbol de decisiones
Todo problema de backtracking puede visualizarse como un árbol de decisiones (también llamado árbol de búsqueda o espacio de estados). Cada nodo del árbol representa una decisión parcial, y cada rama representa una opción disponible en ese punto:
[Inicio: necesito leche]
/ \
[Tienda esquina] [Supermercado]
| / \
[¡Cerrado! ✗] [Refrigerador] [Pasillo alimentación]
← BACKTRACK | |
[No hay leche ✗] [¡Leche encontrada! ✓]
← BACKTRACK SOLUCIÓN
Recorrido: Tienda → ✗ → Backtrack → Supermercado → Refrigerador → ✗ →
Backtrack → Pasillo → ✓ ¡Solución!
El algoritmo recorre este árbol en profundidad (DFS, Depth-First Search). Cada vez que encuentra un callejón sin salida, retrocede al nodo padre y explora la siguiente rama. Los cuatro componentes esenciales son:
| Componente | Descripción | Ejemplo (N-Reinas) |
|---|---|---|
| Estado | Representación de una solución parcial en un momento dado. | Las posiciones de las reinas ya colocadas en el tablero. |
| Opciones (candidatos) | Las decisiones posibles desde el estado actual. | Las columnas disponibles para la siguiente reina. |
| Restricciones | Condiciones que debe cumplir una solución válida. | Ninguna reina amenaza a otra (fila, columna, diagonal). |
| Criterio de parada | Condición que indica que se ha encontrado una solución completa. | Se han colocado las N reinas sin conflictos. |
📋 Plantilla genérica en Java
Todos los algoritmos de backtracking siguen la misma estructura. Esta plantilla te servirá como punto de partida para cualquier problema:
/**
* Plantilla genérica de backtracking.
* @param solucion la solución parcial que se está construyendo
* @param paso el nivel de decisión actual (profundidad en el árbol)
*/
public void backtrack(Solucion solucion, int paso) {
// 1. ¿Es una solución completa?
if (esCompleta(solucion)) {
procesarSolucion(solucion); // Imprimir, guardar, etc.
return;
}
// 2. Generar candidatos para el paso actual
List<Opcion> candidatos = generarCandidatos(solucion, paso);
// 3. Probar cada candidato
for (Opcion candidato : candidatos) {
// 4. ¿El candidato es válido? (PODA)
if (esValido(solucion, candidato)) {
// 5. ELEGIR: añadir el candidato a la solución
solucion.agregar(candidato);
// 6. EXPLORAR: avanzar al siguiente paso
backtrack(solucion, paso + 1);
// 7. DESHACER (backtrack): quitar el candidato
solucion.quitar(candidato);
}
}
}
🔢 Ejemplo 1: generar permutaciones
El primer ejemplo clásico de backtracking es generar todas las permutaciones de un conjunto. Para {1, 2, 3} queremos obtener: [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1].
import java.util.ArrayList;
import java.util.List;
public class Permutaciones {
private final List<List<Integer>> resultados = new ArrayList<>();
public List<List<Integer>> permutar(int[] nums) {
List<Integer> actual = new ArrayList<>();
boolean[] usado = new boolean[nums.length];
backtrack(nums, actual, usado);
return resultados;
}
private void backtrack(int[] nums, List<Integer> actual, boolean[] usado) {
// Solución completa: tenemos N elementos
if (actual.size() == nums.length) {
resultados.add(new ArrayList<>(actual)); // Copiar (¡importante!)
return;
}
for (int i = 0; i < nums.length; i++) {
if (usado[i]) continue; // Poda: ya está en la permutación actual
// ELEGIR
actual.add(nums[i]);
usado[i] = true;
// EXPLORAR
backtrack(nums, actual, usado);
// DESHACER
actual.remove(actual.size() - 1);
usado[i] = false;
}
}
public static void main(String[] args) {
Permutaciones p = new Permutaciones();
List<List<Integer>> resultado = p.permutar(new int[]{1, 2, 3});
System.out.println("Permutaciones de {1, 2, 3}:");
for (List<Integer> perm : resultado) {
System.out.println(" " + perm);
}
System.out.println("Total: " + resultado.size()); // 6 = 3!
}
}
Permutaciones de {1, 2, 3}:
[1, 2, 3]
[1, 3, 2]
[2, 1, 3]
[2, 3, 1]
[3, 1, 2]
[3, 2, 1]
Total: 6
♛ Ejemplo 2: el problema de las N-Reinas
El problema de las N-Reinas es el ejemplo más famoso de backtracking: colocar N reinas en un tablero N×N de modo que ninguna amenace a otra (ni en la misma fila, columna o diagonal). Fue planteado por primera vez en 1848 por el ajedrecista Max Bezzel para N=8.
public class NReinas {
private final int n;
private final int[] columnas; // columnas[fila] = columna de la reina en esa fila
private int soluciones = 0;
public NReinas(int n) {
this.n = n;
this.columnas = new int[n];
}
public void resolver() {
backtrack(0);
System.out.println("Total de soluciones para " + n + " reinas: " + soluciones);
}
private void backtrack(int fila) {
// Solución completa: todas las filas tienen reina
if (fila == n) {
soluciones++;
imprimirTablero();
return;
}
// Probar cada columna en esta fila
for (int col = 0; col < n; col++) {
if (esSeguro(fila, col)) {
// ELEGIR: colocar reina
columnas[fila] = col;
// EXPLORAR: pasar a la siguiente fila
backtrack(fila + 1);
// DESHACER: implícito (se sobrescribe en el siguiente ciclo)
}
}
}
/**
* Verifica que la posición (fila, col) no entre en conflicto
* con ninguna reina ya colocada en filas anteriores.
*/
private boolean esSeguro(int fila, int col) {
for (int i = 0; i < fila; i++) {
// Misma columna
if (columnas[i] == col) return false;
// Misma diagonal (la diferencia de filas == diferencia de columnas)
if (Math.abs(columnas[i] - col) == Math.abs(i - fila)) return false;
}
return true;
}
private void imprimirTablero() {
System.out.println("--- Solución " + soluciones + " ---");
for (int fila = 0; fila < n; fila++) {
StringBuilder sb = new StringBuilder();
for (int col = 0; col < n; col++) {
sb.append(columnas[fila] == col ? " ♛ " : " · ");
}
System.out.println(sb);
}
System.out.println();
}
public static void main(String[] args) {
new NReinas(8).resolver();
}
}
--- Solución 1 ---
♛ · · · · · · ·
· · · · ♛ · · ·
· · · · · · · ♛
· · · · · ♛ · ·
· · ♛ · · · · ·
· · · · · · ♛ ·
· ♛ · · · · · ·
· · · ♛ · · · ·
...
Total de soluciones para 8 reinas: 92
🧩 Ejemplo 3: resolver un Sudoku
El Sudoku es uno de los problemas más populares resueltos con backtracking. Dado un tablero 9×9 parcialmente relleno, hay que completarlo con dígitos del 1 al 9 respetando tres restricciones: cada fila, cada columna y cada subcuadro 3×3 debe contener todos los dígitos del 1 al 9 sin repetir.
public class SudokuSolver {
private static final int VACIO = 0;
public boolean resolver(int[][] tablero) {
// Buscar la siguiente celda vacía
for (int fila = 0; fila < 9; fila++) {
for (int col = 0; col < 9; col++) {
if (tablero[fila][col] == VACIO) {
// Probar dígitos del 1 al 9
for (int digito = 1; digito <= 9; digito++) {
if (esValido(tablero, fila, col, digito)) {
// ELEGIR
tablero[fila][col] = digito;
// EXPLORAR
if (resolver(tablero)) {
return true; // ¡Solución encontrada!
}
// DESHACER
tablero[fila][col] = VACIO;
}
}
return false; // Ningún dígito funciona → backtrack
}
}
}
return true; // No hay celdas vacías → resuelto
}
private boolean esValido(int[][] tablero, int fila, int col, int digito) {
// Verificar fila
for (int c = 0; c < 9; c++) {
if (tablero[fila][c] == digito) return false;
}
// Verificar columna
for (int f = 0; f < 9; f++) {
if (tablero[f][col] == digito) return false;
}
// Verificar subcuadro 3x3
int inicioFila = (fila / 3) * 3;
int inicioCol = (col / 3) * 3;
for (int f = inicioFila; f < inicioFila + 3; f++) {
for (int c = inicioCol; c < inicioCol + 3; c++) {
if (tablero[f][c] == digito) return false;
}
}
return true;
}
public void imprimir(int[][] tablero) {
for (int f = 0; f < 9; f++) {
if (f % 3 == 0 && f != 0) System.out.println("------+-------+------");
for (int c = 0; c < 9; c++) {
if (c % 3 == 0 && c != 0) System.out.print("| ");
System.out.print(tablero[f][c] + " ");
}
System.out.println();
}
}
public static void main(String[] args) {
int[][] sudoku = {
{5,3,0, 0,7,0, 0,0,0},
{6,0,0, 1,9,5, 0,0,0},
{0,9,8, 0,0,0, 0,6,0},
{8,0,0, 0,6,0, 0,0,3},
{4,0,0, 8,0,3, 0,0,1},
{7,0,0, 0,2,0, 0,0,6},
{0,6,0, 0,0,0, 2,8,0},
{0,0,0, 4,1,9, 0,0,5},
{0,0,0, 0,8,0, 0,7,9}
};
SudokuSolver solver = new SudokuSolver();
System.out.println("Sudoku original:");
solver.imprimir(sudoku);
if (solver.resolver(sudoku)) {
System.out.println("\nSudoku resuelto:");
solver.imprimir(sudoku);
} else {
System.out.println("No tiene solución.");
}
}
}
🏰 Ejemplo 4: encontrar la salida de un laberinto
Un laberinto representado como una matriz donde 0 es camino libre y 1 es pared. El objetivo es encontrar un camino desde la esquina superior izquierda (0,0) hasta la esquina inferior derecha (n-1, n-1).
public class Laberinto {
// Movimientos: arriba, abajo, izquierda, derecha
private static final int[][] MOVIMIENTOS = {{-1,0},{1,0},{0,-1},{0,1}};
private static final String[] DIRS = {"↑", "↓", "←", "→"};
private final int[][] mapa;
private final int n;
private final boolean[][] visitado;
public Laberinto(int[][] mapa) {
this.mapa = mapa;
this.n = mapa.length;
this.visitado = new boolean[n][n];
}
public boolean resolver() {
return backtrack(0, 0);
}
private boolean backtrack(int fila, int col) {
// ¿Hemos llegado a la meta?
if (fila == n - 1 && col == n - 1) {
visitado[fila][col] = true;
return true;
}
// ¿Es válida esta posición?
if (fila < 0 || fila >= n || col < 0 || col >= n) return false;
if (mapa[fila][col] == 1) return false; // Pared
if (visitado[fila][col]) return false; // Ya visitado
// ELEGIR: marcar como visitado
visitado[fila][col] = true;
// EXPLORAR: probar los 4 movimientos
for (int[] mov : MOVIMIENTOS) {
if (backtrack(fila + mov[0], col + mov[1])) {
return true; // Camino encontrado
}
}
// DESHACER: desmarcar (no forma parte del camino)
visitado[fila][col] = false;
return false;
}
public void imprimirCamino() {
for (int f = 0; f < n; f++) {
for (int c = 0; c < n; c++) {
if (visitado[f][c]) System.out.print(" ★ ");
else if (mapa[f][c] == 1) System.out.print(" ▓ ");
else System.out.print(" · ");
}
System.out.println();
}
}
public static void main(String[] args) {
int[][] mapa = {
{0, 1, 0, 0, 0},
{0, 0, 0, 1, 0},
{1, 1, 0, 1, 0},
{0, 0, 0, 0, 1},
{0, 1, 1, 0, 0}
};
Laberinto lab = new Laberinto(mapa);
if (lab.resolver()) {
System.out.println("¡Camino encontrado!");
lab.imprimirCamino();
} else {
System.out.println("No hay camino posible.");
}
}
}
¡Camino encontrado!
★ ▓ · · ·
★ ★ ★ ▓ ·
▓ ▓ ★ ▓ ·
· · ★ ★ ▓
· ▓ ▓ ★ ★
✂️ Técnicas de poda: hacer backtracking eficiente
Sin poda, backtracking degenera en fuerza bruta. Las técnicas de poda (pruning) son lo que lo hacen práctico. La idea es descartar ramas lo antes posible:
| Técnica | Descripción | Ejemplo |
|---|---|---|
| Poda de factibilidad | Descartar opciones que ya violan una restricción, sin necesidad de seguir explorando. | En N-Reinas, no probar columnas donde ya hay conflicto de diagonal. |
| Poda de optimalidad (Branch & Bound) | Descartar ramas que no pueden mejorar la mejor solución encontrada hasta ahora. | En el problema de la mochila, descartar ramas cuyo beneficio máximo potencial es menor que el beneficio de la mejor solución actual. |
| Propagación de restricciones | Deducir valores antes de elegir, reduciendo candidatos. Usado en solucionadores CSP. | En Sudoku, si una celda solo puede contener un valor, asignarlo directamente sin probar. |
| Ordenación de candidatos | Probar primero los candidatos más prometedores para encontrar soluciones antes. | En el problema de coloración de grafos, empezar por el nodo con más conexiones. |
⚖️ Backtracking vs. otros paradigmas algorítmicos
| Paradigma | Enfoque | Cuándo usarlo en vez de backtracking |
|---|---|---|
| Fuerza bruta | Genera TODAS las soluciones y las comprueba. | Solo cuando el espacio de búsqueda es muy pequeño y la implementación más simple justifica la ineficiencia. |
| Divide y vencerás | Divide el problema en subproblemas independientes. | Cuando los subproblemas no comparten restricciones y pueden resolverse por separado (MergeSort, QuickSort). |
| Programación dinámica | Subproblemas solapados con subestructura óptima. | Cuando los mismos subproblemas se recalculan muchas veces (Fibonacci, mochila, caminos). |
| Algoritmos voraces | Decisiones locales óptimas sin retroceder. | Cuando se puede demostrar que la decisión localmente óptima lleva al óptimo global (Dijkstra, Kruskal). |
🎯 Cuándo usar y cuándo evitar backtracking
✅ Usa backtracking cuando:
Necesitas encontrar todas las soluciones (o una que cumpla restricciones específicas) y no existe un algoritmo polinomial conocido para el problema.
El espacio de búsqueda tiene estructura de árbol y las restricciones permiten podar ramas tempranamente, reduciendo el espacio explorado en la práctica.
El problema es de satisfacción de restricciones (CSP): Sudoku, coloración de grafos, planificación de horarios, configuración de componentes.
❌ Evita backtracking cuando:
Existe un algoritmo polinomial. Si puedes resolver el problema con divide y vencerás, programación dinámica o un algoritmo greedy en tiempo polinomial, backtracking es innecesariamente lento.
El espacio de búsqueda es enorme sin posibilidad de poda. Si no hay restricciones que permitan descartar ramas tempranamente, backtracking degenera en fuerza bruta exponencial.
Los subproblemas se solapan masivamente. Si descubres que estás resolviendo los mismos subproblemas una y otra vez, necesitas memoización → programación dinámica.
🐛 Errores comunes al implementar backtracking
| Error | Consecuencia | Solución |
|---|---|---|
| Olvidar el paso «deshacer» | Las decisiones se acumulan → soluciones incorrectas o bucles infinitos. | Siempre restaurar el estado tras la llamada recursiva. Si agregas, quita. Si marcas, desmarca. |
| No copiar la solución al guardarla | Todas las soluciones guardadas apuntan al mismo objeto → al final todas son iguales (la última). | Hacer new ArrayList<>(actual) al almacenar una solución en una lista de resultados. |
| Poda insuficiente | Rendimiento inaceptable en tamaños moderados. | Verificar restricciones lo antes posible, antes de la llamada recursiva, no después. |
| Modificar la lista mientras se itera | ConcurrentModificationException en Java. |
Usar índices numéricos o un array boolean[] usado en lugar de modificar la colección de candidatos. |
| Confundir «encontrar una solución» con «encontrar todas» | El algoritmo sigue buscando después de encontrar la primera, o para demasiado pronto. | Para una sola solución: return true en cuanto la encuentres. Para todas: no hacer return, seguir con el bucle. |
📝 Ejercicios resueltos
🏋️ Ejercicio 1: Generar todos los subconjuntos
Enunciado: Dado un conjunto de enteros sin duplicados, genera todos los subconjuntos posibles (el conjunto potencia). Por ejemplo, para {1, 2, 3} el resultado debe incluir: [], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3].
import java.util.ArrayList;
import java.util.List;
public class Subconjuntos {
public static List<List<Integer>> generar(int[] nums) {
List<List<Integer>> resultado = new ArrayList<>();
backtrack(nums, 0, new ArrayList<>(), resultado);
return resultado;
}
private static void backtrack(int[] nums, int inicio,
List<Integer> actual,
List<List<Integer>> resultado) {
// Cada estado parcial es un subconjunto válido
resultado.add(new ArrayList<>(actual));
for (int i = inicio; i < nums.length; i++) {
actual.add(nums[i]); // Elegir
backtrack(nums, i + 1, actual, resultado); // Explorar
actual.remove(actual.size() - 1); // Deshacer
}
}
public static void main(String[] args) {
List<List<Integer>> subs = generar(new int[]{1, 2, 3});
System.out.println("Subconjuntos: " + subs);
System.out.println("Total: " + subs.size()); // 8 = 2³
}
}
// Salida: [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]
🏋️ Ejercicio 2: Suma de combinaciones (Combination Sum)
Enunciado: Dado un array de candidatos sin duplicados y un valor objetivo (target), encuentra todas las combinaciones únicas de candidatos donde los números sumen el target. Un mismo candidato puede usarse ilimitadas veces. Por ejemplo, para candidatos=[2,3,6,7] y target=7: resultado = [[2,2,3], [7]].
import java.util.*;
public class CombinationSum {
public static List<List<Integer>> combinationSum(int[] candidatos, int target) {
List<List<Integer>> resultado = new ArrayList<>();
Arrays.sort(candidatos); // Ordenar para poda
backtrack(candidatos, target, 0, new ArrayList<>(), resultado);
return resultado;
}
private static void backtrack(int[] candidatos, int restante, int inicio,
List<Integer> actual,
List<List<Integer>> resultado) {
if (restante == 0) {
resultado.add(new ArrayList<>(actual)); // Solución encontrada
return;
}
for (int i = inicio; i < candidatos.length; i++) {
// PODA: si el candidato es mayor que el restante, no seguir
if (candidatos[i] > restante) break;
actual.add(candidatos[i]); // Elegir
backtrack(candidatos, restante - candidatos[i], i, actual, resultado); // Explorar (i, no i+1: se puede repetir)
actual.remove(actual.size() - 1); // Deshacer
}
}
public static void main(String[] args) {
System.out.println(combinationSum(new int[]{2,3,6,7}, 7));
// [[2, 2, 3], [7]]
}
}
🏋️ Ejercicio 3: Verificar si un camino del caballo existe
Enunciado: En un tablero 5×5, determina si un caballo de ajedrez puede visitar todas las casillas exactamente una vez partiendo de la posición (0,0). Imprime el tablero con el orden de visitas. Este es el famoso Knight's Tour problem.
public class CaminoCaballo {
private static final int N = 5;
// Los 8 movimientos posibles del caballo
private static final int[] DX = {2,1,-1,-2,-2,-1, 1, 2};
private static final int[] DY = {1,2, 2, 1,-1,-2,-2,-1};
private final int[][] tablero = new int[N][N];
public boolean resolver() {
// Inicializar tablero con -1 (no visitado)
for (int[] fila : tablero)
java.util.Arrays.fill(fila, -1);
// El caballo empieza en (0, 0) como movimiento 0
tablero[0][0] = 0;
return backtrack(0, 0, 1);
}
private boolean backtrack(int x, int y, int movimiento) {
// ¿Hemos visitado todas las casillas?
if (movimiento == N * N) return true;
// Probar los 8 movimientos del caballo
for (int i = 0; i < 8; i++) {
int nx = x + DX[i];
int ny = y + DY[i];
if (esValido(nx, ny)) {
tablero[nx][ny] = movimiento; // Elegir
if (backtrack(nx, ny, movimiento + 1)) {
return true; // Explorar
}
tablero[nx][ny] = -1; // Deshacer
}
}
return false;
}
private boolean esValido(int x, int y) {
return x >= 0 && x < N && y >= 0 && y < N && tablero[x][y] == -1;
}
public void imprimir() {
for (int[] fila : tablero) {
for (int casilla : fila) {
System.out.printf("%3d", casilla);
}
System.out.println();
}
}
public static void main(String[] args) {
CaminoCaballo cab = new CaminoCaballo();
if (cab.resolver()) {
System.out.println("Camino del caballo (5×5):");
cab.imprimir();
} else {
System.out.println("No existe solución.");
}
}
}
❓ Preguntas frecuentes sobre Backtracking en Java: algoritmos de vuelta atrás con ejemplos
Las dudas más comunes respondidas de forma clara y directa.
💬 Foro de discusión
¿Tienes dudas sobre Backtracking en Java: algoritmos de vuelta atrás con ejemplos? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!