回溯法
算法定义
回溯法(Backtracking Paradigm)是一种通过探索所有可能的候选解来找出所有(或一个)有效解的算法。它采用试错的思想,当探索到某一步发现当前选择不能得到有效解时,就回溯到上一步,尝试其他选择,直到找到有效的解或探索完所有可能的选择。
算法思路
- 定义问题的解空间,确定解的组织结构
- 采用深度优先搜索(DFS)的方式搜索解空间
- 在搜索过程中使用剪枝函数(约束函数和限界函数)避免无效搜索
- 当找到完整解或确定当前路径无法得到解时,回溯到上一层继续搜索
回溯法的核心思想可概括为:"向前走,碰壁就回头"。
算法复杂度
- 时间复杂度:
- 最好情况:O (n),在搜索早期就找到解
- 最坏情况:O (k^n) 或 O (n!),取决于问题的解空间大小(k 为每步的选择数,n 为问题规模)
- 平均情况:通常介于最好和最坏情况之间,受剪枝效果影响较大
- 空间复杂度:
- 通常为 O (n),主要用于存储递归栈和当前路径信息,n 为问题的深度
优点和缺点
- 优点:
- 可以找出所有可能的解,适合求解组合优化问题
- 通过剪枝可以大幅减少搜索空间,提高效率
- 实现思路直观,易于理解和编码
- 缺点:
- 时间复杂度较高,在解空间较大时效率低下
- 可能会进行大量重复计算
- 不适合处理规模过大的问题
应用场景
- 组合问题:如子集、组合总和等
- 排列问题:如全排列、N 皇后问题
- 路径问题:如迷宫求解、最短路径
- 棋盘问题:如八皇后、数独
- 图论问题:如哈密顿回路、图的着色
- 背包问题:0-1 背包的回溯解法
Java 代码实现
import java.util.*;
public class BacktrackingExamples {
// 1. 基础实现:子集问题
public static List<List<Integer>> subsetsBasic(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
backtrackBasic(nums, 0, new ArrayList<>(), result);
return result;
}
private static void backtrackBasic(int[] nums, int start, List<Integer> path, List<List<Integer>> result) {
// 将当前路径添加到结果集
result.add(new ArrayList<>(path));
// 探索所有可能的选择
for (int i = start; i < nums.length; i++) {
// 做选择
path.add(nums[i]);
// 递归探索
backtrackBasic(nums, i + 1, path, result);
// 撤销选择(回溯)
path.remove(path.size() - 1);
}
}
// 2. 带剪枝的实现:组合总和(找出所有和为目标值的组合)
public static List<List<Integer>> combinationSumWithPruning(int[] candidates, int target) {
List<List<Integer>> result = new ArrayList<>();
// 排序以便剪枝
Arrays.sort(candidates);
backtrackWithPruning(candidates, target, 0, new ArrayList<>(), result);
return result;
}
private static void backtrackWithPruning(int[] candidates, int target, int start,
List<Integer> path, List<List<Integer>> result) {
// 找到符合条件的组合
if (target == 0) {
result.add(new ArrayList<>(path));
return;
}
for (int i = start; i < candidates.length; i++) {
// 剪枝:如果当前数字大于剩余目标值,后续数字更大,无需考虑
if (candidates[i] > target) {
break;
}
// 做选择
path.add(candidates[i]);
// 递归探索(允许重复使用同一元素,所以start仍为i)
backtrackWithPruning(candidates, target - candidates[i], i, path, result);
// 撤销选择(回溯)
path.remove(path.size() - 1);
}
}
// 3. N皇后问题优化实现
public static List<List<String>> solveNQueens(int n) {
List<List<String>> result = new ArrayList<>();
// 优化:使用数组记录皇后位置,col[i]表示第i行皇后所在的列
int[] queens = new int[n];
Arrays.fill(queens, -1);
// 优化:使用集合记录已占用的列和对角线,加速判断
Set<Integer> columns = new HashSet<>();
Set<Integer> diagonals1 = new HashSet<>(); // 行-列 为常数的对角线
Set<Integer> diagonals2 = new HashSet<>(); // 行+列 为常数的对角线
backtrackQueens(n, 0, queens, columns, diagonals1, diagonals2, result);
return result;
}
private static void backtrackQueens(int n, int row, int[] queens,
Set<Integer> columns, Set<Integer> diagonals1,
Set<Integer> diagonals2, List<List<String>> result) {
// 找到一个有效解
if (row == n) {
result.add(generateBoard(queens, n));
return;
}
for (int col = 0; col < n; col++) {
int diagonal1 = row - col;
int diagonal2 = row + col;
// 剪枝:如果当前位置不合法(同列或同对角线已有皇后)
if (columns.contains(col) || diagonals1.contains(diagonal1) || diagonals2.contains(diagonal2)) {
continue;
}
// 做选择
queens[row] = col;
columns.add(col);
diagonals1.add(diagonal1);
diagonals2.add(diagonal2);
// 递归探索下一行
backtrackQueens(n, row + 1, queens, columns, diagonals1, diagonals2, result);
// 撤销选择(回溯)
queens[row] = -1;
columns.remove(col);
diagonals1.remove(diagonal1);
diagonals2.remove(diagonal2);
}
}
// 辅助方法:生成N皇后问题的棋盘表示
private static List<String> generateBoard(int[] queens, int n) {
List<String> board = new ArrayList<>();
for (int i = 0; i < n; i++) {
char[] row = new char[n];
Arrays.fill(row, '.');
row[queens[i]] = 'Q';
board.add(new String(row));
}
return board;
}
// 测试
public static void main(String[] args) {
// 测试子集问题
int[] nums = {1, 2, 3};
System.out.println("子集问题结果:");
List<List<Integer>> subsets = subsetsBasic(nums);
for (List<Integer> subset : subsets) {
System.out.println(subset);
}
// 测试组合总和问题
int[] candidates = {2, 3, 6, 7};
int target = 7;
System.out.println("\n组合总和问题结果:");
List<List<Integer>> combinations = combinationSumWithPruning(candidates, target);
for (List<Integer> combination : combinations) {
System.out.println(combination);
}
// 测试N皇后问题
int n = 4;
System.out.println("\n" + n + "皇后问题结果:");
List<List<String>> queens = solveNQueens(n);
for (List<String> board : queens) {
for (String row : board) {
System.out.println(row);
}
System.out.println();
}
}
}
代码解析
上面的代码实现了三种不同的回溯算法写法,展示了不同的优化思路:
- 基础实现(子集问题):
- 典型的回溯框架,包含 "做选择 - 递归探索 - 撤销选择" 三个步骤
- 使用 start 参数避免重复的子集(如 [1,2] 和 [2,1] 视为同一子集)
- 带剪枝的实现(组合总和):
- 先对数组排序,使得可以提前终止无效的搜索路径
- 当当前元素大于剩余目标值时,直接 break,因为后续元素更大,不可能得到有效解
- 剪枝是回溯法优化的核心手段,能显著提高算法效率
- N 皇后问题优化实现:
- 使用数组记录皇后位置,避免使用二维数组,节省空间
- 使用集合记录已占用的列和对角线,将皇后位置合法性判断从 O (n) 优化到 O (1)
- 这种空间换时间的策略是回溯法中常用的优化手段
回溯法的关键在于合理设计解空间结构和有效的剪枝策略。对于不同的问题,需要根据其特点设计针对性的剪枝条件,以提高算法效率。在实现时,也要注意选择合适的数据结构来记录中间状态,平衡时间和空间开销。
资料
回溯法套路模板 刷通leetcode https://zhuanlan.zhihu.com/p/112926891
八皇后问题
https://juejin.im/post/5accdb236fb9a028bb195562
解决八皇后问题,可以分为两个层面:
1.找出第一种正确摆放方式,也就是深度优先遍历。
2.找出全部的正确摆放方式,也就是广度优先遍历。